1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-22 16:57:36 +01:00

Organization autoscaling (#1585)

* Add autoscale fields to Organization

* Add autoscale setting changes

* Autoscale organizations

updates InviteUsersAsync to support all invite sources.

sends an email to org owners when organization autoscaled

* All organizations autoscale

Disabling autoscaling can be done by setting max seats to current seats.

We only warn about autoscaling on the first autoscaling event.

* Fix tests

* Bug fixes

* Simplify subscription update logic

* Void invoices that fail to delete

Stripe no longer allows deletion of draft invoices that were created as part of subscription updates. It's necessary to void out these invoices without sending tem to the client.

* Notify org owners when their subscription runs out of seats

* Use datetime for notifications

Allows for later re-sending email if we want to periodically remind
owners

* Do not update subscription if it already matches new quatity

* Include all migrations

* Remove unnecessary inline styling

* SubscriptionUpdate handles update decisions

* Remove unnecessary html setter

* PR review

* Use minimum access for class methods
This commit is contained in:
Matt Gibson 2021-09-23 06:36:08 -04:00 committed by GitHub
parent c2d5106a4d
commit d39f45c81c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 4113 additions and 231 deletions

View File

@ -104,7 +104,7 @@ namespace Bit.CommCore.Services
} }
var providerUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, ownerUserId); 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."); throw new BadRequestException("Invalid owner.");
} }
@ -211,7 +211,7 @@ namespace Bit.CommCore.Services
{ {
throw new BadRequestException("User invalid."); throw new BadRequestException("User invalid.");
} }
if (providerUser.Status != ProviderUserStatusType.Invited) if (providerUser.Status != ProviderUserStatusType.Invited)
{ {
throw new BadRequestException("Already accepted."); throw new BadRequestException("Already accepted.");
@ -228,7 +228,7 @@ namespace Bit.CommCore.Services
{ {
throw new BadRequestException("User email does not match invite."); throw new BadRequestException("User email does not match invite.");
} }
providerUser.Status = ProviderUserStatusType.Accepted; providerUser.Status = ProviderUserStatusType.Accepted;
providerUser.UserId = user.Id; providerUser.UserId = user.Id;
providerUser.Email = null; providerUser.Email = null;
@ -252,7 +252,7 @@ namespace Bit.CommCore.Services
} }
var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList(); var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList();
var provider = await _providerRepository.GetByIdAsync(providerId); var provider = await _providerRepository.GetByIdAsync(providerId);
var users = await _userRepository.GetManyAsync(validOrganizationUserIds); var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
@ -274,7 +274,7 @@ namespace Bit.CommCore.Services
{ {
throw new BadRequestException("Invalid user."); throw new BadRequestException("Invalid user.");
} }
providerUser.Status = ProviderUserStatusType.Confirmed; providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Key = keys[providerUser.Id]; providerUser.Key = keys[providerUser.Id];
providerUser.Email = null; providerUser.Email = null;
@ -303,7 +303,7 @@ namespace Bit.CommCore.Services
} }
if (user.Type != ProviderUserType.ProviderAdmin && 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."); throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
} }
@ -413,14 +413,21 @@ namespace Bit.CommCore.Services
await _providerOrganizationRepository.CreateAsync(providerOrganization); await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
await _organizationService.InviteUserAsync(organization.Id, user.Id, null, new OrganizationUserInvite await _organizationService.InviteUsersAsync(organization.Id, user.Id,
{ new (OrganizationUserInvite, string)[]
Emails = new[] { clientOwnerEmail }, {
AccessAll = true, (
Type = OrganizationUserType.Owner, new OrganizationUserInvite
Permissions = null, {
Collections = Array.Empty<SelectionReadOnly>(), Emails = new[] { clientOwnerEmail },
}); AccessAll = true,
Type = OrganizationUserType.Owner,
Permissions = null,
Collections = Array.Empty<SelectionReadOnly>(),
},
null
)
});
return providerOrganization; return providerOrganization;
} }

View File

@ -470,12 +470,13 @@ namespace Bit.CommCore.Test.Services
.Received().LogProviderOrganizationEventAsync(providerOrganization, .Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created); EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>() await sutProvider.GetDependency<IOrganizationService>()
.Received().InviteUserAsync(organization.Id, user.Id, null, .Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
Arg.Is<OrganizationUserInvite>( t => t.Count() == 1 &&
i => i.Emails.Count() == 1 && t.First().Item1.Emails.Count() == 1 &&
i.Emails.First() == clientOwnerEmail && t.First().Item1.Emails.First() == clientOwnerEmail &&
i.Type == OrganizationUserType.Owner && t.First().Item1.Type == OrganizationUserType.Owner &&
i.AccessAll)); t.First().Item1.AccessAll &&
t.First().Item2 == null));
} }
[Theory, CustomAutoData(typeof(SutProviderCustomization))] [Theory, CustomAutoData(typeof(SutProviderCustomization))]

View File

@ -28,6 +28,7 @@ namespace Bit.Admin.Models
PlanType = org.PlanType; PlanType = org.PlanType;
Plan = org.Plan; Plan = org.Plan;
Seats = org.Seats; Seats = org.Seats;
MaxAutoscaleSeats = org.MaxAutoscaleSeats;
MaxCollections = org.MaxCollections; MaxCollections = org.MaxCollections;
UsePolicies = org.UsePolicies; UsePolicies = org.UsePolicies;
UseSso = org.UseSso; UseSso = org.UseSso;
@ -69,6 +70,8 @@ namespace Bit.Admin.Models
public string Plan { get; set; } public string Plan { get; set; }
[Display(Name = "Seats")] [Display(Name = "Seats")]
public int? Seats { get; set; } public int? Seats { get; set; }
[Display(Name = "Max. Autoscale Seats")]
public int? MaxAutoscaleSeats { get; set; }
[Display(Name = "Max. Collections")] [Display(Name = "Max. Collections")]
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
[Display(Name = "Policies")] [Display(Name = "Policies")]
@ -136,6 +139,7 @@ namespace Bit.Admin.Models
existingOrganization.Enabled = Enabled; existingOrganization.Enabled = Enabled;
existingOrganization.LicenseKey = LicenseKey; existingOrganization.LicenseKey = LicenseKey;
existingOrganization.ExpirationDate = ExpirationDate; existingOrganization.ExpirationDate = ExpirationDate;
existingOrganization.MaxAutoscaleSeats = MaxAutoscaleSeats;
return existingOrganization; return existingOrganization;
} }
} }

