1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 13:08:17 -05:00

[EC-387] Don't count revoked users towards occupied seat count (#2256)

Also autoscale seats when restoring user if required
This commit is contained in:
Thomas Rittson 2022-09-23 14:30:39 +10:00 committed by GitHub
parent c494d344d2
commit 7c3637c8ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 49 additions and 21 deletions

View File

@ -483,9 +483,9 @@ public class AccountController : Controller
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
if (orgUser == null && organization.Seats.HasValue) if (orgUser == null && organization.Seats.HasValue)
{ {
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(orgId); var occupiedSeats = await _organizationService.GetOccupiedSeatCount(organization);
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - userCount; var availableSeats = initialSeatCount - occupiedSeats;
var prorationDate = DateTime.UtcNow; var prorationDate = DateTime.UtcNow;
if (availableSeats < 1) if (availableSeats < 1)
{ {

View File

@ -18,7 +18,7 @@ public class OrganizationViewModel
UserInvitedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Invited); UserInvitedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Invited);
UserAcceptedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Accepted); UserAcceptedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Accepted);
UserConfirmedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Confirmed); UserConfirmedCount = orgUsers.Count(u => u.Status == OrganizationUserStatusType.Confirmed);
UserCount = orgUsers.Count(); OccupiedSeatCount = orgUsers.Count(u => u.OccupiesOrganizationSeat);
CipherCount = ciphers.Count(); CipherCount = ciphers.Count();
CollectionCount = collections.Count(); CollectionCount = collections.Count();
GroupCount = groups?.Count() ?? 0; GroupCount = groups?.Count() ?? 0;
@ -40,7 +40,7 @@ public class OrganizationViewModel
public int UserInvitedCount { get; set; } public int UserInvitedCount { get; set; }
public int UserConfirmedCount { get; set; } public int UserConfirmedCount { get; set; }
public int UserAcceptedCount { get; set; } public int UserAcceptedCount { get; set; }
public int UserCount { get; set; } public int OccupiedSeatCount { get; set; }
public int CipherCount { get; set; } public int CipherCount { get; set; }
public int CollectionCount { get; set; } public int CollectionCount { get; set; }
public int GroupCount { get; set; } public int GroupCount { get; set; }

View File

@ -11,7 +11,7 @@
<dt class="col-sm-4 col-lg-3">Users</dt> <dt class="col-sm-4 col-lg-3">Users</dt>
<dd class="col-sm-8 col-lg-9"> <dd class="col-sm-8 col-lg-9">
@Model.UserCount / @(Model.Organization.Seats?.ToString() ?? "-") @Model.OccupiedSeatCount / @(Model.Organization.Seats?.ToString() ?? "-")
(<span title="Invited">@Model.UserInvitedCount</span> / (<span title="Invited">@Model.UserInvitedCount</span> /
<span title="Accepted">@Model.UserAcceptedCount</span> / <span title="Accepted">@Model.UserAcceptedCount</span> /
<span title="Confirmed">@Model.UserConfirmedCount</span>) <span title="Confirmed">@Model.UserConfirmedCount</span>)

View File

@ -56,4 +56,12 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser
{ {
return Premium.GetValueOrDefault(false); return Premium.GetValueOrDefault(false);
} }
public bool OccupiesOrganizationSeat
{
get
{
return Status != OrganizationUserStatusType.Revoked;
}
}
} }

View File

@ -64,4 +64,5 @@ public interface IOrganizationService
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, IUserService userService); Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, IUserService userService);
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService); IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
Task<int> GetOccupiedSeatCount(Organization organization);
} }

View File

