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); + } }