View File

@ -145,6 +145,14 @@
</div> </div>
</div> </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> <h2>Features</h2>
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseTotp"> <input type="checkbox" class="form-check-input" asp-for="UseTotp">

View File

@ -134,7 +134,8 @@ namespace Bit.Api.Controllers
} }
var userId = _userService.GetProperUserId(User); 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")] [HttpPost("reinvite")]

View File

@ -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")] [HttpPost("{id}/seat")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody]OrganizationSeatRequestModel model) public async Task<PaymentResponseModel> PostSeat(string id, [FromBody]OrganizationSeatRequestModel model)

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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}}

View File

@ -40,6 +40,7 @@ namespace Bit.Core.Models.Api
public string BillingAddressPostalCode { get; set; } public string BillingAddressPostalCode { get; set; }
[StringLength(2)] [StringLength(2)]
public string BillingAddressCountry { get; set; } public string BillingAddressCountry { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public virtual OrganizationSignup ToOrganizationSignup(User user) public virtual OrganizationSignup ToOrganizationSignup(User user)
{ {
@ -52,6 +53,7 @@ namespace Bit.Core.Models.Api
PaymentMethodType = PaymentMethodType, PaymentMethodType = PaymentMethodType,
PaymentToken = PaymentToken, PaymentToken = PaymentToken,
AdditionalSeats = AdditionalSeats, AdditionalSeats = AdditionalSeats,
MaxAutoscaleSeats = MaxAutoscaleSeats,
AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0), AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0),
PremiumAccessAddon = PremiumAccessAddon, PremiumAccessAddon = PremiumAccessAddon,
BillingEmail = BillingEmail, BillingEmail = BillingEmail,

View File

@ -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; }
}
}

View File

@ -30,6 +30,7 @@ namespace Bit.Core.Models.Api
Plan = new PlanResponseModel(Utilities.StaticStore.Plans.FirstOrDefault(plan => plan.Type == organization.PlanType)); Plan = new PlanResponseModel(Utilities.StaticStore.Plans.FirstOrDefault(plan => plan.Type == organization.PlanType));
PlanType = organization.PlanType; PlanType = organization.PlanType;
Seats = organization.Seats; Seats = organization.Seats;
MaxAutoscaleSeats = organization.MaxAutoscaleSeats;
MaxCollections = organization.MaxCollections; MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb; MaxStorageGb = organization.MaxStorageGb;
UsePolicies = organization.UsePolicies; UsePolicies = organization.UsePolicies;
@ -59,6 +60,7 @@ namespace Bit.Core.Models.Api
public PlanResponseModel Plan { get; set; } public PlanResponseModel Plan { get; set; }
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
public int? Seats { get; set; } public int? Seats { get; set; }
public int? MaxAutoscaleSeats { get; set; } = null;
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public bool UsePolicies { get; set; } public bool UsePolicies { get; set; }

View File

@ -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) if (!Enabled || Issued > DateTime.UtcNow || Expires < DateTime.UtcNow)
{ {

View File

@ -12,5 +12,6 @@ namespace Bit.Core.Models.Business
public string CollectionName { get; set; } public string CollectionName { get; set; }
public PaymentMethodType? PaymentMethodType { get; set; } public PaymentMethodType? PaymentMethodType { get; set; }
public string PaymentToken { get; set; } public string PaymentToken { get; set; }
public int? MaxAutoscaleSeats { get; set; } = null;
} }
} }

View File

@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using Bit.Core.Models.Table; using Bit.Core.Models.Table;
using Stripe; using Stripe;
using StaticStore = Bit.Core.Models.StaticStore;
namespace Bit.Core.Models.Business namespace Bit.Core.Models.Business
{ {
@ -11,6 +10,10 @@ namespace Bit.Core.Models.Business
public abstract SubscriptionItemOptions RevertItemOptions(Subscription subscription); public abstract SubscriptionItemOptions RevertItemOptions(Subscription subscription);
public abstract SubscriptionItemOptions UpgradeItemOptions(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) => protected SubscriptionItem SubscriptionItem(Subscription subscription) =>
subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == PlanId); subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == PlanId);
} }

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -15,6 +15,7 @@ namespace Bit.Core.Models.StaticStore
public short? BaseStorageGb { get; set; } public short? BaseStorageGb { get; set; }
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxUsers { get; set; } public short? MaxUsers { get; set; }
public bool AllowSeatAutoscale { get; set; }
public bool HasAdditionalSeatsOption { get; set; } public bool HasAdditionalSeatsOption { get; set; }
public int? MaxAdditionalSeats { get; set; } public int? MaxAdditionalSeats { get; set; }

View File

