diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs new file mode 100644 index 000000000..7cbb37f18 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -0,0 +1,45 @@ +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Queries.Projects.Interfaces; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Utilities; + +namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; + +public class MaxProjectsQuery : IMaxProjectsQuery +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IProjectRepository _projectRepository; + + public MaxProjectsQuery( + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository) + { + _organizationRepository = organizationRepository; + _projectRepository = projectRepository; + } + + public async Task<(short? max, bool? atMax)> GetByOrgIdAsync(Guid organizationId) + { + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException(); + } + + var plan = StaticStore.GetSecretsManagerPlan(org.PlanType); + if (plan == null) + { + throw new BadRequestException("Existing plan not found."); + } + + if (plan.Type == PlanType.Free) + { + var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); + return projects >= plan.MaxProjects ? (plan.MaxProjects, true) : (plan.MaxProjects, false); + } + + return (null, null); + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs index be9534cfc..47547eb0b 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs @@ -10,6 +10,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.Secrets; using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; using Bit.Commercial.Core.SecretsManager.Commands.Trash; using Bit.Commercial.Core.SecretsManager.Queries; +using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Commercial.Core.SecretsManager.Queries.ServiceAccounts; using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces; @@ -19,6 +20,7 @@ using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Commands.Trash.Interfaces; using Bit.Core.SecretsManager.Queries.Interfaces; +using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -34,6 +36,7 @@ public static class SecretsManagerCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); 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 new file mode 100644 index 000000000..6706e0165 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -0,0 +1,97 @@ +using Bit.Commercial.Core.SecretsManager.Queries.Projects; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Queries.Projects; + +[SutProviderCustomize] +public class MaxProjectsQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetByOrgIdAsync_OrganizationIsNull_ThrowsNotFound(SutProvider sutProvider, + Guid organizationId) + { + sutProvider.GetDependency().GetByIdAsync(default).ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetByOrgIdAsync(organizationId)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organizationId); + } + + [Theory] + [BitAutoData(PlanType.FamiliesAnnually2019)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.Custom)] + [BitAutoData(PlanType.FamiliesAnnually)] + public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id); + + Assert.Null(limit); + Assert.Null(overLimit); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetProjectCountByOrganizationIdAsync(organization.Id); + } + + [Theory] + [BitAutoData(PlanType.Free, 0, false)] + [BitAutoData(PlanType.Free, 1, false)] + [BitAutoData(PlanType.Free, 2, false)] + [BitAutoData(PlanType.Free, 3, true)] + [BitAutoData(PlanType.Free, 4, true)] + [BitAutoData(PlanType.Free, 40, true)] + public async Task GetByOrgIdAsync_SmFreePlan_Success(PlanType planType, int projects, bool shouldBeAtMax, + SutProvider sutProvider, Organization organization) + { + organization.PlanType = planType; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) + .Returns(projects); + + var (max, atMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id); + + Assert.NotNull(max); + Assert.NotNull(atMax); + Assert.Equal(3, max.Value); + Assert.Equal(shouldBeAtMax, atMax); + + await sutProvider.GetDependency().Received(1) + .GetProjectCountByOrganizationIdAsync(organization.Id); + } +} diff --git a/src/Api/SecretsManager/Controllers/ProjectsController.cs b/src/Api/SecretsManager/Controllers/ProjectsController.cs index 7ed428ad5..4f3815ca7 100644 --- a/src/Api/SecretsManager/Controllers/ProjectsController.cs +++ b/src/Api/SecretsManager/Controllers/ProjectsController.cs @@ -7,6 +7,7 @@ using Bit.Core.Exceptions; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -22,6 +23,7 @@ public class ProjectsController : Controller private readonly ICurrentContext _currentContext; private readonly IUserService _userService; private readonly IProjectRepository _projectRepository; + private readonly IMaxProjectsQuery _maxProjectsQuery; private readonly ICreateProjectCommand _createProjectCommand; private readonly IUpdateProjectCommand _updateProjectCommand; private readonly IDeleteProjectCommand _deleteProjectCommand; @@ -31,6 +33,7 @@ public class ProjectsController : Controller ICurrentContext currentContext, IUserService userService, IProjectRepository projectRepository, + IMaxProjectsQuery maxProjectsQuery, ICreateProjectCommand createProjectCommand, IUpdateProjectCommand updateProjectCommand, IDeleteProjectCommand deleteProjectCommand, @@ -39,6 +42,7 @@ public class ProjectsController : Controller _currentContext = currentContext; _userService = userService; _projectRepository = projectRepository; + _maxProjectsQuery = maxProjectsQuery; _createProjectCommand = createProjectCommand; _updateProjectCommand = updateProjectCommand; _deleteProjectCommand = deleteProjectCommand; @@ -74,6 +78,13 @@ public class ProjectsController : Controller { throw new NotFoundException(); } + + var (max, atMax) = await _maxProjectsQuery.GetByOrgIdAsync(organizationId); + if (atMax != null && atMax.Value) + { + throw new BadRequestException($"You have reached the maximum number of projects ({max}) for this plan."); + } + var userId = _userService.GetProperUserId(User).Value; var result = await _createProjectCommand.CreateAsync(project, userId, _currentContext.ClientType); diff --git a/src/Core/SecretsManager/Queries/Projects/Interfaces/IMaxProjectsQuery.cs b/src/Core/SecretsManager/Queries/Projects/Interfaces/IMaxProjectsQuery.cs new file mode 100644 index 000000000..e00f5ed67 --- /dev/null +++ b/src/Core/SecretsManager/Queries/Projects/Interfaces/IMaxProjectsQuery.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.SecretsManager.Queries.Projects.Interfaces; + +public interface IMaxProjectsQuery +{ + Task<(short? max, bool? atMax)> GetByOrgIdAsync(Guid organizationId); +} diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs index 0ef31085f..fa88a44b8 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/ProjectsControllerTests.cs @@ -116,6 +116,19 @@ public class ProjectsControllerTests : IClassFixture, IAs Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Theory] + [InlineData(PermissionType.RunAsAdmin)] + [InlineData(PermissionType.RunAsUserWithPermission)] + public async Task Create_AtMaxProjects_BadRequest(PermissionType permissionType) + { + var (_, organization) = await SetupProjectsWithAccessAsync(permissionType, 3); + var request = new ProjectCreateRequestModel { Name = _mockEncryptedString }; + + var response = await _client.PostAsJsonAsync($"/organizations/{organization.Id}/projects", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Theory] [InlineData(PermissionType.RunAsAdmin)] [InlineData(PermissionType.RunAsUserWithPermission)] diff --git a/test/Api.Test/SecretsManager/Controllers/ProjectsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/ProjectsControllerTests.cs index 30287eb95..32239159a 100644 --- a/test/Api.Test/SecretsManager/Controllers/ProjectsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/ProjectsControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Exceptions; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture; @@ -122,6 +123,24 @@ public class ProjectsControllerTests .CreateAsync(Arg.Any(), Arg.Any(), sutProvider.GetDependency().ClientType); } + [Theory] + [BitAutoData] + public async void Create_AtMaxProjects_Throws(SutProvider sutProvider, + Guid orgId, ProjectCreateRequestModel data) + { + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), data.ToProject(orgId), + Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Success()); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(Guid.NewGuid()); + sutProvider.GetDependency().GetByOrgIdAsync(orgId).Returns(((short)3, true)); + + + await Assert.ThrowsAsync(() => sutProvider.Sut.CreateAsync(orgId, data)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CreateAsync(Arg.Any(), Arg.Any(), sutProvider.GetDependency().ClientType); + } + [Theory] [BitAutoData] public async void Create_Success(SutProvider sutProvider,