1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-25 13:24:50 -05:00

Resolve the comment regarding abstraction

This commit is contained in:
Cy Okeke 2025-05-20 12:20:59 +01:00
parent f62a3e2735
commit 3206b9aee5
No known key found for this signature in database
GPG Key ID: 88B341B55C84B45C
13 changed files with 152 additions and 35 deletions

View File

@ -501,7 +501,7 @@ public class AccountController : Controller
{ {
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats; var availableSeats = initialSeatCount - occupiedSeats.Total;
if (availableSeats < 1) if (availableSeats < 1)
{ {
try try

View File

@ -87,13 +87,14 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId))); new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
} }
var seatCounts = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
{ {
Invites = invitesToSend.ToArray(), Invites = invitesToSend.ToArray(),
InviteOrganization = request.InviteOrganization, InviteOrganization = request.InviteOrganization,
PerformedBy = request.PerformedBy, PerformedBy = request.PerformedBy,
PerformedAt = request.PerformedAt, PerformedAt = request.PerformedAt,
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId), OccupiedPmSeats = seatCounts.Total,
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId) OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
}); });

View File

@ -66,8 +66,8 @@ public class RestoreOrganizationUserCommand(
} }
var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var seatCounts = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;
if (availableSeats < 1) if (availableSeats < 1)
{ {
@ -159,8 +159,8 @@ public class RestoreOrganizationUserCommand(
} }
var organization = await organizationRepository.GetByIdAsync(organizationId); var organization = await organizationRepository.GetByIdAsync(organizationId);
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var seatCounts = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;
var newSeatsRequired = organizationUserIds.Count() - availableSeats; var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);

View File

@ -27,7 +27,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
/// </summary> /// </summary>
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param> /// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
/// <returns>The number of occupied seats for the organization.</returns> /// <returns>The number of occupied seats for the organization.</returns>
Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers); Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers);
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId); Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id); Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);

View File

@ -342,15 +342,12 @@ public class OrganizationService : IOrganizationService
if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal) if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal)
{ {
var totalConsumedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var seatCounts = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, null);
var organizationUserOccupiedSeats = organizationUsers.Where(user => user.Status >= 0).Count();
var sponsoredFamiliesOccupiedSeats = totalConsumedSeats - organizationUserOccupiedSeats;
if (totalConsumedSeats > newSeatTotal) if (seatCounts.Total > newSeatTotal)
{ {
throw new BadRequestException($"Your organization has {organizationUserOccupiedSeats} members and {sponsoredFamiliesOccupiedSeats} sponsored families. " + throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
$"To decrease the seat count below {totalConsumedSeats}, you must remove members or sponsorships."); $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships.");
} }
} }
@ -846,8 +843,8 @@ public class OrganizationService : IOrganizationService
var newSeatsRequired = 0; var newSeatsRequired = 0;
if (organization.Seats.HasValue) if (organization.Seats.HasValue)
{ {
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var seatCounts = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.Value - occupiedSeats; var availableSeats = organization.Seats.Value - seatCounts.Total;
newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats; newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats;
} }
@ -1303,8 +1300,8 @@ public class OrganizationService : IOrganizationService
var enoughSeatsAvailable = true; var enoughSeatsAvailable = true;
if (organization.Seats.HasValue) if (organization.Seats.HasValue)
{ {
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var seatCounts = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
seatsAvailable = organization.Seats.Value - occupiedSeats; seatsAvailable = organization.Seats.Value - seatCounts.Total;
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
} }

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationSeatCounts
{
public int Users { get; set; }
public int Sponsored { get; set; }
public int Total => Users + Sponsored;
}

View File

@ -89,8 +89,8 @@ public class CreateSponsorshipCommand(
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue) if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
{ {
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id); var seatCounts = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id);
var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats; var availableSeats = sponsoringOrganization.Seats.Value - seatCounts.Total;
if (availableSeats <= 0) if (availableSeats <= 0)
{ {

View File

@ -117,12 +117,12 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
(newPlan.PasswordManager.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); (newPlan.PasswordManager.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0));
if (!organization.Seats.HasValue || organization.Seats.Value > updatedPasswordManagerSeats) if (!organization.Seats.HasValue || organization.Seats.Value > updatedPasswordManagerSeats)
{ {
var occupiedSeats = var seatCounts =
await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (occupiedSeats > updatedPasswordManagerSeats) if (seatCounts.Total > updatedPasswordManagerSeats)
{ {
throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
$"Your new plan only has ({updatedPasswordManagerSeats}) seats. Remove some users."); $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships.");
} }
} }

View File

@ -88,16 +88,16 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
} }
} }
public async Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId) public async Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))
{ {
var result = await connection.ExecuteScalarAsync<int>( var result = await connection.QueryFirstOrDefaultAsync<OrganizationSeatCounts>(
"[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]", "[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]",
new { OrganizationId = organizationId }, new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return result; return result ?? new OrganizationSeatCounts();
} }
} }

