diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index d9a7d4a2ce..394e8aa9bc 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,9 +1,14 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Utilities; +using Bit.Core.Services; +using Bit.Core.Settings; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -11,13 +16,22 @@ public class MaxProjectsQuery : IMaxProjectsQuery { private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; + private readonly IGlobalSettings _globalSettings; + private readonly ILicensingService _licensingService; + private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, - IProjectRepository projectRepository) + IProjectRepository projectRepository, + IGlobalSettings globalSettings, + ILicensingService licensingService, + IPricingClient pricingClient) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; + _globalSettings = globalSettings; + _licensingService = licensingService; + _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) @@ -28,19 +42,47 @@ public class MaxProjectsQuery : IMaxProjectsQuery throw new NotFoundException(); } - // TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122 - var plan = StaticStore.GetPlan(org.PlanType); - if (plan?.SecretsManager == null) + var (planType, maxProjects) = await GetPlanTypeAndMaxProjectsAsync(org); + + if (planType != PlanType.Free) { - throw new BadRequestException("Existing plan not found."); + return (null, null); } - if (plan.Type == PlanType.Free) + var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); + return ((short? max, bool? overMax))(projects + projectsToAdd > maxProjects ? (maxProjects, true) : (maxProjects, false)); + } + + private async Task<(PlanType planType, int maxProjects)> GetPlanTypeAndMaxProjectsAsync(Organization organization) + { + if (_globalSettings.SelfHosted) { - var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); - return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false)); + var license = await _licensingService.ReadOrganizationLicenseAsync(organization); + + if (license == null) + { + throw new BadRequestException("License not found."); + } + + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var maxProjects = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmMaxProjects); + + if (!maxProjects.HasValue) + { + throw new BadRequestException("License does not contain a value for max Secrets Manager projects"); + } + + var planType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType); + return (planType, maxProjects.Value); } - return (null, null); + var plan = await _pricingClient.GetPlan(organization.PlanType); + + if (plan is { SupportsSecretsManager: true }) + { + return (plan.Type, plan.SecretsManager.MaxProjects); + } + + throw new BadRequestException("Existing plan not found."); } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 347f5b2128..158463fcfa 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,9 +1,16 @@ -using Bit.Commercial.Core.SecretsManager.Queries.Projects; +using System.Security.Claims; +using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; +using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -32,7 +39,7 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.FamiliesAnnually2019)] [BitAutoData(PlanType.Custom)] [BitAutoData(PlanType.FamiliesAnnually)] - public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType, + public async Task GetByOrgIdAsync_Cloud_SmPlanIsNull_ThrowsBadRequest(PlanType planType, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; @@ -40,6 +47,34 @@ public class MaxProjectsQueryTests .GetByIdAsync(organization.Id) .Returns(organization); + sutProvider.GetDependency().SelfHosted.Returns(false); + var plan = StaticStore.GetPlan(planType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData] + public async Task GetByOrgIdAsync_SelfHosted_NoMaxProjectsClaim_ThrowsBadRequest( + SutProvider sutProvider, Organization organization) + { + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency().SelfHosted.Returns(true); + + var license = new OrganizationLicense(); + var claimsPrincipal = new ClaimsPrincipal(); + sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); + sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + await Assert.ThrowsAsync( async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); @@ -62,12 +97,58 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.EnterpriseAnnually2019)] [BitAutoData(PlanType.EnterpriseAnnually2020)] [BitAutoData(PlanType.EnterpriseAnnually)] - public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType, + public async Task GetByOrgIdAsync_Cloud_SmNoneFreePlans_ReturnsNull(PlanType planType, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().SelfHosted.Returns(false); + var plan = StaticStore.GetPlan(planType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); + + var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); + + Assert.Null(limit); + Assert.Null(overLimit); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsStarter)] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task GetByOrgIdAsync_SelfHosted_SmNoneFreePlans_ReturnsNull(PlanType planType, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().SelfHosted.Returns(true); + + var license = new OrganizationLicense(); + var plan = StaticStore.GetPlan(planType); + var claims = new List + { + new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()), + new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString()) + }; + var identity = new ClaimsIdentity(claims, "TestAuthenticationType"); + var claimsPrincipal = new ClaimsPrincipal(identity); + sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); + sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); Assert.Null(limit); @@ -102,7 +183,7 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.Free, 3, 4, true)] [BitAutoData(PlanType.Free, 4, 4, true)] [BitAutoData(PlanType.Free, 40, 4, true)] - public async Task GetByOrgIdAsync_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, + public async Task GetByOrgIdAsync_Cloud_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; @@ -110,6 +191,67 @@ public class MaxProjectsQueryTests sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); + sutProvider.GetDependency().SelfHosted.Returns(false); + var plan = StaticStore.GetPlan(planType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); + + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); + + Assert.NotNull(max); + Assert.NotNull(overMax); + Assert.Equal(3, max.Value); + Assert.Equal(expectedOverMax, overMax); + + await sutProvider.GetDependency().Received(1) + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData(PlanType.Free, 0, 1, false)] + [BitAutoData(PlanType.Free, 1, 1, false)] + [BitAutoData(PlanType.Free, 2, 1, false)] + [BitAutoData(PlanType.Free, 3, 1, true)] + [BitAutoData(PlanType.Free, 4, 1, true)] + [BitAutoData(PlanType.Free, 40, 1, true)] + [BitAutoData(PlanType.Free, 0, 2, false)] + [BitAutoData(PlanType.Free, 1, 2, false)] + [BitAutoData(PlanType.Free, 2, 2, true)] + [BitAutoData(PlanType.Free, 3, 2, true)] + [BitAutoData(PlanType.Free, 4, 2, true)] + [BitAutoData(PlanType.Free, 40, 2, true)] + [BitAutoData(PlanType.Free, 0, 3, false)] + [BitAutoData(PlanType.Free, 1, 3, true)] + [BitAutoData(PlanType.Free, 2, 3, true)] + [BitAutoData(PlanType.Free, 3, 3, true)] + [BitAutoData(PlanType.Free, 4, 3, true)] + [BitAutoData(PlanType.Free, 40, 3, true)] + [BitAutoData(PlanType.Free, 0, 4, true)] + [BitAutoData(PlanType.Free, 1, 4, true)] + [BitAutoData(PlanType.Free, 2, 4, true)] + [BitAutoData(PlanType.Free, 3, 4, true)] + [BitAutoData(PlanType.Free, 4, 4, true)] + [BitAutoData(PlanType.Free, 40, 4, true)] + public async Task GetByOrgIdAsync_SelfHosted_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) + .Returns(projects); + sutProvider.GetDependency().SelfHosted.Returns(true); + + var license = new OrganizationLicense(); + var plan = StaticStore.GetPlan(planType); + var claims = new List + { + new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()), + new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString()) + }; + var identity = new ClaimsIdentity(claims, "TestAuthenticationType"); + var claimsPrincipal = new ClaimsPrincipal(identity); + sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); + sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); Assert.NotNull(max); diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 513578f43e..8ef896d6f9 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -34,6 +34,7 @@ public static class OrganizationLicenseConstants public const string UseSecretsManager = nameof(UseSecretsManager); public const string SmSeats = nameof(SmSeats); public const string SmServiceAccounts = nameof(SmServiceAccounts); + public const string SmMaxProjects = nameof(SmMaxProjects); public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion); public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems); public const string UseRiskInsights = nameof(UseRiskInsights); diff --git a/src/Core/Billing/Licenses/Models/LicenseContext.cs b/src/Core/Billing/Licenses/Models/LicenseContext.cs index 8dcc24e939..01eb3ac80c 100644 --- a/src/Core/Billing/Licenses/Models/LicenseContext.cs +++ b/src/Core/Billing/Licenses/Models/LicenseContext.cs @@ -7,4 +7,5 @@ public class LicenseContext { public Guid? InstallationId { get; init; } public required SubscriptionInfo SubscriptionInfo { get; init; } + public int? SmMaxProjects { get; set; } } diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 6819d3cc0b..7406ac16d9 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -112,6 +112,11 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory GetLicenseAsync(Organization organization, Guid installationId, @@ -42,7 +46,11 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer var subscriptionInfo = await GetSubscriptionAsync(organization); var license = new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version); - license.Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo); + var plan = await _pricingClient.GetPlan(organization.PlanType); + int? smMaxProjects = plan?.SupportsSecretsManager ?? false + ? plan.SecretsManager.MaxProjects + : null; + license.Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo, smMaxProjects); return license; } diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Services/ILicensingService.cs index 2115e43085..9c497ed538 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Services/ILicensingService.cs @@ -21,7 +21,8 @@ public interface ILicensingService Task CreateOrganizationTokenAsync( Organization organization, Guid installationId, - SubscriptionInfo subscriptionInfo); + SubscriptionInfo subscriptionInfo, + int? smMaxProjects); Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); } diff --git a/src/Core/Services/Implementations/LicensingService.cs b/src/Core/Services/Implementations/LicensingService.cs index dd603b4b63..e3509bc964 100644 --- a/src/Core/Services/Implementations/LicensingService.cs +++ b/src/Core/Services/Implementations/LicensingService.cs @@ -339,12 +339,13 @@ public class LicensingService : ILicensingService } } - public async Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + public async Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo, int? smMaxProjects) { var licenseContext = new LicenseContext { InstallationId = installationId, SubscriptionInfo = subscriptionInfo, + SmMaxProjects = smMaxProjects }; var claims = await _organizationLicenseClaimsFactory.GenerateClaims(organization, licenseContext); diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Services/NoopImplementations/NoopLicensingService.cs index b181e61138..de5e954d44 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Services/NoopImplementations/NoopLicensingService.cs @@ -62,7 +62,7 @@ public class NoopLicensingService : ILicensingService return null; } - public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo, int? smMaxProjects) { return Task.FromResult(null); } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs index cc8ab956ca..7af9044c80 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/CloudGetOrganizationLicenseQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -8,6 +9,7 @@ using Bit.Core.OrganizationFeatures.OrganizationLicenses; using Bit.Core.Platform.Installations; using Bit.Core.Services; using Bit.Core.Test.AutoFixture; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -76,8 +78,10 @@ public class CloudGetOrganizationLicenseQueryTests sutProvider.GetDependency().GetByIdAsync(installationId).Returns(installation); sutProvider.GetDependency().GetSubscriptionAsync(organization).Returns(subInfo); sutProvider.GetDependency().SignLicense(Arg.Any()).Returns(licenseSignature); + var plan = StaticStore.GetPlan(organization.PlanType); + sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); sutProvider.GetDependency() - .CreateOrganizationTokenAsync(organization, installationId, subInfo) + .CreateOrganizationTokenAsync(organization, installationId, subInfo, plan.SecretsManager.MaxProjects) .Returns(token); var result = await sutProvider.Sut.GetLicenseAsync(organization, installationId);