@ -66,6 +66,8 @@ namespace Bit.Core.Models.Table
public DateTime? ExpirationDate { get; set; } public DateTime? ExpirationDate { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { 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() public void SetNewId()
{ {

View File

@ -20,7 +20,7 @@ namespace Bit.Core.Repositories.EntityFramework
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationUsers) : 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); var organizationUser = await base.CreateAsync(obj);
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())
@ -41,13 +41,15 @@ namespace Bit.Core.Repositories.EntityFramework
await dbContext.CollectionUsers.AddRangeAsync(collectionUsers); await dbContext.CollectionUsers.AddRangeAsync(collectionUsers);
await dbContext.SaveChangesAsync(); 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()) if (!organizationUsers.Any())
{ {
return; return new List<Guid>();
} }
foreach (var organizationUser in organizationUsers) foreach (var organizationUser in organizationUsers)
@ -61,6 +63,8 @@ namespace Bit.Core.Repositories.EntityFramework
var entities = Mapper.Map<List<EfModel.OrganizationUser>>(organizationUsers); var entities = Mapper.Map<List<EfModel.OrganizationUser>>(organizationUsers);
await dbContext.AddRangeAsync(entities); await dbContext.AddRangeAsync(entities);
} }
return organizationUsers.Select(u => u.Id).ToList();
} }
public async Task DeleteManyAsync(IEnumerable<Guid> organizationUserIds) public async Task DeleteManyAsync(IEnumerable<Guid> organizationUserIds)

View File

@ -28,8 +28,8 @@ namespace Bit.Core.Repositories
OrganizationUserStatusType? status = null); OrganizationUserStatusType? status = null);
Task UpdateGroupsAsync(Guid orgUserId, IEnumerable<Guid> groupIds); Task UpdateGroupsAsync(Guid orgUserId, IEnumerable<Guid> groupIds);
Task UpsertManyAsync(IEnumerable<OrganizationUser> organizationUsers); Task UpsertManyAsync(IEnumerable<OrganizationUser> organizationUsers);
Task CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections); Task<Guid> CreateAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
Task CreateManyAsync(IEnumerable<OrganizationUser> organizationIdUsers); Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationIdUsers);
Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections); Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections);
Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers); Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers);
Task<ICollection<OrganizationUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds); Task<ICollection<OrganizationUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);

View File

@ -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(); obj.SetNewId();
var objWithCollections = JsonConvert.DeserializeObject<OrganizationUserWithCollections>( var objWithCollections = JsonConvert.DeserializeObject<OrganizationUserWithCollections>(
@ -254,6 +254,8 @@ namespace Bit.Core.Repositories.SqlServer
objWithCollections, objWithCollections,
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
return obj.Id;
} }
public async Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections) public async Task ReplaceAsync(OrganizationUser obj, IEnumerable<SelectionReadOnly> collections)
@ -339,11 +341,11 @@ namespace Bit.Core.Repositories.SqlServer
await ReplaceManyAsync(replaceUsers); await ReplaceManyAsync(replaceUsers);
} }
public async Task CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers) public async Task<ICollection<Guid>> CreateManyAsync(IEnumerable<OrganizationUser> organizationUsers)
{ {
if (!organizationUsers.Any()) if (!organizationUsers.Any())
{ {
return; return default;
} }
foreach(var organizationUser in organizationUsers) foreach(var organizationUser in organizationUsers)
@ -359,6 +361,8 @@ namespace Bit.Core.Repositories.SqlServer
new { OrganizationUsersInput = orgUsersTVP }, new { OrganizationUsersInput = orgUsersTVP },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
return organizationUsers.Select(u => u.Id).ToList();
} }
public async Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers) public async Task ReplaceManyAsync(IEnumerable<OrganizationUser> organizationUsers)

View File

@ -20,6 +20,8 @@ namespace Bit.Core.Services
Task SendMasterPasswordHintEmailAsync(string email, string hint); Task SendMasterPasswordHintEmailAsync(string email, string hint);
Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token); Task SendOrganizationInviteEmailAsync(string organizationName, OrganizationUser orgUser, ExpiringToken token);
Task BulkSendOrganizationInviteEmailAsync(string organizationName, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> invites); 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 SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email); Task SendOrganizationConfirmedEmailAsync(string organizationName, string email);
Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email);

View File

