diff --git a/bitwarden_license/src/CommCore/Services/ProviderService.cs b/bitwarden_license/src/CommCore/Services/ProviderService.cs index 62ecae6dce..9bc513ece3 100644 --- a/bitwarden_license/src/CommCore/Services/ProviderService.cs +++ b/bitwarden_license/src/CommCore/Services/ProviderService.cs @@ -104,7 +104,7 @@ namespace Bit.CommCore.Services } var providerUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, ownerUserId); - if (!(providerUser is {Type: ProviderUserType.ProviderAdmin})) + if (!(providerUser is { Type: ProviderUserType.ProviderAdmin })) { throw new BadRequestException("Invalid owner."); } @@ -211,7 +211,7 @@ namespace Bit.CommCore.Services { throw new BadRequestException("User invalid."); } - + if (providerUser.Status != ProviderUserStatusType.Invited) { throw new BadRequestException("Already accepted."); @@ -228,7 +228,7 @@ namespace Bit.CommCore.Services { throw new BadRequestException("User email does not match invite."); } - + providerUser.Status = ProviderUserStatusType.Accepted; providerUser.UserId = user.Id; providerUser.Email = null; @@ -252,7 +252,7 @@ namespace Bit.CommCore.Services } var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList(); - + var provider = await _providerRepository.GetByIdAsync(providerId); var users = await _userRepository.GetManyAsync(validOrganizationUserIds); @@ -274,7 +274,7 @@ namespace Bit.CommCore.Services { throw new BadRequestException("Invalid user."); } - + providerUser.Status = ProviderUserStatusType.Confirmed; providerUser.Key = keys[providerUser.Id]; providerUser.Email = null; @@ -303,7 +303,7 @@ namespace Bit.CommCore.Services } if (user.Type != ProviderUserType.ProviderAdmin && - !await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] {user.Id})) + !await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] { user.Id })) { throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin."); } @@ -413,14 +413,21 @@ namespace Bit.CommCore.Services await _providerOrganizationRepository.CreateAsync(providerOrganization); await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); - await _organizationService.InviteUserAsync(organization.Id, user.Id, null, new OrganizationUserInvite - { - Emails = new[] { clientOwnerEmail }, - AccessAll = true, - Type = OrganizationUserType.Owner, - Permissions = null, - Collections = Array.Empty<SelectionReadOnly>(), - }); + await _organizationService.InviteUsersAsync(organization.Id, user.Id, + new (OrganizationUserInvite, string)[] + { + ( + new OrganizationUserInvite + { + Emails = new[] { clientOwnerEmail }, + AccessAll = true, + Type = OrganizationUserType.Owner, + Permissions = null, + Collections = Array.Empty<SelectionReadOnly>(), + }, + null + ) + }); return providerOrganization; } diff --git a/bitwarden_license/test/CmmCore.Test/Services/ProviderServiceTests.cs b/bitwarden_license/test/CmmCore.Test/Services/ProviderServiceTests.cs index c52fe31b17..1e639ea88a 100644 --- a/bitwarden_license/test/CmmCore.Test/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/CmmCore.Test/Services/ProviderServiceTests.cs @@ -470,12 +470,13 @@ namespace Bit.CommCore.Test.Services .Received().LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); await sutProvider.GetDependency<IOrganizationService>() - .Received().InviteUserAsync(organization.Id, user.Id, null, - Arg.Is<OrganizationUserInvite>( - i => i.Emails.Count() == 1 && - i.Emails.First() == clientOwnerEmail && - i.Type == OrganizationUserType.Owner && - i.AccessAll)); + .Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>( + t => t.Count() == 1 && + t.First().Item1.Emails.Count() == 1 && + t.First().Item1.Emails.First() == clientOwnerEmail && + t.First().Item1.Type == OrganizationUserType.Owner && + t.First().Item1.AccessAll && + t.First().Item2 == null)); } [Theory, CustomAutoData(typeof(SutProviderCustomization))] diff --git a/src/Admin/Models/OrganizationEditModel.cs b/src/Admin/Models/OrganizationEditModel.cs index 6b6f65cc99..30b6f897d2 100644 --- a/src/Admin/Models/OrganizationEditModel.cs +++ b/src/Admin/Models/OrganizationEditModel.cs @@ -28,6 +28,7 @@ namespace Bit.Admin.Models PlanType = org.PlanType; Plan = org.Plan; Seats = org.Seats; + MaxAutoscaleSeats = org.MaxAutoscaleSeats; MaxCollections = org.MaxCollections; UsePolicies = org.UsePolicies; UseSso = org.UseSso; @@ -69,6 +70,8 @@ namespace Bit.Admin.Models public string Plan { get; set; } [Display(Name = "Seats")] public int? Seats { get; set; } + [Display(Name = "Max. Autoscale Seats")] + public int? MaxAutoscaleSeats { get; set; } [Display(Name = "Max. Collections")] public short? MaxCollections { get; set; } [Display(Name = "Policies")] @@ -136,6 +139,7 @@ namespace Bit.Admin.Models existingOrganization.Enabled = Enabled; existingOrganization.LicenseKey = LicenseKey; existingOrganization.ExpirationDate = ExpirationDate; + existingOrganization.MaxAutoscaleSeats = MaxAutoscaleSeats; return existingOrganization; } } diff --git a/src/Admin/Views/Organizations/Edit.cshtml b/src/Admin/Views/Organizations/Edit.cshtml index d63a6cb43d..c15d432b9c 100644 --- a/src/Admin/Views/Organizations/Edit.cshtml +++ b/src/Admin/Views/Organizations/Edit.cshtml @@ -145,6 +145,14 @@ </div> </div> </div> + <div class="row"> + <div class="col-4"> + <div class="form-group"> + <label asp-for="MaxAutoscaleSeats"></label> + <input type="number" class="form-control" asp-for="MaxAutoscaleSeats" min="1"> + </div> + </div> + </div> <h2>Features</h2> <div class="form-check"> <input type="checkbox" class="form-check-input" asp-for="UseTotp"> diff --git a/src/Api/Controllers/OrganizationUsersController.cs b/src/Api/Controllers/OrganizationUsersController.cs index 72de94880b..7dcaec66c6 100644 --- a/src/Api/Controllers/OrganizationUsersController.cs +++ b/src/Api/Controllers/OrganizationUsersController.cs @@ -134,7 +134,8 @@ namespace Bit.Api.Controllers } var userId = _userService.GetProperUserId(User); - var result = await _organizationService.InviteUserAsync(orgGuidId, userId.Value, null, new OrganizationUserInvite(model)); + var result = await _organizationService.InviteUsersAsync(orgGuidId, userId.Value, + new (OrganizationUserInvite, string)[] { (new OrganizationUserInvite(model), null) }); } [HttpPost("reinvite")] diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 5f555735ba..0dba5cd837 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -281,6 +281,19 @@ namespace Bit.Api.Controllers }; } + [HttpPost("{id}/subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) + { + var orgIdGuid = new Guid(id); + if (!await _currentContext.OrganizationOwner(orgIdGuid)) + { + throw new NotFoundException(); + } + + await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats); + } + [HttpPost("{id}/seat")] [SelfHosted(NotSelfHostedOnly = true)] public async Task<PaymentResponseModel> PostSeat(string id, [FromBody]OrganizationSeatRequestModel model) diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs new file mode 100644 index 0000000000..739ed07cd3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs @@ -0,0 +1,45 @@ +{{#>FullHtmlLayout}} +<table width="100%" cellpadding="0" cellspacing="0" + style="margin: 0; box-sizing: border-box;"> + <tr + style="margin: 0; box-sizing: border-box;"> + <td class="content-block" + style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" + valign="top"> + To accommodate new user invitations, your seat count has increased from {{InitialSeatCount}} to {{CurrentSeatCount}}. A + prorated charge has been immediately applied to your subscription for the new users. This notification will only be sent + once. + </td> + </tr> + <tr + style="margin: 0; box-sizing: border-box;"> + <td class="content-block" + style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" + valign="top"> + To manage your subscription: + <ol> + <li>Log in to your + <a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/settings/subscription"> + Web Vault + </a> + and open your Organization. + </li> + <li>Open the <b>Settings</b> tab and select <b>Subscription</b> from the left-hand menu.</li> + <li>Update your subscription.</li> + <li>Click <b>Save</b>.</li> + </ol> + </td> + </tr> + <tr + style="margin: 0; box-sizing: border-box;"> + <td class="content-block last" + style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" + valign="top"> + For more information, please refer to + <a href="https://bitwarden.com/help/article/managing-users/#subscription"> + our help article. + </a> + </td> + </tr> +</table> +{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.text.hbs new file mode 100644 index 0000000000..426776c613 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.text.hbs @@ -0,0 +1,13 @@ +{{#>BasicTextLayout}} +To accommodate new user invitations, your seat count has increased from {{InitialSeatCount}} to {{CurrentSeatCount}}. A +prorated charge has been immediately applied to your subscription for the new users. This notification will only be sent +once. + +To manage your subscription: +1. Log in to your Web Vault and open your Organization. +2. Open the Settings tab and select Subscription from the left-hand menu. +4. Update your subscription. +5. Click Save. + +For more information, please refer to the following help article: https://bitwarden.com/help/article/managing-user/#subscription +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs new file mode 100644 index 0000000000..1922770ecb --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs @@ -0,0 +1,38 @@ +{{#>FullHtmlLayout}} +<table width="100%" cellpadding="0" cellspacing="0" + style="margin: 0; box-sizing: border-box; "> + <tr + style="margin: 0; box-sizing: border-box; "> + <td class="content-block" + style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" + valign="top"> + Your organization has reached the seat limit of {{MaxSeatCount}} and new users cannot be invited. + </td> + </tr> + <tr + style="margin: 0; box-sizing: border-box; "> + <td class="content-block" + style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" + valign="top"> + To increase your subscription: + <ol> + <li>Log in to your <a + href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/settings/subscription">Web + Vault</a> and open your Organization.</li> + <li>Open the <b>Settings</b> tab and select <b>Subscription</b> from the left-hand menu.</li> + <li>Update your subscription.</li> + <li>Click <b>Save</b>.</li> + </ol> + </td> + </tr> + <tr + style="margin: 0; box-sizing: border-box; "> + <td class="content-block last" + style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" + valign="top"> + For more information, please refer to the following help article: <a + href="https://bitwarden.com/help/article/managing-users/#subscription">https://bitwarden.com/help/article/managing-users/#subscription</a> + </td> + </tr> +</table> +{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.text.hbs new file mode 100644 index 0000000000..7c02784bfa --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.text.hbs @@ -0,0 +1,11 @@ +{{#>BasicTextLayout}} +Your organization has reached the seat limit of {{MaxSeatCount}} and new users cannot be invited. + +To increase your subscription: +1. Log in to your Web Vault and open your Organization. +2. Open the Settings tab and select Subscription from the left-hand menu. +4. Update your subscription. +5. Click Save. + +For more information, please refer to the following help article: https://bitwarden.com/help/article/managing-user/#subscription +{{/BasicTextLayout}} diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs index 3d9617f1f4..13517ef7ae 100644 --- a/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Core/Models/Api/Request/Organizations/OrganizationCreateRequestModel.cs @@ -40,6 +40,7 @@ namespace Bit.Core.Models.Api public string BillingAddressPostalCode { get; set; } [StringLength(2)] public string BillingAddressCountry { get; set; } + public int? MaxAutoscaleSeats { get; set; } public virtual OrganizationSignup ToOrganizationSignup(User user) { @@ -52,6 +53,7 @@ namespace Bit.Core.Models.Api PaymentMethodType = PaymentMethodType, PaymentToken = PaymentToken, AdditionalSeats = AdditionalSeats, + MaxAutoscaleSeats = MaxAutoscaleSeats, AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0), PremiumAccessAddon = PremiumAccessAddon, BillingEmail = BillingEmail, diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationSubscriptionUpdateRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationSubscriptionUpdateRequestModel.cs new file mode 100644 index 0000000000..3a22a67445 --- /dev/null +++ b/src/Core/Models/Api/Request/Organizations/OrganizationSubscriptionUpdateRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class OrganizationSubscriptionUpdateRequestModel + { + [Required] + public int SeatAdjustment { get; set; } + public int? MaxAutoscaleSeats { get; set; } + } +} diff --git a/src/Core/Models/Api/Response/OrganizationResponseModel.cs b/src/Core/Models/Api/Response/OrganizationResponseModel.cs index 2332075ed5..7454a37d4a 100644 --- a/src/Core/Models/Api/Response/OrganizationResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationResponseModel.cs @@ -30,6 +30,7 @@ namespace Bit.Core.Models.Api Plan = new PlanResponseModel(Utilities.StaticStore.Plans.FirstOrDefault(plan => plan.Type == organization.PlanType)); PlanType = organization.PlanType; Seats = organization.Seats; + MaxAutoscaleSeats = organization.MaxAutoscaleSeats; MaxCollections = organization.MaxCollections; MaxStorageGb = organization.MaxStorageGb; UsePolicies = organization.UsePolicies; @@ -59,6 +60,7 @@ namespace Bit.Core.Models.Api public PlanResponseModel Plan { get; set; } public PlanType PlanType { get; set; } public int? Seats { get; set; } + public int? MaxAutoscaleSeats { get; set; } = null; public short? MaxCollections { get; set; } public short? MaxStorageGb { get; set; } public bool UsePolicies { get; set; } diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index 29381098bc..cd68527e3d 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -177,7 +177,7 @@ namespace Bit.Core.Models.Business } } - public bool CanUse(GlobalSettings globalSettings) + public bool CanUse(IGlobalSettings globalSettings) { if (!Enabled || Issued > DateTime.UtcNow || Expires < DateTime.UtcNow) { diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index 5e00c1f24f..1e09fc3232 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -12,5 +12,6 @@ namespace Bit.Core.Models.Business public string CollectionName { get; set; } public PaymentMethodType? PaymentMethodType { get; set; } public string PaymentToken { get; set; } + public int? MaxAutoscaleSeats { get; set; } = null; } } diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 85a1b69459..f4aaedf8b7 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -1,7 +1,6 @@ using System.Linq; using Bit.Core.Models.Table; using Stripe; -using StaticStore = Bit.Core.Models.StaticStore; namespace Bit.Core.Models.Business { @@ -11,6 +10,10 @@ namespace Bit.Core.Models.Business public abstract SubscriptionItemOptions RevertItemOptions(Subscription subscription); public abstract SubscriptionItemOptions UpgradeItemOptions(Subscription subscription); + + public bool UpdateNeeded(Subscription subscription) => + (SubscriptionItem(subscription)?.Quantity ?? 0) != (UpgradeItemOptions(subscription).Quantity ?? 0); + protected SubscriptionItem SubscriptionItem(Subscription subscription) => subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == PlanId); } diff --git a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs new file mode 100644 index 0000000000..8a2f30ee0e --- /dev/null +++ b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs @@ -0,0 +1,11 @@ +using System; + +namespace Bit.Core.Models.Mail +{ + public class OrganizationSeatsAutoscaledViewModel : BaseMailModel + { + public Guid OrganizationId { get; set; } + public int InitialSeatCount { get; set; } + public int CurrentSeatCount { get; set; } + } +} diff --git a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs new file mode 100644 index 0000000000..18ea3b0588 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs @@ -0,0 +1,10 @@ +using System; + +namespace Bit.Core.Models.Mail +{ + public class OrganizationSeatsMaxReachedViewModel : BaseMailModel + { + public Guid OrganizationId { get; set; } + public int MaxSeatCount { get; set; } + } +} diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index 768f596dc6..b03e35d4cd 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -15,6 +15,7 @@ namespace Bit.Core.Models.StaticStore public short? BaseStorageGb { get; set; } public short? MaxCollections { get; set; } public short? MaxUsers { get; set; } + public bool AllowSeatAutoscale { get; set; } public bool HasAdditionalSeatsOption { get; set; } public int? MaxAdditionalSeats { get; set; } diff --git a/src/Core/Models/Table/Organization.cs b/src/Core/Models/Table/Organization.cs index 4a23bcd163..de65fbc83b 100644 --- a/src/Core/Models/Table/Organization.cs +++ b/src/Core/Models/Table/Organization.cs @@ -66,6 +66,8 @@ namespace Bit.Core.Models.Table public DateTime? ExpirationDate { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + public int? MaxAutoscaleSeats { get; set; } = null; + public DateTime? OwnersNotifiedOfAutoscaling { get; set; } = null; public void SetNewId() { diff --git a/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs b/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs index eca35b6b96..17ea260b46 100644 --- a/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs +++ b/src/Core/Repositories/EntityFramework/OrganizationUserRepository.cs @@ -20,7 +20,7 @@ namespace Bit.Core.Repositories.EntityFramework : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationUsers) { } - public async Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections) + public async Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections) { var organizationUser = await base.CreateAsync(obj); using (var scope = ServiceScopeFactory.CreateScope()) @@ -41,13 +41,15 @@ namespace Bit.Core.Repositories.EntityFramework await dbContext.CollectionUsers.AddRangeAsync(collectionUsers); await dbContext.SaveChangesAsync(); } + + return organizationUser.Id; } - public async Task CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers) + public async Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers) { if (!organizationUsers.Any()) { - return; + return new List<Guid>(); } foreach (var organizationUser in organizationUsers) @@ -61,6 +63,8 @@ namespace Bit.Core.Repositories.EntityFramework var entities = Mapper.Map<List<EfModel.OrganizationUser>>(organizationUsers); await dbContext.AddRangeAsync(entities); } + + return organizationUsers.Select(u => u.Id).ToList(); } public async Task DeleteManyAsync(IEnumerable<Guid> organizationUserIds) diff --git a/src/Core/Repositories/IOrganizationUserRepository.cs b/src/Core/Repositories/IOrganizationUserRepository.cs index 2d00ca45cd..2c12d97124 100644 --- a/src/Core/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/Repositories/IOrganizationUserRepository.cs @@ -28,8 +28,8 @@ namespace Bit.Core.Repositories OrganizationUserStatusType? status = null); Task UpdateGroupsAsync(Guid orgUserId, IEnumerable<Guid> groupIds); Task UpsertManyAsync(IEnumerable<OrganizationUser> organizationUsers); - Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections); - Task CreateManyAsync(IEnumerable<OrganizationUser> organizationIdUsers); + Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections); + Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationIdUsers); Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections); Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers); Task<ICollection<OrganizationUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds); diff --git a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs index dee1eeb218..c0a3632be3 100644 --- a/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs +++ b/src/Core/Repositories/SqlServer/OrganizationUserRepository.cs @@ -240,7 +240,7 @@ namespace Bit.Core.Repositories.SqlServer } } - public async Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections) + public async Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections) { obj.SetNewId(); var objWithCollections = JsonConvert.DeserializeObject<OrganizationUserWithCollections>( @@ -254,6 +254,8 @@ namespace Bit.Core.Repositories.SqlServer objWithCollections, commandType: CommandType.StoredProcedure); } + + return obj.Id; } public async Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections) @@ -339,11 +341,11 @@ namespace Bit.Core.Repositories.SqlServer await ReplaceManyAsync(replaceUsers); } - public async Task CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers) + public async Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers) { if (!organizationUsers.Any()) { - return; + return default; } foreach(var organizationUser in organizationUsers) @@ -359,6 +361,8 @@ namespace Bit.Core.Repositories.SqlServer new { OrganizationUsersInput = orgUsersTVP }, commandType: CommandType.StoredProcedure); } + + return organizationUsers.Select(u => u.Id).ToList(); } public async Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers) diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 970d1e07e7..8abf5a0d51 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -20,6 +20,8 @@ namespace Bit.Core.Services Task SendMasterPasswordHintEmailAsync(string email, string hint); Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token); Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites); + Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails); + Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails); Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails); Task SendOrganizationConfirmedEmailAsync(string organizationName, string email); Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); diff --git a/src/Core/Services/IOrganizationService.cs b/src/Core/Services/IOrganizationService.cs index cbee1efdc3..de11854567 100644 --- a/src/Core/Services/IOrganizationService.cs +++ b/src/Core/Services/IOrganizationService.cs @@ -17,7 +17,8 @@ namespace Bit.Core.Services Task ReinstateSubscriptionAsync(Guid organizationId); Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade); Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); - Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment); + Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); + Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment, DateTime? prorationDate = null); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false); Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationLicense license, User owner, @@ -31,9 +32,10 @@ namespace Bit.Core.Services Task UpdateAsync(Organization organization, bool updateBilling = false); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); + Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, + IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email, OrganizationUserType type, bool accessAll, string externalId, IEnumerable<SelectionReadOnly> collections); - Task<List<OrganizationUser>> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string externalId, OrganizationUserInvite orgUserInvite); Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId); Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token, diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index e83d18062a..8e981f386e 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -144,6 +144,35 @@ namespace Bit.Core.Services await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails) + { + var message = CreateDefaultMessage($"{organization.Name} Seat Count Has Increased", ownerEmails); + var model = new OrganizationSeatsAutoscaledViewModel + { + OrganizationId = organization.Id, + InitialSeatCount = initialSeatCount, + CurrentSeatCount = organization.Seats.Value, + }; + + await AddMessageContentAsync(message, "OrganizationSeatsAutoscaled", model); + message.Category = "OrganizationSeatsAutoscaled"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails) + { + var message = CreateDefaultMessage($"{organization.Name} Seat Limit Reached", ownerEmails); + var model = new OrganizationSeatsMaxReachedViewModel + { + OrganizationId = organization.Id, + MaxSeatCount = maxSeatCount, + }; + + await AddMessageContentAsync(message, "OrganizationSeatsMaxReached", model); + message.Category = "OrganizationSeatsMaxReached"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails) { diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 358f255edc..256a94b336 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -16,6 +16,7 @@ using System.IO; using Newtonsoft.Json; using System.Text.Json; using Bit.Core.Context; +using Microsoft.Extensions.Logging; namespace Bit.Core.Services { @@ -40,9 +41,11 @@ namespace Bit.Core.Services private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoUserRepository _ssoUserRepository; private readonly IReferenceEventService _referenceEventService; - private readonly GlobalSettings _globalSettings; + private readonly IGlobalSettings _globalSettings; private readonly ITaxRateRepository _taxRateRepository; private readonly ICurrentContext _currentContext; + private readonly ILogger<OrganizationService> _logger; + public OrganizationService( IOrganizationRepository organizationRepository, @@ -64,9 +67,10 @@ namespace Bit.Core.Services ISsoConfigRepository ssoConfigRepository, ISsoUserRepository ssoUserRepository, IReferenceEventService referenceEventService, - GlobalSettings globalSettings, + IGlobalSettings globalSettings, ITaxRateRepository taxRateRepository, - ICurrentContext currentContext) + ICurrentContext currentContext, + ILogger<OrganizationService> logger) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -90,6 +94,7 @@ namespace Bit.Core.Services _globalSettings = globalSettings; _taxRateRepository = taxRateRepository; _currentContext = currentContext; + _logger = logger; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -241,14 +246,14 @@ namespace Bit.Core.Services $"Disable your SSO configuration."); } } - + if (!newPlan.HasResetPassword && organization.UseResetPassword) { var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) { - throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + + throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + "Disable your Password Reset policy."); } } @@ -347,7 +352,7 @@ namespace Bit.Core.Services return secret; } - public async Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment) + public async Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats) { var organization = await GetOrgById(organizationId); if (organization == null) @@ -355,6 +360,69 @@ namespace Bit.Core.Services throw new NotFoundException(); } + var newSeatCount = organization.Seats + seatAdjustment; + if (maxAutoscaleSeats.HasValue && newSeatCount > maxAutoscaleSeats.Value) + { + throw new BadRequestException("Cannot set max seat autoscaling below seat count."); + } + + if (seatAdjustment != 0) + { + await AdjustSeatsAsync(organization, seatAdjustment); + } + if (maxAutoscaleSeats != organization.MaxAutoscaleSeats) + { + await UpdateAutoscalingAsync(organization, maxAutoscaleSeats); + } + } + + private async Task UpdateAutoscalingAsync(Organization organization, int? maxAutoscaleSeats) + { + + if (maxAutoscaleSeats.HasValue && + organization.Seats.HasValue && + maxAutoscaleSeats.Value < organization.Seats.Value) + { + throw new BadRequestException($"Cannot set max seat autoscaling below current seat count."); + } + + var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); + if (plan == null) + { + throw new BadRequestException("Existing plan not found."); + } + + if (!plan.AllowSeatAutoscale) + { + throw new BadRequestException("Your plan does not allow seat autoscaling."); + } + + if (plan.MaxUsers.HasValue && maxAutoscaleSeats.HasValue && + maxAutoscaleSeats > plan.MaxUsers) + { + throw new BadRequestException(string.Concat($"Your plan has a seat limit of {plan.MaxUsers}, ", + $"but you have specified a max autoscale count of {maxAutoscaleSeats}.", + "Reduce your max autoscale seat count.")); + } + + organization.MaxAutoscaleSeats = maxAutoscaleSeats; + + await ReplaceAndUpdateCache(organization); + } + + public async Task<string> AdjustSeatsAsync(Guid organizationId, int seatAdjustment, DateTime? prorationDate = null) + { + var organization = await GetOrgById(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + return await AdjustSeatsAsync(organization, seatAdjustment, prorationDate); + } + + private async Task<string> AdjustSeatsAsync(Organization organization, int seatAdjustment, DateTime? prorationDate = null, IEnumerable<string> ownerEmails = null) + { if (organization.Seats == null) { throw new BadRequestException("Organization has no seat limit, no need to adjust seats"); @@ -420,6 +488,24 @@ namespace Bit.Core.Services }); organization.Seats = (short?)newSeatTotal; await ReplaceAndUpdateCache(organization); + + if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && organization.Seats == organization.MaxAutoscaleSeats) + { + try + { + if (ownerEmails == null) + { + ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, + OrganizationUserType.Owner)).Select(u => u.Email).Distinct(); + } + await _mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSeats.Value, ownerEmails); + } + catch (Exception e) + { + _logger.LogError(e, "Error encountered notifying organization owners of seat limit reached."); + } + } + return paymentIntentClientSecret; } @@ -470,7 +556,7 @@ namespace Bit.Core.Services bool provider = false) { var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan); - if (!(plan is {LegacyYear: null})) + if (!(plan is { LegacyYear: null })) { throw new BadRequestException("Invalid plan selected."); } @@ -558,7 +644,7 @@ namespace Bit.Core.Services var orgsWithSingleOrgPolicy = policies.Where(p => p.Enabled && p.Type == PolicyType.SingleOrg) .Select(p => p.OrganizationId); - var blockedBySingleOrgPolicy = orgUsers.Any(ou => ou is {Type: OrganizationUserType.Owner} && + var blockedBySingleOrgPolicy = orgUsers.Any(ou => ou is { Type: OrganizationUserType.Owner } && ou.Type != OrganizationUserType.Admin && ou.Status != OrganizationUserStatusType.Invited && orgsWithSingleOrgPolicy.Contains(ou.OrganizationId)); @@ -786,14 +872,14 @@ namespace Bit.Core.Services $"Your new license does not allow for the use of SSO. Disable your SSO configuration."); } } - + if (!license.UseResetPassword && organization.UseResetPassword) { var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) { - throw new BadRequestException("Your new license does not allow the Password Reset feature. " + throw new BadRequestException("Your new license does not allow the Password Reset feature. " + "Disable your Password Reset policy."); } } @@ -964,11 +1050,12 @@ namespace Bit.Core.Services await UpdateAsync(organization); } - private async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, + public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) { var organization = await GetOrgById(organizationId); - if (organization == null || invites.Any(i => i.invite.Emails == null || i.externalId == null)) + var initialSeatCount = organization.Seats; + if (organization == null || invites.Any(i => i.invite.Emails == null)) { throw new NotFoundException(); } @@ -983,23 +1070,34 @@ namespace Bit.Core.Services } } + var newSeatsRequired = 0; + var existingEmails = new HashSet<string>(await _organizationUserRepository.SelectKnownEmailsAsync( + organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); if (organization.Seats.HasValue) { var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId); var availableSeats = organization.Seats.Value - userCount; - if (availableSeats < invites.Select(i => i.invite.Emails.Count()).Sum()) - { - throw new BadRequestException("You have reached the maximum number of users " + - $"({organization.Seats.Value}) for this organization."); - } + newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats; } + var (canScale, failureReason) = await CanScaleAsync(organization, newSeatsRequired); + if (!canScale) + { + throw new BadRequestException(failureReason); + } + + var invitedAreAllOwners = invites.All(i => i.invite.Type == OrganizationUserType.Owner); + if (!invitedAreAllOwners && !await HasConfirmedOwnersExceptAsync(organizationId, new Guid[] { })) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + var orgUsers = new List<OrganizationUser>(); + var limitedCollectionOrgUsers = new List<(OrganizationUser, IEnumerable<SelectionReadOnly>)>(); var orgUserInvitedCount = 0; var exceptions = new List<Exception>(); var events = new List<(OrganizationUser, EventType, DateTime?)>(); - var existingEmails = new HashSet<string>(await _organizationUserRepository.SelectKnownEmailsAsync( - organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); foreach (var (invite, externalId) in invites) { foreach (var email in invite.Emails) @@ -1036,11 +1134,14 @@ namespace Bit.Core.Services if (!orgUser.AccessAll && invite.Collections.Any()) { - throw new Exception("Bulk invite does not support limited collection invites"); + limitedCollectionOrgUsers.Add((orgUser, invite.Collections)); + } + else + { + orgUsers.Add(orgUser); } events.Add((orgUser, EventType.OrganizationUser_Invited, DateTime.UtcNow)); - orgUsers.Add(orgUser); orgUserInvitedCount++; } catch (Exception e) @@ -1050,9 +1151,21 @@ namespace Bit.Core.Services } } + if (exceptions.Any()) + { + throw new AggregateException("One or more errors occurred while inviting users.", exceptions); + } + + var prorationDate = DateTime.UtcNow; try { await _organizationUserRepository.CreateManyAsync(orgUsers); + foreach (var (orgUser, collections) in limitedCollectionOrgUsers) + { + await _organizationUserRepository.CreateAsync(orgUser, collections); + } + + await AutoAddSeatsAsync(organization, newSeatsRequired, prorationDate); await SendInvitesAsync(orgUsers, organization); await _eventService.LogOrganizationUserEventsAsync(events); @@ -1064,6 +1177,16 @@ namespace Bit.Core.Services } catch (Exception e) { + // Revert any added users. + var invitedOrgUserIds = orgUsers.Select(u => u.Id).Concat(limitedCollectionOrgUsers.Select(u => u.Item1.Id)); + await _organizationUserRepository.DeleteManyAsync(invitedOrgUserIds); + var currentSeatCount = (await _organizationRepository.GetByIdAsync(organization.Id)).Seats; + + if (initialSeatCount.HasValue && currentSeatCount.HasValue && currentSeatCount.Value != initialSeatCount.Value) + { + await AdjustSeatsAsync(organization, initialSeatCount.Value - currentSeatCount.Value, prorationDate); + } + exceptions.Add(e); } @@ -1075,94 +1198,6 @@ namespace Bit.Core.Services return orgUsers; } - public async Task<List<OrganizationUser>> InviteUserAsync(Guid organizationId, Guid? invitingUserId, - string externalId, OrganizationUserInvite invite) - { - var organization = await GetOrgById(organizationId); - if (organization == null || invite?.Emails == null) - { - throw new NotFoundException(); - } - - if (invitingUserId.HasValue && invite.Type.HasValue) - { - await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null); - } - - if (organization.Seats.HasValue) - { - var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId); - var availableSeats = organization.Seats.Value - userCount; - if (availableSeats < invite.Emails.Count()) - { - throw new BadRequestException("You have reached the maximum number of users " + - $"({organization.Seats.Value}) for this organization."); - } - } - - var invitedIsOwner = invite.Type is OrganizationUserType.Owner; - if (!invitedIsOwner && !await HasConfirmedOwnersExceptAsync(organizationId, new Guid[] {})) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var orgUsers = new List<OrganizationUser>(); - var orgUserInvitedCount = 0; - foreach (var email in invite.Emails) - { - // Make sure user is not already invited - var existingOrgUserCount = await _organizationUserRepository.GetCountByOrganizationAsync( - organizationId, email, false); - if (existingOrgUserCount > 0) - { - continue; - } - - var orgUser = new OrganizationUser - { - OrganizationId = organizationId, - UserId = null, - Email = email.ToLowerInvariant(), - Key = null, - Type = invite.Type.Value, - Status = OrganizationUserStatusType.Invited, - AccessAll = invite.AccessAll, - ExternalId = externalId, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - }; - - if (invite.Permissions != null) - { - orgUser.Permissions = System.Text.Json.JsonSerializer.Serialize(invite.Permissions, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }); - } - - if (!orgUser.AccessAll && invite.Collections.Any()) - { - await _organizationUserRepository.CreateAsync(orgUser, invite.Collections); - } - else - { - await _organizationUserRepository.CreateAsync(orgUser); - } - - await SendInviteAsync(orgUser, organization); - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Invited); - orgUsers.Add(orgUser); - orgUserInvitedCount++; - } - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization) - { - Users = orgUserInvitedCount - }); - - return orgUsers; - } - public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId) { @@ -1349,7 +1384,7 @@ namespace Bit.Core.Services public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, IUserService userService) { - var result = await ConfirmUsersAsync(organizationId, new Dictionary<Guid, string>() {{organizationUserId, key}}, + var result = await ConfirmUsersAsync(organizationId, new Dictionary<Guid, string>() { { organizationUserId, key } }, confirmingUserId, userService); if (!result.Any()) @@ -1364,7 +1399,7 @@ namespace Bit.Core.Services } return orgUser; } - + public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys, Guid confirmingUserId, IUserService userService) { @@ -1379,7 +1414,7 @@ namespace Bit.Core.Services } var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList(); - + var organization = await GetOrgById(organizationId); var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId); var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds); @@ -1417,7 +1452,7 @@ namespace Bit.Core.Services orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Key = keys[orgUser.Id]; orgUser.Email = null; - + await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); await _mailService.SendOrganizationConfirmedEmailAsync(organization.Name, user.Email); await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); @@ -1435,6 +1470,68 @@ namespace Bit.Core.Services return result; } + internal async Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd) + { + var failureReason = ""; + if (_globalSettings.SelfHosted) + { + failureReason = "Cannot autoscale on self-hosted instance."; + return (false, failureReason); + } + + if (!await _currentContext.ManageUsers(organization.Id)) + { + failureReason = "Cannot manage organization users."; + return (false, failureReason); + } + // if (!await _currentContext.OrganizationOwner(organization.Id)) + // { + // failureReason = "Only organization owners can autoscale seats."; + // return (false, failureReason); + // } + + if (seatsToAdd < 1) + { + return (true, failureReason); + } + + if (organization.Seats.HasValue && + organization.MaxAutoscaleSeats.HasValue && + organization.MaxAutoscaleSeats.Value < organization.Seats.Value + seatsToAdd) + { + return (false, $"Cannot invite new users. Seat limit has been reached."); + } + + return (true, failureReason); + } + + private async Task AutoAddSeatsAsync(Organization organization, int seatsToAdd, DateTime? prorationDate = null) + { + if (seatsToAdd < 1 || !organization.Seats.HasValue) + { + return; + } + + var (canScale, failureMessage) = await CanScaleAsync(organization, seatsToAdd); + if (!canScale) + { + throw new BadRequestException(failureMessage); + } + + var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, + OrganizationUserType.Owner)).Select(u => u.Email).Distinct(); + + await AdjustSeatsAsync(organization, seatsToAdd, prorationDate, ownerEmails); + + if (!organization.OwnersNotifiedOfAutoscaling.HasValue) + { + await _mailService.SendOrganizationAutoscaledEmailAsync(organization, organization.Seats.Value + seatsToAdd, + ownerEmails); + organization.OwnersNotifiedOfAutoscaling = DateTime.UtcNow; + await _organizationRepository.UpsertAsync(organization); + } + } + private async Task CheckPolicies(ICollection<Policy> policies, Guid organizationId, User user, ICollection<OrganizationUser> userOrgs, IUserService userService) { @@ -1474,7 +1571,7 @@ namespace Bit.Core.Services } if (user.Type != OrganizationUserType.Owner && - !await HasConfirmedOwnersExceptAsync(user.OrganizationId, new[] {user.Id})) + !await HasConfirmedOwnersExceptAsync(user.OrganizationId, new[] { user.Id })) { throw new BadRequestException("Organization must have at least one confirmed owner."); } @@ -1507,7 +1604,7 @@ namespace Bit.Core.Services throw new BadRequestException("Only owners can delete other owners."); } - if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] {organizationUserId})) + if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId })) { throw new BadRequestException("Organization must have at least one confirmed owner."); } @@ -1529,7 +1626,7 @@ namespace Bit.Core.Services throw new NotFoundException(); } - if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] {orgUser.Id})) + if (!await HasConfirmedOwnersExceptAsync(organizationId, new[] { orgUser.Id })) { throw new BadRequestException("Organization must have at least one confirmed owner."); } @@ -1630,12 +1727,12 @@ namespace Bit.Core.Services { // Org User must be the same as the calling user and the organization ID associated with the user must match passed org ID var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, organizationUserId); - if (!callingUserId.HasValue || orgUser == null || orgUser.UserId != callingUserId.Value || + if (!callingUserId.HasValue || orgUser == null || orgUser.UserId != callingUserId.Value || orgUser.OrganizationId != organizationId) { throw new BadRequestException("User not valid."); } - + // Make sure the organization has the ability to use password reset var org = await _organizationRepository.GetByIdAsync(organizationId); if (org == null || !org.UseResetPassword) @@ -1650,7 +1747,7 @@ namespace Bit.Core.Services { throw new BadRequestException("Organization does not have the password reset policy enabled."); } - + // Block the user from withdrawal if auto enrollment is enabled if (resetPasswordKey == null && resetPasswordPolicy.Data != null) { @@ -1661,7 +1758,7 @@ namespace Bit.Core.Services throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from Password Reset."); } } - + orgUser.ResetPasswordKey = resetPasswordKey; await _organizationUserRepository.ReplaceAsync(orgUser); await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ? @@ -1702,7 +1799,8 @@ namespace Bit.Core.Services AccessAll = accessAll, Collections = collections, }; - var results = await InviteUserAsync(organizationId, invitingUserId, externalId, invite); + var results = await InviteUsersAsync(organizationId, invitingUserId, + new (OrganizationUserInvite, string)[] { (invite, externalId) }); var result = results.FirstOrDefault(); if (result == null) { @@ -1797,11 +1895,6 @@ namespace Bit.Core.Services enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } - if (!enoughSeatsAvailable) - { - throw new BadRequestException($"Organization does not have enough seats available. Need {usersToAdd.Count} but {seatsAvailable} available."); - } - var userInvites = new List<(OrganizationUserInvite, string)>(); foreach (var user in newUsers) { @@ -1891,7 +1984,7 @@ namespace Bit.Core.Services } } } - + await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.DirectorySynced, organization)); } @@ -1934,7 +2027,7 @@ namespace Bit.Core.Services org.PublicKey = publicKey; org.PrivateKey = privateKey; await UpdateAsync(org); - + return org; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 66718633c7..57132ea862 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -718,6 +718,12 @@ namespace Bit.Core.Services ProrationDate = prorationDate, }; + if (!subscriptionUpdate.UpdateNeeded(sub)) + { + // No need to update subscription, quantity matches + return null; + } + var customer = await new CustomerService().GetAsync(sub.CustomerId); if (!string.IsNullOrWhiteSpace(customer?.Address?.Country) && !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode)) diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 4fa9805318..f514993d17 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -35,6 +35,16 @@ namespace Bit.Core.Services return Task.FromResult(0); } + public Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails) + { + return Task.FromResult(0); + } + + public Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails) + { + return Task.FromResult(0); + } + public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails) { return Task.FromResult(0); diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 122258b1a6..23b75d61fe 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -1,8 +1,14 @@ +using static Bit.Core.Settings.GlobalSettings; + namespace Bit.Core.Settings { public interface IGlobalSettings { // This interface exists for testing. Add settings here as needed for testing + bool SelfHosted { get; set; } + string LicenseDirectory { get; set; } + int OrganizationInviteExpirationHours { get; set; } + InstallationSettings Installation { get; set; } IFileStorageSettings Attachment { get; set; } } } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 04d0068be5..d9cac44776 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -643,7 +643,7 @@ namespace Bit.Core.Utilities } public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail, - Guid orgUserId, GlobalSettings globalSettings) + Guid orgUserId, IGlobalSettings globalSettings) { return TokenIsValid("OrganizationUserInvite", protector, token, userEmail, orgUserId, globalSettings.OrganizationInviteExpirationHours); diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 9b065c33b4..f809adc062 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -115,7 +115,9 @@ namespace Bit.Core.Utilities MaxUsers = 2, UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to - DisplaySortOrder = -1 + DisplaySortOrder = -1, + + AllowSeatAutoscale = false, }, new Plan { @@ -145,7 +147,9 @@ namespace Bit.Core.Utilities StripePremiumAccessPlanId = "personal-org-premium-access-annually", BasePrice = 12, AdditionalStoragePricePerGb = 4, - PremiumAccessOptionPrice = 40 + PremiumAccessOptionPrice = 40, + + AllowSeatAutoscale = false, }, new Plan { @@ -174,7 +178,9 @@ namespace Bit.Core.Utilities StripeStoragePlanId = "storage-gb-annually", BasePrice = 60, SeatPrice = 24, - AdditionalStoragePricePerGb = 4 + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, }, new Plan { @@ -202,7 +208,9 @@ namespace Bit.Core.Utilities StripeStoragePlanId = "storage-gb-monthly", BasePrice = 8, SeatPrice = 2.5M, - AdditionalStoragePricePerGb = 0.5M + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, }, new Plan { @@ -239,7 +247,9 @@ namespace Bit.Core.Utilities StripeStoragePlanId = "storage-gb-annually", BasePrice = 0, SeatPrice = 36, - AdditionalStoragePricePerGb = 4 + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, }, new Plan { @@ -275,7 +285,9 @@ namespace Bit.Core.Utilities StripeStoragePlanId = "storage-gb-monthly", BasePrice = 0, SeatPrice = 4M, - AdditionalStoragePricePerGb = 0.5M + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, }, new Plan { @@ -302,7 +314,9 @@ namespace Bit.Core.Utilities StripePlanId = "2020-families-org-annually", StripeStoragePlanId = "storage-gb-annually", BasePrice = 40, - AdditionalStoragePricePerGb = 4 + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = false, }, new Plan { @@ -334,7 +348,9 @@ namespace Bit.Core.Utilities StripeSeatPlanId = "2020-teams-org-seat-annually", StripeStoragePlanId = "storage-gb-annually", SeatPrice = 36, - AdditionalStoragePricePerGb = 4 + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, }, new Plan { @@ -365,7 +381,9 @@ namespace Bit.Core.Utilities StripeSeatPlanId = "2020-teams-org-seat-monthly", StripeStoragePlanId = "storage-gb-monthly", SeatPrice = 4, - AdditionalStoragePricePerGb = 0.5M + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, }, new Plan { @@ -402,7 +420,9 @@ namespace Bit.Core.Utilities StripeStoragePlanId = "storage-gb-annually", BasePrice = 0, SeatPrice = 60, - AdditionalStoragePricePerGb = 4 + AdditionalStoragePricePerGb = 4, + + AllowSeatAutoscale = true, }, new Plan { @@ -438,11 +458,15 @@ namespace Bit.Core.Utilities StripeStoragePlanId = "storage-gb-monthly", BasePrice = 0, SeatPrice = 6, - AdditionalStoragePricePerGb = 0.5M + AdditionalStoragePricePerGb = 0.5M, + + AllowSeatAutoscale = true, }, new Plan { - Type = PlanType.Custom + Type = PlanType.Custom, + + AllowSeatAutoscale = true, }, }; diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index 7082edd4ed..22e190b1c8 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -38,7 +38,9 @@ @TwoFactorProviders NVARCHAR(MAX), @ExpirationDate DATETIME2(7), @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT AS BEGIN SET NOCOUNT ON @@ -84,7 +86,9 @@ BEGIN [TwoFactorProviders], [ExpirationDate], [CreationDate], - [RevisionDate] + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats] ) VALUES ( @@ -127,6 +131,8 @@ BEGIN @TwoFactorProviders, @ExpirationDate, @CreationDate, - @RevisionDate + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats ) END diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 186645ded6..2d1eb4fb08 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -38,7 +38,9 @@ @TwoFactorProviders NVARCHAR(MAX), @ExpirationDate DATETIME2(7), @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT AS BEGIN SET NOCOUNT ON @@ -84,7 +86,9 @@ BEGIN [TwoFactorProviders] = @TwoFactorProviders, [ExpirationDate] = @ExpirationDate, [CreationDate] = @CreationDate, - [RevisionDate] = @RevisionDate + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats WHERE [Id] = @Id END diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql index 5ee2c15106..cd1ffcae69 100644 --- a/src/Sql/dbo/Tables/Organization.sql +++ b/src/Sql/dbo/Tables/Organization.sql @@ -1,44 +1,46 @@ CREATE TABLE [dbo].[Organization] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [Identifier] NVARCHAR (50) NULL, - [Name] NVARCHAR (50) NOT NULL, - [BusinessName] NVARCHAR (50) NULL, - [BusinessAddress1] NVARCHAR (50) NULL, - [BusinessAddress2] NVARCHAR (50) NULL, - [BusinessAddress3] NVARCHAR (50) NULL, - [BusinessCountry] VARCHAR (2) NULL, - [BusinessTaxNumber] NVARCHAR (30) NULL, - [BillingEmail] NVARCHAR (256) NOT NULL, - [Plan] NVARCHAR (50) NOT NULL, - [PlanType] TINYINT NOT NULL, - [Seats] INT NULL, - [MaxCollections] SMALLINT NULL, - [UsePolicies] BIT NOT NULL, - [UseSso] BIT NOT NULL, - [UseGroups] BIT NOT NULL, - [UseDirectory] BIT NOT NULL, - [UseEvents] BIT NOT NULL, - [UseTotp] BIT NOT NULL, - [Use2fa] BIT NOT NULL, - [UseApi] BIT NOT NULL, - [UseResetPassword] BIT NOT NULL, - [SelfHost] BIT NOT NULL, - [UsersGetPremium] BIT NOT NULL, - [Storage] BIGINT NULL, - [MaxStorageGb] SMALLINT NULL, - [Gateway] TINYINT NULL, - [GatewayCustomerId] VARCHAR (50) NULL, - [GatewaySubscriptionId] VARCHAR (50) NULL, - [ReferenceData] NVARCHAR (MAX) NULL, - [Enabled] BIT NOT NULL, - [LicenseKey] VARCHAR (100) NULL, - [ApiKey] VARCHAR (30) NOT NULL, - [PublicKey] VARCHAR (MAX) NULL, - [PrivateKey] VARCHAR (MAX) NULL, - [TwoFactorProviders] NVARCHAR (MAX) NULL, - [ExpirationDate] DATETIME2 (7) NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - [RevisionDate] DATETIME2 (7) NOT NULL, + [Id] UNIQUEIDENTIFIER NOT NULL, + [Identifier] NVARCHAR (50) NULL, + [Name] NVARCHAR (50) NOT NULL, + [BusinessName] NVARCHAR (50) NULL, + [BusinessAddress1] NVARCHAR (50) NULL, + [BusinessAddress2] NVARCHAR (50) NULL, + [BusinessAddress3] NVARCHAR (50) NULL, + [BusinessCountry] VARCHAR (2) NULL, + [BusinessTaxNumber] NVARCHAR (30) NULL, + [BillingEmail] NVARCHAR (256) NOT NULL, + [Plan] NVARCHAR (50) NOT NULL, + [PlanType] TINYINT NOT NULL, + [Seats] INT NULL, + [MaxCollections] SMALLINT NULL, + [UsePolicies] BIT NOT NULL, + [UseSso] BIT NOT NULL, + [UseGroups] BIT NOT NULL, + [UseDirectory] BIT NOT NULL, + [UseEvents] BIT NOT NULL, + [UseTotp] BIT NOT NULL, + [Use2fa] BIT NOT NULL, + [UseApi] BIT NOT NULL, + [UseResetPassword] BIT NOT NULL, + [SelfHost] BIT NOT NULL, + [UsersGetPremium] BIT NOT NULL, + [Storage] BIGINT NULL, + [MaxStorageGb] SMALLINT NULL, + [Gateway] TINYINT NULL, + [GatewayCustomerId] VARCHAR (50) NULL, + [GatewaySubscriptionId] VARCHAR (50) NULL, + [ReferenceData] NVARCHAR (MAX) NULL, + [Enabled] BIT NOT NULL, + [LicenseKey] VARCHAR (100) NULL, + [ApiKey] VARCHAR (30) NOT NULL, + [PublicKey] VARCHAR (MAX) NULL, + [PrivateKey] VARCHAR (MAX) NULL, + [TwoFactorProviders] NVARCHAR (MAX) NULL, + [ExpirationDate] DATETIME2 (7) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + [OwnersNotifiedOfAutoscaling] DATETIME2(7) NULL, + [MaxAutoscaleSeats] INT NULL, CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC) ); diff --git a/test/Core.Test/AutoFixture/OrganizationFixtures.cs b/test/Core.Test/AutoFixture/OrganizationFixtures.cs index 36f61e4d1c..2eb84ea6ae 100644 --- a/test/Core.Test/AutoFixture/OrganizationFixtures.cs +++ b/test/Core.Test/AutoFixture/OrganizationFixtures.cs @@ -78,6 +78,15 @@ namespace Bit.Core.Test.AutoFixture.OrganizationFixtures } } + internal class FreeOrganization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize<Core.Models.Table.Organization>(composer => composer + .With(o => o.PlanType, PlanType.Free)); + } + } + internal class FreeOrganizationUpgrade : ICustomization { public void Customize(IFixture fixture) @@ -133,18 +142,30 @@ namespace Bit.Core.Test.AutoFixture.OrganizationFixtures internal class PaidOrganizationAutoDataAttribute : CustomAutoDataAttribute { - public PaidOrganizationAutoDataAttribute(int planType = 0) : base(new SutProviderCustomization(), - new PaidOrganization { CheckedPlanType = (PlanType)planType }) + public PaidOrganizationAutoDataAttribute(PlanType planType) : base(new SutProviderCustomization(), + new PaidOrganization { CheckedPlanType = planType }) { } + public PaidOrganizationAutoDataAttribute(int planType = 0) : this((PlanType)planType) { } } internal class InlinePaidOrganizationAutoDataAttribute : InlineCustomAutoDataAttribute { + public InlinePaidOrganizationAutoDataAttribute(PlanType planType, object[] values) : base( + new ICustomization[] { new SutProviderCustomization(), new PaidOrganization { CheckedPlanType = planType } }, values) + { } + public InlinePaidOrganizationAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization), typeof(PaidOrganization) }, values) { } } + internal class InlineFreeOrganizationAutoDataAttribute : InlineCustomAutoDataAttribute + { + public InlineFreeOrganizationAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization), + typeof(FreeOrganization) }, values) + { } + } + internal class FreeOrganizationUpgradeAutoDataAttribute : CustomAutoDataAttribute { public FreeOrganizationUpgradeAutoDataAttribute() : base(new SutProviderCustomization(), new FreeOrganizationUpgrade()) diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index ecfd225a16..1aa369c471 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -21,6 +21,8 @@ using Organization = Bit.Core.Models.Table.Organization; using OrganizationUser = Bit.Core.Models.Table.OrganizationUser; using Policy = Bit.Core.Models.Table.Policy; using Bit.Core.Test.AutoFixture.PolicyFixtures; +using Bit.Core.Settings; +using AutoFixture.Xunit2; namespace Bit.Core.Test.Services { @@ -40,11 +42,15 @@ namespace Bit.Core.Test.Services }); var expectedNewUsersCount = newUsers.Count - 1; + existingUsers.First().Type = OrganizationUserType.Owner; + sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id) .Returns(existingUsers); sutProvider.GetDependency<IOrganizationUserRepository>().GetCountByOrganizationIdAsync(org.Id) .Returns(existingUsers.Count); + sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner) + .Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList()); sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true); await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false); @@ -96,6 +102,8 @@ namespace Bit.Core.Test.Services .Returns(existingUsers.Count); sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(reInvitedUser.Id) .Returns(new OrganizationUser { Id = reInvitedUser.Id }); + sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByOrganizationAsync(org.Id, OrganizationUserType.Owner) + .Returns(existingUsers.Select(u => new OrganizationUser { Status = OrganizationUserStatusType.Confirmed, Type = OrganizationUserType.Owner, Id = u.Id }).ToList()); var currentContext = sutProvider.GetDependency<ICurrentContext>(); currentContext.ManageUsers(org.Id).Returns(true); @@ -188,7 +196,7 @@ namespace Bit.Core.Test.Services invite.Emails = null; sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); await Assert.ThrowsAsync<NotFoundException>( - () => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite)); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); } [Theory] @@ -201,8 +209,9 @@ namespace Bit.Core.Test.Services { sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true); + sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync<BadRequestException>( - () => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite)); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("Organization must have at least one confirmed owner.", exception.Message); } @@ -221,7 +230,7 @@ namespace Bit.Core.Test.Services currentContext.OrganizationAdmin(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync<BadRequestException>( - () => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite)); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("only an owner", exception.Message.ToLowerInvariant()); } @@ -240,7 +249,7 @@ namespace Bit.Core.Test.Services currentContext.OrganizationUser(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync<BadRequestException>( - () => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite)); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("only owners and admins", exception.Message.ToLowerInvariant()); } @@ -266,7 +275,7 @@ namespace Bit.Core.Test.Services currentContext.ManageUsers(organization.Id).Returns(false); var exception = await Assert.ThrowsAsync<BadRequestException>( - () => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite)); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("account does not have permission", exception.Message.ToLowerInvariant()); } @@ -292,7 +301,7 @@ namespace Bit.Core.Test.Services currentContext.ManageUsers(organization.Id).Returns(true); var exception = await Assert.ThrowsAsync<BadRequestException>( - () => sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite)); + () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("can not manage admins", exception.Message.ToLowerInvariant()); } @@ -314,8 +323,9 @@ namespace Bit.Core.Test.Services organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) .Returns(new [] {invitor}); currentContext.OrganizationOwner(organization.Id).Returns(true); + currentContext.ManageUsers(organization.Id).Returns(true); - await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); } [Theory] @@ -343,7 +353,7 @@ namespace Bit.Core.Test.Services .Returns(new [] {owner}); currentContext.ManageUsers(organization.Id).Returns(true); - await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, null, invite); + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); } [Theory, CustomAutoData(typeof(SutProviderCustomization))] @@ -819,5 +829,80 @@ namespace Bit.Core.Test.Services await sutProvider.Sut.UpdateOrganizationKeysAsync(org.Id, publicKey, privateKey); } + + [Theory] + [InlinePaidOrganizationAutoData(PlanType.EnterpriseAnnually, new object[] { "Cannot set max seat autoscaling below seat count", 1, 0, 2 })] + [InlinePaidOrganizationAutoData(PlanType.EnterpriseAnnually, new object[] { "Cannot set max seat autoscaling below seat count", 4, -1, 6 })] + [InlineFreeOrganizationAutoData("Your plan does not allow seat autoscaling", 10, 0, null)] + public async Task UpdateSubscription_BadInputThrows(string expectedMessage, + int? maxAutoscaleSeats, int seatAdjustment, int? currentSeats, Organization organization, SutProvider<OrganizationService> sutProvider) + { + organization.Seats = currentSeats; + sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); + + var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSubscription(organization.Id, + seatAdjustment, maxAutoscaleSeats)); + + Assert.Contains(expectedMessage, exception.Message); + } + + [Theory, CustomAutoData(typeof(SutProviderCustomization))] + public async Task UpdateSubscription_NoOrganization_Throws(Guid organizationId, SutProvider<OrganizationService> sutProvider) + { + sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns((Organization)null); + + await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.UpdateSubscription(organizationId, 0, null)); + } + + [Theory] + [InlinePaidOrganizationAutoData(0, 100, null, true, "")] + [InlinePaidOrganizationAutoData(0, 100, 100, true, "")] + [InlinePaidOrganizationAutoData(0, null, 100, true, "")] + [InlinePaidOrganizationAutoData(1, 100, null, true, "")] + [InlinePaidOrganizationAutoData(1, 100, 100, false, "Cannot invite new users. Seat limit has been reached")] + public async Task CanScale(int seatsToAdd, int? currentSeats, int? maxAutoscaleSeats, + bool expectedResult, string expectedFailureMessage, Organization organization, + SutProvider<OrganizationService> sutProvider) + { + organization.Seats = currentSeats; + organization.MaxAutoscaleSeats = maxAutoscaleSeats; + sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true); + + var (result, failureMessage) = await sutProvider.Sut.CanScaleAsync(organization, seatsToAdd); + + if (expectedFailureMessage == string.Empty) + { + Assert.Empty(failureMessage); + } + else + { + Assert.Contains(expectedFailureMessage, failureMessage); + } + Assert.Equal(expectedResult, result); + } + + [Theory, PaidOrganizationAutoData] + public async Task CanScale_FailsOnSelfHosted(Organization organization, + SutProvider<OrganizationService> sutProvider) + { + sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true); + var (result, failureMessage) = await sutProvider.Sut.CanScaleAsync(organization, 10); + + Assert.False(result); + Assert.Contains("Cannot autoscale on self-hosted instance", failureMessage); + } + + [Theory, PaidOrganizationAutoData] + public async Task CanScale_FailsIfCannotManageUsers(Organization organization, + SutProvider<OrganizationService> sutProvider) + { + organization.MaxAutoscaleSeats = null; + sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(false); + + var (result, failureMessage) = await sutProvider.Sut.CanScaleAsync(organization, 10); + + Assert.False(result); + Assert.Contains("Cannot manage organization users", failureMessage); + } } } diff --git a/util/Migrator/DbScripts/2021-08-18_00_AutoscaleOrganizationsSeats.sql b/util/Migrator/DbScripts/2021-08-18_00_AutoscaleOrganizationsSeats.sql new file mode 100644 index 0000000000..ab307aa0d9 --- /dev/null +++ b/util/Migrator/DbScripts/2021-08-18_00_AutoscaleOrganizationsSeats.sql @@ -0,0 +1,276 @@ +-- Add Autoscaling columns to Organization and OrganizationView +IF COL_LENGTH('[dbo].[Organization]', 'OwnersNotifiedOfAutoscaling') IS NULL +BEGIN +ALTER TABLE + [dbo].[Organization] + ADD + [OwnersNotifiedOfAutoscaling] DATETIME2(7) NULL +END +GO + +IF COL_LENGTH('[dbo].[Organization]', 'MaxAutoscaleSeats') IS NULL +BEGIN +ALTER TABLE + [dbo].[Organization] + ADD + [MaxAutoscaleSeats] INT NULL +END +GO + +ALTER VIEW [dbo].[OrganizationView] +AS +SELECT + * +FROM + [dbo].[Organization] +GO + +-- Update Organization Create +IF OBJECT_ID('[dbo].[Organization_Create]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_Create] +END +GO + +CREATE PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @ApiKey VARCHAR(30), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [ApiKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @ApiKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats + ) +END +GO + +-- Update Organization Update +IF OBJECT_ID('[dbo].[Organization_Update]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_Update] +END +GO + +CREATE PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @ApiKey VARCHAR(30), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [ApiKey] = @ApiKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats + WHERE + [Id] = @Id +END +GO diff --git a/util/MySqlMigrations/Migrations/20210921132418_AddMaxAutoscaleSeatsToOrganization.Designer.cs b/util/MySqlMigrations/Migrations/20210921132418_AddMaxAutoscaleSeatsToOrganization.Designer.cs new file mode 100644 index 0000000000..9aac1898fb --- /dev/null +++ b/util/MySqlMigrations/Migrations/20210921132418_AddMaxAutoscaleSeatsToOrganization.Designer.cs @@ -0,0 +1,1492 @@ +// <auto-generated /> +using System; +using Bit.Core.Repositories.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20210921132418_AddMaxAutoscaleSeatsToOrganization")] + partial class AddMaxAutoscaleSeatsToOrganization + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 64) + .HasAnnotation("ProductVersion", "5.0.9"); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<string>("Attachments") + .HasColumnType("longtext"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Data") + .HasColumnType("longtext"); + + b.Property<DateTime?>("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Favorites") + .HasColumnType("longtext"); + + b.Property<string>("Folders") + .HasColumnType("longtext"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<byte?>("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Collection", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property<string>("Name") + .HasColumnType("longtext"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionCipher", b => + { + b.Property<Guid>("CollectionId") + .HasColumnType("char(36)"); + + b.Property<Guid>("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionGroup", b => + { + b.Property<Guid>("CollectionId") + .HasColumnType("char(36)"); + + b.Property<Guid>("GroupId") + .HasColumnType("char(36)"); + + b.Property<bool>("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionUser", b => + { + b.Property<Guid>("CollectionId") + .HasColumnType("char(36)"); + + b.Property<Guid>("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property<bool>("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.HasIndex("UserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Device", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.EmergencyAccess", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property<Guid?>("GranteeId") + .HasColumnType("char(36)"); + + b.Property<Guid>("GrantorId") + .HasColumnType("char(36)"); + + b.Property<string>("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property<DateTime?>("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property<DateTime?>("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Status") + .HasColumnType("tinyint unsigned"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<int>("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Event", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<Guid?>("ActingUserId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("CipherId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("CollectionId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("Date") + .HasColumnType("datetime(6)"); + + b.Property<byte?>("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid?>("GroupId") + .HasColumnType("char(36)"); + + b.Property<string>("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("PolicyId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("ProviderId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property<int>("Type") + .HasColumnType("int"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("Event"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Folder", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Name") + .HasColumnType("longtext"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<Guid>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Grant", b => + { + b.Property<string>("Key") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property<string>("ClientId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property<DateTime?>("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Data") + .HasColumnType("longtext"); + + b.Property<string>("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property<string>("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property<string>("Type") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Key"); + + b.ToTable("Grant"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Group", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<bool>("AccessAll") + .HasColumnType("tinyint(1)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property<string>("Name") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.GroupUser", b => + { + b.Property<Guid>("GroupId") + .HasColumnType("char(36)"); + + b.Property<Guid>("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.HasIndex("UserId"); + + b.ToTable("GroupUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Installation", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property<bool>("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property<string>("Key") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.HasKey("Id"); + + b.ToTable("Installation"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Organization", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<string>("ApiKey") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property<string>("BillingEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property<string>("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property<string>("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<bool>("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property<byte?>("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property<string>("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property<int?>("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property<short?>("MaxCollections") + .HasColumnType("smallint"); + + b.Property<short?>("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property<string>("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<DateTime?>("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property<string>("Plan") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<byte>("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property<string>("PrivateKey") + .HasColumnType("longtext"); + + b.Property<string>("PublicKey") + .HasColumnType("longtext"); + + b.Property<string>("ReferenceData") + .HasColumnType("longtext"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<int?>("Seats") + .HasColumnType("int"); + + b.Property<bool>("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property<long?>("Storage") + .HasColumnType("bigint"); + + b.Property<string>("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property<bool>("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property<bool>("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.OrganizationUser", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<bool>("AccessAll") + .HasColumnType("tinyint(1)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property<string>("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property<string>("Key") + .HasColumnType("longtext"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<string>("Permissions") + .HasColumnType("longtext"); + + b.Property<string>("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Status") + .HasColumnType("tinyint unsigned"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Policy", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Data") + .HasColumnType("longtext"); + + b.Property<bool>("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Policy"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.Provider", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<string>("BillingEmail") + .HasColumnType("longtext"); + + b.Property<string>("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property<string>("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property<string>("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property<string>("BusinessCountry") + .HasColumnType("longtext"); + + b.Property<string>("BusinessName") + .HasColumnType("longtext"); + + b.Property<string>("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<bool>("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property<string>("Name") + .HasColumnType("longtext"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Status") + .HasColumnType("tinyint unsigned"); + + b.Property<bool>("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderOrganization", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Key") + .HasColumnType("longtext"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<Guid>("ProviderId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderUser", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Email") + .HasColumnType("longtext"); + + b.Property<string>("Key") + .HasColumnType("longtext"); + + b.Property<string>("Permissions") + .HasColumnType("longtext"); + + b.Property<Guid>("ProviderId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Status") + .HasColumnType("tinyint unsigned"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Send", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<int>("AccessCount") + .HasColumnType("int"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Data") + .HasColumnType("longtext"); + + b.Property<DateTime>("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property<bool>("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property<bool?>("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property<string>("Key") + .HasColumnType("longtext"); + + b.Property<int?>("MaxAccessCount") + .HasColumnType("int"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<string>("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Send"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoConfig", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Data") + .HasColumnType("longtext"); + + b.Property<bool>("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoUser", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("ExternalId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<Guid>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("SsoUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.TaxRate", b => + { + b.Property<string>("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property<bool>("Active") + .HasColumnType("tinyint(1)"); + + b.Property<string>("Country") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("PostalCode") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property<decimal>("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property<string>("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Transaction", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<decimal>("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property<byte?>("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property<string>("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("char(36)"); + + b.Property<byte?>("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property<bool?>("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property<decimal?>("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property<byte>("Type") + .HasColumnType("tinyint unsigned"); + + b.Property<Guid?>("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Transaction"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.U2f", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property<string>("AppId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("Challenge") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("KeyHandle") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property<Guid>("UserId") + .HasColumnType("char(36)"); + + b.Property<string>("Version") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("U2f"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.User", b => + { + b.Property<Guid>("Id") + .HasColumnType("char(36)"); + + b.Property<DateTime>("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Culture") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property<bool>("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property<string>("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property<string>("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property<bool>("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property<byte?>("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property<string>("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<string>("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<byte>("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property<int>("KdfIterations") + .HasColumnType("int"); + + b.Property<string>("Key") + .HasColumnType("longtext"); + + b.Property<string>("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property<string>("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property<string>("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<short?>("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property<string>("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<bool>("Premium") + .HasColumnType("tinyint(1)"); + + b.Property<DateTime?>("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("PrivateKey") + .HasColumnType("longtext"); + + b.Property<string>("PublicKey") + .HasColumnType("longtext"); + + b.Property<string>("ReferenceData") + .HasColumnType("longtext"); + + b.Property<DateTime?>("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property<long?>("Storage") + .HasColumnType("bigint"); + + b.Property<string>("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property<string>("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Collection", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionCipher", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionGroup", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", null) + .WithMany("CollectionUsers") + .HasForeignKey("UserId"); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Device", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.EmergencyAccess", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Folder", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Group", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.GroupUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", null) + .WithMany("GroupUsers") + .HasForeignKey("UserId"); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.OrganizationUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Policy", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Send", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoConfig", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Transaction", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.U2f", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("U2fs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Organization", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("CollectionUsers"); + + b.Navigation("Folders"); + + b.Navigation("GroupUsers"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + + b.Navigation("U2fs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20210921132418_AddMaxAutoscaleSeatsToOrganization.cs b/util/MySqlMigrations/Migrations/20210921132418_AddMaxAutoscaleSeatsToOrganization.cs new file mode 100644 index 0000000000..54018634f7 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20210921132418_AddMaxAutoscaleSeatsToOrganization.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Bit.MySqlMigrations.Migrations +{ + public partial class AddMaxAutoscaleSeatsToOrganization : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<int>( + name: "MaxAutoscaleSeats", + table: "Organization", + type: "int", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "OwnersNotifiedOfAutoscaling", + table: "Organization", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn<Guid>( + name: "ProviderOrganizationId", + table: "Event", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxAutoscaleSeats", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "OwnersNotifiedOfAutoscaling", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "ProviderOrganizationId", + table: "Event"); + } + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 1dfd73ec95..9bc3ba7f39 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Bit.MySqlMigrations.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Relational:MaxIdentifierLength", 64) - .HasAnnotation("ProductVersion", "5.0.5"); + .HasAnnotation("ProductVersion", "5.0.9"); modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => { @@ -278,6 +278,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property<Guid?>("ProviderId") .HasColumnType("char(36)"); + b.Property<Guid?>("ProviderOrganizationId") + .HasColumnType("char(36)"); + b.Property<Guid?>("ProviderUserId") .HasColumnType("char(36)"); @@ -500,6 +503,9 @@ namespace Bit.MySqlMigrations.Migrations .HasMaxLength(100) .HasColumnType("varchar(100)"); + b.Property<int?>("MaxAutoscaleSeats") + .HasColumnType("int"); + b.Property<short?>("MaxCollections") .HasColumnType("smallint"); @@ -510,6 +516,9 @@ namespace Bit.MySqlMigrations.Migrations .HasMaxLength(50) .HasColumnType("varchar(50)"); + b.Property<DateTime?>("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + b.Property<string>("Plan") .HasMaxLength(50) .HasColumnType("varchar(50)"); diff --git a/util/MySqlMigrations/Scripts/2021-09-21_00_AddMaxAutoscaleSeatsToOrganization.psql b/util/MySqlMigrations/Scripts/2021-09-21_00_AddMaxAutoscaleSeatsToOrganization.psql new file mode 100644 index 0000000000..f1983936f3 --- /dev/null +++ b/util/MySqlMigrations/Scripts/2021-09-21_00_AddMaxAutoscaleSeatsToOrganization.psql @@ -0,0 +1,12 @@ +START TRANSACTION; + +ALTER TABLE `Organization` ADD `MaxAutoscaleSeats` int NULL; + +ALTER TABLE `Organization` ADD `OwnersNotifiedOfAutoscaling` datetime(6) NULL; + +ALTER TABLE `Event` ADD `ProviderOrganizationId` char(36) COLLATE ascii_general_ci NULL; + +INSERT INTO `__EFMigrationsHistory` (`MigrationId`, `ProductVersion`) +VALUES ('20210921132418_AddMaxAutoscaleSeatsToOrganization', '5.0.9'); + +COMMIT; diff --git a/util/PostgresMigrations/Migrations/20210920201829_AddMaxAutoscaleSeatsToOrganization.Designer.cs b/util/PostgresMigrations/Migrations/20210920201829_AddMaxAutoscaleSeatsToOrganization.Designer.cs new file mode 100644 index 0000000000..a992e8aad5 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20210920201829_AddMaxAutoscaleSeatsToOrganization.Designer.cs @@ -0,0 +1,1501 @@ +// <auto-generated /> +using System; +using Bit.Core.Repositories.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20210920201829_AddMaxAutoscaleSeatsToOrganization")] + partial class AddMaxAutoscaleSeatsToOrganization + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.9") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<string>("Attachments") + .HasColumnType("text"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Data") + .HasColumnType("text"); + + b.Property<DateTime?>("DeletedDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Favorites") + .HasColumnType("text"); + + b.Property<string>("Folders") + .HasColumnType("text"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<byte?>("Reprompt") + .HasColumnType("smallint"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Collection", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionCipher", b => + { + b.Property<Guid>("CollectionId") + .HasColumnType("uuid"); + + b.Property<Guid>("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionGroup", b => + { + b.Property<Guid>("CollectionId") + .HasColumnType("uuid"); + + b.Property<Guid>("GroupId") + .HasColumnType("uuid"); + + b.Property<bool>("HidePasswords") + .HasColumnType("boolean"); + + b.Property<bool>("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionUser", b => + { + b.Property<Guid>("CollectionId") + .HasColumnType("uuid"); + + b.Property<Guid>("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property<bool>("HidePasswords") + .HasColumnType("boolean"); + + b.Property<bool>("ReadOnly") + .HasColumnType("boolean"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.HasIndex("UserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Device", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.EmergencyAccess", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<Guid?>("GranteeId") + .HasColumnType("uuid"); + + b.Property<Guid>("GrantorId") + .HasColumnType("uuid"); + + b.Property<string>("KeyEncrypted") + .HasColumnType("text"); + + b.Property<DateTime?>("LastNotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime?>("RecoveryInitiatedDate") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Status") + .HasColumnType("smallint"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<int>("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Event", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<Guid?>("ActingUserId") + .HasColumnType("uuid"); + + b.Property<Guid?>("CipherId") + .HasColumnType("uuid"); + + b.Property<Guid?>("CollectionId") + .HasColumnType("uuid"); + + b.Property<DateTime>("Date") + .HasColumnType("timestamp without time zone"); + + b.Property<byte?>("DeviceType") + .HasColumnType("smallint"); + + b.Property<Guid?>("GroupId") + .HasColumnType("uuid"); + + b.Property<string>("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<Guid?>("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property<Guid?>("PolicyId") + .HasColumnType("uuid"); + + b.Property<Guid?>("ProviderId") + .HasColumnType("uuid"); + + b.Property<Guid?>("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property<Guid?>("ProviderUserId") + .HasColumnType("uuid"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Event"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Folder", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Grant", b => + { + b.Property<string>("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("ClientId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<DateTime?>("ConsumedDate") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Data") + .HasColumnType("text"); + + b.Property<string>("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Key"); + + b.ToTable("Grant"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Group", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<bool>("AccessAll") + .HasColumnType("boolean"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<string>("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.GroupUser", b => + { + b.Property<Guid>("GroupId") + .HasColumnType("uuid"); + + b.Property<Guid>("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.HasIndex("UserId"); + + b.ToTable("GroupUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Installation", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<string>("Key") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.ToTable("Installation"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Organization", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<string>("ApiKey") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<string>("BillingEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property<string>("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte?>("Gateway") + .HasColumnType("smallint"); + + b.Property<string>("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property<string>("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<int?>("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property<short?>("MaxCollections") + .HasColumnType("smallint"); + + b.Property<short?>("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property<string>("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<DateTime?>("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Plan") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<byte>("PlanType") + .HasColumnType("smallint"); + + b.Property<string>("PrivateKey") + .HasColumnType("text"); + + b.Property<string>("PublicKey") + .HasColumnType("text"); + + b.Property<string>("ReferenceData") + .HasColumnType("text"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<int?>("Seats") + .HasColumnType("integer"); + + b.Property<bool>("SelfHost") + .HasColumnType("boolean"); + + b.Property<long?>("Storage") + .HasColumnType("bigint"); + + b.Property<string>("TwoFactorProviders") + .HasColumnType("text"); + + b.Property<bool>("Use2fa") + .HasColumnType("boolean"); + + b.Property<bool>("UseApi") + .HasColumnType("boolean"); + + b.Property<bool>("UseDirectory") + .HasColumnType("boolean"); + + b.Property<bool>("UseEvents") + .HasColumnType("boolean"); + + b.Property<bool>("UseGroups") + .HasColumnType("boolean"); + + b.Property<bool>("UsePolicies") + .HasColumnType("boolean"); + + b.Property<bool>("UseResetPassword") + .HasColumnType("boolean"); + + b.Property<bool>("UseSso") + .HasColumnType("boolean"); + + b.Property<bool>("UseTotp") + .HasColumnType("boolean"); + + b.Property<bool>("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.OrganizationUser", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<bool>("AccessAll") + .HasColumnType("boolean"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<string>("Key") + .HasColumnType("text"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<string>("Permissions") + .HasColumnType("text"); + + b.Property<string>("ResetPasswordKey") + .HasColumnType("text"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Status") + .HasColumnType("smallint"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Policy", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Data") + .HasColumnType("text"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Policy"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.Provider", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<string>("BillingEmail") + .HasColumnType("text"); + + b.Property<string>("BusinessAddress1") + .HasColumnType("text"); + + b.Property<string>("BusinessAddress2") + .HasColumnType("text"); + + b.Property<string>("BusinessAddress3") + .HasColumnType("text"); + + b.Property<string>("BusinessCountry") + .HasColumnType("text"); + + b.Property<string>("BusinessName") + .HasColumnType("text"); + + b.Property<string>("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Status") + .HasColumnType("smallint"); + + b.Property<bool>("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderOrganization", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Key") + .HasColumnType("text"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<Guid>("ProviderId") + .HasColumnType("uuid"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderUser", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .HasColumnType("text"); + + b.Property<string>("Key") + .HasColumnType("text"); + + b.Property<string>("Permissions") + .HasColumnType("text"); + + b.Property<Guid>("ProviderId") + .HasColumnType("uuid"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Status") + .HasColumnType("smallint"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Send", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<int>("AccessCount") + .HasColumnType("integer"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Data") + .HasColumnType("text"); + + b.Property<DateTime>("DeletionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("Disabled") + .HasColumnType("boolean"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<bool?>("HideEmail") + .HasColumnType("boolean"); + + b.Property<string>("Key") + .HasColumnType("text"); + + b.Property<int?>("MaxAccessCount") + .HasColumnType("integer"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<string>("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Send"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoConfig", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Data") + .HasColumnType("text"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<Guid>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoUser", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ExternalId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("SsoUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.TaxRate", b => + { + b.Property<string>("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property<bool>("Active") + .HasColumnType("boolean"); + + b.Property<string>("Country") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("PostalCode") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property<decimal>("Rate") + .HasColumnType("numeric"); + + b.Property<string>("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Transaction", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<decimal>("Amount") + .HasColumnType("numeric"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<byte?>("Gateway") + .HasColumnType("smallint"); + + b.Property<string>("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("uuid"); + + b.Property<byte?>("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property<bool?>("Refunded") + .HasColumnType("boolean"); + + b.Property<decimal?>("RefundedAmount") + .HasColumnType("numeric"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<Guid?>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Transaction"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.U2f", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property<string>("AppId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Challenge") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("KeyHandle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("Version") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("U2f"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.User", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("AccountRevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<DateTime>("CreationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Culture") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property<bool>("EmailVerified") + .HasColumnType("boolean"); + + b.Property<string>("EquivalentDomains") + .HasColumnType("text"); + + b.Property<string>("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property<bool>("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property<byte?>("Gateway") + .HasColumnType("smallint"); + + b.Property<string>("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<byte>("Kdf") + .HasColumnType("smallint"); + + b.Property<int>("KdfIterations") + .HasColumnType("integer"); + + b.Property<string>("Key") + .HasColumnType("text"); + + b.Property<string>("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<string>("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<short?>("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property<string>("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<bool>("Premium") + .HasColumnType("boolean"); + + b.Property<DateTime?>("PremiumExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("PrivateKey") + .HasColumnType("text"); + + b.Property<string>("PublicKey") + .HasColumnType("text"); + + b.Property<string>("ReferenceData") + .HasColumnType("text"); + + b.Property<DateTime?>("RenewalReminderDate") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime>("RevisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<long?>("Storage") + .HasColumnType("bigint"); + + b.Property<string>("TwoFactorProviders") + .HasColumnType("text"); + + b.Property<string>("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Collection", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionCipher", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionGroup", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.CollectionUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", null) + .WithMany("CollectionUsers") + .HasForeignKey("UserId"); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Device", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.EmergencyAccess", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Folder", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Group", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.GroupUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", null) + .WithMany("GroupUsers") + .HasForeignKey("UserId"); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.OrganizationUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Policy", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Provider.ProviderUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Send", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoConfig", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.SsoUser", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Transaction", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.U2f", b => + { + b.HasOne("Bit.Core.Models.EntityFramework.User", "User") + .WithMany("U2fs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.Organization", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Core.Models.EntityFramework.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("CollectionUsers"); + + b.Navigation("Folders"); + + b.Navigation("GroupUsers"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + + b.Navigation("U2fs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20210920201829_AddMaxAutoscaleSeatsToOrganization.cs b/util/PostgresMigrations/Migrations/20210920201829_AddMaxAutoscaleSeatsToOrganization.cs new file mode 100644 index 0000000000..33ad21e145 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20210920201829_AddMaxAutoscaleSeatsToOrganization.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Bit.PostgresMigrations.Migrations +{ + public partial class AddMaxAutoscaleSeatsToOrganization : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<int>( + name: "MaxAutoscaleSeats", + table: "Organization", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "OwnersNotifiedOfAutoscaling", + table: "Organization", + type: "timestamp without time zone", + nullable: true); + + migrationBuilder.AddColumn<Guid>( + name: "ProviderOrganizationId", + table: "Event", + type: "uuid", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxAutoscaleSeats", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "OwnersNotifiedOfAutoscaling", + table: "Organization"); + + migrationBuilder.DropColumn( + name: "ProviderOrganizationId", + table: "Event"); + } + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index d9abe018e0..859380ad58 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Bit.PostgresMigrations.Migrations modelBuilder .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.5") + .HasAnnotation("ProductVersion", "5.0.9") .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => @@ -281,6 +281,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property<Guid?>("ProviderId") .HasColumnType("uuid"); + b.Property<Guid?>("ProviderOrganizationId") + .HasColumnType("uuid"); + b.Property<Guid?>("ProviderUserId") .HasColumnType("uuid"); @@ -504,6 +507,9 @@ namespace Bit.PostgresMigrations.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property<int?>("MaxAutoscaleSeats") + .HasColumnType("integer"); + b.Property<short?>("MaxCollections") .HasColumnType("smallint"); @@ -514,6 +520,9 @@ namespace Bit.PostgresMigrations.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property<DateTime?>("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp without time zone"); + b.Property<string>("Plan") .HasMaxLength(50) .HasColumnType("character varying(50)"); diff --git a/util/PostgresMigrations/Scripts/2021-09-20_00_AddMaxAutoscaleSeatsToOrganization.psql b/util/PostgresMigrations/Scripts/2021-09-20_00_AddMaxAutoscaleSeatsToOrganization.psql new file mode 100644 index 0000000000..c4dc6e1ed8 --- /dev/null +++ b/util/PostgresMigrations/Scripts/2021-09-20_00_AddMaxAutoscaleSeatsToOrganization.psql @@ -0,0 +1,12 @@ +START TRANSACTION; + +ALTER TABLE "Organization" ADD "MaxAutoscaleSeats" integer NULL; + +ALTER TABLE "Organization" ADD "OwnersNotifiedOfAutoscaling" timestamp without time zone NULL; + +ALTER TABLE "Event" ADD "ProviderOrganizationId" uuid NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20210920201829_AddMaxAutoscaleSeatsToOrganization', '5.0.9'); + +COMMIT;