@ -44,7 +44,6 @@ public class OrganizationService : IOrganizationService
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ILogger<OrganizationService> _logger; private readonly ILogger<OrganizationService> _logger;
public OrganizationService( public OrganizationService(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -199,10 +198,10 @@ public class OrganizationService : IOrganizationService
(newPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); (newPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0));
if (!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats) if (!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats)
{ {
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organization.Id); var occupiedSeats = await GetOccupiedSeatCount(organization);
if (userCount > newPlanSeats) if (occupiedSeats > newPlanSeats)
{ {
throw new BadRequestException($"Your organization currently has {userCount} seats filled. " + throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
$"Your new plan only has ({newPlanSeats}) seats. Remove some users."); $"Your new plan only has ({newPlanSeats}) seats. Remove some users.");
} }
} }
@ -494,10 +493,10 @@ public class OrganizationService : IOrganizationService
if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal) if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal)
{ {
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organization.Id); var occupiedSeats = await GetOccupiedSeatCount(organization);
if (userCount > newSeatTotal) if (occupiedSeats > newSeatTotal)
{ {
throw new BadRequestException($"Your organization currently has {userCount} seats filled. " + throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
$"Your new plan only has ({newSeatTotal}) seats. Remove some users."); $"Your new plan only has ({newSeatTotal}) seats. Remove some users.");
} }
} }
@ -861,10 +860,10 @@ public class OrganizationService : IOrganizationService
if (license.Seats.HasValue && if (license.Seats.HasValue &&
(!organization.Seats.HasValue || organization.Seats.Value > license.Seats.Value)) (!organization.Seats.HasValue || organization.Seats.Value > license.Seats.Value))
{ {
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organization.Id); var occupiedSeats = await GetOccupiedSeatCount(organization);
if (userCount > license.Seats.Value) if (occupiedSeats > license.Seats.Value)
{ {
throw new BadRequestException($"Your organization currently has {userCount} seats filled. " + throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
$"Your new license only has ({license.Seats.Value}) seats. Remove some users."); $"Your new license only has ({license.Seats.Value}) seats. Remove some users.");
} }
} }
@ -1138,8 +1137,8 @@ public class OrganizationService : IOrganizationService
organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); 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 occupiedSeats = await GetOccupiedSeatCount(organization);
var availableSeats = organization.Seats.Value - userCount; var availableSeats = organization.Seats.Value - occupiedSeats;
newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats; newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats;
} }
@ -1559,7 +1558,7 @@ public class OrganizationService : IOrganizationService
organization.MaxAutoscaleSeats.HasValue && organization.MaxAutoscaleSeats.HasValue &&
organization.MaxAutoscaleSeats.Value < organization.Seats.Value + seatsToAdd) organization.MaxAutoscaleSeats.Value < organization.Seats.Value + seatsToAdd)
{ {
return (false, $"Cannot invite new users. Seat limit has been reached."); return (false, $"Seat limit has been reached.");
} }
return (true, failureReason); return (true, failureReason);
@ -1951,8 +1950,8 @@ public class OrganizationService : IOrganizationService
var enoughSeatsAvailable = true; var enoughSeatsAvailable = true;
if (organization.Seats.HasValue) if (organization.Seats.HasValue)
{ {
var userCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(organizationId); var occupiedSeats = await GetOccupiedSeatCount(organization);
seatsAvailable = organization.Seats.Value - userCount; seatsAvailable = organization.Seats.Value - occupiedSeats;
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
} }
@ -2324,6 +2323,14 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("Only owners can restore other owners."); throw new BadRequestException("Only owners can restore other owners.");
} }
var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
var occupiedSeats = await GetOccupiedSeatCount(organization);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
if (availableSeats < 1)
{
await AutoAddSeatsAsync(organization, 1, DateTime.UtcNow);
}
await CheckPoliciesBeforeRestoreAsync(organizationUser, userService); await CheckPoliciesBeforeRestoreAsync(organizationUser, userService);
var status = GetPriorActiveOrganizationUserStatusType(organizationUser); var status = GetPriorActiveOrganizationUserStatusType(organizationUser);
@ -2345,6 +2352,12 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("Users invalid."); throw new BadRequestException("Users invalid.");
} }
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var occupiedSeats = await GetOccupiedSeatCount(organization);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await AutoAddSeatsAsync(organization, newSeatsRequired, DateTime.UtcNow);
var deletingUserIsOwner = false; var deletingUserIsOwner = false;
if (restoringUserId.HasValue) if (restoringUserId.HasValue)
{ {
@ -2455,4 +2468,10 @@ public class OrganizationService : IOrganizationService
return status; return status;
} }
public async Task<int> GetOccupiedSeatCount(Organization organization)
{
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id);
return orgUsers.Count(ou => ou.OccupiesOrganizationSeat);
}
} }

View File

@ -897,7 +897,7 @@ public class OrganizationServiceTests
[BitAutoData(0, 100, 100, true, "")] [BitAutoData(0, 100, 100, true, "")]
[BitAutoData(0, null, 100, true, "")] [BitAutoData(0, null, 100, true, "")]
[BitAutoData(1, 100, null, true, "")] [BitAutoData(1, 100, null, true, "")]
[BitAutoData(1, 100, 100, false, "Cannot invite new users. Seat limit has been reached")] [BitAutoData(1, 100, 100, false, "Seat limit has been reached")]
public void CanScale(int seatsToAdd, int? currentSeats, int? maxAutoscaleSeats, public void CanScale(int seatsToAdd, int? currentSeats, int? maxAutoscaleSeats,
bool expectedResult, string expectedFailureMessage, Organization organization, bool expectedResult, string expectedFailureMessage, Organization organization,
SutProvider<OrganizationService> sutProvider) SutProvider<OrganizationService> sutProvider)