@ -17,7 +17,8 @@ namespace Bit.Core.Services
Task ReinstateSubscriptionAsync(Guid organizationId); Task ReinstateSubscriptionAsync(Guid organizationId);
Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade); Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade);
Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); 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 VerifyBankAsync(Guid organizationId, int amount1, int amount2);
Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false); Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false);
Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationLicense license, User owner, 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 UpdateAsync(Organization organization, bool updateBilling = false);
Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type);
Task DisableTwoFactorProviderAsync(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, Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, string email,
OrganizationUserType type, bool accessAll, string externalId, IEnumerable<SelectionReadOnly> collections); 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<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId);
Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token, Task<OrganizationUser> AcceptUserAsync(Guid organizationUserId, User user, string token,

View File

@ -144,6 +144,35 @@ namespace Bit.Core.Services
await _mailDeliveryService.SendEmailAsync(message); 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, public async Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier,
IEnumerable<string> adminEmails) IEnumerable<string> adminEmails)
{ {

View File

@ -16,6 +16,7 @@ using System.IO;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Text.Json; using System.Text.Json;
using Bit.Core.Context; using Bit.Core.Context;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services namespace Bit.Core.Services
{ {
@ -40,9 +41,11 @@ namespace Bit.Core.Services
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository; private readonly ISsoUserRepository _ssoUserRepository;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly GlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly ITaxRateRepository _taxRateRepository; private readonly ITaxRateRepository _taxRateRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ILogger<OrganizationService> _logger;
public OrganizationService( public OrganizationService(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -64,9 +67,10 @@ namespace Bit.Core.Services
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository, ISsoUserRepository ssoUserRepository,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
GlobalSettings globalSettings, IGlobalSettings globalSettings,
ITaxRateRepository taxRateRepository, ITaxRateRepository taxRateRepository,
ICurrentContext currentContext) ICurrentContext currentContext,
ILogger<OrganizationService> logger)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -90,6 +94,7 @@ namespace Bit.Core.Services
_globalSettings = globalSettings; _globalSettings = globalSettings;
_taxRateRepository = taxRateRepository; _taxRateRepository = taxRateRepository;
_currentContext = currentContext; _currentContext = currentContext;
_logger = logger;
} }
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -241,14 +246,14 @@ namespace Bit.Core.Services
$"Disable your SSO configuration."); $"Disable your SSO configuration.");
} }
} }
if (!newPlan.HasResetPassword && organization.UseResetPassword) if (!newPlan.HasResetPassword && organization.UseResetPassword)
{ {
var resetPasswordPolicy = var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) 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."); "Disable your Password Reset policy.");
} }
} }
@ -347,7 +352,7 @@ namespace Bit.Core.Services
return secret; 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); var organization = await GetOrgById(organizationId);
if (organization == null) if (organization == null)
@ -355,6 +360,69 @@ namespace Bit.Core.Services
throw new NotFoundException(); 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) if (organization.Seats == null)
{ {
throw new BadRequestException("Organization has no seat limit, no need to adjust seats"); 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; organization.Seats = (short?)newSeatTotal;
await ReplaceAndUpdateCache(organization); 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; return paymentIntentClientSecret;
} }
@ -470,7 +556,7 @@ namespace Bit.Core.Services
bool provider = false) bool provider = false)
{ {
var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == signup.Plan); 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."); 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) var orgsWithSingleOrgPolicy = policies.Where(p => p.Enabled && p.Type == PolicyType.SingleOrg)
.Select(p => p.OrganizationId); .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.Type != OrganizationUserType.Admin &&
ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Invited &&
orgsWithSingleOrgPolicy.Contains(ou.OrganizationId)); 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."); $"Your new license does not allow for the use of SSO. Disable your SSO configuration.");
} }
} }
if (!license.UseResetPassword && organization.UseResetPassword) if (!license.UseResetPassword && organization.UseResetPassword)
{ {
var resetPasswordPolicy = var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) 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."); + "Disable your Password Reset policy.");
} }
} }
@ -964,11 +1050,12 @@ namespace Bit.Core.Services
await UpdateAsync(organization); 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) IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
{ {
var organization = await GetOrgById(organizationId); 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(); 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) if (organization.Seats.HasValue)
{ {
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId); var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId);
var availableSeats = organization.Seats.Value - userCount; var availableSeats = organization.Seats.Value - userCount;
if (availableSeats < invites.Select(i => i.invite.Emails.Count()).Sum()) newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats;
{
throw new BadRequestException("You have reached the maximum number of users " +
$"({organization.Seats.Value}) for this organization.");
}
} }
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 orgUsers = new List<OrganizationUser>();
var limitedCollectionOrgUsers = new List<(OrganizationUser, IEnumerable<SelectionReadOnly>)>();
var orgUserInvitedCount = 0; var orgUserInvitedCount = 0;
var exceptions = new List<Exception>(); var exceptions = new List<Exception>();
var events = new List<(OrganizationUser, EventType, DateTime?)>(); 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 (invite, externalId) in invites)
{ {
foreach (var email in invite.Emails) foreach (var email in invite.Emails)
@ -1036,11 +1134,14 @@ namespace Bit.Core.Services
if (!orgUser.AccessAll && invite.Collections.Any()) 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)); events.Add((orgUser, EventType.OrganizationUser_Invited, DateTime.UtcNow));
orgUsers.Add(orgUser);
orgUserInvitedCount++; orgUserInvitedCount++;
} }
catch (Exception e) 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 try
{ {
await _organizationUserRepository.CreateManyAsync(orgUsers); 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 SendInvitesAsync(orgUsers, organization);
await _eventService.LogOrganizationUserEventsAsync(events); await _eventService.LogOrganizationUserEventsAsync(events);
@ -1064,6 +1177,16 @@ namespace Bit.Core.Services
} }
catch (Exception e) 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); exceptions.Add(e);
} }
@ -1075,94 +1198,6 @@ namespace Bit.Core.Services
return orgUsers; 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, public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId,
IEnumerable<Guid> organizationUsersId) IEnumerable<Guid> organizationUsersId)
{ {
@ -1349,7 +1384,7 @@ namespace Bit.Core.Services
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId, IUserService userService) 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); confirmingUserId, userService);
if (!result.Any()) if (!result.Any())
@ -1364,7 +1399,7 @@ namespace Bit.Core.Services
} }
return orgUser; return orgUser;
} }
public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys, public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId, IUserService userService) Guid confirmingUserId, IUserService userService)
{ {
@ -1379,7 +1414,7 @@ namespace Bit.Core.Services
} }
var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList(); var validOrganizationUserIds = validOrganizationUsers.Select(u => u.UserId.Value).ToList();
var organization = await GetOrgById(organizationId); var organization = await GetOrgById(organizationId);
var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId); var policies = await _policyRepository.GetManyByOrganizationIdAsync(organizationId);
var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds); var usersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validOrganizationUserIds);
@ -1417,7 +1452,7 @@ namespace Bit.Core.Services
orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Status = OrganizationUserStatusType.Confirmed;
orgUser.Key = keys[orgUser.Id]; orgUser.Key = keys[orgUser.Id];
orgUser.Email = null; orgUser.Email = null;
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
await _mailService.SendOrganizationConfirmedEmailAsync(organization.Name, user.Email); await _mailService.SendOrganizationConfirmedEmailAsync(organization.Name, user.Email);
await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
@ -1435,6 +1470,68 @@ namespace Bit.Core.Services
return result; 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, private async Task CheckPolicies(ICollection<Policy> policies, Guid organizationId, User user,
ICollection<OrganizationUser> userOrgs, IUserService userService) ICollection<OrganizationUser> userOrgs, IUserService userService)
{ {
@ -1474,7 +1571,7 @@ namespace Bit.Core.Services
} }
if (user.Type != OrganizationUserType.Owner && 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."); 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."); 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."); throw new BadRequestException("Organization must have at least one confirmed owner.");
} }
@ -1529,7 +1626,7 @@ namespace Bit.Core.Services
throw new NotFoundException(); 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."); 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 // 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); 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) orgUser.OrganizationId != organizationId)
{ {
throw new BadRequestException("User not valid."); throw new BadRequestException("User not valid.");
} }
// Make sure the organization has the ability to use password reset // Make sure the organization has the ability to use password reset
var org = await _organizationRepository.GetByIdAsync(organizationId); var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null || !org.UseResetPassword) 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."); throw new BadRequestException("Organization does not have the password reset policy enabled.");
} }
// Block the user from withdrawal if auto enrollment is enabled // Block the user from withdrawal if auto enrollment is enabled
if (resetPasswordKey == null && resetPasswordPolicy.Data != null) 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."); throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from Password Reset.");
} }
} }
orgUser.ResetPasswordKey = resetPasswordKey; orgUser.ResetPasswordKey = resetPasswordKey;
await _organizationUserRepository.ReplaceAsync(orgUser); await _organizationUserRepository.ReplaceAsync(orgUser);
await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ? await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ?
@ -1702,7 +1799,8 @@ namespace Bit.Core.Services
AccessAll = accessAll, AccessAll = accessAll,
Collections = collections, 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(); var result = results.FirstOrDefault();
if (result == null) if (result == null)
{ {
@ -1797,11 +1895,6 @@ namespace Bit.Core.Services
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; 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)>(); var userInvites = new List<(OrganizationUserInvite, string)>();
foreach (var user in newUsers) foreach (var user in newUsers)
{ {
@ -1891,7 +1984,7 @@ namespace Bit.Core.Services
} }
} }
} }
await _referenceEventService.RaiseEventAsync( await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.DirectorySynced, organization)); new ReferenceEvent(ReferenceEventType.DirectorySynced, organization));
} }
@ -1934,7 +2027,7 @@ namespace Bit.Core.Services
org.PublicKey = publicKey; org.PublicKey = publicKey;
org.PrivateKey = privateKey; org.PrivateKey = privateKey;
await UpdateAsync(org); await UpdateAsync(org);
return org; return org;
} }