View File

@ -227,10 +227,28 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
return await GetCountFromQuery(query); return await GetCountFromQuery(query);
} }
public async Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId) public async Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
{ {
var query = new OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(organizationId); using (var scope = ServiceScopeFactory.CreateScope())
return await GetCountFromQuery(query); {
var dbContext = GetDatabaseContext(scope);
var users = await dbContext.OrganizationUsers
.Where(ou => ou.OrganizationId == organizationId && ou.Status >= 0)
.CountAsync();
var sponsored = await dbContext.OrganizationSponsorships
.Where(os => os.SponsoringOrganizationId == organizationId &&
os.IsAdminInitiated &&
(os.ToDelete == false || (os.ToDelete == true && os.ValidUntil != null && os.ValidUntil > DateTime.UtcNow)) &&
(os.SponsoredOrganizationId == null || (os.SponsoredOrganizationId != null && (os.ValidUntil == null || os.ValidUntil > DateTime.UtcNow))))
.CountAsync();
return new OrganizationSeatCounts
{
Users = users,
Sponsored = sponsored
};
}
} }
public async Task<int> GetCountByOrganizationIdAsync(Guid organizationId) public async Task<int> GetCountByOrganizationIdAsync(Guid organizationId)

View File

@ -476,7 +476,11 @@ public class InviteOrganizationUserCommandTests
orgUserRepository orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]); .Returns([ownerDetails]);
orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1); orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
{
Sponsored = 0,
Users = 1
});
orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1); orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
var orgRepository = sutProvider.GetDependency<IOrganizationRepository>(); var orgRepository = sutProvider.GetDependency<IOrganizationRepository>();

View File

@ -5,6 +5,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -171,7 +172,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
// Setup for checking available seats // Setup for checking available seats
sutProvider.GetDependency<IOrganizationUserRepository>() sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
.Returns(0); .Returns(new OrganizationSeatCounts
{
Sponsored = 0,
Users = 0
});
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
@ -320,7 +325,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
// Setup for checking available seats - organization has plenty of seats // Setup for checking available seats - organization has plenty of seats
sutProvider.GetDependency<IOrganizationUserRepository>() sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
.Returns(5); .Returns(new OrganizationSeatCounts
{
Sponsored = 0,
Users = 5
});
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
@ -380,7 +389,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
// Setup for checking available seats - organization has no available seats // Setup for checking available seats - organization has no available seats
sutProvider.GetDependency<IOrganizationUserRepository>() sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
.Returns(10); .Returns(new OrganizationSeatCounts
{
Sponsored = 0,
Users = 10
});
// Setup for checking if can scale // Setup for checking if can scale
sutProvider.GetDependency<IOrganizationService>() sutProvider.GetDependency<IOrganizationService>()
@ -445,7 +458,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
// Setup for checking available seats - organization has no available seats // Setup for checking available seats - organization has no available seats
sutProvider.GetDependency<IOrganizationUserRepository>() sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
.Returns(10); .Returns(new OrganizationSeatCounts
{
Sponsored = 0,
Users = 10
});
// Setup for checking if can scale - cannot scale // Setup for checking if can scale - cannot scale
var failureReason = "Seat limit has been reached."; var failureReason = "Seat limit has been reached.";

View File

@ -0,0 +1,72 @@
IF OBJECT_ID('[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]
END
GO
CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
(
-- Count organization users
SELECT COUNT(1)
FROM [dbo].[OrganizationUserView]
WHERE OrganizationId = @OrganizationId
AND Status >= 0 --Invited
) as Users,
(
-- Count admin-initiated sponsorships towards the seat count
-- Introduced in https://bitwarden.atlassian.net/browse/PM-17772
SELECT COUNT(1)
FROM [dbo].[OrganizationSponsorship]
WHERE SponsoringOrganizationId = @OrganizationId
AND IsAdminInitiated = 1
AND (
-- Not marked for deletion - always count
(ToDelete = 0)
OR
-- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
(ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE())
)
AND (
-- SENT status: When SponsoredOrganizationId is null
SponsoredOrganizationId IS NULL
OR
-- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
(SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE()))
)
) as Sponsored,
(
-- Count organization users
SELECT COUNT(1)
FROM [dbo].[OrganizationUserView]
WHERE OrganizationId = @OrganizationId
AND Status >= 0 --Invited
) +
(
-- Count admin-initiated sponsorships towards the seat count
SELECT COUNT(1)
FROM [dbo].[OrganizationSponsorship]
WHERE SponsoringOrganizationId = @OrganizationId
AND IsAdminInitiated = 1
AND (
-- Not marked for deletion - always count
(ToDelete = 0)
OR
-- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
(ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE())
)
AND (
-- SENT status: When SponsoredOrganizationId is null
SponsoredOrganizationId IS NULL
OR
-- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
(SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE()))
)
) as Total
END
GO