From 1bd11e61ef564648ad662c5707e7f845a2c1cfb8 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Tue, 10 Jun 2025 15:50:22 -0400 Subject: [PATCH 01/11] Add enable-pm-prelogin-settings flag (#5946) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e6a822452a..1a2af31d03 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -181,6 +181,7 @@ public static class FeatureFlagKeys public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string MobileErrorReporting = "mobile-error-reporting"; public const string AndroidChromeAutofill = "android-chrome-autofill"; + public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; From f532236f05da8695b58ce4df9a0949986c61b030 Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Tue, 10 Jun 2025 17:27:51 -0300 Subject: [PATCH 02/11] [PM-22572] Added app-intents feature flag key (#5948) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1a2af31d03..49d360ec50 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -182,6 +182,7 @@ public static class FeatureFlagKeys public const string MobileErrorReporting = "mobile-error-reporting"; public const string AndroidChromeAutofill = "android-chrome-autofill"; public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; + public const string AppIntents = "app-intents"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; From a618f9723484d986c71f719b212d4587544584aa Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:03:45 +0100 Subject: [PATCH 03/11] [PM 20621]Update error message when lowering seat count (#5836) * implement the seat decrease error message * Resolve the comment regarding abstraction * Resolved the database failure Signed-off-by: Cy Okeke * Resolve the failing test Signed-off-by: Cy Okeke * Resolve the failing test Signed-off-by: Cy Okeke * Resolve the failing upgrade test Signed-off-by: Cy Okeke * Resolve the failing test Signed-off-by: Cy Okeke * Resolve the failing test Signed-off-by: Cy Okeke * Removed the unused method * Remove the total calculation from the stored procedure * Refactoring base on pr feedback * Refactoring base on pr feedback * Resolve the fauiling database * Resolve the failing database test * Resolve the database test * Remove duplicate migrations * resolve the failing test * Removed the unneeded change * remove this file * Reverted Deleted migration * revert the added space * resolve the stored procedure name * Rename the migration name * Updated the stored procedure name * Revert the changes on the sproc * Revert unrelated changes * Remove the unused method * improved the xmldoc * Add an integration testing * Add the use of helper test class Signed-off-by: Cy Okeke * Resolve the failing test Signed-off-by: Cy Okeke * Resolve the failing test Signed-off-by: Cy Okeke * remove object look up * Resolve message rollback Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke --- .../src/Sso/Controllers/AccountController.cs | 4 +- .../InviteOrganizationUsersCommand.cs | 2 +- .../v1/RestoreOrganizationUserCommand.cs | 8 +- .../Repositories/IOrganizationRepository.cs | 11 ++ .../IOrganizationUserRepository.cs | 10 -- .../Implementations/OrganizationService.cs | 25 ++- .../OrganizationBillingService.cs | 7 +- .../OrganizationSeatCounts.cs | 8 + .../CreateSponsorshipCommand.cs | 6 +- .../UpgradeOrganizationPlanCommand.cs | 16 +- .../Repositories/OrganizationRepository.cs | 15 +- .../OrganizationUserRepository.cs | 13 -- .../Repositories/OrganizationRepository.cs | 25 +++ .../OrganizationUserRepository.cs | 6 - ...dOccupiedSeatCountByOrganizationIdQuery.cs | 48 ----- ..._ReadOccupiedSeatCountByOrganizationId.sql | 38 ++++ .../InviteOrganizationUserCommandTests.cs | 89 +++++++++- .../RestoreOrganizationUserCommandTests.cs | 164 ++++++++++++++++-- .../Services/OrganizationServiceTests.cs | 103 +++++++++-- .../OrganizationBillingServiceTests.cs | 33 ++-- .../CreateSponsorshipCommandTests.cs | 33 +++- .../UpgradeOrganizationPlanCommandTests.cs | 33 +++- .../OrganizationRepositoryTests.cs | 137 +++++++++++++++ ...rgReadOccupiedSeatCountForSponsorships.sql | 38 ++++ 24 files changed, 715 insertions(+), 157 deletions(-) create mode 100644 src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs delete mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs create mode 100644 src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql create mode 100644 util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 5c03ba0017..12394ff598 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -499,9 +499,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 if (orgUser == null && organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var initialSeatCount = organization.Seats.Value; - var availableSeats = initialSeatCount - occupiedSeats; + var availableSeats = initialSeatCount - occupiedSeats.Total; if (availableSeats < 1) { try diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index db5d011e1d..1dddc8bf0c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -87,7 +87,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, InviteOrganization = request.InviteOrganization, PerformedBy = request.PerformedBy, PerformedAt = request.PerformedAt, - OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId), + OccupiedPmSeats = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)).Total, OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId) }); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index fe19cd1389..0d9955eecf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -70,8 +70,8 @@ public class RestoreOrganizationUserCommand( } var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; if (availableSeats < 1) { @@ -163,8 +163,8 @@ public class RestoreOrganizationUserCommand( } var organization = await organizationRepository.GetByIdAsync(organizationId); - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; var newSeatsRequired = organizationUserIds.Count() - availableSeats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 7e315ed58b..7fff0d437f 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; #nullable enable @@ -25,4 +26,14 @@ public interface IOrganizationRepository : IRepository Task> GetByVerifiedUserEmailDomainAsync(Guid userId); Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task> GetManyByIdsAsync(IEnumerable ids); + + /// + /// Returns the number of occupied seats for an organization. + /// OrganizationUsers occupy a seat, unless they are revoked. + /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an + /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. + /// + /// The ID of the organization to get the occupied seat count for. + /// The number of occupied seats for the organization. + Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 9692de897c..6e07bd9ff8 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -18,16 +18,6 @@ public interface IOrganizationUserRepository : IRepository> GetManyByUserAsync(Guid userId); Task> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type); Task GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers); - - /// - /// Returns the number of occupied seats for an organization. - /// Occupied seats are OrganizationUsers that have at least been invited. - /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an - /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. - /// - /// The ID of the organization to get the occupied seat count for. - /// The number of occupied seats for the organization. - Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers); Task GetByOrganizationAsync(Guid organizationId, Guid userId); Task>> GetByIdWithCollectionsAsync(Guid id); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 16e58d27ad..4d709bb7cf 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -294,11 +294,20 @@ public class OrganizationService : IOrganizationService if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - if (occupiedSeats > newSeatTotal) + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + if (seatCounts.Total > newSeatTotal) { - throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + - $"Your new plan only has ({newSeatTotal}) seats. Remove some users."); + if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0) + { + throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " + + $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships."); + } + else + { + throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " + + $"Your new plan only has ({newSeatTotal}) seats. Remove some users."); + } } } @@ -726,8 +735,8 @@ public class OrganizationService : IOrganizationService var newSeatsRequired = 0; if (organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.Value - occupiedSeats; + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.Value - seatCounts.Total; newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats; } @@ -1177,8 +1186,8 @@ public class OrganizationService : IOrganizationService var enoughSeatsAvailable = true; if (organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - seatsAvailable = organization.Seats.Value - occupiedSeats; + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + seatsAvailable = organization.Seats.Value - seatCounts.Total; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 5e59d0d108..32521f00c8 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -31,7 +31,6 @@ public class OrganizationBillingService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, IPricingClient pricingClient, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, @@ -78,14 +77,14 @@ public class OrganizationBillingService( var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization); var isManaged = organization.Status == OrganizationStatusType.Managed; - var orgOccupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { return OrganizationMetadata.Default with { IsEligibleForSelfHost = isEligibleForSelfHost, IsManaged = isManaged, - OrganizationOccupiedSeats = orgOccupiedSeats + OrganizationOccupiedSeats = orgOccupiedSeats.Total }; } @@ -120,7 +119,7 @@ public class OrganizationBillingService( invoice?.DueDate, invoice?.Created, subscription.CurrentPeriodEnd, - orgOccupiedSeats); + orgOccupiedSeats.Total); } public async Task diff --git a/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs new file mode 100644 index 0000000000..6b9f615f64 --- /dev/null +++ b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs @@ -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; +} diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index b15cbea240..a729937fad 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -16,7 +16,7 @@ public class CreateSponsorshipCommand( IOrganizationSponsorshipRepository organizationSponsorshipRepository, IUserService userService, IOrganizationService organizationService, - IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand + IOrganizationRepository organizationRepository) : ICreateSponsorshipCommand { public async Task CreateSponsorshipAsync( Organization sponsoringOrganization, @@ -89,8 +89,8 @@ public class CreateSponsorshipCommand( if (isAdminInitiated && sponsoringOrganization.Seats.HasValue) { - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id); - var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id); + var availableSeats = sponsoringOrganization.Seats.Value - seatCounts.Total; if (availableSeats <= 0) { diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 838c1e97b9..761f59920c 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -107,12 +107,20 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand (newPlan.PasswordManager.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0)); if (!organization.Seats.HasValue || organization.Seats.Value > updatedPasswordManagerSeats) { - var occupiedSeats = - await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - if (occupiedSeats > updatedPasswordManagerSeats) + var seatCounts = + await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + if (seatCounts.Total > updatedPasswordManagerSeats) { - throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + + if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0) + { + throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " + + $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships."); + } + else + { + throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " + $"Your new plan only has ({updatedPasswordManagerSeats}) seats. Remove some users."); + } } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 3da8ad1a6c..27a08df3ed 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Settings; using Dapper; @@ -200,11 +201,23 @@ public class OrganizationRepository : Repository, IOrganizat public async Task> GetManyByIdsAsync(IEnumerable ids) { await using var connection = new SqlConnection(ConnectionString); - return (await connection.QueryAsync( $"[{Schema}].[{Table}_ReadManyByIds]", new { OrganizationIds = ids.ToGuidIdArrayTVP() }, commandType: CommandType.StoredProcedure)) .ToList(); } + + public async Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.QueryAsync( + "[dbo].[Organization_ReadOccupiedSeatCountByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result.SingleOrDefault() ?? new OrganizationSeatCounts(); + } + } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 8968d1d243..5a6fcbe4aa 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -88,19 +88,6 @@ public class OrganizationUserRepository : Repository, IO } } - public async Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId) - { - using (var connection = new SqlConnection(ConnectionString)) - { - var result = await connection.ExecuteScalarAsync( - "[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]", - new { OrganizationId = organizationId }, - commandType: CommandType.StoredProcedure); - - return result; - } - } - public async Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index f83f7b70b6..c378fe5e7e 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using LinqToDB.Tools; using Microsoft.EntityFrameworkCore; @@ -375,4 +376,28 @@ public class OrganizationRepository : Repository GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + 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 + }; + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index fc5626631a..26a72bb991 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -228,12 +228,6 @@ public class OrganizationUserRepository : Repository GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId) - { - var query = new OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(organizationId); - return await GetCountFromQuery(query); - } - public async Task GetCountByOrganizationIdAsync(Guid organizationId) { var query = new OrganizationUserReadCountByOrganizationIdQuery(organizationId); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs deleted file mode 100644 index 6be51f2036..0000000000 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Bit.Core.Enums; -using Bit.Infrastructure.EntityFramework.Models; - -namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; - -public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery -{ - private readonly Guid _organizationId; - - public OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(Guid organizationId) - { - _organizationId = organizationId; - } - - public IQueryable Run(DatabaseContext dbContext) - { - var orgUsersQuery = from ou in dbContext.OrganizationUsers - where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited - select new OrganizationUser { Id = ou.Id, OrganizationId = ou.OrganizationId, Status = ou.Status }; - - // As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an - // organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. - var sponsorshipsQuery = from os in dbContext.OrganizationSponsorships - where os.SponsoringOrganizationId == _organizationId && - os.IsAdminInitiated && - ( - // Not marked for deletion - always count - (!os.ToDelete) || - // Marked for deletion but has a valid until date in the future (RevokeWhenExpired status) - (os.ToDelete && os.ValidUntil.HasValue && os.ValidUntil.Value > DateTime.UtcNow) - ) && - ( - // SENT status: When SponsoredOrganizationId is null - os.SponsoredOrganizationId == null || - // ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future - (os.SponsoredOrganizationId != null && - (!os.ValidUntil.HasValue || os.ValidUntil.Value > DateTime.UtcNow)) - ) - select new OrganizationUser - { - Id = os.Id, - OrganizationId = _organizationId, - Status = OrganizationUserStatusType.Invited - }; - - return orgUsersQuery.Concat(sponsorshipsQuery); - } -} diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql new file mode 100644 index 0000000000..d560e994a1 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql @@ -0,0 +1,38 @@ +CREATE PROCEDURE [dbo].[Organization_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 +END +GO \ No newline at end of file diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs index e54e4aa99b..cee801d190 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs @@ -137,6 +137,14 @@ public class InviteOrganizationUserCommandTests .ValidateAsync(Arg.Any()) .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -202,6 +210,14 @@ public class InviteOrganizationUserCommandTests .Returns(new Invalid( new Error(errorMessage, validationRequest))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -272,6 +288,14 @@ public class InviteOrganizationUserCommandTests .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) .WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -343,6 +367,14 @@ public class InviteOrganizationUserCommandTests .WithPasswordManagerUpdate( new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -413,6 +445,14 @@ public class InviteOrganizationUserCommandTests .Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization) .WithPasswordManagerUpdate(passwordManagerUpdate))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -469,6 +509,7 @@ public class InviteOrganizationUserCommandTests .AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager)); var orgUserRepository = sutProvider.GetDependency(); + var orgRepository = sutProvider.GetDependency(); orgUserRepository .SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false) @@ -476,11 +517,13 @@ public class InviteOrganizationUserCommandTests orgUserRepository .GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner) .Returns([ownerDetails]); - orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1); + orgRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1); - var orgRepository = sutProvider.GetDependency(); - orgRepository.GetByIdAsync(organization.Id) .Returns(organization); @@ -566,6 +609,14 @@ public class InviteOrganizationUserCommandTests .SendInvitesAsync(Arg.Any()) .Throws(new Exception("Something went wrong")); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -671,6 +722,14 @@ public class InviteOrganizationUserCommandTests } }); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -762,6 +821,14 @@ public class InviteOrganizationUserCommandTests } }); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -829,6 +896,14 @@ public class InviteOrganizationUserCommandTests .WithPasswordManagerUpdate( new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); @@ -900,6 +975,14 @@ public class InviteOrganizationUserCommandTests .WithPasswordManagerUpdate( new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1)))); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 }); + + sutProvider.GetDependency() + .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) + .Returns(0); + // Act var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index fbd711307c..4fa5e92abe 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -31,7 +31,12 @@ public class RestoreOrganizationUserCommandTests [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) { RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); await sutProvider.GetDependency() @@ -49,7 +54,12 @@ public class RestoreOrganizationUserCommandTests public async Task RestoreUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) { RestoreUser_Setup(organization, null, organizationUser, sutProvider); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser); await sutProvider.GetDependency() @@ -151,7 +161,12 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency() .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) .Returns(true); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var user = new User(); user.Email = "test@bitwarden.com"; sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); @@ -184,7 +199,12 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) }); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); RestoreUser_Setup(organization, owner, organizationUser, sutProvider); sutProvider.GetDependency() @@ -219,7 +239,12 @@ public class RestoreOrganizationUserCommandTests SutProvider sutProvider) { organizationUser.Email = null; - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PolicyRequirements) .Returns(true); @@ -278,7 +303,12 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); await sutProvider.GetDependency() @@ -344,6 +374,15 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency() .GetManyByUserAsync(organizationUser.UserId.Value) .Returns(new[] { organizationUser, secondOrganizationUser }); + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) .Returns(new[] @@ -392,7 +431,12 @@ public class RestoreOrganizationUserCommandTests { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } }); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) .Returns([ @@ -455,7 +499,12 @@ public class RestoreOrganizationUserCommandTests PolicyType = PolicyType.TwoFactorAuthentication } ])); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var user = new User { Email = "test@bitwarden.com" }; sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); @@ -475,6 +524,40 @@ public class RestoreOrganizationUserCommandTests .PushSyncOrgKeysAsync(Arg.Any()); } + [Theory, BitAutoData] + public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithUser2FAConfigured_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } + ]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + } + [Theory, BitAutoData] public async Task RestoreUser_WhenUserOwningAnotherFreeOrganization_ThenRestoreUserFails( Organization organization, @@ -492,7 +575,12 @@ public class RestoreOrganizationUserCommandTests otherOrganization.PlanType = PlanType.Free; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .GetManyByUserAsync(organizationUser.UserId.Value) .Returns([orgUserOwnerFromDifferentOrg]); @@ -533,7 +621,12 @@ public class RestoreOrganizationUserCommandTests otherOrganization.PlanType = PlanType.Free; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var organizationUserRepository = sutProvider.GetDependency(); organizationUserRepository .GetManyByUserAsync(organizationUser.UserId.Value) @@ -584,7 +677,12 @@ public class RestoreOrganizationUserCommandTests otherOrganization.PlanType = PlanType.Free; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var organizationUserRepository = sutProvider.GetDependency(); organizationUserRepository .GetManyByUserAsync(organizationUser.UserId.Value) @@ -636,7 +734,12 @@ public class RestoreOrganizationUserCommandTests organizationUserRepository .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) .Returns([orgUser1, orgUser2]); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); twoFactorIsEnabledQuery .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> @@ -685,7 +788,12 @@ public class RestoreOrganizationUserCommandTests organizationUserRepository .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id))) .Returns(new[] { orgUser1, orgUser2, orgUser3 }); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" }); // Setup 2FA policy @@ -820,7 +928,12 @@ public class RestoreOrganizationUserCommandTests organizationUserRepository .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id))) .Returns([orgUser1, orgUser2, orgUser3]); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" }); sutProvider.GetDependency() @@ -882,7 +995,12 @@ public class RestoreOrganizationUserCommandTests organizationUserRepository .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id))) .Returns([orgUser1]); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); organizationUserRepository .GetManyByManyUsersAsync(Arg.Any>()) .Returns([orgUserFromOtherOrg]); @@ -942,7 +1060,12 @@ public class RestoreOrganizationUserCommandTests organizationUserRepository .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id))) .Returns([orgUser1]); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); organizationUserRepository .GetManyByManyUsersAsync(Arg.Any>()) .Returns([orgUserFromOtherOrg]); @@ -972,7 +1095,14 @@ public class RestoreOrganizationUserCommandTests } targetOrganizationUser.OrganizationId = organization.Id; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var organizationRepository = sutProvider.GetDependency(); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); + organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)); } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index d926e282c9..3271ea559b 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -60,7 +60,12 @@ public class OrganizationServiceTests existingUsers.First().Type = OrganizationUserType.Owner; sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var organizationUserRepository = sutProvider.GetDependency(); SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); @@ -117,7 +122,12 @@ public class OrganizationServiceTests ExternalId = reInvitedUser.Email, }); var expectedNewUsersCount = newUsers.Count - 1; - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id) .Returns(existingUsers); @@ -190,7 +200,12 @@ public class OrganizationServiceTests sutProvider.Create(); invite.Emails = invite.Emails.Append(invite.Emails.First()); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); @@ -221,6 +236,12 @@ public class OrganizationServiceTests sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) })); Assert.Contains("Organization must have at least one confirmed owner.", exception.Message); @@ -314,6 +335,12 @@ public class OrganizationServiceTests sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); @@ -340,6 +367,13 @@ public class OrganizationServiceTests var organizationUserRepository = sutProvider.GetDependency(); var currentContext = sutProvider.GetDependency(); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + organizationRepository.GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) @@ -397,7 +431,12 @@ public class OrganizationServiceTests var organizationRepository = sutProvider.GetDependency(); var currentContext = sutProvider.GetDependency(); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); organizationRepository.GetByIdAsync(organization.Id).Returns(organization); currentContext.OrganizationCustom(organization.Id).Returns(true); currentContext.ManageUsers(organization.Id).Returns(true); @@ -425,7 +464,12 @@ public class OrganizationServiceTests sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); currentContext.OrganizationOwner(organization.Id).Returns(true); @@ -473,7 +517,12 @@ public class OrganizationServiceTests SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId); await sutProvider.GetDependency().Received(1) @@ -538,7 +587,12 @@ public class OrganizationServiceTests SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut .InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId)); Assert.Contains("This user has already been invited", exception.Message); @@ -595,7 +649,12 @@ public class OrganizationServiceTests var organizationUserRepository = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>()) .Returns(true); @@ -631,7 +690,12 @@ public class OrganizationServiceTests { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var organizationRepository = sutProvider.GetDependency(); var organizationUserRepository = sutProvider.GetDependency(); var currentContext = sutProvider.GetDependency(); @@ -664,6 +728,13 @@ public class OrganizationServiceTests organization.PlanType = PlanType.EnterpriseAnnually; InviteUserHelper_ArrangeValidPermissions(organization, savingUser, sutProvider); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + // Set up some invites to grant access to SM invites.First().invite.AccessSecretsManager = true; var invitedSmUsers = invites.First().invite.Emails.Count(); @@ -708,6 +779,13 @@ public class OrganizationServiceTests invite.AccessSecretsManager = false; } + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + // Assume we need to add seats for all invited SM users sutProvider.GetDependency() .CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers); @@ -813,7 +891,12 @@ public class OrganizationServiceTests sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) .Returns(StaticStore.GetPlan(organization.PlanType)); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var actual = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscription(organization.Id, seatAdjustment, null)); diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index ab2f3a64c0..26e6b98667 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; @@ -25,30 +26,32 @@ public class OrganizationBillingServiceTests SutProvider sutProvider) { sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); - sutProvider.GetDependency().ListPlans().Returns(StaticStore.Plans.ToList()); sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) .Returns(StaticStore.GetPlan(organization.PlanType)); var subscriberService = sutProvider.GetDependency(); - - subscriberService - .GetCustomer(organization, Arg.Is(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to")) - .Returns(new Customer + var organizationSeatCount = new OrganizationSeatCounts { Users = 1, Sponsored = 0 }; + var customer = new Customer + { + Discount = new Discount { - Discount = new Discount + Coupon = new Coupon { - Coupon = new Coupon + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = ["product_id"] - } + Products = ["product_id"] } } - }); + } + }; + + subscriberService + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .Returns(customer); subscriberService.GetSubscription(organization).Returns(new Subscription { @@ -67,6 +70,10 @@ public class OrganizationBillingServiceTests } }); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 }); + var metadata = await sutProvider.Sut.GetMetadata(organizationId); Assert.True(metadata!.IsOnSecretsManagerStandalone); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs index 7dc6b7360d..770a566b44 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; using Bit.Core.Repositories; using Bit.Core.Services; @@ -169,9 +170,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value); // Setup for checking available seats - sutProvider.GetDependency() + sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) - .Returns(0); + .Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 0 + }); await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, @@ -318,9 +323,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase ]); // Setup for checking available seats - organization has plenty of seats - sutProvider.GetDependency() + sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) - .Returns(5); + .Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 5 + }); var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); @@ -378,9 +387,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase ]); // Setup for checking available seats - organization has no available seats - sutProvider.GetDependency() + sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) - .Returns(10); + .Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 10 + }); // Setup for checking if can scale sutProvider.GetDependency() @@ -443,9 +456,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase ]); // Setup for checking available seats - organization has no available seats - sutProvider.GetDependency() + sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id) - .Returns(10); + .Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 10 + }); // Setup for checking if can scale - cannot scale var failureReason = "Seat limit has been reached."; diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 8bcee1e8c6..704f89ba3f 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Models.Business; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; @@ -77,6 +78,12 @@ public class UpgradeOrganizationPlanCommandTests upgrade.AdditionalSeats = 10; upgrade.Plan = PlanType.TeamsAnnually; sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan)); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); } @@ -107,7 +114,12 @@ public class UpgradeOrganizationPlanCommandTests organizationUpgrade.Plan = planType; sutProvider.GetDependency().GetPlanOrThrow(organizationUpgrade.Plan).Returns(StaticStore.GetPlan(organizationUpgrade.Plan)); - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); await sutProvider.Sut.UpgradePlanAsync(organization.Id, organizationUpgrade); await sutProvider.GetDependency().Received(1).AdjustSubscription( organization, @@ -141,7 +153,12 @@ public class UpgradeOrganizationPlanCommandTests upgrade.AdditionalSeats = 15; upgrade.AdditionalSmSeats = 10; upgrade.AdditionalServiceAccounts = 20; - + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( @@ -173,6 +190,12 @@ public class UpgradeOrganizationPlanCommandTests sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(2); @@ -202,6 +225,12 @@ public class UpgradeOrganizationPlanCommandTests sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); sutProvider.GetDependency() .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1); sutProvider.GetDependency() diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index a95778b199..a0df63c94e 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -286,4 +286,141 @@ public class OrganizationRepositoryTests await organizationRepository.DeleteAsync(organization1); await organizationRepository.DeleteAsync(organization2); } + + [DatabaseTheory, DatabaseData] + public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithUsersAndSponsorships_ReturnsCorrectCounts( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationSponsorshipRepository organizationSponsorshipRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + // Create users in different states + var user1 = await userRepository.CreateTestUserAsync("test1"); + var user2 = await userRepository.CreateTestUserAsync("test2"); + var user3 = await userRepository.CreateTestUserAsync("test3"); + + // Create organization users in different states + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); // Confirmed state + await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); // Invited state + + // Create a revoked user manually since there's no helper for it + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Revoked, + }); + + // Create sponsorships in different states + await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship + { + SponsoringOrganizationId = organization.Id, + IsAdminInitiated = true, + ToDelete = false, + ValidUntil = null, + }); + + await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship + { + SponsoringOrganizationId = organization.Id, + IsAdminInitiated = true, + ToDelete = true, + ValidUntil = DateTime.UtcNow.AddDays(1), + }); + + await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship + { + SponsoringOrganizationId = organization.Id, + IsAdminInitiated = true, + ToDelete = true, + ValidUntil = DateTime.UtcNow.AddDays(-1), // Expired + }); + + await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship + { + SponsoringOrganizationId = organization.Id, + IsAdminInitiated = false, // Not admin initiated + ToDelete = false, + ValidUntil = null, + }); + + // Act + var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Equal(2, result.Users); // Confirmed + Invited users + Assert.Equal(2, result.Sponsored); // Two valid sponsorships + Assert.Equal(4, result.Total); // Total occupied seats + } + + [DatabaseTheory, DatabaseData] + public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithNoUsersOrSponsorships_ReturnsZero( + IOrganizationRepository organizationRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + // Act + var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Equal(0, result.Users); + Assert.Equal(0, result.Sponsored); + Assert.Equal(0, result.Total); + } + + [DatabaseTheory, DatabaseData] + public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyRevokedUsers_ReturnsZero( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var user = await userRepository.CreateTestUserAsync("test1"); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Revoked, + }); + + // Act + var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Equal(0, result.Users); + Assert.Equal(0, result.Sponsored); + Assert.Equal(0, result.Total); + } + + [DatabaseTheory, DatabaseData] + public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyExpiredSponsorships_ReturnsZero( + IOrganizationRepository organizationRepository, + IOrganizationSponsorshipRepository organizationSponsorshipRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship + { + SponsoringOrganizationId = organization.Id, + IsAdminInitiated = true, + ToDelete = true, + ValidUntil = DateTime.UtcNow.AddDays(-1), // Expired + }); + + // Act + var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Equal(0, result.Users); + Assert.Equal(0, result.Sponsored); + Assert.Equal(0, result.Total); + } } diff --git a/util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql b/util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql new file mode 100644 index 0000000000..d3db0e5ce6 --- /dev/null +++ b/util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql @@ -0,0 +1,38 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_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 +END +GO From be72a77c72c932861d04bece5055eefd143df91c Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 11 Jun 2025 11:01:04 -0400 Subject: [PATCH 04/11] [PM-22099] add collection type (#5942) * add CollectionType enum * add CollectionType to CollectionResponseModels * cleanup * clean up * change types * add collection type to public API model * remove redundant statements --- src/Api/Models/Public/Response/CollectionResponseModel.cs | 6 ++++++ src/Api/Models/Response/CollectionResponseModel.cs | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 58968d4be7..d08db64290 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; namespace Bit.Api.Models.Public.Response; @@ -20,6 +21,7 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel Id = collection.Id; ExternalId = collection.ExternalId; Groups = groups?.Select(c => new AssociationWithPermissionsResponseModel(c)); + Type = collection.Type; } /// @@ -38,4 +40,8 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel /// The associated groups that this collection is assigned to. /// public IEnumerable Groups { get; set; } + /// + /// The type of this collection + /// + public CollectionType Type { get; set; } } diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index d56ef5469a..5ce8310117 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -18,12 +19,14 @@ public class CollectionResponseModel : ResponseModel OrganizationId = collection.OrganizationId; Name = collection.Name; ExternalId = collection.ExternalId; + Type = collection.Type; } public Guid Id { get; set; } public Guid OrganizationId { get; set; } public string Name { get; set; } public string ExternalId { get; set; } + public CollectionType Type { get; set; } } /// From 821f66e99f822516351ccc8d8bdd01800db4699f Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Wed, 11 Jun 2025 16:55:42 -0400 Subject: [PATCH 05/11] [PM-22205] Fix logic for sending out revoked email (#5933) --- .../TwoFactorAuthenticationPolicyValidator.cs | 4 +- ...actorAuthenticationPolicyValidatorTests.cs | 42 ++++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs index 13cc935eb9..5ce72df6c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -104,8 +104,8 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages)); } - await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x => - _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email))); + await Task.WhenAll(nonCompliantUsers.Select(nonCompliantUser => + _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), nonCompliantUser.user.Email))); } private static bool MembersWithNoMasterPasswordWillLoseAccess( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs index 6a97f6bc1e..7b344d3b29 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -60,16 +60,19 @@ public class TwoFactorAuthenticationPolicyValidatorTests } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( + public async Task OnSaveSideEffectsAsync_RevokesOnlyNonCompliantUsers( Organization organization, [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, SutProvider sutProvider) { - policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + // Arrange + policy.OrganizationId = policyUpdate.OrganizationId; + organization.Id = policyUpdate.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails + var nonCompliantUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), Status = OrganizationUserStatusType.Confirmed, @@ -80,30 +83,57 @@ public class TwoFactorAuthenticationPolicyValidatorTests HasMasterPassword = true }; + var compliantUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + Email = "user4@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([orgUserDetailUserWithout2Fa]); + .Returns([nonCompliantUser, compliantUser]); sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() { - (orgUserDetailUserWithout2Fa, false) + (nonCompliantUser, false), + (compliantUser, true) }); sutProvider.GetDependency() .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) .Returns(new CommandResult()); + // Act await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + // Assert await sutProvider.GetDependency() .Received(1) .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .RevokeNonCompliantOrganizationUsersAsync(Arg.Is(req => + req.OrganizationId == policyUpdate.OrganizationId && + req.OrganizationUsers.SequenceEqual(new[] { nonCompliantUser }) + )); + await sutProvider.GetDependency() .Received(1) .SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), - "user3@test.com"); + nonCompliantUser.Email); + + // Did not send out an email for compliantUser + await sutProvider.GetDependency() + .Received(0) + .SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), + compliantUser.Email); } } From 6d36f636c45d6d4c89843ae600d0e0b8b552e417 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Wed, 11 Jun 2025 17:46:21 -0400 Subject: [PATCH 06/11] Revert "replace fallback icon default with response (#5878)" (#5956) This reverts commit ed780d45d3f5e839bd1e390aecb2447ac33b006f. --- src/Icons/Controllers/IconsController.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Icons/Controllers/IconsController.cs b/src/Icons/Controllers/IconsController.cs index b0d9306f94..871219b366 100644 --- a/src/Icons/Controllers/IconsController.cs +++ b/src/Icons/Controllers/IconsController.cs @@ -8,6 +8,16 @@ namespace Bit.Icons.Controllers; [Route("")] public class IconsController : Controller { + // Basic bwi-globe icon + private static readonly byte[] _notFoundImage = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUg" + + "AAABMAAAATCAQAAADYWf5HAAABu0lEQVR42nXSvWuTURTH8R+t0heI9Y04aJycdBLNJNrBFBU7OFgUER3q21I0bXK+JwZ" + + "pXISm/QdcRB3EgqBBsNihsUbbgODQQSKCuKSDOApJuuhj8tCYQj/jvYfD795z1MZ+nBKrNKhSwrMxbZTrtRnqlEjZkB/x" + + "C/xmhZrlc71qS0Up8yVzTCGucFNKD1JhORVd70SZNU4okNx5d4+U2UXRIpJFWLClsR79YzN88wQvLWNzzPKEeS/wkQGpW" + + "VhhqhW8TtDJD3Mm1x/23zLSrZCdpBY8BueTNjHSbc+8wC9HlHgU5Aj5AW5zPdcVdpq0UcknWBSr/pjixO4gfp899Kd23p" + + "M2qQCH7LkCnqAqGh73OK/8NPOcaibr90LrW/yWAnaUhqjaOSl9nFR2r5rsqo22ypn1B5IN8VOUMHVgOnNQIX+d62plcz6" + + "rg1/jskK8CMb4we4pG6OWHtR/LBJkC2E4a7ZPkuX5ntumAOM2xxveclEhLvGH6XCmLPs735Eetrw63NnOgr9P9q1viC3x" + + "lRUGOjImqFDuOBvrYYoaZU9z1uPpYae5NfdvbNVG2ZjDIlXq/oMi46lo++4vjjPBl2Dlg00AAAAASUVORK5CYII="); + private readonly IMemoryCache _memoryCache; private readonly IDomainMappingService _domainMappingService; private readonly IIconFetchingService _iconFetchingService; @@ -89,7 +99,7 @@ public class IconsController : Controller if (icon == null) { - return new NotFoundResult(); + return new FileContentResult(_notFoundImage, "image/png"); } return new FileContentResult(icon.Image, icon.Format); From 463dc1232d8097ac17b328259697c85c4a9fa038 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:47:41 +1000 Subject: [PATCH 07/11] Add xmldoc for OrganizationUser (#5949) --- .../AdminConsole/Entities/OrganizationUser.cs | 55 ++++++++++++++++++- .../Enums/OrganizationUserStatusType.cs | 27 ++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index 9828482a7e..3166ebf3a8 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.Enums; using Bit.Core.Models; @@ -9,23 +10,75 @@ using Bit.Core.Utilities; namespace Bit.Core.Entities; +/// +/// An association table between one and one , representing that user's +/// membership in the organization. "Member" refers to the OrganizationUser object. +/// public class OrganizationUser : ITableObject, IExternal, IOrganizationUser { + /// + /// A unique random identifier. + /// public Guid Id { get; set; } + /// + /// The ID of the Organization that the user is a member of. + /// public Guid OrganizationId { get; set; } + /// + /// The ID of the User that is the member. This is NULL if the Status is Invited (or Invited and then Revoked), because + /// it is not linked to a specific User yet. + /// public Guid? UserId { get; set; } + /// + /// The email address of the user invited to the organization. This is NULL if the Status is not Invited (or + /// Invited and then Revoked), because in that case the OrganizationUser is linked to a User + /// and the email is stored on the User object. + /// [MaxLength(256)] public string? Email { get; set; } + /// + /// The Organization symmetric key encrypted with the User's public key. NULL if the user is not in a Confirmed + /// (or Confirmed and then Revoked) status. + /// public string? Key { get; set; } + /// + /// The User's symmetric key encrypted with the Organization's public key. NULL if the OrganizationUser + /// is not enrolled in account recovery. + /// public string? ResetPasswordKey { get; set; } + /// public OrganizationUserStatusType Status { get; set; } + /// + /// The User's role in the Organization. + /// public OrganizationUserType Type { get; set; } - + /// + /// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector + /// and SCIM. + /// [MaxLength(300)] public string? ExternalId { get; set; } + /// + /// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization. + /// public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + /// + /// The last date the OrganizationUser entry was updated. + /// public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + /// + /// A json blob representing the of the OrganizationUser if they + /// are a Custom user role (i.e. the is Custom). MAY be NULL if they are not + /// a custom user, but this is not guaranteed; do not use this to determine their role. + /// + /// + /// Avoid using this property directly - instead use the and + /// helper methods. + /// public string? Permissions { get; set; } + /// + /// True if the User has access to Secrets Manager for this Organization, false otherwise. + /// public bool AccessSecretsManager { get; set; } public void SetNewId() diff --git a/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs b/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs index 576e98ea74..3b4098715d 100644 --- a/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs +++ b/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs @@ -1,9 +1,34 @@ -namespace Bit.Core.Enums; +using Bit.Core.Entities; +namespace Bit.Core.Enums; + +/// +/// Represents the different stages of a member's lifecycle in an organization. +/// The object is populated differently depending on their Status. +/// public enum OrganizationUserStatusType : short { + /// + /// The OrganizationUser entry only represents an invitation to join the organization. It is not linked to a + /// specific User yet. + /// Invited = 0, + /// + /// The User has accepted the invitation and linked their User account to the OrganizationUser entry. + /// Accepted = 1, + /// + /// An administrator has granted the User access to the organization. This is the final step in the User becoming + /// a "full" member of the organization, including a key exchange so that they can decrypt organization data. + /// Confirmed = 2, + /// + /// The OrganizationUser has been revoked from the organization and cannot access organization data while in this state. + /// + /// + /// An OrganizationUser may move into this status from any other status, and will move back to their original status + /// if restored. This allows an administrator to easily suspend and restore access without going through the + /// Invite flow again. + /// Revoked = -1, } From 64b288035caae240cda759a2a9f4b8aac6511fa4 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 12 Jun 2025 19:21:05 +1000 Subject: [PATCH 08/11] Chore: document SutProvider and clean up UserServiceTests (#5879) * UserServiceTests - use builder pattern for SutProvider to reduce boilerplate * SutProvider - add xmldoc --- .../Services/Implementations/UserService.cs | 19 +- test/Common/AutoFixture/SutProvider.cs | 97 ++++++-- .../Policies/SavePolicyCommandTests.cs | 2 +- test/Core.Test/Services/UserServiceTests.cs | 217 +++++++----------- 4 files changed, 169 insertions(+), 166 deletions(-) diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index bf90190ee6..fe5a064c44 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -12,7 +12,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; -using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; @@ -29,12 +28,9 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tokens; using Bit.Core.Utilities; -using Bit.Core.Vault.Repositories; using Fido2NetLib; using Fido2NetLib.Objects; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; @@ -44,12 +40,11 @@ using JsonSerializer = System.Text.Json.JsonSerializer; namespace Bit.Core.Services; -public class UserService : UserManager, IUserService, IDisposable +public class UserService : UserManager, IUserService { private const string PremiumPlanId = "premium-annually"; private readonly IUserRepository _userRepository; - private readonly ICipherRepository _cipherRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationDomainRepository _organizationDomainRepository; @@ -65,17 +60,14 @@ public class UserService : UserManager, IUserService, IDisposable private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; - private readonly IDataProtector _organizationServiceDataProtector; private readonly IFido2 _fido2; private readonly ICurrentContext _currentContext; private readonly IGlobalSettings _globalSettings; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IProviderUserRepository _providerUserRepository; private readonly IStripeSyncService _stripeSyncService; - private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly IPremiumUserBillingService _premiumUserBillingService; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDistributedCache _distributedCache; @@ -83,7 +75,6 @@ public class UserService : UserManager, IUserService, IDisposable public UserService( IUserRepository userRepository, - ICipherRepository cipherRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, IOrganizationDomainRepository organizationDomainRepository, @@ -101,7 +92,6 @@ public class UserService : UserManager, IUserService, IDisposable ILicensingService licenseService, IEventService eventService, IApplicationCacheService applicationCacheService, - IDataProtectionProvider dataProtectionProvider, IPaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, @@ -111,10 +101,8 @@ public class UserService : UserManager, IUserService, IDisposable IAcceptOrgUserCommand acceptOrgUserCommand, IProviderUserRepository providerUserRepository, IStripeSyncService stripeSyncService, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IFeatureService featureService, IPremiumUserBillingService premiumUserBillingService, - IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDistributedCache distributedCache, @@ -131,7 +119,6 @@ public class UserService : UserManager, IUserService, IDisposable logger) { _userRepository = userRepository; - _cipherRepository = cipherRepository; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; _organizationDomainRepository = organizationDomainRepository; @@ -147,18 +134,14 @@ public class UserService : UserManager, IUserService, IDisposable _paymentService = paymentService; _policyRepository = policyRepository; _policyService = policyService; - _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( - "OrganizationServiceDataProtector"); _fido2 = fido2; _currentContext = currentContext; _globalSettings = globalSettings; _acceptOrgUserCommand = acceptOrgUserCommand; _providerUserRepository = providerUserRepository; _stripeSyncService = stripeSyncService; - _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _premiumUserBillingService = premiumUserBillingService; - _removeOrganizationUserCommand = removeOrganizationUserCommand; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _distributedCache = distributedCache; diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index 4b6f268ac3..bdab622754 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -4,8 +4,19 @@ using AutoFixture.Kernel; namespace Bit.Test.Common.AutoFixture; +/// +/// A utility class that encapsulates a system under test (sut) and its dependencies. +/// By default, all dependencies are initialized as mocks using the NSubstitute library. +/// SutProvider provides an interface for accessing these dependencies in the arrange and assert stages of your tests. +/// +/// The concrete implementation of the class being tested. public class SutProvider : ISutProvider { + /// + /// A record of the configured dependencies (constructor parameters). The outer Dictionary is keyed by the dependency's + /// type, and the inner dictionary is keyed by the parameter name (optionally used to disambiguate parameters with the same type). + /// The inner dictionary value is the dependency. + /// private Dictionary> _dependencies; private readonly IFixture _fixture; private readonly ConstructorParameterRelay _constructorParameterRelay; @@ -23,9 +34,21 @@ public class SutProvider : ISutProvider _fixture.Customizations.Add(_constructorParameterRelay); } + /// + /// Registers a dependency to be injected when the sut is created. You must call after + /// this method to (re)create the sut with the dependency. + /// + /// The dependency to register. + /// An optional parameter name to disambiguate the dependency if there are multiple of the same type. You generally don't need this. + /// The type to register the dependency under - usually an interface. This should match the type expected by the sut's constructor. + /// public SutProvider SetDependency(T dependency, string parameterName = "") => SetDependency(typeof(T), dependency, parameterName); - public SutProvider SetDependency(Type dependencyType, object dependency, string parameterName = "") + + /// + /// An overload for which takes a runtime object rather than a compile-time type. + /// + private SutProvider SetDependency(Type dependencyType, object dependency, string parameterName = "") { if (_dependencies.TryGetValue(dependencyType, out var dependencyForType)) { @@ -39,45 +62,69 @@ public class SutProvider : ISutProvider return this; } + /// + /// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with + /// or automatically with . + /// As dependencies are initialized with NSubstitute mocks by default, this is often used to retrieve those mocks in order to + /// configure them during the arrange stage, or check received calls in the assert stage. + /// + /// An optional parameter name to disambiguate the dependency if there are multiple of the same type. You generally don't need this. + /// The type of the dependency you want to get - usually an interface. + /// The dependency. public T GetDependency(string parameterName = "") => (T)GetDependency(typeof(T), parameterName); - public object GetDependency(Type dependencyType, string parameterName = "") + + /// + /// An overload for which takes a runtime object rather than a compile-time type. + /// + private object GetDependency(Type dependencyType, string parameterName = "") { if (DependencyIsSet(dependencyType, parameterName)) { return _dependencies[dependencyType][parameterName]; } - else if (_dependencies.TryGetValue(dependencyType, out var knownDependencies)) + + if (_dependencies.TryGetValue(dependencyType, out var knownDependencies)) { if (knownDependencies.Values.Count == 1) { return knownDependencies.Values.Single(); } - else - { - throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ", - $"{parameterName} does not exist. Available dependency names are: ", - string.Join(", ", knownDependencies.Keys))); - } - } - else - { - throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set."); + + throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ", + $"{parameterName} does not exist. Available dependency names are: ", + string.Join(", ", knownDependencies.Keys))); } + + throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set."); } + /// + /// Clear all the dependencies and the sut. This reverts the SutProvider back to a fully uninitialized state. + /// public void Reset() { _dependencies = new Dictionary>(); Sut = default; } + /// + /// Recreate a new sut with all new dependencies. This will reset all dependencies, including mocked return values + /// and any dependencies set with . + /// public void Recreate() { _dependencies = new Dictionary>(); Sut = _fixture.Create(); } + /// > ISutProvider ISutProvider.Create() => Create(); + + /// + /// Creates the sut, injecting any dependencies configured via and falling back to + /// NSubstitute mocks for any dependencies that have not been explicitly configured. + /// + /// public SutProvider Create() { Sut = _fixture.Create(); @@ -89,6 +136,19 @@ public class SutProvider : ISutProvider private object GetDefault(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null; + /// + /// A specimen builder which tells Autofixture to use the dependency registered in + /// when creating test data. If no matching dependency exists in , it creates + /// an NSubstitute mock and registers it using + /// so it can be retrieved later. + /// This is the link between and Autofixture. + /// + /// + /// Autofixture knows how to create sample data of simple types (such as an int or string) but not more complex classes. + /// We create our own and register it with the in + /// to provide that instruction. + /// + /// The type of the sut. private class ConstructorParameterRelay : ISpecimenBuilder { private readonly SutProvider _sutProvider; @@ -102,6 +162,7 @@ public class SutProvider : ISutProvider public object Create(object request, ISpecimenContext context) { + // Basic checks to filter out irrelevant requests from Autofixture if (context == null) { throw new ArgumentNullException(nameof(context)); @@ -116,16 +177,22 @@ public class SutProvider : ISutProvider return new NoSpecimen(); } + // Use the dependency set under this parameter name, if any if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, parameterInfo.Name)) { return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name); } - // Return default type if set - else if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, "")) + + // Use the default dependency set for this type, if any (i.e. no parameter name has been specified) + if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, "")) { return _sutProvider.GetDependency(parameterInfo.ParameterType, ""); } + // Fallback: pass the request down the chain. This lets another fixture customization populate the value. + // If you haven't added any customizations, this should be an NSubstitute mock. + // It is registered with SetDependency so you can retrieve it later. + // This is the equivalent of _fixture.Create, but no overload for // Create(Type type) exists. var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index 3ca7004e70..426389f33c 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -288,7 +288,7 @@ public class SavePolicyCommandTests { return new SutProvider() .WithFakeTimeProvider() - .SetDependency(typeof(IEnumerable), policyValidators ?? []) + .SetDependency(policyValidators ?? []) .Create(); } diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 9252d28588..0589753dd7 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -7,13 +7,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; -using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -21,22 +18,15 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -using Bit.Core.Vault.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Bit.Test.Common.Fakes; using Bit.Test.Common.Helpers; -using Fido2NetLib; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -179,9 +169,12 @@ public class UserServiceTests [Theory] [BitAutoData(DeviceType.UnknownBrowser, "Unknown Browser")] [BitAutoData(DeviceType.Android, "Android")] - public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName, SutProvider sutProvider, User user) + public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName, + User user) { - SetupFakeTokenProvider(sutProvider, user); + var sutProvider = new SutProvider() + .CreateWithUserServiceCustomizations(user); + var context = sutProvider.GetDependency(); context.DeviceType = deviceType; context.IpAddress = "1.1.1.1"; @@ -194,9 +187,11 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(SutProvider sutProvider, User user) + public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(User user) { - SetupFakeTokenProvider(sutProvider, user); + var sutProvider = new SutProvider() + .CreateWithUserServiceCustomizations(user); + var context = sutProvider.GetDependency(); context.DeviceType = null; context.IpAddress = "1.1.1.1"; @@ -266,76 +261,28 @@ public class UserServiceTests [BitAutoData(true, "bad_test_password", false, ShouldCheck.Password | ShouldCheck.OTP)] public async Task VerifySecretAsync_Works( bool shouldHavePassword, string secret, bool expectedIsVerified, ShouldCheck shouldCheck, // inline theory data - SutProvider sutProvider, User user) // AutoFixture injected data + User user) // AutoFixture injected data { // Arrange - var tokenProvider = SetupFakeTokenProvider(sutProvider, user); SetupUserAndDevice(user, shouldHavePassword); + var sutProvider = new SutProvider() + .CreateWithUserServiceCustomizations(user); + // Setup the fake password verification - var substitutedUserPasswordStore = Substitute.For>(); - substitutedUserPasswordStore + sutProvider.GetDependency>() .GetPasswordHashAsync(user, Arg.Any()) - .Returns((ci) => - { - return Task.FromResult("hashed_test_password"); - }); + .Returns(Task.FromResult("hashed_test_password")); - sutProvider.SetDependency>(substitutedUserPasswordStore, "store"); - - sutProvider.GetDependency>("passwordHasher") + sutProvider.GetDependency>() .VerifyHashedPassword(user, "hashed_test_password", "test_password") - .Returns((ci) => - { - return PasswordVerificationResult.Success; - }); + .Returns(PasswordVerificationResult.Success); - // HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured - var sut = new UserService( - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - new FakeDataProtectorTokenFactory(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency() - ); - - var actualIsVerified = await sut.VerifySecretAsync(user, secret); + var actualIsVerified = await sutProvider.Sut.VerifySecretAsync(user, secret); Assert.Equal(expectedIsVerified, actualIsVerified); - await tokenProvider + await sutProvider.GetDependency>() .Received(shouldCheck.HasFlag(ShouldCheck.OTP) ? 1 : 0) .ValidateAsync(Arg.Any(), secret, Arg.Any>(), user); @@ -661,26 +608,25 @@ public class UserServiceTests } [Theory, BitAutoData] - public async Task ResendNewDeviceVerificationEmail_SendsToken_Success( - SutProvider sutProvider, User user) + public async Task ResendNewDeviceVerificationEmail_SendsToken_Success(User user) { // Arrange var testPassword = "test_password"; - var tokenProvider = SetupFakeTokenProvider(sutProvider, user); SetupUserAndDevice(user, true); + var sutProvider = new SutProvider() + .CreateWithUserServiceCustomizations(user); + // Setup the fake password verification - var substitutedUserPasswordStore = Substitute.For>(); - substitutedUserPasswordStore + sutProvider + .GetDependency>() .GetPasswordHashAsync(user, Arg.Any()) .Returns((ci) => { return Task.FromResult("hashed_test_password"); }); - sutProvider.SetDependency>(substitutedUserPasswordStore, "store"); - - sutProvider.GetDependency>("passwordHasher") + sutProvider.GetDependency>() .VerifyHashedPassword(user, "hashed_test_password", testPassword) .Returns((ci) => { @@ -695,10 +641,7 @@ public class UserServiceTests context.DeviceType = DeviceType.Android; context.IpAddress = "1.1.1.1"; - // HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured - var sut = RebuildSut(sutProvider); - - await sut.ResendNewDeviceVerificationEmail(user.Email, testPassword); + await sutProvider.Sut.ResendNewDeviceVerificationEmail(user.Email, testPassword); await sutProvider.GetDependency() .Received(1) @@ -842,8 +785,15 @@ public class UserServiceTests user.MasterPassword = null; } } +} - private static IUserTwoFactorTokenProvider SetupFakeTokenProvider(SutProvider sutProvider, User user) +public static class UserServiceSutProviderExtensions +{ + /// + /// Arranges a fake token provider. Must call as part of a builder pattern that ends in Create(), as it modifies + /// the SutProvider build chain. + /// + private static SutProvider SetFakeTokenProvider(this SutProvider sutProvider, User user) { var fakeUserTwoFactorProvider = Substitute.For>(); @@ -859,8 +809,11 @@ public class UserServiceTests .ValidateAsync(Arg.Any(), "otp_token", Arg.Any>(), user) .Returns(true); - sutProvider.GetDependency>() - .Value.Returns(new IdentityOptions + var fakeIdentityOptions = Substitute.For>(); + + fakeIdentityOptions + .Value + .Returns(new IdentityOptions { Tokens = new TokenOptions { @@ -874,54 +827,54 @@ public class UserServiceTests } }); - // The above arranging of dependencies is used in the constructor of UserManager - // ref: https://github.com/dotnet/aspnetcore/blob/bfeb3bf9005c36b081d1e48725531ee0e15a9dfb/src/Identity/Extensions.Core/src/UserManager.cs#L103-L120 - // since the constructor of the Sut has ran already (when injected) I need to recreate it to get it to run again - sutProvider.Create(); + sutProvider.SetDependency(fakeIdentityOptions); + // Also set the fake provider dependency so that we can retrieve it easily via GetDependency + sutProvider.SetDependency(fakeUserTwoFactorProvider); - return fakeUserTwoFactorProvider; + return sutProvider; } - private IUserService RebuildSut(SutProvider sutProvider) + /// + /// Properly registers IUserPasswordStore as IUserStore so it's injected when the sut is initialized. + /// + /// + /// + private static SutProvider SetUserPasswordStore(this SutProvider sutProvider) { - return new UserService( - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency>>(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - new FakeDataProtectorTokenFactory(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency(), - sutProvider.GetDependency() - ); + var substitutedUserPasswordStore = Substitute.For>(); + + // IUserPasswordStore must be registered under the IUserStore parameter to be properly injected + // because this is what the constructor expects + sutProvider.SetDependency>(substitutedUserPasswordStore); + + // Also store it under its own type for retrieval and configuration + sutProvider.SetDependency(substitutedUserPasswordStore); + + return sutProvider; } + + /// + /// This is a hack: when autofixture initializes the sut in sutProvider, it overwrites the public + /// PasswordHasher property with a new substitute, so it loses the configured sutProvider mock. + /// This doesn't usually happen because our dependencies are not usually public. + /// Call this AFTER SutProvider.Create(). + /// + private static SutProvider FixPasswordHasherBug(this SutProvider sutProvider) + { + // Get the configured sutProvider mock and assign it back to the public property in the base class + sutProvider.Sut.PasswordHasher = sutProvider.GetDependency>(); + return sutProvider; + } + + /// + /// A helper that combines all SutProvider configuration usually required for UserService. + /// Call this instead of SutProvider.Create, after any additional configuration your test needs. + /// + public static SutProvider CreateWithUserServiceCustomizations(this SutProvider sutProvider, User user) + => sutProvider + .SetUserPasswordStore() + .SetFakeTokenProvider(user) + .Create() + .FixPasswordHasherBug(); + } From 66a89245f932e7125dee71f23fa8b7aacc98191e Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 12 Jun 2025 09:48:15 -0400 Subject: [PATCH 09/11] [PM-20590] Correcting error provided when scaling provider org (#5951) * Moved provider org check above organization stripe id checks. --- .../InviteUsersPasswordManagerValidator.cs | 17 ++++++++++------- .../Payments/InviteUserPaymentValidation.cs | 3 +-- .../InviteUserPaymentValidationTests.cs | 1 - 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index a1536ad439..f5259d1066 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Validation; @@ -83,14 +84,9 @@ public class InviteUsersPasswordManagerValidator( return invalidEnvironment.Map(request); } - var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); - - if (organizationValidationResult is Invalid organizationValidation) - { - return organizationValidation.Map(request); - } - + // Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing. var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId); + if (provider is not null) { var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider)); @@ -101,6 +97,13 @@ public class InviteUsersPasswordManagerValidator( } } + var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); + + if (organizationValidationResult is Invalid organizationValidation) + { + return organizationValidation.Map(request); + } + var paymentSubscription = await paymentService.GetSubscriptionAsync( await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId)); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs index 496dddc916..6de219f1cf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs @@ -1,10 +1,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; public static class InviteUserPaymentValidation { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs index d508f7cc5e..738ae71298 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteUserPaymentValidationTests.cs @@ -1,6 +1,5 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.Utilities.Validation; From 0bdd18d6b7502260ee9efbc97bf3031fce37943d Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:26:35 +0000 Subject: [PATCH 10/11] Remove logic for generating and uploading checksum artifacts (#5950) --- .github/workflows/build.yml | 28 ---------------------------- .github/workflows/release.yml | 9 +++------ 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5c5d0e7a8..e7d4a83fed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -350,14 +350,6 @@ jobs: cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. - - name: Make Docker stub checksums - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - run: | - sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt - sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt - - name: Upload Docker stub US artifact if: | github.event_name != 'pull_request' @@ -378,26 +370,6 @@ jobs: path: docker-stub-EU.zip if-no-files-found: error - - name: Upload Docker stub US checksum artifact - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: docker-stub-US-sha256.txt - path: docker-stub-US-sha256.txt - if-no-files-found: error - - - name: Upload Docker stub EU checksum artifact - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: docker-stub-EU-sha256.txt - path: docker-stub-EU-sha256.txt - if-no-files-found: error - - name: Build Public API Swagger run: | cd ./src/Api diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f749d2e4f0..1a9cc2d966 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,9 @@ on: env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" +permissions: + contents: read + jobs: setup: name: Setup @@ -65,9 +68,7 @@ jobs: workflow_conclusion: success branch: ${{ needs.setup.outputs.branch-name }} artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" - name: Dry Run - Download latest release Docker stubs @@ -78,9 +79,7 @@ jobs: workflow_conclusion: success branch: main artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" - name: Create release @@ -88,9 +87,7 @@ jobs: uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 with: artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" commit: ${{ github.sha }} tag: "v${{ needs.setup.outputs.release_version }}" From db77201ca47412653f506ac2d8880e2241a03a44 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Thu, 12 Jun 2025 16:07:52 +0000 Subject: [PATCH 11/11] Bumped version to 2025.6.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b369d6574d..a798c7f335 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.6.0 + 2025.6.1 Bit.$(MSBuildProjectName) enable