View File

@ -718,6 +718,12 @@ namespace Bit.Core.Services
ProrationDate = prorationDate, ProrationDate = prorationDate,
}; };
if (!subscriptionUpdate.UpdateNeeded(sub))
{
// No need to update subscription, quantity matches
return null;
}
var customer = await new CustomerService().GetAsync(sub.CustomerId); var customer = await new CustomerService().GetAsync(sub.CustomerId);
if (!string.IsNullOrWhiteSpace(customer?.Address?.Country) if (!string.IsNullOrWhiteSpace(customer?.Address?.Country)
&& !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode)) && !string.IsNullOrWhiteSpace(customer?.Address?.PostalCode))

View File

@ -35,6 +35,16 @@ namespace Bit.Core.Services
return Task.FromResult(0); 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) public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails)
{ {
return Task.FromResult(0); return Task.FromResult(0);

View File

@ -1,8 +1,14 @@
using static Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Settings namespace Bit.Core.Settings
{ {
public interface IGlobalSettings public interface IGlobalSettings
{ {
// This interface exists for testing. Add settings here as needed for testing // 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; } IFileStorageSettings Attachment { get; set; }
} }
} }

View File

@ -643,7 +643,7 @@ namespace Bit.Core.Utilities
} }
public static bool UserInviteTokenIsValid(IDataProtector protector, string token, string userEmail, 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, return TokenIsValid("OrganizationUserInvite", protector, token, userEmail, orgUserId,
globalSettings.OrganizationInviteExpirationHours); globalSettings.OrganizationInviteExpirationHours);

View File

