diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs
index 42263aa88b..f2c2418bf5 100644
--- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs
+++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs
@@ -77,9 +77,10 @@ public class OrganizationSponsorshipsController : Controller
{
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
+ var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value;
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg,
- await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
+ await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName);
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
}
diff --git a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs
index ba88f1b90e..9bdb8a3a16 100644
--- a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs
+++ b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs
@@ -16,4 +16,10 @@ 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; }
}
diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs
index 54661e22a7..9b418bccf9 100644
--- a/src/Core/AdminConsole/Entities/Organization.cs
+++ b/src/Core/AdminConsole/Entities/Organization.cs
@@ -114,6 +114,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable,
///
public bool UseRiskInsights { get; set; }
+ ///
+ /// If set to true, admins can initiate organization-issued sponsorships.
+ ///
+ public bool UseAdminSponsoredFamilies { get; set; }
+
public void SetNewId()
{
if (Id == default(Guid))
diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs
index 62914f6fa8..d27bf40994 100644
--- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs
+++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs
@@ -26,6 +26,7 @@ public class OrganizationAbility
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
+ UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
}
public Guid Id { get; set; }
@@ -45,4 +46,5 @@ public class OrganizationAbility
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
+ public bool UseAdminSponsoredFamilies { get; set; }
}
diff --git a/src/Core/Entities/OrganizationSponsorship.cs b/src/Core/Entities/OrganizationSponsorship.cs
index 77c77eab21..fa211b686e 100644
--- a/src/Core/Entities/OrganizationSponsorship.cs
+++ b/src/Core/Entities/OrganizationSponsorship.cs
@@ -20,6 +20,7 @@ public class OrganizationSponsorship : ITableObject
public DateTime? LastSyncDate { get; set; }
public DateTime? ValidUntil { get; set; }
public bool ToDelete { get; set; }
+ public bool IsAdminInitiated { get; set; }
public void SetNewId()
{
diff --git a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs
index 927262957a..df0d431ce0 100644
--- a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs
+++ b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs
@@ -16,6 +16,7 @@ public class OrganizationSponsorshipData
LastSyncDate = sponsorship.LastSyncDate;
ValidUntil = sponsorship.ValidUntil;
ToDelete = sponsorship.ToDelete;
+ IsAdminInitiated = sponsorship.IsAdminInitiated;
}
public Guid SponsoringOrganizationUserId { get; set; }
public Guid? SponsoredOrganizationId { get; set; }
@@ -25,6 +26,7 @@ public class OrganizationSponsorshipData
public DateTime? LastSyncDate { get; set; }
public DateTime? ValidUntil { get; set; }
public bool ToDelete { get; set; }
+ public bool IsAdminInitiated { get; set; }
public bool CloudSponsorshipRemoved { get; set; }
}
diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs
index ac65d3b897..e7b637eb32 100644
--- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs
+++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs
@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
+using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -10,22 +11,16 @@ using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
-public class CreateSponsorshipCommand : ICreateSponsorshipCommand
+public class CreateSponsorshipCommand(
+ IOrganizationSponsorshipRepository organizationSponsorshipRepository,
+ IUserService userService,
+ ICurrentContext currentContext)
+ : ICreateSponsorshipCommand
{
- private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository;
- private readonly IUserService _userService;
-
- public CreateSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository,
- IUserService userService)
- {
- _organizationSponsorshipRepository = organizationSponsorshipRepository;
- _userService = userService;
- }
-
public async Task CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser,
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName)
{
- var sponsoringUser = await _userService.GetUserByIdAsync(sponsoringOrgUser.UserId.Value);
+ var sponsoringUser = await userService.GetUserByIdAsync(sponsoringOrgUser.UserId.Value);
if (sponsoringUser == null || string.Equals(sponsoringUser.Email, sponsoredEmail, System.StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.");
@@ -45,7 +40,24 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
}
- var existingOrgSponsorship = await _organizationSponsorshipRepository
+ var isAdminInitiated = false;
+ if (currentContext.UserId != sponsoringOrgUser.UserId)
+ {
+ var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrg.Id);
+ OrganizationUserType[] allowedUserTypes =
+ [
+ OrganizationUserType.Admin,
+ OrganizationUserType.Owner,
+ OrganizationUserType.Custom
+ ];
+ 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.");
+ }
+ isAdminInitiated = true;
+ }
+
+ var existingOrgSponsorship = await organizationSponsorshipRepository
.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id);
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
{
@@ -59,6 +71,7 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
FriendlyName = friendlyName,
OfferedToEmail = sponsoredEmail,
PlanSponsorshipType = sponsorshipType,
+ IsAdminInitiated = isAdminInitiated
};
if (existingOrgSponsorship != null)
@@ -69,14 +82,14 @@ public class CreateSponsorshipCommand : ICreateSponsorshipCommand
try
{
- await _organizationSponsorshipRepository.UpsertAsync(sponsorship);
+ await organizationSponsorshipRepository.UpsertAsync(sponsorship);
return sponsorship;
}
catch
{
if (sponsorship.Id != default)
{
- await _organizationSponsorshipRepository.DeleteAsync(sponsorship);
+ await organizationSponsorshipRepository.DeleteAsync(sponsorship);
}
throw;
}
diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql
index 6d10126972..e4c474fdc7 100644
--- a/src/Sql/dbo/Tables/Organization.sql
+++ b/src/Sql/dbo/Tables/Organization.sql
@@ -56,6 +56,7 @@ CREATE TABLE [dbo].[Organization] (
[LimitItemDeletion] BIT NOT NULL CONSTRAINT [DF_Organization_LimitItemDeletion] DEFAULT (0),
[AllowAdminAccessToAllCollectionItems] BIT NOT NULL CONSTRAINT [DF_Organization_AllowAdminAccessToAllCollectionItems] DEFAULT (0),
[UseRiskInsights] BIT NOT NULL CONSTRAINT [DF_Organization_UseRiskInsights] DEFAULT (0),
+ [UseAdminSponsoredFamilies] BIT NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] DEFAULT (0),
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
);
diff --git a/src/Sql/dbo/Tables/OrganizationSponsorship.sql b/src/Sql/dbo/Tables/OrganizationSponsorship.sql
index 7377162b5c..b4526d5b65 100644
--- a/src/Sql/dbo/Tables/OrganizationSponsorship.sql
+++ b/src/Sql/dbo/Tables/OrganizationSponsorship.sql
@@ -9,6 +9,7 @@ CREATE TABLE [dbo].[OrganizationSponsorship] (
[ToDelete] BIT DEFAULT (0) NOT NULL,
[LastSyncDate] DATETIME2 (7) NULL,
[ValidUntil] DATETIME2 (7) NULL,
+ [IsAdminInitiated] BIT NOT NULL CONSTRAINT [DF_OrganizationSponsorship_IsAdminInitiated] DEFAULT (0),
CONSTRAINT [PK_OrganizationSponsorship] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_OrganizationSponsorship_SponsoringOrg] FOREIGN KEY ([SponsoringOrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_OrganizationSponsorship_SponsoredOrg] FOREIGN KEY ([SponsoredOrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs
index df75663045..d69feb5471 100644
--- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs
+++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs
@@ -1,8 +1,10 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
+using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
+using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -110,6 +112,8 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency()
.GetBySponsoringOrganizationUserIdAsync(orgUser.Id).Returns(sponsorship);
+ sutProvider.GetDependency().UserId.Returns(orgUser.UserId.Value);
+
var exception = await Assert.ThrowsAsync(() =>
sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType.Value, default, default));
@@ -132,6 +136,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var sponsorship = callInfo.Arg();
sponsorship.Id = sponsorshipId;
});
+ sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value);
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
@@ -168,6 +173,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
createdSponsorship.Id = Guid.NewGuid();
return expectedException;
});
+ sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value);
var actualException = await Assert.ThrowsAsync(() =>
sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
@@ -177,4 +183,74 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
await sutProvider.GetDependency().Received(1)
.DeleteAsync(createdSponsorship);
}
+
+ [Theory]
+ [BitAutoData]
+ public async Task CreateSponsorship_MissingManageUsersPermission_ThrowsUnauthorizedException(
+ Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
+ string friendlyName, Guid sponsorshipId, Guid currentUserId, SutProvider sutProvider)
+ {
+ sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
+ sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
+
+ sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId.Value).Returns(user);
+ sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(default)).Do(callInfo =>
+ {
+ var sponsorship = callInfo.Arg();
+ sponsorship.Id = sponsorshipId;
+ });
+ sutProvider.GetDependency().UserId.Returns(currentUserId);
+ sutProvider.GetDependency().Organizations.Returns([
+ new()
+ {
+ Id = sponsoringOrg.Id,
+ Permissions = new Permissions(),
+ Type = OrganizationUserType.Admin
+ }
+ ]);
+
+
+ var actual = await Assert.ThrowsAsync(async () =>
+ await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
+ PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName));
+
+ Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message);
+ }
+
+ [Theory]
+ [BitAutoData(OrganizationUserType.User)]
+ public async Task CreateSponsorship_InvalidUserType_ThrowsUnauthorizedException(
+ OrganizationUserType organizationUserType,
+ Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
+ string friendlyName, Guid sponsorshipId, Guid currentUserId, SutProvider sutProvider)
+ {
+ sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
+ sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
+
+ sutProvider.GetDependency().GetUserByIdAsync(sponsoringOrgUser.UserId.Value).Returns(user);
+ sutProvider.GetDependency().WhenForAnyArgs(x => x.UpsertAsync(default)).Do(callInfo =>
+ {
+ var sponsorship = callInfo.Arg();
+ sponsorship.Id = sponsorshipId;
+ });
+ sutProvider.GetDependency().UserId.Returns(currentUserId);
+ sutProvider.GetDependency().Organizations.Returns([
+ new()
+ {
+ Id = sponsoringOrg.Id,
+ Permissions = new Permissions
+ {
+ ManageUsers = true,
+ },
+ Type = organizationUserType
+ }
+ ]);
+
+
+ var actual = await Assert.ThrowsAsync(async () =>
+ await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
+ PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName));
+
+ Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message);
+ }
}