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