@ -115,7 +115,9 @@ namespace Bit.Core.Utilities
MaxUsers = 2, MaxUsers = 2,
UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to
DisplaySortOrder = -1 DisplaySortOrder = -1,
AllowSeatAutoscale = false,
}, },
new Plan new Plan
{ {
@ -145,7 +147,9 @@ namespace Bit.Core.Utilities
StripePremiumAccessPlanId = "personal-org-premium-access-annually", StripePremiumAccessPlanId = "personal-org-premium-access-annually",
BasePrice = 12, BasePrice = 12,
AdditionalStoragePricePerGb = 4, AdditionalStoragePricePerGb = 4,
PremiumAccessOptionPrice = 40 PremiumAccessOptionPrice = 40,
AllowSeatAutoscale = false,
}, },
new Plan new Plan
{ {
@ -174,7 +178,9 @@ namespace Bit.Core.Utilities
StripeStoragePlanId = "storage-gb-annually", StripeStoragePlanId = "storage-gb-annually",
BasePrice = 60, BasePrice = 60,
SeatPrice = 24, SeatPrice = 24,
AdditionalStoragePricePerGb = 4 AdditionalStoragePricePerGb = 4,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -202,7 +208,9 @@ namespace Bit.Core.Utilities
StripeStoragePlanId = "storage-gb-monthly", StripeStoragePlanId = "storage-gb-monthly",
BasePrice = 8, BasePrice = 8,
SeatPrice = 2.5M, SeatPrice = 2.5M,
AdditionalStoragePricePerGb = 0.5M AdditionalStoragePricePerGb = 0.5M,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -239,7 +247,9 @@ namespace Bit.Core.Utilities
StripeStoragePlanId = "storage-gb-annually", StripeStoragePlanId = "storage-gb-annually",
BasePrice = 0, BasePrice = 0,
SeatPrice = 36, SeatPrice = 36,
AdditionalStoragePricePerGb = 4 AdditionalStoragePricePerGb = 4,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -275,7 +285,9 @@ namespace Bit.Core.Utilities
StripeStoragePlanId = "storage-gb-monthly", StripeStoragePlanId = "storage-gb-monthly",
BasePrice = 0, BasePrice = 0,
SeatPrice = 4M, SeatPrice = 4M,
AdditionalStoragePricePerGb = 0.5M AdditionalStoragePricePerGb = 0.5M,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -302,7 +314,9 @@ namespace Bit.Core.Utilities
StripePlanId = "2020-families-org-annually", StripePlanId = "2020-families-org-annually",
StripeStoragePlanId = "storage-gb-annually", StripeStoragePlanId = "storage-gb-annually",
BasePrice = 40, BasePrice = 40,
AdditionalStoragePricePerGb = 4 AdditionalStoragePricePerGb = 4,
AllowSeatAutoscale = false,
}, },
new Plan new Plan
{ {
@ -334,7 +348,9 @@ namespace Bit.Core.Utilities
StripeSeatPlanId = "2020-teams-org-seat-annually", StripeSeatPlanId = "2020-teams-org-seat-annually",
StripeStoragePlanId = "storage-gb-annually", StripeStoragePlanId = "storage-gb-annually",
SeatPrice = 36, SeatPrice = 36,
AdditionalStoragePricePerGb = 4 AdditionalStoragePricePerGb = 4,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -365,7 +381,9 @@ namespace Bit.Core.Utilities
StripeSeatPlanId = "2020-teams-org-seat-monthly", StripeSeatPlanId = "2020-teams-org-seat-monthly",
StripeStoragePlanId = "storage-gb-monthly", StripeStoragePlanId = "storage-gb-monthly",
SeatPrice = 4, SeatPrice = 4,
AdditionalStoragePricePerGb = 0.5M AdditionalStoragePricePerGb = 0.5M,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -402,7 +420,9 @@ namespace Bit.Core.Utilities
StripeStoragePlanId = "storage-gb-annually", StripeStoragePlanId = "storage-gb-annually",
BasePrice = 0, BasePrice = 0,
SeatPrice = 60, SeatPrice = 60,
AdditionalStoragePricePerGb = 4 AdditionalStoragePricePerGb = 4,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
@ -438,11 +458,15 @@ namespace Bit.Core.Utilities
StripeStoragePlanId = "storage-gb-monthly", StripeStoragePlanId = "storage-gb-monthly",
BasePrice = 0, BasePrice = 0,
SeatPrice = 6, SeatPrice = 6,
AdditionalStoragePricePerGb = 0.5M AdditionalStoragePricePerGb = 0.5M,
AllowSeatAutoscale = true,
}, },
new Plan new Plan
{ {
Type = PlanType.Custom Type = PlanType.Custom,
AllowSeatAutoscale = true,
}, },
}; };

View File

@ -38,7 +38,9 @@
@TwoFactorProviders NVARCHAR(MAX), @TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7), @ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7), @CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -84,7 +86,9 @@ BEGIN
[TwoFactorProviders], [TwoFactorProviders],
[ExpirationDate], [ExpirationDate],
[CreationDate], [CreationDate],
[RevisionDate] [RevisionDate],
[OwnersNotifiedOfAutoscaling],
[MaxAutoscaleSeats]
) )
VALUES VALUES
( (
@ -127,6 +131,8 @@ BEGIN
@TwoFactorProviders, @TwoFactorProviders,
@ExpirationDate, @ExpirationDate,
@CreationDate, @CreationDate,
@RevisionDate @RevisionDate,
@OwnersNotifiedOfAutoscaling,
@MaxAutoscaleSeats
) )
END END

View File

