From 8a2012bb83df2a678c9f370c7c8307906f9eede1 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:53:34 -0400 Subject: [PATCH 1/5] [PM-17777] sponsorships consume seats (#5694) * Admin initiated sponsorships now use seats similarly to inviting an organization user * Updated f4e endpoint to not expect a user ID, and instead just send a boolean * Fixed failing tests * Updated OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery to ensure both left and right sides are selecting the same columns --- .../OrganizationSponsorshipsController.cs | 8 +- ...ostedOrganizationSponsorshipsController.cs | 12 ++- ...ganizationSponsorshipCreateRequestModel.cs | 6 +- .../IOrganizationUserRepository.cs | 9 ++ .../CreateSponsorshipCommand.cs | 90 ++++++++++++------- .../Interfaces/ICreateSponsorshipCommand.cs | 10 ++- ...dOccupiedSeatCountByOrganizationIdQuery.cs | 22 ++++- ..._ReadOccupiedSeatCountByOrganizationId.sql | 21 +++-- .../CreateSponsorshipCommandTests.cs | 28 +++--- ...eOrgUserReadOccupiedSeatCountProcedure.sql | 32 +++++++ 10 files changed, 165 insertions(+), 73 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-04-22_00_UpdateOrgUserReadOccupiedSeatCountProcedure.sql diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 04667e61ad..67cd691a34 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -86,9 +86,9 @@ public class OrganizationSponsorshipsController : Controller if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) { - if (model.SponsoringUserId.HasValue) + if (model.IsAdminInitiated.GetValueOrDefault()) { - throw new NotFoundException(); + throw new BadRequestException(); } if (!string.IsNullOrWhiteSpace(model.Notes)) @@ -97,13 +97,13 @@ public class OrganizationSponsorshipsController : Controller } } - var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value; var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( sponsoringOrg, - await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser), + await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, + model.IsAdminInitiated.GetValueOrDefault(), model.Notes); await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name); } diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index d2c87c6b6f..e328b7c3e4 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -47,9 +47,9 @@ public class SelfHostedOrganizationSponsorshipsController : Controller { if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) { - if (model.SponsoringUserId.HasValue) + if (model.IsAdminInitiated.GetValueOrDefault()) { - throw new NotFoundException(); + throw new BadRequestException(); } if (!string.IsNullOrWhiteSpace(model.Notes)) @@ -60,8 +60,12 @@ public class SelfHostedOrganizationSponsorshipsController : Controller await _offerSponsorshipCommand.CreateSponsorshipAsync( await _organizationRepository.GetByIdAsync(sponsoringOrgId), - await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, model.SponsoringUserId ?? _currentContext.UserId ?? default), - model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, model.Notes); + await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), + model.PlanSponsorshipType, + model.SponsoredEmail, + model.FriendlyName, + model.IsAdminInitiated.GetValueOrDefault(), + model.Notes); } [HttpDelete("{sponsoringOrgId}")] diff --git a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs index d3f03a7ddc..896b5799e0 100644 --- a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs @@ -17,11 +17,7 @@ public class OrganizationSponsorshipCreateRequestModel [StringLength(256)] public string FriendlyName { get; set; } - /// - /// (optional) The user to target for the sponsorship. - /// - /// Left empty when creating a sponsorship for the authenticated user. - public Guid? SponsoringUserId { get; set; } + public bool? IsAdminInitiated { get; set; } [EncryptedString] [EncryptedStringLength(512)] diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 108641b5e6..9692de897c 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -18,6 +18,15 @@ 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); diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs index 27589bea3e..f81a1d9e84 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs @@ -14,11 +14,17 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte public class CreateSponsorshipCommand( ICurrentContext currentContext, IOrganizationSponsorshipRepository organizationSponsorshipRepository, - IUserService userService) : ICreateSponsorshipCommand + IUserService userService, + IOrganizationService organizationService) : ICreateSponsorshipCommand { - public async Task CreateSponsorshipAsync(Organization sponsoringOrganization, - OrganizationUser sponsoringMember, PlanSponsorshipType sponsorshipType, string sponsoredEmail, - string friendlyName, string notes) + public async Task CreateSponsorshipAsync( + Organization sponsoringOrganization, + OrganizationUser sponsoringMember, + PlanSponsorshipType sponsorshipType, + string sponsoredEmail, + string friendlyName, + bool isAdminInitiated, + string notes) { var sponsoringUser = await userService.GetUserByIdAsync(sponsoringMember.UserId!.Value); @@ -48,12 +54,21 @@ public class CreateSponsorshipCommand( throw new BadRequestException("Can only sponsor one organization per Organization User."); } - var sponsorship = new OrganizationSponsorship(); - sponsorship.SponsoringOrganizationId = sponsoringOrganization.Id; - sponsorship.SponsoringOrganizationUserId = sponsoringMember.Id; - sponsorship.FriendlyName = friendlyName; - sponsorship.OfferedToEmail = sponsoredEmail; - sponsorship.PlanSponsorshipType = sponsorshipType; + if (isAdminInitiated) + { + ValidateAdminInitiatedSponsorship(sponsoringOrganization); + } + + var sponsorship = new OrganizationSponsorship + { + SponsoringOrganizationId = sponsoringOrganization.Id, + SponsoringOrganizationUserId = sponsoringMember.Id, + FriendlyName = friendlyName, + OfferedToEmail = sponsoredEmail, + PlanSponsorshipType = sponsorshipType, + IsAdminInitiated = isAdminInitiated, + Notes = notes + }; if (existingOrgSponsorship != null) { @@ -61,35 +76,22 @@ public class CreateSponsorshipCommand( sponsorship.Id = existingOrgSponsorship.Id; } - var isAdminInitiated = false; - if (currentContext.UserId != sponsoringMember.UserId) + if (isAdminInitiated && sponsoringOrganization.Seats.HasValue) { - var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrganization.Id); - OrganizationUserType[] allowedUserTypes = - [ - OrganizationUserType.Admin, - OrganizationUserType.Owner - ]; - - if (!organization.Permissions.ManageUsers && allowedUserTypes.All(x => x != organization.Type)) - { - throw new UnauthorizedAccessException("You do not have permissions to send sponsorships on behalf of the organization."); - } - - if (!sponsoringOrganization.UseAdminSponsoredFamilies) - { - throw new BadRequestException("Sponsoring organization cannot sponsor other Family organizations."); - } - - isAdminInitiated = true; + await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1); } - sponsorship.IsAdminInitiated = isAdminInitiated; - sponsorship.Notes = notes; - try { - await organizationSponsorshipRepository.UpsertAsync(sponsorship); + if (isAdminInitiated) + { + await organizationSponsorshipRepository.CreateAsync(sponsorship); + } + else + { + await organizationSponsorshipRepository.UpsertAsync(sponsorship); + } + return sponsorship; } catch @@ -101,4 +103,24 @@ public class CreateSponsorshipCommand( throw; } } + + private void ValidateAdminInitiatedSponsorship(Organization sponsoringOrganization) + { + var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrganization.Id); + OrganizationUserType[] allowedUserTypes = + [ + OrganizationUserType.Admin, + OrganizationUserType.Owner + ]; + + if (!organization.Permissions.ManageUsers && allowedUserTypes.All(x => x != organization.Type)) + { + throw new UnauthorizedAccessException("You do not have permissions to send sponsorships on behalf of the organization"); + } + + if (!sponsoringOrganization.UseAdminSponsoredFamilies) + { + throw new BadRequestException("Sponsoring organization cannot send admin-initiated sponsorship invitations"); + } + } } diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs index 4a3e5a63dc..31fc834bc3 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Interfaces/ICreateSponsorshipCommand.cs @@ -6,6 +6,12 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte public interface ICreateSponsorshipCommand { - Task CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, - PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName, string notes); + Task CreateSponsorshipAsync( + Organization sponsoringOrg, + OrganizationUser sponsoringOrgUser, + PlanSponsorshipType sponsorshipType, + string sponsoredEmail, + string friendlyName, + bool isAdminInitiated, + string notes); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs index 47b50617a0..0a681e2b5f 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs @@ -14,9 +14,23 @@ public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery public IQueryable Run(DatabaseContext dbContext) { - var query = from ou in dbContext.OrganizationUsers - where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited - select ou; - return query; + 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 && + !os.ToDelete + select new OrganizationUser + { + Id = os.Id, + OrganizationId = _organizationId, + Status = OrganizationUserStatusType.Invited + }; + + return orgUsersQuery.Concat(sponsorshipsQuery); } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql index 892ed51012..933441a210 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadOccupiedSeatCountByOrganizationId.sql @@ -5,10 +5,19 @@ BEGIN SET NOCOUNT ON SELECT - COUNT(1) - FROM - [dbo].[OrganizationUserView] - WHERE - OrganizationId = @OrganizationId - AND Status >= 0 --Invited + ( + -- Count organization users + SELECT COUNT(1) + FROM [dbo].[OrganizationUserView] + WHERE OrganizationId = @OrganizationId + AND Status >= 0 --Invited + ) + + ( + -- 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 + ) END diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs index 93a2b629f0..f6b6721bd2 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs @@ -41,7 +41,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).ReturnsNull(); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); + sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, false, null)); Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -55,7 +55,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, default, null)); + sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, default, false, null)); Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -72,7 +72,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); + sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, false, null)); Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -91,7 +91,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sutProvider.GetDependency().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); + sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, false, null)); Assert.Contains("Only confirmed users can sponsor other organizations.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -115,7 +115,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase sutProvider.GetDependency().UserId.Returns(orgUser.UserId.Value); var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType!.Value, null, null, null)); + sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType!.Value, null, null, false, null)); Assert.Contains("Can only sponsor one organization per Organization User.", exception.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() @@ -147,7 +147,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase var actual = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null)); + await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null)); Assert.Equal("Only confirmed users can sponsor other organizations.", actual.Message); } @@ -170,7 +170,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null); var expectedSponsorship = new OrganizationSponsorship { @@ -209,7 +209,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase var actualException = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null)); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null)); Assert.Same(expectedException, actualException); await sutProvider.GetDependency().Received(1) @@ -244,9 +244,9 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase var actual = await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes: null)); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, null)); - Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message); + Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization", actual.Message); } [Theory] @@ -278,9 +278,9 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase var actual = await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes: null)); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, null)); - Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message); + Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization", actual.Message); } [Theory] @@ -312,7 +312,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase ]); var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, - PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes); + PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes); var expectedSponsorship = new OrganizationSponsorship @@ -330,6 +330,6 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase Assert.True(SponsorshipValidator(expectedSponsorship, actual)); await sutProvider.GetDependency().Received(1) - .UpsertAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); + .CreateAsync(Arg.Is(s => SponsorshipValidator(s, expectedSponsorship))); } } diff --git a/util/Migrator/DbScripts/2025-04-22_00_UpdateOrgUserReadOccupiedSeatCountProcedure.sql b/util/Migrator/DbScripts/2025-04-22_00_UpdateOrgUserReadOccupiedSeatCountProcedure.sql new file mode 100644 index 0000000000..824e0987b2 --- /dev/null +++ b/util/Migrator/DbScripts/2025-04-22_00_UpdateOrgUserReadOccupiedSeatCountProcedure.sql @@ -0,0 +1,32 @@ +-- Update OrganizationUser_ReadOccupiedSeatCountByOrganizationId to include admin-initiated sponsorships +-- Based on https://bitwarden.atlassian.net/browse/PM-17772 +IF OBJECT_ID('[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId] +END +GO + +CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + ( + -- Count organization users + SELECT COUNT(1) + FROM [dbo].[OrganizationUserView] + WHERE OrganizationId = @OrganizationId + AND Status >= 0 --Invited + ) + + ( + -- 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 + ) +END +GO \ No newline at end of file From 0434191bcafbddb402daaf8c29a202766bacfd20 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 25 Apr 2025 05:47:21 +0200 Subject: [PATCH 2/5] [deps] Tools: Update aws-sdk-net monorepo (#5704) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index ce412cb47c..88dcea30fa 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -23,8 +23,8 @@ - - + + From cb2860c0c1beeb12d35c5166442d419535dd871f Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Fri, 25 Apr 2025 05:54:54 -0500 Subject: [PATCH 3/5] chore: update public api members delete xmldoc, refs PM-20520 (#5708) --- src/Api/AdminConsole/Public/Controllers/MembersController.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 76bd29d38e..92e5071801 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -221,8 +221,7 @@ public class MembersController : Controller /// Remove a member. /// /// - /// Permanently removes a member from the organization. This cannot be undone. - /// The user account will still remain. The user is only removed from the organization. + /// Removes a member from the organization. This cannot be undone. The user account will still remain. /// /// The identifier of the member to be removed. [HttpDelete("{id}")] From 5184d109954d9cd66df1e91b8ec1765df678396c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 25 Apr 2025 13:06:06 -0400 Subject: [PATCH 4/5] Create customer for client organization that was converted to BU upon unlinking (#5706) --- .../Providers/RemoveOrganizationFromProviderCommand.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 2c34e57a92..9a62be8dd5 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -110,9 +110,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv IEnumerable organizationOwnerEmails) { if (provider.IsBillable() && - organization.IsValidClient() && - !string.IsNullOrEmpty(organization.GatewayCustomerId)) + organization.IsValidClient()) { + // An organization converted to a business unit will not have a Customer since it was given to the business unit. + if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + await _providerBillingService.CreateCustomerForClientOrganization(provider, organization); + } + var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Description = string.Empty, From 9a7fddd77c38c1013ae9e58e23127544afe92f3d Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 25 Apr 2025 13:15:26 -0400 Subject: [PATCH 5/5] Removed feature flag (#5707) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e4a8465bcb..b6cc079600 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -195,7 +195,6 @@ public static class FeatureFlagKeys public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; - public const string VaultBulkManagementAction = "vault-bulk-management-action"; public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption";