diff --git a/src/Api/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Controllers/OrganizationSponsorshipsController.cs index 74e5feeb4a..d8707960d3 100644 --- a/src/Api/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/OrganizationSponsorshipsController.cs @@ -42,8 +42,11 @@ namespace Bit.Api.Controllers { // TODO: validate has right to sponsor, send sponsorship email var sponsoringOrgIdGuid = new Guid(sponsoringOrgId); + var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(model.PlanSponsorshipType)?.SponsoringProductType; var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgIdGuid); - if (sponsoringOrg == null || !PlanTypeHelper.HasEnterprisePlan(sponsoringOrg)) + if (requiredSponsoringProductType == null || + sponsoringOrg == null || + StaticStore.GetPlan(sponsoringOrg.PlanType).Product != requiredSponsoringProductType.Value) { throw new BadRequestException("Specified Organization cannot sponsor other organizations."); } @@ -64,14 +67,14 @@ namespace Bit.Api.Controllers throw new BadRequestException("Can only sponsor one organization per Organization User."); } - await _organizationsSponsorshipService.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, model.PlanSponsorshipType, model.sponsoredEmail); + await _organizationsSponsorshipService.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, + model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName); } [HttpPost("sponsored/redeem/families-for-enterprise")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RedeemSponsorship([FromQuery] string sponsorshipToken, [FromBody] OrganizationSponsorshipRedeemRequestModel model) { - // TODO: parse out sponsorshipInfo if (!await _organizationsSponsorshipService.ValidateRedemptionTokenAsync(sponsorshipToken)) { throw new BadRequestException("Failed to parse sponsorship token."); @@ -99,9 +102,12 @@ namespace Bit.Api.Controllers throw new BadRequestException("Cannot redeem a sponsorship offer for an organization that is already sponsored. Revoke existing sponsorship first."); } + // Check org to sponsor's product type + var requiredSponsoredProductType = StaticStore.GetSponsoredPlan(model.PlanSponsorshipType)?.SponsoredProductType; var organizationToSponsor = await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId); - // TODO: only current families plan? - if (organizationToSponsor == null || !PlanTypeHelper.HasFamiliesPlan(organizationToSponsor)) + if (requiredSponsoredProductType == null || + organizationToSponsor == null || + StaticStore.GetPlan(organizationToSponsor.PlanType).Product != requiredSponsoredProductType.Value) { throw new BadRequestException("Can only redeem sponsorship offer on families organizations."); } @@ -124,12 +130,19 @@ namespace Bit.Api.Controllers var existingOrgSponsorship = await _organizationSponsorshipRepository .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUserIdGuid); - if (existingOrgSponsorship == null) + if (existingOrgSponsorship == null || existingOrgSponsorship.SponsoredOrganizationId == null) { - throw new BadRequestException("You are not currently sponsoring and organization."); + throw new BadRequestException("You are not currently sponsoring an organization."); } - await _organizationsSponsorshipService.RemoveSponsorshipAsync(existingOrgSponsorship); + var sponsoredOrganization = await _organizationRepository + .GetByIdAsync(existingOrgSponsorship.SponsoredOrganizationId.Value); + if (sponsoredOrganization == null) + { + throw new BadRequestException("Unable to find the sponsored Organization."); + } + + await _organizationsSponsorshipService.RemoveSponsorshipAsync(sponsoredOrganization, existingOrgSponsorship); } [HttpDelete("sponsored/{sponsoredOrgId}")] @@ -146,12 +159,20 @@ namespace Bit.Api.Controllers var existingOrgSponsorship = await _organizationSponsorshipRepository .GetBySponsoredOrganizationIdAsync(sponsoredOrgIdGuid); - if (existingOrgSponsorship == null) + if (existingOrgSponsorship == null || existingOrgSponsorship.SponsoredOrganizationId == null) { throw new BadRequestException("The requested organization is not currently being sponsored."); } - await _organizationsSponsorshipService.RemoveSponsorshipAsync(existingOrgSponsorship); + var sponsoredOrganization = await _organizationRepository + .GetByIdAsync(existingOrgSponsorship.SponsoredOrganizationId.Value); + if (sponsoredOrganization == null) + { + throw new BadRequestException("Unable to find the sponsored Organization."); + } + + + await _organizationsSponsorshipService.RemoveSponsorshipAsync(sponsoredOrganization, existingOrgSponsorship); } } } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 12a4c5c97c..ba6a12a6a2 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -29,6 +29,7 @@ namespace Bit.Billing.Controllers private readonly BillingSettings _billingSettings; private readonly IWebHostEnvironment _hostingEnvironment; private readonly IOrganizationService _organizationService; + private readonly IOrganizationSponsorshipService _organizationSponsorshipService; private readonly IOrganizationRepository _organizationRepository; private readonly ITransactionRepository _transactionRepository; private readonly IUserService _userService; @@ -45,6 +46,7 @@ namespace Bit.Billing.Controllers IOptions billingSettings, IWebHostEnvironment hostingEnvironment, IOrganizationService organizationService, + IOrganizationSponsorshipService organizationSponsorshipService, IOrganizationRepository organizationRepository, ITransactionRepository transactionRepository, IUserService userService, @@ -58,6 +60,7 @@ namespace Bit.Billing.Controllers _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; _organizationService = organizationService; + _organizationSponsorshipService = organizationSponsorshipService; _organizationRepository = organizationRepository; _transactionRepository = transactionRepository; _userService = userService; @@ -136,6 +139,16 @@ namespace Bit.Billing.Controllers // org if (ids.Item1.HasValue) { + var newEndPeriod = subscription.CurrentPeriodEnd; + + // sponsored org + if (IsSponsoredSubscription(subscription)) + { + var sponsorshipValid = await _organizationSponsorshipService + .ValidateSponsorshipAsync(ids.Item1.Value); + // TODO: How do we return from this? + } + await _organizationService.UpdateExpirationDateAsync(ids.Item1.Value, subscription.CurrentPeriodEnd); } @@ -783,5 +796,8 @@ namespace Bit.Billing.Controllers } return subscription; } + + private static bool IsSponsoredSubscription(Subscription subscription) => + StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); } } diff --git a/src/Core/Enums/PaymentMethodType.cs b/src/Core/Enums/PaymentMethodType.cs index 81b536bd7d..b0290f92b3 100644 --- a/src/Core/Enums/PaymentMethodType.cs +++ b/src/Core/Enums/PaymentMethodType.cs @@ -22,5 +22,7 @@ namespace Bit.Core.Enums GoogleInApp = 7, [Display(Name = "Check")] Check = 8, + [Display(Name = "None")] + None = 255, } } diff --git a/src/Core/Enums/PlanSponsorshipType.cs b/src/Core/Enums/PlanSponsorshipType.cs index 59f778e101..79145bf1eb 100644 --- a/src/Core/Enums/PlanSponsorshipType.cs +++ b/src/Core/Enums/PlanSponsorshipType.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Enums { diff --git a/src/Core/Enums/PlanType.cs b/src/Core/Enums/PlanType.cs index 501b8ec3c0..037f1f8938 100644 --- a/src/Core/Enums/PlanType.cs +++ b/src/Core/Enums/PlanType.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Linq; -using Bit.Core.Models.Table; namespace Bit.Core.Enums { @@ -29,26 +27,6 @@ namespace Bit.Core.Enums [Display(Name = "Enterprise (Monthly)")] EnterpriseMonthly = 10, [Display(Name = "Enterprise (Annually)")] - EnterpriseAnnually= 11, - } - - public static class PlanTypeHelper - { - private static readonly PlanType[] _freePlans = new[] { PlanType.Free }; - private static readonly PlanType[] _familiesPlans = new[] { PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 }; - private static readonly PlanType[] _teamsPlans = new[] { PlanType.TeamsAnnually, PlanType.TeamsAnnually2019, - PlanType.TeamsMonthly, PlanType.TeamsMonthly2019}; - private static readonly PlanType[] _enterprisePlans = new[] { PlanType.EnterpriseAnnually, - PlanType.EnterpriseAnnually2019, PlanType.EnterpriseMonthly, PlanType.EnterpriseMonthly2019 }; - - private static bool HasPlan(PlanType[] planTypes, PlanType planType) => planTypes.Any(p => p == planType); - public static bool HasFreePlan(Organization org) => IsFree(org.PlanType); - public static bool IsFree(PlanType planType) => HasPlan(_freePlans, planType); - public static bool HasFamiliesPlan(Organization org) => IsFamilies(org.PlanType); - public static bool IsFamilies(PlanType planType) => HasPlan(_familiesPlans, planType); - public static bool HasTeamsPlan(Organization org) => IsTeams(org.PlanType); - public static bool IsTeams(PlanType planType) => HasPlan(_teamsPlans, planType); - public static bool HasEnterprisePlan(Organization org) => IsEnterprise(org.PlanType); - public static bool IsEnterprise(PlanType planType) => HasPlan(_enterprisePlans, planType); + EnterpriseAnnually = 11, } } diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs index a23ac5f9a4..08012746e5 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs @@ -1,10 +1,13 @@ using System; using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; namespace Bit.Core.Models.Api { public class OrganizationSponsorshipRedeemRequestModel { + [Required] + public PlanSponsorshipType PlanSponsorshipType { get; set; } [Required] public Guid SponsoredOrganizationId { get; set; } } diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs index 646b5d2128..e04e47bc84 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs @@ -16,6 +16,9 @@ namespace Bit.Core.Models.Api.Request [Required] [StringLength(256)] [StrictEmailAddress] - public string sponsoredEmail { get; set; } + public string SponsoredEmail { get; set; } + + [StringLength(256)] + public string FriendlyName { get; set; } } } diff --git a/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs b/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs index bd2124fe94..5058f6fa0e 100644 --- a/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/ProfileOrganizationResponseModel.cs @@ -38,6 +38,7 @@ namespace Bit.Core.Models.Api UserId = organization.UserId?.ToString(); ProviderId = organization.ProviderId?.ToString(); ProviderName = organization.ProviderName; + FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName; } public string Id { get; set; } @@ -68,5 +69,6 @@ namespace Bit.Core.Models.Api public bool HasPublicAndPrivateKeys { get; set; } public string ProviderId { get; set; } public string ProviderName { get; set; } + public string FamilySponsorshipFriendlyName { get; set; } } } diff --git a/src/Core/Models/Business/SponsoredOrganizationSubscription.cs b/src/Core/Models/Business/SponsoredOrganizationSubscription.cs new file mode 100644 index 0000000000..30ea3e047b --- /dev/null +++ b/src/Core/Models/Business/SponsoredOrganizationSubscription.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Bit.Core.Models.Table; + +namespace Bit.Core.Models.Business +{ + public class SponsoredOrganizationSubscription + { + public const string OrganizationSponsorhipIdMetadataKey = "OrganizationSponsorshipId"; + private readonly string _customerId; + private readonly Organization _org; + private readonly StaticStore.Plan _plan; + private readonly List _taxRates; + + public SponsoredOrganizationSubscription(Organization org, Stripe.Subscription existingSubscription) + { + _org = org; + _customerId = org.GatewayCustomerId; + _plan = Utilities.StaticStore.GetPlan(org.PlanType); + _taxRates = existingSubscription.DefaultTaxRates; + } + + public SponsorOrganizationSubscriptionOptions GetSponsorSubscriptionOptions(OrganizationSponsorship sponsorship, + int additionalSeats = 0, int additionalStorageGb = 0, bool premiumAccessAddon = false) + { + var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value); + + var subCreateOptions = new SponsorOrganizationSubscriptionOptions(_customerId, _org, _plan, + sponsoredPlan, _taxRates, additionalSeats, additionalStorageGb, premiumAccessAddon); + + subCreateOptions.Metadata.Add(OrganizationSponsorhipIdMetadataKey, sponsorship.Id.ToString()); + return subCreateOptions; + } + + public OrganizationUpgradeSubscriptionOptions RemoveOrganizationSubscriptionOptions(int additionalSeats = 0, + int additionalStorageGb = 0, bool premiumAccessAddon = false) => + new OrganizationUpgradeSubscriptionOptions(_customerId, _org, _plan, _taxRates, + additionalSeats, additionalStorageGb, premiumAccessAddon); + } +} diff --git a/src/Core/Models/Business/SubscriptionCreateOptions.cs b/src/Core/Models/Business/SubscriptionCreateOptions.cs index 43f5cc3672..bcd95c8c25 100644 --- a/src/Core/Models/Business/SubscriptionCreateOptions.cs +++ b/src/Core/Models/Business/SubscriptionCreateOptions.cs @@ -1,12 +1,14 @@ using Bit.Core.Models.Table; using Stripe; using System.Collections.Generic; +using System.Linq; namespace Bit.Core.Models.Business { public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOptions { - public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, int additionalSeats, int additionalStorageGb, bool premiumAccessAddon) + public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, + int additionalSeats, int additionalStorageGb, bool premiumAccessAddon) { Items = new List(); Metadata = new Dictionary @@ -14,15 +16,6 @@ namespace Bit.Core.Models.Business [org.GatewayIdField()] = org.Id.ToString() }; - if (plan.StripePlanId != null) - { - Items.Add(new SubscriptionItemOptions - { - Plan = plan.StripePlanId, - Quantity = 1 - }); - } - if (additionalSeats > 0 && plan.StripeSeatPlanId != null) { Items.Add(new SubscriptionItemOptions @@ -49,15 +42,53 @@ namespace Bit.Core.Models.Business Quantity = 1 }); } + } - if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId)) + protected void AddPlanItem(StaticStore.Plan plan) => AddPlanItem(plan.StripePlanId); + protected void AddPlanItem(StaticStore.SponsoredPlan sponsoredPlan) => AddPlanItem(sponsoredPlan.StripePlanId); + protected void AddPlanItem(string stripePlanId) + { + if (stripePlanId != null) { - DefaultTaxRates = new List{ taxInfo.StripeTaxRateId }; + Items.Add(new SubscriptionItemOptions + { + Plan = stripePlanId, + Quantity = 1, + }); + } + } + + protected void AddTaxRateItem(TaxInfo taxInfo) => AddTaxRateItem(new List { taxInfo.StripeTaxRateId }); + protected void AddTaxRateItem(List taxRates) => AddTaxRateItem(taxRates?.Select(t => t.Id).ToList()); + protected void AddTaxRateItem(List taxRateIds) + { + if (taxRateIds != null && taxRateIds.Any()) + { + DefaultTaxRates = taxRateIds; } } } - public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase + public abstract class UnsponsoredOrganizationSubscriptionOptionsBase : OrganizationSubscriptionOptionsBase + { + public UnsponsoredOrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, + int additionalSeats, int additionalStorage, bool premiumAccessAddon) : + base(org, plan, additionalSeats, additionalStorage, premiumAccessAddon) + { + AddPlanItem(plan); + AddTaxRateItem(taxInfo); + } + public UnsponsoredOrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, List taxInfo, + int additionalSeats, int additionalStorage, bool premiumAccessAddon) : + base(org, plan, additionalSeats, additionalStorage, premiumAccessAddon) + { + AddPlanItem(plan); + AddTaxRateItem(taxInfo); + } + + } + + public class OrganizationPurchaseSubscriptionOptions : UnsponsoredOrganizationSubscriptionOptionsBase { public OrganizationPurchaseSubscriptionOptions( Organization org, StaticStore.Plan plan, @@ -70,7 +101,7 @@ namespace Bit.Core.Models.Business } } - public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOptionsBase + public class OrganizationUpgradeSubscriptionOptions : UnsponsoredOrganizationSubscriptionOptionsBase { public OrganizationUpgradeSubscriptionOptions( string customerId, Organization org, @@ -81,5 +112,43 @@ namespace Bit.Core.Models.Business { Customer = customerId; } + public OrganizationUpgradeSubscriptionOptions( + string customerId, Organization org, + StaticStore.Plan plan, List taxInfo, + int additionalSeats = 0, int additionalStorageGb = 0, + bool premiumAccessAddon = false) : + base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon) + { + Customer = customerId; + } + } + + public class RemoveOrganizationSubscriptionOptions : OrganizationSubscriptionOptionsBase + { + public RemoveOrganizationSubscriptionOptions(string customerId, Organization org, + StaticStore.Plan plan, List existingTaxRateStripeIds, + int additionalSeats = 0, int additionalStorageGb = 0, + bool premiumAccessAddon = false) : + base(org, plan, additionalSeats, additionalStorageGb, premiumAccessAddon) + { + Customer = customerId; + AddPlanItem(plan); + AddTaxRateItem(existingTaxRateStripeIds); + } + + } + + public class SponsorOrganizationSubscriptionOptions : OrganizationSubscriptionOptionsBase + { + public SponsorOrganizationSubscriptionOptions( + string customerId, Organization org, StaticStore.Plan existingPlan, + StaticStore.SponsoredPlan sponsorshipPlan, List existingTaxRates, int additionalSeats = 0, + int additionalStorageGb = 0, bool premiumAccessAddon = false) : + base(org, existingPlan, additionalSeats, additionalStorageGb, premiumAccessAddon) + { + Customer = customerId; + AddPlanItem(sponsorshipPlan); + AddTaxRateItem(existingTaxRates); + } } } diff --git a/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs b/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs index dc9e4dbfa4..71aa790033 100644 --- a/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs +++ b/src/Core/Models/Data/OrganizationUserOrganizationDetails.cs @@ -33,5 +33,6 @@ namespace Bit.Core.Models.Data public string PrivateKey { get; set; } public Guid? ProviderId { get; set; } public string ProviderName { get; set; } + public string FamilySponsorshipFriendlyName { get; set; } } } diff --git a/src/Core/Models/StaticStore/SponsoredPlan.cs b/src/Core/Models/StaticStore/SponsoredPlan.cs new file mode 100644 index 0000000000..782775255f --- /dev/null +++ b/src/Core/Models/StaticStore/SponsoredPlan.cs @@ -0,0 +1,12 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.StaticStore +{ + public class SponsoredPlan + { + public PlanSponsorshipType PlanSponsorshipType { get; set; } + public ProductType SponsoredProductType { get; set; } + public ProductType SponsoringProductType { get; set; } + public string StripePlanId { get; set; } + } +} diff --git a/src/Core/Models/Table/OrganizationSponsorship.cs b/src/Core/Models/Table/OrganizationSponsorship.cs index 47bded5be2..11be3c66c4 100644 --- a/src/Core/Models/Table/OrganizationSponsorship.cs +++ b/src/Core/Models/Table/OrganizationSponsorship.cs @@ -9,12 +9,12 @@ namespace Bit.Core.Models.Table { public Guid Id { get; set; } public Guid? InstallationId { get; set; } - [Required] - public Guid SponsoringOrganizationId { get; set; } - [Required] - public Guid SponsoringOrganizationUserId { get; set; } + public Guid? SponsoringOrganizationId { get; set; } + public Guid? SponsoringOrganizationUserId { get; set; } public Guid? SponsoredOrganizationId { get; set; } [MaxLength(256)] + public string FriendlyName { get; set; } + [MaxLength(256)] public string OfferedToEmail { get; set; } public PlanSponsorshipType? PlanSponsorshipType { get; set; } [Required] diff --git a/src/Core/Repositories/EntityFramework/DatabaseContext.cs b/src/Core/Repositories/EntityFramework/DatabaseContext.cs index d352e69803..0131ab1c89 100644 --- a/src/Core/Repositories/EntityFramework/DatabaseContext.cs +++ b/src/Core/Repositories/EntityFramework/DatabaseContext.cs @@ -26,7 +26,7 @@ namespace Bit.Core.Repositories.EntityFramework public DbSet GroupUsers { get; set; } public DbSet Installations { get; set; } public DbSet Organizations { get; set; } - public DbSet organizationSponsorships { get; set; } + public DbSet OrganizationSponsorships { get; set; } public DbSet OrganizationUsers { get; set; } public DbSet Policies { get; set; } public DbSet Providers { get; set; } diff --git a/src/Core/Repositories/EntityFramework/OrganizationRepository.cs b/src/Core/Repositories/EntityFramework/OrganizationRepository.cs index 2f7c5781d5..b7e9ccbaf6 100644 --- a/src/Core/Repositories/EntityFramework/OrganizationRepository.cs +++ b/src/Core/Repositories/EntityFramework/OrganizationRepository.cs @@ -95,5 +95,29 @@ namespace Bit.Core.Repositories.EntityFramework { await OrganizationUpdateStorage(id); } + + public override async Task DeleteAsync(Organization organization) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var orgUser = dbContext.FindAsync(organization.Id); + var sponsorships = dbContext.OrganizationSponsorships + .Where(os => + os.SponsoringOrganizationId == organization.Id || + os.SponsoredOrganizationId == organization.Id); + dbContext.RemoveRange(sponsorships.Where(os => os.CloudSponsor)); + + Guid? UpdatedOrgId(Guid? orgId) => orgId == organization.Id ? null : organization.Id; + foreach (var sponsorship in sponsorships.Where(os => !os.CloudSponsor)) + { + sponsorship.SponsoredOrganizationId = UpdatedOrgId(sponsorship.SponsoredOrganizationId); + sponsorship.SponsoringOrganizationId = UpdatedOrgId(sponsorship.SponsoringOrganizationId); + } + + dbContext.Remove(orgUser); + await dbContext.SaveChangesAsync(); + } + } } } diff --git a/src/Core/Repositories/EntityFramework/OrganizationSponsorshipRepository.cs b/src/Core/Repositories/EntityFramework/OrganizationSponsorshipRepository.cs index 611ede3cde..ee41c80a57 100644 --- a/src/Core/Repositories/EntityFramework/OrganizationSponsorshipRepository.cs +++ b/src/Core/Repositories/EntityFramework/OrganizationSponsorshipRepository.cs @@ -13,7 +13,7 @@ namespace Bit.Core.Repositories.EntityFramework public class OrganizationSponsorshipRepository : Repository, IOrganizationSponsorshipRepository { public OrganizationSponsorshipRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) : - base(serviceScopeFactory, mapper, (DatabaseContext context) => context.organizationSponsorships) + base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationSponsorships) { } diff --git a/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs b/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs index 17ea260b46..2be24d83b1 100644 --- a/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs +++ b/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs @@ -67,12 +67,32 @@ namespace Bit.Core.Repositories.EntityFramework return organizationUsers.Select(u => u.Id).ToList(); } + public override async Task DeleteAsync(OrganizationUser organizationUser) => await DeleteAsync(organizationUser.Id); + public async Task DeleteAsync(Guid organizationUserId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var orgUser = dbContext.FindAsync(organizationUserId); + var sponsorships = dbContext.OrganizationSponsorships + .Where(os => os.SponsoringOrganizationUserId != default && + os.SponsoringOrganizationUserId.Value == organizationUserId); + dbContext.RemoveRange(sponsorships); + dbContext.Remove(orgUser); + await dbContext.SaveChangesAsync(); + } + } + public async Task DeleteManyAsync(IEnumerable organizationUserIds) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); var entities = dbContext.FindAsync(organizationUserIds); + var sponsorships = dbContext.OrganizationSponsorships + .Where(os => os.SponsoringOrganizationUserId != default && + organizationUserIds.Contains(os.SponsoringOrganizationUserId ?? default)); + dbContext.RemoveRange(sponsorships); dbContext.RemoveRange(entities); await dbContext.SaveChangesAsync(); } diff --git a/src/Core/Repositories/EntityFramework/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Core/Repositories/EntityFramework/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 1fff508518..95b7a9c99f 100644 --- a/src/Core/Repositories/EntityFramework/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Core/Repositories/EntityFramework/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -16,8 +16,10 @@ namespace Bit.Core.Repositories.EntityFramework.Queries from po in po_g.DefaultIfEmpty() join p in dbContext.Providers on po.ProviderId equals p.Id into p_g from p in p_g.DefaultIfEmpty() + join os in dbContext.OrganizationSponsorships on ou.Id equals os.SponsoringOrganizationUserId into os_g + from os in os_g.DefaultIfEmpty() where ((su == null || !su.OrganizationId.HasValue) || su.OrganizationId == ou.OrganizationId) - select new { ou, o, su, p }; + select new { ou, o, su, p, os }; return query.Select(x => new OrganizationUserOrganizationDetails { OrganizationId = x.ou.OrganizationId, @@ -48,6 +50,7 @@ namespace Bit.Core.Repositories.EntityFramework.Queries PrivateKey = x.o.PrivateKey, ProviderId = x.p.Id, ProviderName = x.p.Name, + FamilySponsorshipFriendlyName = x.os.FriendlyName }); } } diff --git a/src/Core/Services/IOrganizationSponsorshipService.cs b/src/Core/Services/IOrganizationSponsorshipService.cs index cd2023f176..78a47092c3 100644 --- a/src/Core/Services/IOrganizationSponsorshipService.cs +++ b/src/Core/Services/IOrganizationSponsorshipService.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Bit.Core.Enums; using Bit.Core.Models.Table; @@ -7,8 +8,10 @@ namespace Bit.Core.Services public interface IOrganizationSponsorshipService { Task ValidateRedemptionTokenAsync(string encryptedToken); - Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, PlanSponsorshipType sponsorshipType, string sponsoredEmail); + Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, + PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName); Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization); - Task RemoveSponsorshipAsync(OrganizationSponsorship sponsorship); + Task ValidateSponsorshipAsync(Guid sponsoredOrganizationId); + Task RemoveSponsorshipAsync(Organization sponsoredOrganization, OrganizationSponsorship sponsorship); } } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 5f4a5ecc36..4227934528 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Bit.Core.Models.Table; using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; using Bit.Core.Enums; namespace Bit.Core.Services @@ -10,13 +11,15 @@ namespace Bit.Core.Services { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, - string paymentToken, Models.StaticStore.Plan plan, short additionalStorageGb, int additionalSeats, + string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); - Task UpgradeFreeOrganizationAsync(Organization org, Models.StaticStore.Plan plan, + Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); + Task RemoveOrganizationSponsorshipAsync(Organization org); + Task UpgradeFreeOrganizationAsync(Organization org, Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb, TaxInfo taxInfo); - Task AdjustSeatsAsync(Organization organization, Models.StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null); + Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, bool skipInAppPurchaseCheck = false); diff --git a/src/Core/Services/Implementations/OrganizationSponsorshipService.cs b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs index 2e4984374f..1e1bff3c62 100644 --- a/src/Core/Services/Implementations/OrganizationSponsorshipService.cs +++ b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Table; using Bit.Core.Repositories; using Microsoft.AspNetCore.DataProtection; @@ -13,12 +14,18 @@ namespace Bit.Core.Services private const string TokenClearTextPrefix = "BWOrganizationSponsorship_"; private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPaymentService _paymentService; private readonly IDataProtector _dataProtector; public OrganizationSponsorshipService(IOrganizationSponsorshipRepository organizationSponsorshipRepository, + IOrganizationRepository organizationRepository, + IPaymentService paymentService, IDataProtector dataProtector) { _organizationSponsorshipRepository = organizationSponsorshipRepository; + _organizationRepository = organizationRepository; + _paymentService = paymentService; _dataProtector = dataProtector; } @@ -63,13 +70,16 @@ namespace Bit.Core.Services _dataProtector.Protect($"{FamiliesForEnterpriseTokenName} {sponsorshipId} {sponsorshipType}") ); - public async Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, PlanSponsorshipType sponsorshipType, string sponsoredEmail) + public async Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, + PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName) { var sponsorship = new OrganizationSponsorship { SponsoringOrganizationId = sponsoringOrg.Id, SponsoringOrganizationUserId = sponsoringOrgUser.Id, + FriendlyName = friendlyName, OfferedToEmail = sponsoredEmail, + PlanSponsorshipType = sponsorshipType, CloudSponsor = true, }; @@ -78,6 +88,7 @@ namespace Bit.Core.Services sponsorship = await _organizationSponsorshipRepository.CreateAsync(sponsorship); // TODO: send email to sponsoredEmail w/ redemption token link + var _ = RedemptionToken(sponsorship.Id, sponsorshipType); } catch { @@ -91,14 +102,117 @@ namespace Bit.Core.Services public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization) { - // TODO: set up sponsorship, remember remove offeredToEmail from sponsorship - throw new NotImplementedException(); + if (sponsorship.PlanSponsorshipType == null) + { + throw new BadRequestException("Cannot set up sponsorship without a known sponsorship type."); + } + + // TODO: rollback? + await _paymentService.SponsorOrganizationAsync(sponsoredOrganization, sponsorship); + await _organizationRepository.UpsertAsync(sponsoredOrganization); + + sponsorship.SponsoredOrganizationId = sponsoredOrganization.Id; + sponsorship.OfferedToEmail = null; + await _organizationSponsorshipRepository.UpsertAsync(sponsorship); } - public async Task RemoveSponsorshipAsync(OrganizationSponsorship sponsorship) + public async Task ValidateSponsorshipAsync(Guid sponsoredOrganizationId) { - // TODO: remove sponsorship - throw new NotImplementedException(); + var sponsoredOrganization = await _organizationRepository.GetByIdAsync(sponsoredOrganizationId); + var existingSponsorship = await _organizationSponsorshipRepository + .GetBySponsoredOrganizationIdAsync(sponsoredOrganizationId); + + if (existingSponsorship == null) + { + await RemoveSponsorshipAsync(sponsoredOrganization); + // TODO on fail, mark org as disabled. + return false; + } + + var validated = true; + if (existingSponsorship.SponsoringOrganizationId == null || existingSponsorship.SponsoringOrganizationUserId == null) + { + await RemoveSponsorshipAsync(sponsoredOrganization); + validated = false; + } + + var sponsoringOrganization = await _organizationRepository + .GetByIdAsync(existingSponsorship.SponsoringOrganizationId.Value); + if (!sponsoringOrganization.Enabled) + { + await RemoveSponsorshipAsync(sponsoredOrganization); + validated = false; + } + + if (!validated && existingSponsorship.SponsoredOrganizationId != null) + { + existingSponsorship.TimesRenewedWithoutValidation += 1; + existingSponsorship.SponsorshipLapsedDate ??= DateTime.UtcNow; + + await _organizationSponsorshipRepository.UpsertAsync(existingSponsorship); + if (existingSponsorship.TimesRenewedWithoutValidation >= 6) + { + sponsoredOrganization.Enabled = false; + await _organizationRepository.UpsertAsync(sponsoredOrganization); + } + } + + return true; + } + + public async Task RemoveSponsorshipAsync(Organization sponsoredOrganization, OrganizationSponsorship sponsorship = null) + { + var success = await _paymentService.RemoveOrganizationSponsorshipAsync(sponsoredOrganization); + await _organizationRepository.UpsertAsync(sponsoredOrganization); + + if (sponsorship == null) + { + return; + } + + if (success) + { + // Initialize the record as available + sponsorship.SponsoredOrganizationId = null; + sponsorship.FriendlyName = null; + sponsorship.OfferedToEmail = null; + sponsorship.PlanSponsorshipType = null; + sponsorship.TimesRenewedWithoutValidation = 0; + sponsorship.SponsorshipLapsedDate = null; + + if (sponsorship.CloudSponsor || sponsorship.SponsorshipLapsedDate.HasValue) + { + await _organizationSponsorshipRepository.DeleteAsync(sponsorship); + } + else + { + await _organizationSponsorshipRepository.UpsertAsync(sponsorship); + } + } + else + { + sponsorship.SponsoringOrganizationId = null; + sponsorship.SponsoringOrganizationUserId = null; + + if (!sponsorship.CloudSponsor) + { + // Sef-hosted sponsorship record + // we need to make the existing sponsorship available, and add + // a new sponsorship record to record the lapsed sponsorship + var cleanSponsorship = new OrganizationSponsorship + { + InstallationId = sponsorship.InstallationId, + SponsoringOrganizationId = sponsorship.SponsoringOrganizationId, + SponsoringOrganizationUserId = sponsorship.SponsoringOrganizationUserId, + CloudSponsor = sponsorship.CloudSponsor, + }; + await _organizationSponsorshipRepository.UpsertAsync(cleanSponsorship); + } + + sponsorship.SponsorshipLapsedDate ??= DateTime.UtcNow; + await _organizationSponsorshipRepository.UpsertAsync(sponsorship); + } + } } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 690391d78b..b4460d6020 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -192,6 +192,44 @@ namespace Bit.Core.Services } } + public async Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship) + { + var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId); + var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); + + var sponsoredSubscription = new SponsoredOrganizationSubscription(org, sub); + + var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, + false, PaymentMethodType.None, sponsoredSubscription.GetSponsorSubscriptionOptions(sponsorship), null); + org.GatewaySubscriptionId = subscription.Id; + + org.ExpirationDate = subscription.CurrentPeriodEnd; + } + + public async Task RemoveOrganizationSponsorshipAsync(Organization org) + { + var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId); + var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); + + var sponsoredSubscription = new SponsoredOrganizationSubscription(org, sub); + var subCreateOptions = sponsoredSubscription.RemoveOrganizationSubscriptionOptions(); + + var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); + var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, + stripePaymentMethod, paymentMethodType, subCreateOptions, null); + + if (subscription.Status == "incomplete") + { + // TODO: revert + return false; + } + org.GatewaySubscriptionId = subscription.Id; + org.Enabled = true; + org.ExpirationDate = subscription.CurrentPeriodEnd; + + return true; + } + public async Task UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo) { @@ -227,6 +265,29 @@ namespace Bit.Core.Services } var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon); + var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions); + + var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, + stripePaymentMethod, paymentMethodType, subCreateOptions, null); + org.GatewaySubscriptionId = subscription.Id; + + if (subscription.Status == "incomplete" && + subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action") + { + org.Enabled = false; + return subscription.LatestInvoice.PaymentIntent.ClientSecret; + } + else + { + org.Enabled = true; + org.ExpirationDate = subscription.CurrentPeriodEnd; + return null; + } + } + + private (bool stripePaymentMethod, PaymentMethodType PaymentMethodType) IdentifyPaymentMethod( + Stripe.Customer customer, Stripe.SubscriptionCreateOptions subCreateOptions) + { var stripePaymentMethod = false; var paymentMethodType = PaymentMethodType.Credit; var hasBtCustomerId = customer.Metadata.ContainsKey("btCustomerId"); @@ -265,23 +326,7 @@ namespace Bit.Core.Services } } } - - var subscription = await ChargeForNewSubscriptionAsync(org, customer, false, - stripePaymentMethod, paymentMethodType, subCreateOptions, null); - org.GatewaySubscriptionId = subscription.Id; - - if (subscription.Status == "incomplete" && - subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action") - { - org.Enabled = false; - return subscription.LatestInvoice.PaymentIntent.ClientSecret; - } - else - { - org.Enabled = true; - org.ExpirationDate = subscription.CurrentPeriodEnd; - return null; - } + return (stripePaymentMethod, paymentMethodType); } public async Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index f809adc062..3607332fcc 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -1,6 +1,8 @@ using Bit.Core.Enums; using Bit.Core.Models.StaticStore; +using Bit.Core.Models.Table; using System.Collections.Generic; +using System.Linq; namespace Bit.Core.Utilities { @@ -475,5 +477,19 @@ namespace Bit.Core.Utilities public static IDictionary> GlobalDomains { get; set; } public static IEnumerable Plans { get; set; } + public static IEnumerable SponsoredPlans { get; set; } = new[] + { + new SponsoredPlan + { + PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + SponsoredProductType = ProductType.Families, + SponsoringProductType = ProductType.Enterprise, + StripePlanId = "2021-enterprise-sponsored-families-org-monthly" + } + }; + public static Plan GetPlan(PlanType planType) => + Plans.FirstOrDefault(p => p.Type == planType); + public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => + SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType); } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationDeleted.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationDeleted.sql new file mode 100644 index 0000000000..f7685b7e17 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationDeleted.sql @@ -0,0 +1,31 @@ +CREATE PROCEDURE [dbo].[OrganizationSponsorship_OrganizationDeleted] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationSponsorship] + SET + [SponsoringOrganizationId] = NULL + WHERE + [SponsoringOrganizationId] = @OrganizationId AND + [CloudSponsor] = 0 + + UPDATE + [dbo].[OrganizationSponsorship] + SET + [SponsoredOrganizationId] = NULL + WHERE + [SponsoredOrganizationId] = @OrganizationId AND + [CloudSponsor] = 0 + + DELETE + FROM + [dbo].[OrganizationSponsorship] + WHERE + [CloudSponsor] = 1 AND + ([SponsoredOrganizationId] = @OrganizationId OR + [SponsoringOrganizationId] = @OrganizationId) +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql new file mode 100644 index 0000000000..a324b76d32 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUserDeleted.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationSponsorship_OrganizationUserDeleted] + @OrganizationUserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationSponsorship] + WHERE + [SponsoringOrganizationUserId] = @OrganizationUserId +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql new file mode 100644 index 0000000000..80457b3fd5 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationSponsorship_OrganizationUsersDeleted.sql @@ -0,0 +1,24 @@ +CREATE PROCEDURE [dbo].[OrganizationSponsorship_OrganizationUsersDeleted] + @SponsoringOrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SET @BatchSize = 100; + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION OrganizationSponsorship_DeleteOUs + + DELETE TOP(@BatchSize) OS + FROM + [dbo].[OrganiozationSponsorship] OS + INNER JOIN + @Ids I ON I.Id = OS.SponsoringOrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION OrganizationSponsorship_DeleteOUs + END +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql index be2a12eeb0..5817e49394 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql @@ -34,9 +34,19 @@ BEGIN WHERE [OrganizationUserId] = @Id + EXEC [dbo].[OrganizationUser_DeleteById] @Id + DELETE FROM [dbo].[OrganizationUser] WHERE [Id] = @Id -END \ No newline at end of file +END +GO + + +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_DeleteByIds] +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql index 049a2c5c0c..4930a8fbed 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql @@ -61,6 +61,7 @@ BEGIN COMMIT TRANSACTION GoupUser_DeleteMany_GroupUsers END + EXEC [dbo].[OrganizationSponsorship_OrganizationUsersDeleted] @Ids SET @BatchSize = 100; diff --git a/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql b/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql index f19b050e72..3015179290 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql @@ -57,6 +57,8 @@ BEGIN WHERE [OrganizationId] = @Id + EXEC[dbo].[OrganizationSponsorship_OrganizationDeleted] @Id + DELETE FROM [dbo].[Organization] @@ -65,3 +67,4 @@ BEGIN COMMIT TRANSACTION Organization_DeleteById END +GO diff --git a/src/Sql/dbo/Tables/OrganizationSponsorship.sql b/src/Sql/dbo/Tables/OrganizationSponsorship.sql index 9467297b88..391ab599b6 100644 --- a/src/Sql/dbo/Tables/OrganizationSponsorship.sql +++ b/src/Sql/dbo/Tables/OrganizationSponsorship.sql @@ -1,8 +1,8 @@ CREATE TABLE [dbo].[OrganizationSponsorship] ( [Id] UNIQUEIDENTIFIER NOT NULL, [InstallationId] UNIQUEIDENTIFIER NULL, - [SponsoringOrganizationId] UNIQUEIDENTIFIER NOT NULL, - [SponsoringOrganizationUserID] UNIQUEIDENTIFIER NOT NULL, + [SponsoringOrganizationId] UNIQUEIDENTIFIER NULL, + [SponsoringOrganizationUserID] UNIQUEIDENTIFIER NULL, [SponsoredOrganizationId] UNIQUEIDENTIFIER NULL, [OfferedToEmail] NVARCHAR (256) NULL, [PlanSponsorshipType] TINYINT NULL, @@ -19,24 +19,25 @@ CREATE TABLE [dbo].[OrganizationSponsorship] ( GO CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_InstallationId] - ON [dbo].[Organization]([Id] ASC, [InstallationId] ASC) + ON [dbo].[OrganizationSponsorship]([InstallationId] ASC) WHERE [InstallationId] IS NOT NULL; GO CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_SponsoringOrganizationId] - ON [dbo].[Organization]([Id] ASC, [SponsoringOrganizationId] ASC) + ON [dbo].[OrganizationSponsorship]([SponsoringOrganizationId] ASC) + WHERE [SponsoringOrganizationId] IS NOT NULL; GO CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_SponsoringOrganizationUserId] - ON [dbo].[Organization]([Id] ASC, [SponsorginOrganizationUserID] ASC) + ON [dbo].[OrganizationSponsorship]([SponsoringOrganizationUserID] ASC) + WHERE [SponsoringOrganizationUserID] IS NOT NULL; GO CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_OfferedToEmail] - ON [dbo].[Organization]([Id] ASC, [OfferedToEmail] ASC) + ON [dbo].[OrganizationSponsorship]([OfferedToEmail] ASC) WHERE [OfferedToEmail] IS NOT NULL; GO CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_SponsoredOrganizationID] - ON [dbo].[Organization]([Id] ASC, [SponsoredOrganizationId] ASC) + ON [dbo].[OrganizationSponsorship]([SponsoredOrganizationId] ASC) WHERE [SponsoredOrganizationId] IS NOT NULL; - diff --git a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs index 2f1434ba97..f019ade201 100644 --- a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -16,6 +16,7 @@ using Bit.Core.Repositories; using Bit.Core.Models.Api.Request; using Bit.Core.Services; using Bit.Core.Models.Api; +using Bit.Core.Utilities; namespace Bit.Api.Test.Controllers { @@ -24,27 +25,29 @@ namespace Bit.Api.Test.Controllers public class OrganizationSponsorshipsControllerTests { public static IEnumerable EnterprisePlanTypes => - Enum.GetValues().Where(p => PlanTypeHelper.IsEnterprise(p)).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product == ProductType.Enterprise).Select(p => new object[] { p }); public static IEnumerable NonEnterprisePlanTypes => - Enum.GetValues().Where(p => !PlanTypeHelper.IsEnterprise(p)).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product != ProductType.Enterprise).Select(p => new object[] { p }); public static IEnumerable NonFamiliesPlanTypes => - Enum.GetValues().Where(p => !PlanTypeHelper.IsFamilies(p)).Select(p => new object[] { p }); + Enum.GetValues().Where(p => StaticStore.GetPlan(p).Product != ProductType.Families).Select(p => new object[] { p }); [Theory] [BitMemberAutoData(nameof(NonEnterprisePlanTypes))] public async Task CreateSponsorship_BadSponsoringOrgPlan_ThrowsBadRequest(PlanType sponsoringOrgPlan, Organization org, - SutProvider sutProvider) + OrganizationSponsorshipRequestModel model, SutProvider sutProvider) { org.PlanType = sponsoringOrgPlan; + model.PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise; + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorship(org.Id.ToString(), null)); + sutProvider.Sut.CreateSponsorship(org.Id.ToString(), model)); Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .OfferSponsorshipAsync(default, default, default, default); + .OfferSponsorshipAsync(default, default, default, default, default); } public static IEnumerable NonConfirmedOrganizationUsersStatuses => @@ -73,7 +76,7 @@ namespace Bit.Api.Test.Controllers Assert.Contains("Only confirm users can sponsor other organizations.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .OfferSponsorshipAsync(default, default, default, default); + .OfferSponsorshipAsync(default, default, default, default, default); } [Theory] @@ -96,7 +99,7 @@ namespace Bit.Api.Test.Controllers Assert.Contains("Can only create organization sponsorships for yourself.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .OfferSponsorshipAsync(default, default, default, default); + .OfferSponsorshipAsync(default, default, default, default, default); } [Theory] @@ -121,7 +124,7 @@ namespace Bit.Api.Test.Controllers Assert.Contains("Can only sponsor one organization per Organization User.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .OfferSponsorshipAsync(default, default, default, default); + .OfferSponsorshipAsync(default, default, default, default, default); } [Theory] @@ -272,7 +275,7 @@ namespace Bit.Api.Test.Controllers Assert.Contains("Can only revoke a sponsorship you granted.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RemoveSponsorshipAsync(default); + .RemoveSponsorshipAsync(default, default); } [Theory] @@ -293,10 +296,58 @@ namespace Bit.Api.Test.Controllers var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RevokeSponsorship(sponsoringOrgUser.Id.ToString())); - Assert.Contains("You are not currently sponsoring and organization.", exception.Message); + Assert.Contains("You are not currently sponsoring an organization.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RemoveSponsorshipAsync(default); + .RemoveSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RevokeSponsorship_SponsorshipNotRedeemed_ThrowsBadRequest(OrganizationUser sponsoringOrgUser, + OrganizationSponsorship sponsorship, SutProvider sutProvider) + { + sponsorship.SponsoredOrganizationId = null; + + sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId); + sutProvider.GetDependency().GetByIdAsync(sponsoringOrgUser.Id) + .Returns(sponsoringOrgUser); + sutProvider.GetDependency() + .GetBySponsoringOrganizationUserIdAsync(Arg.Is(v => v != sponsoringOrgUser.Id)) + .Returns(sponsorship); + sutProvider.GetDependency() + .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id) + .Returns((OrganizationSponsorship)sponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RevokeSponsorship(sponsoringOrgUser.Id.ToString())); + + Assert.Contains("You are not currently sponsoring an organization.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RemoveSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RevokeSponsorship_SponsoredOrgNotFound_ThrowsBadRequest(OrganizationUser sponsoringOrgUser, + OrganizationSponsorship sponsorship, SutProvider sutProvider) + { + + sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId); + sutProvider.GetDependency().GetByIdAsync(sponsoringOrgUser.Id) + .Returns(sponsoringOrgUser); + sutProvider.GetDependency() + .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id) + .Returns(sponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RevokeSponsorship(sponsoringOrgUser.Id.ToString())); + + Assert.Contains("Unable to find the sponsored Organization.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RemoveSponsorshipAsync(default, default); } [Theory] @@ -312,7 +363,7 @@ namespace Bit.Api.Test.Controllers Assert.Contains("Only the owner of an organization can remove sponsorship.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RemoveSponsorshipAsync(default); + .RemoveSponsorshipAsync(default, default); } [Theory] @@ -334,7 +385,26 @@ namespace Bit.Api.Test.Controllers Assert.Contains("The requested organization is not currently being sponsored.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .RemoveSponsorshipAsync(default); + .RemoveSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RemoveSponsorship_SponsoredOrgNotFound_ThrowsBadRequest(Organization sponsoredOrg, + OrganizationSponsorship sponsorship, SutProvider sutProvider) + { + sutProvider.GetDependency().OrganizationOwner(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(sponsoredOrg.Id) + .Returns(sponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RemoveSponsorship(sponsoredOrg.Id.ToString())); + + Assert.Contains("Unable to find the sponsored Organization.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RemoveSponsorshipAsync(default, default); } } } diff --git a/test/Core.Test/Enums/PlanTypeHelperTests.cs b/test/Core.Test/Enums/PlanTypeHelperTests.cs deleted file mode 100644 index d8af51f1cb..0000000000 --- a/test/Core.Test/Enums/PlanTypeHelperTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using Bit.Core.Enums; -using Bit.Core.Models.Table; -using Xunit; - -namespace Bit.Core.Test.Enums -{ - public class PlanTypeHelperTests - { - private static IEnumerable PlanArchetypeArray(PlanType planType) => new PlanType?[] { - PlanTypeHelper.HasFreePlan(new Organization {PlanType = planType}) ? planType : null, - PlanTypeHelper.HasFamiliesPlan(new Organization {PlanType = planType}) ? planType : null, - PlanTypeHelper.HasTeamsPlan(new Organization {PlanType = planType}) ? planType : null, - PlanTypeHelper.HasEnterprisePlan(new Organization {PlanType = planType}) ? planType : null, - }.Where(v => v.HasValue).Select(v => (PlanType)v); - - public static IEnumerable PlanTypes => Enum.GetValues().Select(p => new object[] { p }); - public static IEnumerable PlanTypesExceptCustom => - Enum.GetValues().Except(new[] { PlanType.Custom }).Select(p => new object[] { p }); - - [Theory] - [MemberData(nameof(PlanTypesExceptCustom))] - public void NonCustomPlanTypesBelongToPlanArchetype(PlanType planType) - { - Assert.Contains(planType, PlanArchetypeArray(planType)); - } - - [Theory] - [MemberData(nameof(PlanTypesExceptCustom))] - public void PlanTypesBelongToOnlyOneArchetype(PlanType planType) - { - Console.WriteLine(planType); - Assert.Single(PlanArchetypeArray(planType)); - } - } -} diff --git a/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs b/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs index f916756919..4fc25b7f8c 100644 --- a/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs +++ b/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs @@ -34,15 +34,16 @@ namespace Bit.Core.Test.Services [Theory] [BitAutoData] public async Task OfferSponsorship_CreatesSponsorship(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, - string sponsoredEmail, SutProvider sutProvider) + string sponsoredEmail, string friendlyName, SutProvider sutProvider) { await sutProvider.Sut.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName); var expectedSponsorship = new OrganizationSponsorship { SponsoringOrganizationId = sponsoringOrg.Id, SponsoringOrganizationUserId = sponsoringOrgUser.Id, + FriendlyName = friendlyName, OfferedToEmail = sponsoredEmail, CloudSponsor = true, }; @@ -55,7 +56,7 @@ namespace Bit.Core.Test.Services [Theory] [BitAutoData] public async Task OfferSponsorship_CreateSponsorshipThrows_RevertsDatabase(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, - string sponsoredEmail, SutProvider sutProvider) + string sponsoredEmail, string friendlyName, SutProvider sutProvider) { var expectedException = new Exception(); OrganizationSponsorship createdSponsorship = null; @@ -68,7 +69,7 @@ namespace Bit.Core.Test.Services var actualException = await Assert.ThrowsAsync(() => sutProvider.Sut.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail)); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName)); Assert.Same(expectedException, actualException); await sutProvider.GetDependency().Received(1) diff --git a/util/Migrator/DbScripts/2021-11-02_00_OrganizationSponsorship.sql b/util/Migrator/DbScripts/2021-11-02_00_OrganizationSponsorship.sql index 8a0bc78093..7c10a47bb7 100644 --- a/util/Migrator/DbScripts/2021-11-02_00_OrganizationSponsorship.sql +++ b/util/Migrator/DbScripts/2021-11-02_00_OrganizationSponsorship.sql @@ -4,9 +4,10 @@ BEGIN CREATE TABLE [dbo].[OrganizationSponsorship] ( [Id] UNIQUEIDENTIFIER NOT NULL, [InstallationId] UNIQUEIDENTIFIER NULL, - [SponsoringOrganizationId] UNIQUEIDENTIFIER NOT NULL, - [SponsoringOrganizationUserID] UNIQUEIDENTIFIER NOT NULL, + [SponsoringOrganizationId] UNIQUEIDENTIFIER NULL, + [SponsoringOrganizationUserID] UNIQUEIDENTIFIER NULL, [SponsoredOrganizationId] UNIQUEIDENTIFIER NULL, + [FriendlyName] NVARCHAR(256) NULL, [OfferedToEmail] NVARCHAR (256) NULL, [PlanSponsorshipType] TINYINT NULL, [CloudSponsor] BIT NULL, @@ -35,6 +36,7 @@ IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationSponsors BEGIN CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_SponsoringOrganizationId] ON [dbo].[OrganizationSponsorship]([SponsoringOrganizationId] ASC) + WHERE [SponsoringOrganizationId] IS NOT NULL; END GO @@ -42,6 +44,7 @@ IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationSponsors BEGIN CREATE NONCLUSTERED INDEX [IX_OrganizationSponsorship_SponsoringOrganizationUserId] ON [dbo].[OrganizationSponsorship]([SponsoringOrganizationUserID] ASC) + WHERE [SponsoringOrganizationUserID] IS NOT NULL; END GO @@ -114,6 +117,7 @@ CREATE PROCEDURE [dbo].[OrganizationSponsorship_Create] @SponsoringOrganizationId UNIQUEIDENTIFIER, @SponsoringOrganizationUserID UNIQUEIDENTIFIER, @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), @OfferedToEmail NVARCHAR(256), @PlanSponsorshipType TINYINT, @CloudSponsor BIT, @@ -131,6 +135,7 @@ BEGIN [SponsoringOrganizationId], [SponsoringOrganizationUserID], [SponsoredOrganizationId], + [FriendlyName], [OfferedToEmail], [PlanSponsorshipType], [CloudSponsor], @@ -145,6 +150,7 @@ BEGIN @SponsoringOrganizationId, @SponsoringOrganizationUserID, @SponsoredOrganizationId, + @FriendlyName, @OfferedToEmail, @PlanSponsorshipType, @CloudSponsor, @@ -168,6 +174,7 @@ CREATE PROCEDURE [dbo].[OrganizationSponsorship_Update] @SponsoringOrganizationId UNIQUEIDENTIFIER, @SponsoringOrganizationUserID UNIQUEIDENTIFIER, @SponsoredOrganizationId UNIQUEIDENTIFIER, + @FriendlyName NVARCHAR(256), @OfferedToEmail NVARCHAR(256), @PlanSponsorshipType TINYINT, @CloudSponsor BIT, @@ -185,6 +192,7 @@ BEGIN [SponsoringOrganizationId] = @SponsoringOrganizationId, [SponsoringOrganizationUserID] = @SponsoringOrganizationUserID, [SponsoredOrganizationId] = @SponsoredOrganizationId, + [FriendlyName] = @FriendlyName, [OfferedToEmail] = @OfferedToEmail, [PlanSponsorshipType] = @PlanSponsorshipType, [CloudSponsor] = @CloudSponsor, @@ -290,3 +298,365 @@ BEGIN [OfferedToEmail] = @OfferedToEmail END GO + +-- OrganizationSponsorship_OrganizationDeleted +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationDeleted]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationSponsorship_OrganizationDeleted] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_OrganizationDeleted] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[OrganizationSponsorship] + SET + [SponsoringOrganizationId] = NULL + WHERE + [SponsoringOrganizationId] = @OrganizationId AND + [CloudSponsor] = 0 + + UPDATE + [dbo].[OrganizationSponsorship] + SET + [SponsoredOrganizationId] = NULL + WHERE + [SponsoredOrganizationId] = @OrganizationId AND + [CloudSponsor] = 0 + + DELETE + FROM + [dbo].[OrganizationSponsorship] + WHERE + [CloudSponsor] = 1 AND + ([SponsoredOrganizationId] = @OrganizationId OR + [SponsoringOrganizationId] = @OrganizationId) +END +GO + +-- OrganizationSponsorship_OrganizationUserDeleted +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationUserDeleted]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationSponsorship_OrganizationUserDeleted] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_OrganizationUserDeleted] + @OrganizationUserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[OrganizationSponsorship] + WHERE + [SponsoringOrganizationUserId] = @OrganizationUserId +END +GO + +-- OrganizationSponsorship_OrganizationUsersDeleted +IF OBJECT_ID('[dbo].[OrganizationSponsorship_OrganizationUsersDeleted]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationSponsorship_OrganizationUsersDeleted] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationSponsorship_OrganizationUsersDeleted] + @SponsoringOrganizationUserIds [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION OS_DeleteMany_OUs + + DELETE TOP(@BatchSize) OS + FROM + [dbo].[OrganizationSponsorship] OS + INNER JOIN + @SponsoringOrganizationUserIds I ON I.Id = OS.SponsoringOrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION OS_DeleteMany_OUs + END +END +GO + +-- Update Organization delete sprocs to handle organization sponsorships +IF OBJECT_ID('[dbo].[Organization_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[Organization_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @Id + + DECLARE @BatchSize INT = 100 + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Organization_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IS NULL + AND [OrganizationId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION Organization_DeleteById_Ciphers + END + + BEGIN TRANSACTION Organization_DeleteById + + DELETE + FROM + [dbo].[SsoUser] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[SsoConfig] + WHERE + [OrganizationId] = @Id + + DELETE CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON [CU].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[ProviderOrganization] + WHERE + [OrganizationId] = @Id + + EXEC[dbo].[OrganizationSponsorship_OrganizationDeleted] @Id + + DELETE + FROM + [dbo].[Organization] + WHERE + [Id] = @Id + + COMMIT TRANSACTION Organization_DeleteById +END +GO + +-- Update Organization User delete sprocs to handle organization sponsorships +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_DeleteById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserId] @Id + + DECLARE @OrganizationId UNIQUEIDENTIFIER + DECLARE @UserId UNIQUEIDENTIFIER + + SELECT + @OrganizationId = [OrganizationId], + @UserId = [UserId] + FROM + [dbo].[OrganizationUser] + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL AND @UserId IS NOT NULL + BEGIN + EXEC [dbo].[SsoUser_Delete] @UserId, @OrganizationId + END + + DELETE + FROM + [dbo].[CollectionUser] + WHERE + [OrganizationUserId] = @Id + + DELETE + FROM + [dbo].[GroupUser] + WHERE + [OrganizationUserId] = @Id + + EXEC [dbo].[OrganizationUser_DeleteById] @Id + + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [Id] = @Id +END +GO + + +IF OBJECT_ID('[dbo].[OrganizationUser_DeleteByIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_DeleteByIds] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_DeleteByIds] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @Ids + + DECLARE @UserAndOrganizationIds [dbo].[TwoGuidIdArray] + + INSERT INTO @UserAndOrganizationIds + (Id1, Id2) + SELECT + UserId, + OrganizationId + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids OUIds ON OUIds.Id = OU.Id + WHERE + UserId IS NOT NULL AND + OrganizationId IS NOT NULL + + BEGIN + EXEC [dbo].[SsoUser_DeleteMany] @UserAndOrganizationIds + END + + DECLARE @BatchSize INT = 100 + + -- Delete CollectionUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionUser_DeleteMany_CUs + + DELETE TOP(@BatchSize) CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + @Ids I ON I.Id = CU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION CollectionUser_DeleteMany_CUs + END + + SET @BatchSize = 100; + + -- Delete GroupUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION GroupUser_DeleteMany_GroupUsers + + DELETE TOP(@BatchSize) GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + @Ids I ON I.Id = GU.OrganizationUserId + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION GoupUser_DeleteMany_GroupUsers + END + + EXEC [dbo].[OrganizationSponsorship_OrganizationUsersDeleted] @Ids + + SET @BatchSize = 100; + + -- Delete OrganizationUsers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION OrganizationUser_DeleteMany_OUs + + DELETE TOP(@BatchSize) OU + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @Ids I ON I.Id = OU.Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION OrganizationUser_DeleteMany_OUs + END +END +GO + +-- OrganizationUserOrganizationDetailsView update +ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView] +AS +SELECT + OU.[UserId], + OU.[OrganizationId], + O.[Name], + O.[Enabled], + O.[UsePolicies], + O.[UseSso], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[SelfHost], + O.[UsersGetPremium], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + OU.[Key], + OU.[ResetPasswordKey], + O.[PublicKey], + O.[PrivateKey], + OU.[Status], + OU.[Type], + SU.[ExternalId] SsoExternalId, + OU.[Permissions], + PO.[ProviderId], + P.[Name] ProviderName, + OS.[FriendlyName] FamilySponsorshipFriendlyName +FROM + [dbo].[OrganizationUser] OU +INNER JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] +LEFT JOIN + [dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId] +LEFT JOIN + [dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id] +LEFT JOIN + [dbo].[Provider] P ON P.[Id] = PO.[ProviderId] +LEFT JOIN + [dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserId] = OU.[Id] diff --git a/util/MySqlMigrations/Migrations/20211104164838_OrganizationSponsorship.Designer.cs b/util/MySqlMigrations/Migrations/20211108225243_OrganizationSponsorship.Designer.cs similarity index 99% rename from util/MySqlMigrations/Migrations/20211104164838_OrganizationSponsorship.Designer.cs rename to util/MySqlMigrations/Migrations/20211108225243_OrganizationSponsorship.Designer.cs index 39646fa67b..4631f9e776 100644 --- a/util/MySqlMigrations/Migrations/20211104164838_OrganizationSponsorship.Designer.cs +++ b/util/MySqlMigrations/Migrations/20211108225243_OrganizationSponsorship.Designer.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Bit.MySqlMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20211104164838_OrganizationSponsorship")] + [Migration("20211108225243_OrganizationSponsorship")] partial class OrganizationSponsorship { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -595,6 +595,10 @@ namespace Bit.MySqlMigrations.Migrations b.Property("CloudSponsor") .HasColumnType("tinyint(1)"); + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + b.Property("InstallationId") .HasColumnType("char(36)"); @@ -611,10 +615,10 @@ namespace Bit.MySqlMigrations.Migrations b.Property("SponsoredOrganizationId") .HasColumnType("char(36)"); - b.Property("SponsoringOrganizationId") + b.Property("SponsoringOrganizationId") .HasColumnType("char(36)"); - b.Property("SponsoringOrganizationUserId") + b.Property("SponsoringOrganizationUserId") .HasColumnType("char(36)"); b.Property("SponsorshipLapsedDate") @@ -1356,9 +1360,7 @@ namespace Bit.MySqlMigrations.Migrations b.HasOne("Bit.Core.Models.EntityFramework.Organization", "SponsoringOrganization") .WithMany() - .HasForeignKey("SponsoringOrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("SponsoringOrganizationId"); b.Navigation("Installation"); diff --git a/util/MySqlMigrations/Migrations/20211104164838_OrganizationSponsorship.cs b/util/MySqlMigrations/Migrations/20211108225243_OrganizationSponsorship.cs similarity index 91% rename from util/MySqlMigrations/Migrations/20211104164838_OrganizationSponsorship.cs rename to util/MySqlMigrations/Migrations/20211108225243_OrganizationSponsorship.cs index 0af0aca109..ae63c29587 100644 --- a/util/MySqlMigrations/Migrations/20211104164838_OrganizationSponsorship.cs +++ b/util/MySqlMigrations/Migrations/20211108225243_OrganizationSponsorship.cs @@ -20,9 +20,11 @@ namespace Bit.MySqlMigrations.Migrations { Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), InstallationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), - SponsoringOrganizationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), - SponsoringOrganizationUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + SponsoringOrganizationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + SponsoringOrganizationUserId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), SponsoredOrganizationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + FriendlyName = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), OfferedToEmail = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), PlanSponsorshipType = table.Column(type: "tinyint unsigned", nullable: true), @@ -51,7 +53,7 @@ namespace Bit.MySqlMigrations.Migrations column: x => x.SponsoringOrganizationId, principalTable: "Organization", principalColumn: "Id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Restrict); }) .Annotation("MySql:CharSet", "utf8mb4"); diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 06545d15f1..1a96cdd89f 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -593,6 +593,10 @@ namespace Bit.MySqlMigrations.Migrations b.Property("CloudSponsor") .HasColumnType("tinyint(1)"); + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + b.Property("InstallationId") .HasColumnType("char(36)"); @@ -609,10 +613,10 @@ namespace Bit.MySqlMigrations.Migrations b.Property("SponsoredOrganizationId") .HasColumnType("char(36)"); - b.Property("SponsoringOrganizationId") + b.Property("SponsoringOrganizationId") .HasColumnType("char(36)"); - b.Property("SponsoringOrganizationUserId") + b.Property("SponsoringOrganizationUserId") .HasColumnType("char(36)"); b.Property("SponsorshipLapsedDate") @@ -1354,9 +1358,7 @@ namespace Bit.MySqlMigrations.Migrations b.HasOne("Bit.Core.Models.EntityFramework.Organization", "SponsoringOrganization") .WithMany() - .HasForeignKey("SponsoringOrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("SponsoringOrganizationId"); b.Navigation("Installation"); diff --git a/util/MySqlMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.sql b/util/MySqlMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.sql index cb1f7c0ffd..5e442e48cd 100644 --- a/util/MySqlMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.sql +++ b/util/MySqlMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.sql @@ -5,9 +5,10 @@ ALTER TABLE `User` ADD `UsesCryptoAgent` tinyint(1) NOT NULL DEFAULT FALSE; CREATE TABLE `OrganizationSponsorship` ( `Id` char(36) COLLATE ascii_general_ci NOT NULL, `InstallationId` char(36) COLLATE ascii_general_ci NULL, - `SponsoringOrganizationId` char(36) COLLATE ascii_general_ci NOT NULL, - `SponsoringOrganizationUserId` char(36) COLLATE ascii_general_ci NOT NULL, + `SponsoringOrganizationId` char(36) COLLATE ascii_general_ci NULL, + `SponsoringOrganizationUserId` char(36) COLLATE ascii_general_ci NULL, `SponsoredOrganizationId` char(36) COLLATE ascii_general_ci NULL, + `FriendlyName` varchar(256) CHARACTER SET utf8mb4 NULL, `OfferedToEmail` varchar(256) CHARACTER SET utf8mb4 NULL, `PlanSponsorshipType` tinyint unsigned NULL, `CloudSponsor` tinyint(1) NOT NULL, @@ -17,7 +18,7 @@ CREATE TABLE `OrganizationSponsorship` ( CONSTRAINT `PK_OrganizationSponsorship` PRIMARY KEY (`Id`), CONSTRAINT `FK_OrganizationSponsorship_Installation_InstallationId` FOREIGN KEY (`InstallationId`) REFERENCES `Installation` (`Id`) ON DELETE RESTRICT, CONSTRAINT `FK_OrganizationSponsorship_Organization_SponsoredOrganizationId` FOREIGN KEY (`SponsoredOrganizationId`) REFERENCES `Organization` (`Id`) ON DELETE RESTRICT, - CONSTRAINT `FK_OrganizationSponsorship_Organization_SponsoringOrganizationId` FOREIGN KEY (`SponsoringOrganizationId`) REFERENCES `Organization` (`Id`) ON DELETE CASCADE + CONSTRAINT `FK_OrganizationSponsorship_Organization_SponsoringOrganizationId` FOREIGN KEY (`SponsoringOrganizationId`) REFERENCES `Organization` (`Id`) ON DELETE RESTRICT ) CHARACTER SET utf8mb4; CREATE INDEX `IX_OrganizationSponsorship_InstallationId` ON `OrganizationSponsorship` (`InstallationId`); @@ -27,6 +28,6 @@ CREATE INDEX `IX_OrganizationSponsorship_SponsoredOrganizationId` ON `Organizati CREATE INDEX `IX_OrganizationSponsorship_SponsoringOrganizationId` ON `OrganizationSponsorship` (`SponsoringOrganizationId`); INSERT INTO `__EFMigrationsHistory` (`MigrationId`, `ProductVersion`) -VALUES ('20211104164838_OrganizationSponsorship', '5.0.9'); +VALUES ('20211108225243_OrganizationSponsorship', '5.0.9'); COMMIT; diff --git a/util/PostgresMigrations/Migrations/20211104164532_OrganizationSponsorship.Designer.cs b/util/PostgresMigrations/Migrations/20211108225011_OrganizationSponsorship.Designer.cs similarity index 99% rename from util/PostgresMigrations/Migrations/20211104164532_OrganizationSponsorship.Designer.cs rename to util/PostgresMigrations/Migrations/20211108225011_OrganizationSponsorship.Designer.cs index d383ce3669..d512fb4022 100644 --- a/util/PostgresMigrations/Migrations/20211104164532_OrganizationSponsorship.Designer.cs +++ b/util/PostgresMigrations/Migrations/20211108225011_OrganizationSponsorship.Designer.cs @@ -10,7 +10,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Bit.PostgresMigrations.Migrations { [DbContext(typeof(DatabaseContext))] - [Migration("20211104164532_OrganizationSponsorship")] + [Migration("20211108225011_OrganizationSponsorship")] partial class OrganizationSponsorship { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -599,6 +599,10 @@ namespace Bit.PostgresMigrations.Migrations b.Property("CloudSponsor") .HasColumnType("boolean"); + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("InstallationId") .HasColumnType("uuid"); @@ -615,10 +619,10 @@ namespace Bit.PostgresMigrations.Migrations b.Property("SponsoredOrganizationId") .HasColumnType("uuid"); - b.Property("SponsoringOrganizationId") + b.Property("SponsoringOrganizationId") .HasColumnType("uuid"); - b.Property("SponsoringOrganizationUserId") + b.Property("SponsoringOrganizationUserId") .HasColumnType("uuid"); b.Property("SponsorshipLapsedDate") @@ -1365,9 +1369,7 @@ namespace Bit.PostgresMigrations.Migrations b.HasOne("Bit.Core.Models.EntityFramework.Organization", "SponsoringOrganization") .WithMany() - .HasForeignKey("SponsoringOrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("SponsoringOrganizationId"); b.Navigation("Installation"); diff --git a/util/PostgresMigrations/Migrations/20211104164532_OrganizationSponsorship.cs b/util/PostgresMigrations/Migrations/20211108225011_OrganizationSponsorship.cs similarity index 93% rename from util/PostgresMigrations/Migrations/20211104164532_OrganizationSponsorship.cs rename to util/PostgresMigrations/Migrations/20211108225011_OrganizationSponsorship.cs index 0de10d335d..162792a6eb 100644 --- a/util/PostgresMigrations/Migrations/20211104164532_OrganizationSponsorship.cs +++ b/util/PostgresMigrations/Migrations/20211108225011_OrganizationSponsorship.cs @@ -20,9 +20,10 @@ namespace Bit.PostgresMigrations.Migrations { Id = table.Column(type: "uuid", nullable: false), InstallationId = table.Column(type: "uuid", nullable: true), - SponsoringOrganizationId = table.Column(type: "uuid", nullable: false), - SponsoringOrganizationUserId = table.Column(type: "uuid", nullable: false), + SponsoringOrganizationId = table.Column(type: "uuid", nullable: true), + SponsoringOrganizationUserId = table.Column(type: "uuid", nullable: true), SponsoredOrganizationId = table.Column(type: "uuid", nullable: true), + FriendlyName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), OfferedToEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), PlanSponsorshipType = table.Column(type: "smallint", nullable: true), CloudSponsor = table.Column(type: "boolean", nullable: false), @@ -50,7 +51,7 @@ namespace Bit.PostgresMigrations.Migrations column: x => x.SponsoringOrganizationId, principalTable: "Organization", principalColumn: "Id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Restrict); }); migrationBuilder.CreateIndex( diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 920d128d6a..675c599ca6 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -597,6 +597,10 @@ namespace Bit.PostgresMigrations.Migrations b.Property("CloudSponsor") .HasColumnType("boolean"); + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("InstallationId") .HasColumnType("uuid"); @@ -613,10 +617,10 @@ namespace Bit.PostgresMigrations.Migrations b.Property("SponsoredOrganizationId") .HasColumnType("uuid"); - b.Property("SponsoringOrganizationId") + b.Property("SponsoringOrganizationId") .HasColumnType("uuid"); - b.Property("SponsoringOrganizationUserId") + b.Property("SponsoringOrganizationUserId") .HasColumnType("uuid"); b.Property("SponsorshipLapsedDate") @@ -1363,9 +1367,7 @@ namespace Bit.PostgresMigrations.Migrations b.HasOne("Bit.Core.Models.EntityFramework.Organization", "SponsoringOrganization") .WithMany() - .HasForeignKey("SponsoringOrganizationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("SponsoringOrganizationId"); b.Navigation("Installation"); diff --git a/util/PostgresMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.psql b/util/PostgresMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.psql index 870aff88a1..24d5eaa080 100644 --- a/util/PostgresMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.psql +++ b/util/PostgresMigrations/Scripts/2021-11-02_00_OrganizationSponsorship.psql @@ -5,9 +5,10 @@ ALTER TABLE "User" ADD "UsesCryptoAgent" boolean NOT NULL DEFAULT FALSE; CREATE TABLE "OrganizationSponsorship" ( "Id" uuid NOT NULL, "InstallationId" uuid NULL, - "SponsoringOrganizationId" uuid NOT NULL, - "SponsoringOrganizationUserId" uuid NOT NULL, + "SponsoringOrganizationId" uuid NULL, + "SponsoringOrganizationUserId" uuid NULL, "SponsoredOrganizationId" uuid NULL, + "FriendlyName" character varying(256) NULL, "OfferedToEmail" character varying(256) NULL, "PlanSponsorshipType" smallint NULL, "CloudSponsor" boolean NOT NULL, @@ -17,7 +18,7 @@ CREATE TABLE "OrganizationSponsorship" ( CONSTRAINT "PK_OrganizationSponsorship" PRIMARY KEY ("Id"), CONSTRAINT "FK_OrganizationSponsorship_Installation_InstallationId" FOREIGN KEY ("InstallationId") REFERENCES "Installation" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_OrganizationSponsorship_Organization_SponsoredOrganizationId" FOREIGN KEY ("SponsoredOrganizationId") REFERENCES "Organization" ("Id") ON DELETE RESTRICT, - CONSTRAINT "FK_OrganizationSponsorship_Organization_SponsoringOrganization~" FOREIGN KEY ("SponsoringOrganizationId") REFERENCES "Organization" ("Id") ON DELETE CASCADE + CONSTRAINT "FK_OrganizationSponsorship_Organization_SponsoringOrganization~" FOREIGN KEY ("SponsoringOrganizationId") REFERENCES "Organization" ("Id") ON DELETE RESTRICT ); CREATE INDEX "IX_OrganizationSponsorship_InstallationId" ON "OrganizationSponsorship" ("InstallationId"); @@ -27,6 +28,6 @@ CREATE INDEX "IX_OrganizationSponsorship_SponsoredOrganizationId" ON "Organizati CREATE INDEX "IX_OrganizationSponsorship_SponsoringOrganizationId" ON "OrganizationSponsorship" ("SponsoringOrganizationId"); INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") -VALUES ('20211104164532_OrganizationSponsorship', '5.0.9'); +VALUES ('20211108225011_OrganizationSponsorship', '5.0.9'); COMMIT;