@ -38,7 +38,9 @@
@TwoFactorProviders NVARCHAR(MAX), @TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7), @ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7), @CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -84,7 +86,9 @@ BEGIN
[TwoFactorProviders] = @TwoFactorProviders, [TwoFactorProviders] = @TwoFactorProviders,
[ExpirationDate] = @ExpirationDate, [ExpirationDate] = @ExpirationDate,
[CreationDate] = @CreationDate, [CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate [RevisionDate] = @RevisionDate,
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats
WHERE WHERE
[Id] = @Id [Id] = @Id
END END

View File

@ -1,44 +1,46 @@
CREATE TABLE [dbo].[Organization] ( CREATE TABLE [dbo].[Organization] (
[Id] UNIQUEIDENTIFIER NOT NULL, [Id] UNIQUEIDENTIFIER NOT NULL,
[Identifier] NVARCHAR (50) NULL, [Identifier] NVARCHAR (50) NULL,
[Name] NVARCHAR (50) NOT NULL, [Name] NVARCHAR (50) NOT NULL,
[BusinessName] NVARCHAR (50) NULL, [BusinessName] NVARCHAR (50) NULL,
[BusinessAddress1] NVARCHAR (50) NULL, [BusinessAddress1] NVARCHAR (50) NULL,
[BusinessAddress2] NVARCHAR (50) NULL, [BusinessAddress2] NVARCHAR (50) NULL,
[BusinessAddress3] NVARCHAR (50) NULL, [BusinessAddress3] NVARCHAR (50) NULL,
[BusinessCountry] VARCHAR (2) NULL, [BusinessCountry] VARCHAR (2) NULL,
[BusinessTaxNumber] NVARCHAR (30) NULL, [BusinessTaxNumber] NVARCHAR (30) NULL,
[BillingEmail] NVARCHAR (256) NOT NULL, [BillingEmail] NVARCHAR (256) NOT NULL,
[Plan] NVARCHAR (50) NOT NULL, [Plan] NVARCHAR (50) NOT NULL,
[PlanType] TINYINT NOT NULL, [PlanType] TINYINT NOT NULL,
[Seats] INT NULL, [Seats] INT NULL,
[MaxCollections] SMALLINT NULL, [MaxCollections] SMALLINT NULL,
[UsePolicies] BIT NOT NULL, [UsePolicies] BIT NOT NULL,
[UseSso] BIT NOT NULL, [UseSso] BIT NOT NULL,
[UseGroups] BIT NOT NULL, [UseGroups] BIT NOT NULL,
[UseDirectory] BIT NOT NULL, [UseDirectory] BIT NOT NULL,
[UseEvents] BIT NOT NULL, [UseEvents] BIT NOT NULL,
[UseTotp] BIT NOT NULL, [UseTotp] BIT NOT NULL,
[Use2fa] BIT NOT NULL, [Use2fa] BIT NOT NULL,
[UseApi] BIT NOT NULL, [UseApi] BIT NOT NULL,
[UseResetPassword] BIT NOT NULL, [UseResetPassword] BIT NOT NULL,
[SelfHost] BIT NOT NULL, [SelfHost] BIT NOT NULL,
[UsersGetPremium] BIT NOT NULL, [UsersGetPremium] BIT NOT NULL,
[Storage] BIGINT NULL, [Storage] BIGINT NULL,
[MaxStorageGb] SMALLINT NULL, [MaxStorageGb] SMALLINT NULL,
[Gateway] TINYINT NULL, [Gateway] TINYINT NULL,
[GatewayCustomerId] VARCHAR (50) NULL, [GatewayCustomerId] VARCHAR (50) NULL,
[GatewaySubscriptionId] VARCHAR (50) NULL, [GatewaySubscriptionId] VARCHAR (50) NULL,
[ReferenceData] NVARCHAR (MAX) NULL, [ReferenceData] NVARCHAR (MAX) NULL,
[Enabled] BIT NOT NULL, [Enabled] BIT NOT NULL,
[LicenseKey] VARCHAR (100) NULL, [LicenseKey] VARCHAR (100) NULL,
[ApiKey] VARCHAR (30) NOT NULL, [ApiKey] VARCHAR (30) NOT NULL,
[PublicKey] VARCHAR (MAX) NULL, [PublicKey] VARCHAR (MAX) NULL,
[PrivateKey] VARCHAR (MAX) NULL, [PrivateKey] VARCHAR (MAX) NULL,
[TwoFactorProviders] NVARCHAR (MAX) NULL, [TwoFactorProviders] NVARCHAR (MAX) NULL,
[ExpirationDate] DATETIME2 (7) NULL, [ExpirationDate] DATETIME2 (7) NULL,
[CreationDate] DATETIME2 (7) NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL,
[OwnersNotifiedOfAutoscaling] DATETIME2(7) NULL,
[MaxAutoscaleSeats] INT NULL,
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC) CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
); );

View File

@ -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 internal class FreeOrganizationUpgrade : ICustomization
{ {
public void Customize(IFixture fixture) public void Customize(IFixture fixture)
@ -133,18 +142,30 @@ namespace Bit.Core.Test.AutoFixture.OrganizationFixtures
internal class PaidOrganizationAutoDataAttribute : CustomAutoDataAttribute internal class PaidOrganizationAutoDataAttribute : CustomAutoDataAttribute
{ {
public PaidOrganizationAutoDataAttribute(int planType = 0) : base(new SutProviderCustomization(), public PaidOrganizationAutoDataAttribute(PlanType planType) : base(new SutProviderCustomization(),
new PaidOrganization { CheckedPlanType = (PlanType)planType }) new PaidOrganization { CheckedPlanType = planType })
{ } { }
public PaidOrganizationAutoDataAttribute(int planType = 0) : this((PlanType)planType) { }
} }
internal class InlinePaidOrganizationAutoDataAttribute : InlineCustomAutoDataAttribute 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), public InlinePaidOrganizationAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization),
typeof(PaidOrganization) }, values) typeof(PaidOrganization) }, values)
{ } { }
} }
internal class InlineFreeOrganizationAutoDataAttribute : InlineCustomAutoDataAttribute
{
public InlineFreeOrganizationAutoDataAttribute(params object[] values) : base(new[] { typeof(SutProviderCustomization),
typeof(FreeOrganization) }, values)
{ }
}
internal class FreeOrganizationUpgradeAutoDataAttribute : CustomAutoDataAttribute internal class FreeOrganizationUpgradeAutoDataAttribute : CustomAutoDataAttribute
{ {
public FreeOrganizationUpgradeAutoDataAttribute() : base(new SutProviderCustomization(), new FreeOrganizationUpgrade()) public FreeOrganizationUpgradeAutoDataAttribute() : base(new SutProviderCustomization(), new FreeOrganizationUpgrade())

View File

@ -21,6 +21,8 @@ using Organization = Bit.Core.Models.Table.Organization;
using OrganizationUser = Bit.Core.Models.Table.OrganizationUser; using OrganizationUser = Bit.Core.Models.Table.OrganizationUser;
using Policy = Bit.Core.Models.Table.Policy; using Policy = Bit.Core.Models.Table.Policy;
using Bit.Core.Test.AutoFixture.PolicyFixtures; using Bit.Core.Test.AutoFixture.PolicyFixtures;
using Bit.Core.Settings;
using AutoFixture.Xunit2;
namespace Bit.Core.Test.Services namespace Bit.Core.Test.Services
{ {
@ -40,11 +42,15 @@ namespace Bit.Core.Test.Services
}); });
var expectedNewUsersCount = newUsers.Count - 1; var expectedNewUsersCount = newUsers.Count - 1;
existingUsers.First().Type = OrganizationUserType.Owner;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id) sutProvider.GetDependency<IOrganizationUserRepository>().GetManyDetailsByOrganizationAsync(org.Id)
.Returns(existingUsers); .Returns(existingUsers);
sutProvider.GetDependency<IOrganizationUserRepository>().GetCountByOrganizationIdAsync(org.Id) sutProvider.GetDependency<IOrganizationUserRepository>().GetCountByOrganizationIdAsync(org.Id)
.Returns(existingUsers.Count); .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); sutProvider.GetDependency<ICurrentContext>().ManageUsers(org.Id).Returns(true);
await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false); await sutProvider.Sut.ImportAsync(org.Id, userId, null, newUsers, null, false);
@ -96,6 +102,8 @@ namespace Bit.Core.Test.Services
.Returns(existingUsers.Count); .Returns(existingUsers.Count);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(reInvitedUser.Id) sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(reInvitedUser.Id)
.Returns(new OrganizationUser { Id = 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>(); var currentContext = sutProvider.GetDependency<ICurrentContext>();
currentContext.ManageUsers(org.Id).Returns(true); currentContext.ManageUsers(org.Id).Returns(true);
@ -188,7 +196,7 @@ namespace Bit.Core.Test.Services
invite.Emails = null; invite.Emails = null;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await Assert.ThrowsAsync<NotFoundException>( 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] [Theory]
@ -201,8 +209,9 @@ namespace Bit.Core.Test.Services
{ {
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true); sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().ManageUsers(organization.Id).Returns(true);
var exception = await Assert.ThrowsAsync<BadRequestException>( 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); 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); currentContext.OrganizationAdmin(organization.Id).Returns(true);
var exception = await Assert.ThrowsAsync<BadRequestException>( 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()); Assert.Contains("only an owner", exception.Message.ToLowerInvariant());
} }
@ -240,7 +249,7 @@ namespace Bit.Core.Test.Services
currentContext.OrganizationUser(organization.Id).Returns(true); currentContext.OrganizationUser(organization.Id).Returns(true);
var exception = await Assert.ThrowsAsync<BadRequestException>( 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()); Assert.Contains("only owners and admins", exception.Message.ToLowerInvariant());
} }
@ -266,7 +275,7 @@ namespace Bit.Core.Test.Services
currentContext.ManageUsers(organization.Id).Returns(false); currentContext.ManageUsers(organization.Id).Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>( 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()); 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); currentContext.ManageUsers(organization.Id).Returns(true);
var exception = await Assert.ThrowsAsync<BadRequestException>( 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()); Assert.Contains("can not manage admins", exception.Message.ToLowerInvariant());
} }
@ -314,8 +323,9 @@ namespace Bit.Core.Test.Services
organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner)
.Returns(new [] {invitor}); .Returns(new [] {invitor});
currentContext.OrganizationOwner(organization.Id).Returns(true); 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] [Theory]
@ -343,7 +353,7 @@ namespace Bit.Core.Test.Services
.Returns(new [] {owner}); .Returns(new [] {owner});
currentContext.ManageUsers(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, CustomAutoData(typeof(SutProviderCustomization))] [Theory, CustomAutoData(typeof(SutProviderCustomization))]
@ -819,5 +829,80 @@ namespace Bit.Core.Test.Services
await sutProvider.Sut.UpdateOrganizationKeysAsync(org.Id, publicKey, privateKey); 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);
}
} }
} }

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -15,7 +15,7 @@ namespace Bit.MySqlMigrations.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 64) .HasAnnotation("Relational:MaxIdentifierLength", 64)
.HasAnnotation("ProductVersion", "5.0.5"); .HasAnnotation("ProductVersion", "5.0.9");
modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b =>
{ {
@ -278,6 +278,9 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<Guid?>("ProviderId") b.Property<Guid?>("ProviderId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<Guid?>("ProviderOrganizationId")
.HasColumnType("char(36)");
b.Property<Guid?>("ProviderUserId") b.Property<Guid?>("ProviderUserId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@ -500,6 +503,9 @@ namespace Bit.MySqlMigrations.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("varchar(100)"); .HasColumnType("varchar(100)");
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("int");
b.Property<short?>("MaxCollections") b.Property<short?>("MaxCollections")
.HasColumnType("smallint"); .HasColumnType("smallint");
@ -510,6 +516,9 @@ namespace Bit.MySqlMigrations.Migrations
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("varchar(50)"); .HasColumnType("varchar(50)");
b.Property<DateTime?>("OwnersNotifiedOfAutoscaling")
.HasColumnType("datetime(6)");
b.Property<string>("Plan") b.Property<string>("Plan")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("varchar(50)"); .HasColumnType("varchar(50)");

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -17,7 +17,7 @@ namespace Bit.PostgresMigrations.Migrations
modelBuilder modelBuilder
.HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False")
.HasAnnotation("Relational:MaxIdentifierLength", 63) .HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.5") .HasAnnotation("ProductVersion", "5.0.9")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b => modelBuilder.Entity("Bit.Core.Models.EntityFramework.Cipher", b =>
@ -281,6 +281,9 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<Guid?>("ProviderId") b.Property<Guid?>("ProviderId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("ProviderOrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ProviderUserId") b.Property<Guid?>("ProviderUserId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@ -504,6 +507,9 @@ namespace Bit.PostgresMigrations.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("integer");
b.Property<short?>("MaxCollections") b.Property<short?>("MaxCollections")
.HasColumnType("smallint"); .HasColumnType("smallint");
@ -514,6 +520,9 @@ namespace Bit.PostgresMigrations.Migrations
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
b.Property<DateTime?>("OwnersNotifiedOfAutoscaling")
.HasColumnType("timestamp without time zone");
b.Property<string>("Plan") b.Property<string>("Plan")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");

View File

@ -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;