From 7f8cea58d0c6d2d03aa75e88df0d1c3ee06bd4a4 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 2 May 2024 11:06:20 -0500 Subject: [PATCH] [SM-923] Add project service accounts access policies management endpoints (#3993) * Add new models * Update repositories * Add new authz handler * Add new query * Add new command * Add authz, command, and query to DI * Add new endpoint to controller * Add query unit tests * Add api unit tests * Add api integration tests --- ...ountsAccessPoliciesAuthorizationHandler.cs | 107 ++++++ ...ectServiceAccountsAccessPoliciesCommand.cs | 26 ++ ...rviceAccountsAccessPoliciesUpdatesQuery.cs | 44 +++ .../SecretsManagerCollectionExtensions.cs | 3 + .../Repositories/AccessPolicyRepository.cs | 72 +++- .../Repositories/ServiceAccountRepository.cs | 67 +++- ...AccessPoliciesAuthorizationHandlerTests.cs | 342 ++++++++++++++++++ ...rviceAccountsAccessPoliciesCommandTests.cs | 42 +++ ...AccountsAccessPoliciesUpdatesQueryTests.cs | 86 +++++ .../Controllers/AccessPoliciesController.cs | 41 +++ ...rviceAccountsAccessPoliciesRequestModel.cs | 28 ++ ...viceAccountsAccessPoliciesResponseModel.cs | 29 ++ ...ountsAccessPoliciesOperationRequirement.cs | 14 + ...ectServiceAccountsAccessPoliciesCommand.cs | 9 + ...ectServiceAccountsAccessPoliciesUpdates.cs | 9 + .../ProjectServiceAccountsAccessPolicies.cs | 80 ++++ ...rviceAccountsAccessPoliciesUpdatesQuery.cs | 10 + .../Repositories/IAccessPolicyRepository.cs | 3 + .../Repositories/IServiceAccountRepository.cs | 3 + .../Noop/NoopServiceAccountRepository.cs | 17 +- .../AccessPoliciesControllerTests.cs | 276 +++++++++++++- .../AccessPoliciesControllerTests.cs | 201 ++++++++++ ...ojectServiceAccountsAccessPoliciesTests.cs | 79 ++++ 23 files changed, 1559 insertions(+), 29 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandler.cs create mode 100644 bitwarden_license/src/Commercial.Core/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommand.cs create mode 100644 bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQuery.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommandTests.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQueryTests.cs create mode 100644 src/Api/SecretsManager/Models/Request/ProjectServiceAccountsAccessPoliciesRequestModel.cs create mode 100644 src/Api/SecretsManager/Models/Response/ProjectServiceAccountsAccessPoliciesResponseModel.cs create mode 100644 src/Core/SecretsManager/AuthorizationRequirements/ProjectServiceAccountsAccessPoliciesOperationRequirement.cs create mode 100644 src/Core/SecretsManager/Commands/AccessPolicies/Interfaces/IUpdateProjectServiceAccountsAccessPoliciesCommand.cs create mode 100644 src/Core/SecretsManager/Models/Data/AccessPolicyUpdates/ProjectServiceAccountsAccessPoliciesUpdates.cs create mode 100644 src/Core/SecretsManager/Models/Data/ProjectServiceAccountsAccessPolicies.cs create mode 100644 src/Core/SecretsManager/Queries/AccessPolicies/Interfaces/IProjectServiceAccountsAccessPoliciesUpdatesQuery.cs create mode 100644 test/Core.Test/SecretsManager/Models/ProjectServiceAccountsAccessPoliciesTests.cs diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandler.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandler.cs new file mode 100644 index 000000000..ace4a2116 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandler.cs @@ -0,0 +1,107 @@ +#nullable enable +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.SecretsManager.AuthorizationRequirements; +using Bit.Core.SecretsManager.Enums.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; +using Bit.Core.SecretsManager.Queries.Interfaces; +using Bit.Core.SecretsManager.Repositories; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies; + +public class ProjectServiceAccountsAccessPoliciesAuthorizationHandler : AuthorizationHandler< + ProjectServiceAccountsAccessPoliciesOperationRequirement, + ProjectServiceAccountsAccessPoliciesUpdates> +{ + private readonly IAccessClientQuery _accessClientQuery; + private readonly ICurrentContext _currentContext; + private readonly IProjectRepository _projectRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; + + public ProjectServiceAccountsAccessPoliciesAuthorizationHandler(ICurrentContext currentContext, + IAccessClientQuery accessClientQuery, + IProjectRepository projectRepository, + IServiceAccountRepository serviceAccountRepository) + { + _currentContext = currentContext; + _accessClientQuery = accessClientQuery; + _serviceAccountRepository = serviceAccountRepository; + _projectRepository = projectRepository; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + ProjectServiceAccountsAccessPoliciesOperationRequirement requirement, + ProjectServiceAccountsAccessPoliciesUpdates resource) + { + if (!_currentContext.AccessSecretsManager(resource.OrganizationId)) + { + return; + } + + // Only users and admins should be able to manipulate access policies + var (accessClient, userId) = + await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId); + if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck) + { + return; + } + + switch (requirement) + { + case not null when requirement == ProjectServiceAccountsAccessPoliciesOperations.Updates: + await CanUpdateAsync(context, requirement, resource, accessClient, + userId); + break; + default: + throw new ArgumentException("Unsupported operation requirement type provided.", + nameof(requirement)); + } + } + + private async Task CanUpdateAsync(AuthorizationHandlerContext context, + ProjectServiceAccountsAccessPoliciesOperationRequirement requirement, + ProjectServiceAccountsAccessPoliciesUpdates resource, + AccessClientType accessClient, Guid userId) + { + var access = + await _projectRepository.AccessToProjectAsync(resource.ProjectId, userId, + accessClient); + if (!access.Write) + { + return; + } + + var serviceAccountIds = resource.ServiceAccountAccessPolicyUpdates.Select(update => + update.AccessPolicy.ServiceAccountId!.Value).ToList(); + + var inSameOrganization = + await _serviceAccountRepository.ServiceAccountsAreInOrganizationAsync(serviceAccountIds, + resource.OrganizationId); + if (!inSameOrganization) + { + return; + } + + // Users can only create access policies for service accounts they have access to. + // User can delete and update any service account access policy if they have write access to the project. + var serviceAccountIdsToCheck = resource.ServiceAccountAccessPolicyUpdates + .Where(update => update.Operation == AccessPolicyOperation.Create).Select(update => + update.AccessPolicy.ServiceAccountId!.Value).ToList(); + + if (serviceAccountIdsToCheck.Count == 0) + { + context.Succeed(requirement); + return; + } + + var serviceAccountsAccess = + await _serviceAccountRepository.AccessToServiceAccountsAsync(serviceAccountIdsToCheck, userId, + accessClient); + if (serviceAccountsAccess.Count == serviceAccountIdsToCheck.Count && + serviceAccountsAccess.All(a => a.Value.Write)) + { + context.Succeed(requirement); + } + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommand.cs new file mode 100644 index 000000000..5357c5af5 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommand.cs @@ -0,0 +1,26 @@ +#nullable enable +using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies; + +public class UpdateProjectServiceAccountsAccessPoliciesCommand : IUpdateProjectServiceAccountsAccessPoliciesCommand +{ + private readonly IAccessPolicyRepository _accessPolicyRepository; + + public UpdateProjectServiceAccountsAccessPoliciesCommand(IAccessPolicyRepository accessPolicyRepository) + { + _accessPolicyRepository = accessPolicyRepository; + } + + public async Task UpdateAsync(ProjectServiceAccountsAccessPoliciesUpdates accessPoliciesUpdates) + { + if (!accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates.Any()) + { + return; + } + + await _accessPolicyRepository.UpdateProjectServiceAccountsAccessPoliciesAsync(accessPoliciesUpdates); + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQuery.cs new file mode 100644 index 000000000..3977b0610 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQuery.cs @@ -0,0 +1,44 @@ +#nullable enable +using Bit.Core.SecretsManager.Enums.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; +using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces; +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies; + +public class ProjectServiceAccountsAccessPoliciesUpdatesQuery : IProjectServiceAccountsAccessPoliciesUpdatesQuery +{ + private readonly IAccessPolicyRepository _accessPolicyRepository; + + public ProjectServiceAccountsAccessPoliciesUpdatesQuery(IAccessPolicyRepository accessPolicyRepository) + { + _accessPolicyRepository = accessPolicyRepository; + } + + public async Task GetAsync( + ProjectServiceAccountsAccessPolicies projectServiceAccountsAccessPolicies) + { + var currentPolicies = + await _accessPolicyRepository.GetProjectServiceAccountsAccessPoliciesAsync( + projectServiceAccountsAccessPolicies.ProjectId); + + if (currentPolicies == null) + { + return new ProjectServiceAccountsAccessPoliciesUpdates + { + ProjectId = projectServiceAccountsAccessPolicies.ProjectId, + OrganizationId = projectServiceAccountsAccessPolicies.OrganizationId, + ServiceAccountAccessPolicyUpdates = + projectServiceAccountsAccessPolicies.ServiceAccountAccessPolicies.Select(p => + new ServiceAccountProjectAccessPolicyUpdate + { + Operation = AccessPolicyOperation.Create, + AccessPolicy = p + }) + }; + } + + return currentPolicies.GetPolicyUpdates(projectServiceAccountsAccessPolicies); + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs index da7804b8e..2f4e31926 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs @@ -42,12 +42,14 @@ public static class SecretsManagerCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -67,5 +69,6 @@ public static class SecretsManagerCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/AccessPolicyRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/AccessPolicyRepository.cs index 9c07c910a..2066e3ae6 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/AccessPolicyRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/AccessPolicyRepository.cs @@ -465,12 +465,68 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli dbContext.RemoveRange(policiesToDelete); } - await UpsertServiceAccountGrantedPoliciesAsync(dbContext, currentAccessPolicies, + await UpsertServiceAccountProjectPoliciesAsync(dbContext, currentAccessPolicies, updates.ProjectGrantedPolicyUpdates.Where(pu => pu.Operation != AccessPolicyOperation.Delete).ToList()); await UpdateServiceAccountRevisionAsync(dbContext, updates.ServiceAccountId); await dbContext.SaveChangesAsync(); } + public async Task GetProjectServiceAccountsAccessPoliciesAsync(Guid projectId) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + var entities = await dbContext.ServiceAccountProjectAccessPolicy + .Where(ap => ap.GrantedProjectId == projectId) + .Include(ap => ap.ServiceAccount) + .Include(ap => ap.GrantedProject) + .ToListAsync(); + + if (entities.Count == 0) + { + return null; + } + + return new ProjectServiceAccountsAccessPolicies(projectId, entities.Select(MapToCore).ToList()); + } + + public async Task UpdateProjectServiceAccountsAccessPoliciesAsync( + ProjectServiceAccountsAccessPoliciesUpdates updates) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var currentAccessPolicies = await dbContext.ServiceAccountProjectAccessPolicy + .Where(ap => ap.GrantedProjectId == updates.ProjectId) + .ToListAsync(); + + if (currentAccessPolicies.Count != 0) + { + var serviceAccountIdsToDelete = updates.ServiceAccountAccessPolicyUpdates + .Where(pu => pu.Operation == AccessPolicyOperation.Delete) + .Select(pu => pu.AccessPolicy.ServiceAccountId!.Value) + .ToList(); + + var accessPolicyIdsToDelete = currentAccessPolicies + .Where(entity => serviceAccountIdsToDelete.Contains(entity.ServiceAccountId!.Value)) + .Select(ap => ap.Id) + .ToList(); + + await dbContext.ServiceAccountProjectAccessPolicy + .Where(ap => accessPolicyIdsToDelete.Contains(ap.Id)) + .ExecuteDeleteAsync(); + } + + await UpsertServiceAccountProjectPoliciesAsync(dbContext, currentAccessPolicies, + updates.ServiceAccountAccessPolicyUpdates.Where(update => update.Operation != AccessPolicyOperation.Delete) + .ToList()); + var effectedServiceAccountIds = updates.ServiceAccountAccessPolicyUpdates + .Select(sa => sa.AccessPolicy.ServiceAccountId!.Value).ToList(); + await UpdateServiceAccountsRevisionAsync(dbContext, effectedServiceAccountIds); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + private static async Task UpsertPeoplePoliciesAsync(DatabaseContext dbContext, List policies, IReadOnlyCollection userPolicyEntities, IReadOnlyCollection groupPolicyEntities) @@ -506,7 +562,7 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli } } - private async Task UpsertServiceAccountGrantedPoliciesAsync(DatabaseContext dbContext, + private async Task UpsertServiceAccountProjectPoliciesAsync(DatabaseContext dbContext, IReadOnlyCollection currentPolices, List policyUpdates) { @@ -515,7 +571,8 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli { var updatedEntity = MapToEntity(policyUpdate.AccessPolicy); var currentEntity = currentPolices.FirstOrDefault(e => - e.GrantedProjectId == policyUpdate.AccessPolicy.GrantedProjectId!.Value); + e.GrantedProjectId == policyUpdate.AccessPolicy.GrantedProjectId!.Value && + e.ServiceAccountId == policyUpdate.AccessPolicy.ServiceAccountId!.Value); switch (policyUpdate.Operation) { @@ -628,4 +685,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli entity.RevisionDate = DateTime.UtcNow; } } + + private static async Task UpdateServiceAccountsRevisionAsync(DatabaseContext dbContext, List serviceAccountIds) + { + var utcNow = DateTime.UtcNow; + await dbContext.ServiceAccount + .Where(sa => serviceAccountIds.Contains(sa.Id)) + .ExecuteUpdateAsync(setters => + setters.SetProperty(sa => sa.RevisionDate, utcNow)); + } } diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs index e1053b247..cf9fa8a2f 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs @@ -112,30 +112,29 @@ public class ServiceAccountRepository : Repository AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType) { - using var scope = ServiceScopeFactory.CreateScope(); + await using var scope = ServiceScopeFactory.CreateAsyncScope(); var dbContext = GetDatabaseContext(scope); - var serviceAccount = dbContext.ServiceAccount.Where(sa => sa.Id == id); + var serviceAccountQuery = dbContext.ServiceAccount.Where(sa => sa.Id == id); - var query = accessType switch - { - AccessClientType.NoAccessCheck => serviceAccount.Select(_ => new { Read = true, Write = true }), - AccessClientType.User => serviceAccount.Select(sa => new - { - Read = sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || - sa.GroupAccessPolicies.Any(ap => - ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)), - Write = sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) || - sa.GroupAccessPolicies.Any(ap => - ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)), - }), - AccessClientType.ServiceAccount => serviceAccount.Select(_ => new { Read = false, Write = false }), - _ => serviceAccount.Select(_ => new { Read = false, Write = false }), - }; + var accessQuery = BuildServiceAccountAccessQuery(serviceAccountQuery, userId, accessType); + var access = await accessQuery.FirstOrDefaultAsync(); - var policy = await query.FirstOrDefaultAsync(); + return access == null ? (false, false) : (access.Read, access.Write); + } - return policy == null ? (false, false) : (policy.Read, policy.Write); + public async Task> AccessToServiceAccountsAsync( + IEnumerable ids, + Guid userId, + AccessClientType accessType) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + var serviceAccountsQuery = dbContext.ServiceAccount.Where(p => ids.Contains(p.Id)); + var accessQuery = BuildServiceAccountAccessQuery(serviceAccountsQuery, userId, accessType); + + return await accessQuery.ToDictionaryAsync(access => access.Id, access => (access.Read, access.Write)); } public async Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId) @@ -148,6 +147,15 @@ public class ServiceAccountRepository : Repository ServiceAccountsAreInOrganizationAsync(List serviceAccountIds, Guid organizationId) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + var result = await dbContext.ServiceAccount.CountAsync(sa => + sa.OrganizationId == organizationId && serviceAccountIds.Contains(sa.Id)); + return serviceAccountIds.Count == result; + } + public async Task> GetManyByOrganizationIdWithSecretsDetailsAsync( Guid organizationId, Guid userId, AccessClientType accessType) { @@ -186,6 +194,27 @@ public class ServiceAccountRepository : Repository BuildServiceAccountAccessQuery(IQueryable serviceAccountQuery, Guid userId, + AccessClientType accessType) => + accessType switch + { + AccessClientType.NoAccessCheck => serviceAccountQuery.Select(sa => new ServiceAccountAccess(sa.Id, true, true)), + AccessClientType.User => serviceAccountQuery.Select(sa => new ServiceAccountAccess + ( + sa.Id, + sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || + sa.GroupAccessPolicies.Any(ap => + ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)), + sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) || + sa.GroupAccessPolicies.Any(ap => + ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)) + )), + AccessClientType.ServiceAccount => serviceAccountQuery.Select(sa => new ServiceAccountAccess(sa.Id, false, false)), + _ => serviceAccountQuery.Select(sa => new ServiceAccountAccess(sa.Id, false, false)) + }; + private static Expression> UserHasReadAccessToServiceAccount(Guid userId) => sa => sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs new file mode 100644 index 000000000..4f8739682 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs @@ -0,0 +1,342 @@ +#nullable enable +using System.Reflection; +using System.Security.Claims; +using Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.SecretsManager.AuthorizationRequirements; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Enums.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; +using Bit.Core.SecretsManager.Queries.Interfaces; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.AuthorizationHandlers.AccessPolicies; + +[SutProviderCustomize] +[ProjectCustomize] +public class ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests +{ + [Fact] + public void ServiceAccountGrantedPoliciesOperations_OnlyPublicStatic() + { + var publicStaticFields = + typeof(ProjectServiceAccountsAccessPoliciesOperations).GetFields(BindingFlags.Public | BindingFlags.Static); + var allFields = typeof(ProjectServiceAccountsAccessPoliciesOperations).GetFields(); + Assert.Equal(publicStaticFields.Length, allFields.Length); + } + + [Theory] + [BitAutoData] + public async Task Handler_AccessSecretsManagerFalse_DoesNotSucceed( + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + sutProvider.GetDependency().AccessSecretsManager(resource.OrganizationId) + .Returns(false); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(AccessClientType.ServiceAccount)] + [BitAutoData(AccessClientType.Organization)] + public async Task Handler_UnsupportedClientTypes_DoesNotSucceed( + AccessClientType accessClientType, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + SetupUserSubstitutes(sutProvider, accessClientType, resource); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task Handler_UnsupportedProjectServiceAccountsPoliciesOperationRequirement_Throws( + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + ClaimsPrincipal claimsPrincipal) + { + var requirement = new ProjectServiceAccountsAccessPoliciesOperationRequirement(); + SetupUserSubstitutes(sutProvider, AccessClientType.NoAccessCheck, resource); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(authzContext)); + } + + [Theory] + [BitAutoData(AccessClientType.NoAccessCheck, false, false)] + [BitAutoData(AccessClientType.NoAccessCheck, true, false)] + [BitAutoData(AccessClientType.User, false, false)] + [BitAutoData(AccessClientType.User, true, false)] + public async Task Handler_UserHasNoWriteAccessToProject_DoesNotSucceed( + AccessClientType accessClientType, + bool projectReadAccess, + bool projectWriteAccess, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + SetupUserSubstitutes(sutProvider, accessClientType, resource, userId); + sutProvider.GetDependency() + .AccessToProjectAsync(resource.ProjectId, userId, accessClientType) + .Returns((projectReadAccess, projectWriteAccess)); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData] + public async Task Handler_ServiceAccountsInDifferentOrganization_DoesNotSucceed( + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + SetupUserSubstitutes(sutProvider, AccessClientType.NoAccessCheck, resource, userId); + sutProvider.GetDependency() + .AccessToProjectAsync(resource.ProjectId, userId, AccessClientType.NoAccessCheck) + .Returns((true, true)); + sutProvider.GetDependency() + .ServiceAccountsAreInOrganizationAsync(Arg.Any>(), resource.OrganizationId) + .Returns(false); + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(AccessClientType.NoAccessCheck)] + [BitAutoData(AccessClientType.User)] + public async Task Handler_UserHasAccessToProject_NoCreatesRequested_Success( + AccessClientType accessClientType, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + resource = RemoveAllCreates(resource); + SetupServiceAccountsAccessTest(sutProvider, accessClientType, resource, userId); + + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.True(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(AccessClientType.NoAccessCheck)] + [BitAutoData(AccessClientType.User)] + public async Task Handler_UserHasNoAccessToCreateServiceAccounts_DoesNotSucceed( + AccessClientType accessClientType, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + resource = AddServiceAccountCreateUpdate(resource); + SetupServiceAccountsAccessTest(sutProvider, accessClientType, resource, userId); + var accessResult = resource.ServiceAccountAccessPolicyUpdates + .Where(x => x.Operation == AccessPolicyOperation.Create) + .Select(x => x.AccessPolicy.ServiceAccountId!.Value) + .ToDictionary(id => id, _ => (false, false)); + + sutProvider.GetDependency() + .AccessToServiceAccountsAsync(Arg.Any>(), userId, accessClientType) + .Returns(accessResult); + + + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(AccessClientType.NoAccessCheck)] + [BitAutoData(AccessClientType.User)] + public async Task Handler_AccessResultsPartial_DoesNotSucceed( + AccessClientType accessClientType, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + resource = AddServiceAccountCreateUpdate(resource); + SetupServiceAccountsAccessTest(sutProvider, accessClientType, resource, userId); + + var accessResult = resource.ServiceAccountAccessPolicyUpdates + .Where(x => x.Operation == AccessPolicyOperation.Create) + .Select(x => x.AccessPolicy.ServiceAccountId!.Value) + .ToDictionary(id => id, _ => (false, false)); + + accessResult[accessResult.First().Key] = (true, true); + accessResult.Remove(accessResult.Last().Key); + sutProvider.GetDependency() + .AccessToServiceAccountsAsync(Arg.Any>(), userId, accessClientType) + .Returns(accessResult); + + + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(AccessClientType.NoAccessCheck)] + [BitAutoData(AccessClientType.User)] + public async Task Handler_UserHasAccessToSomeCreateServiceAccounts_DoesNotSucceed( + AccessClientType accessClientType, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + resource = AddServiceAccountCreateUpdate(resource); + SetupServiceAccountsAccessTest(sutProvider, accessClientType, resource, userId); + + var accessResult = resource.ServiceAccountAccessPolicyUpdates + .Where(x => x.Operation == AccessPolicyOperation.Create) + .Select(x => x.AccessPolicy.ServiceAccountId!.Value) + .ToDictionary(id => id, _ => (false, false)); + + accessResult[accessResult.First().Key] = (true, true); + sutProvider.GetDependency() + .AccessToServiceAccountsAsync(Arg.Any>(), userId, accessClientType) + .Returns(accessResult); + + + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.False(authzContext.HasSucceeded); + } + + [Theory] + [BitAutoData(AccessClientType.NoAccessCheck)] + [BitAutoData(AccessClientType.User)] + public async Task Handler_UserHasAccessToAllCreateServiceAccounts_Success( + AccessClientType accessClientType, + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId, + ClaimsPrincipal claimsPrincipal) + { + var requirement = ProjectServiceAccountsAccessPoliciesOperations.Updates; + resource = AddServiceAccountCreateUpdate(resource); + SetupServiceAccountsAccessTest(sutProvider, accessClientType, resource, userId); + + var accessResult = resource.ServiceAccountAccessPolicyUpdates + .Where(x => x.Operation == AccessPolicyOperation.Create) + .Select(x => x.AccessPolicy.ServiceAccountId!.Value) + .ToDictionary(id => id, _ => (true, true)); + + sutProvider.GetDependency() + .AccessToServiceAccountsAsync(Arg.Any>(), userId, accessClientType) + .Returns(accessResult); + + + var authzContext = new AuthorizationHandlerContext(new List { requirement }, + claimsPrincipal, resource); + + await sutProvider.Sut.HandleAsync(authzContext); + + Assert.True(authzContext.HasSucceeded); + } + + private static void SetupUserSubstitutes( + SutProvider sutProvider, + AccessClientType accessClientType, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId = new()) + { + sutProvider.GetDependency().AccessSecretsManager(resource.OrganizationId) + .Returns(true); + sutProvider.GetDependency().GetAccessClientAsync(default, resource.OrganizationId) + .ReturnsForAnyArgs((accessClientType, userId)); + } + + private static void SetupServiceAccountsAccessTest( + SutProvider sutProvider, + AccessClientType accessClientType, + ProjectServiceAccountsAccessPoliciesUpdates resource, + Guid userId = new()) + { + SetupUserSubstitutes(sutProvider, accessClientType, resource, userId); + + sutProvider.GetDependency() + .AccessToProjectAsync(resource.ProjectId, userId, accessClientType) + .Returns((true, true)); + sutProvider.GetDependency() + .ServiceAccountsAreInOrganizationAsync(Arg.Any>(), resource.OrganizationId) + .Returns(true); + } + + private static ProjectServiceAccountsAccessPoliciesUpdates AddServiceAccountCreateUpdate( + ProjectServiceAccountsAccessPoliciesUpdates resource) + { + resource.ServiceAccountAccessPolicyUpdates = resource.ServiceAccountAccessPolicyUpdates.Append( + new ServiceAccountProjectAccessPolicyUpdate + { + AccessPolicy = new ServiceAccountProjectAccessPolicy + { + ServiceAccountId = Guid.NewGuid(), + GrantedProjectId = resource.ProjectId, + Read = true, + Write = true + } + }); + return resource; + } + + private static ProjectServiceAccountsAccessPoliciesUpdates RemoveAllCreates( + ProjectServiceAccountsAccessPoliciesUpdates resource) + { + resource.ServiceAccountAccessPolicyUpdates = + resource.ServiceAccountAccessPolicyUpdates.Where(x => x.Operation != AccessPolicyOperation.Create); + return resource; + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommandTests.cs new file mode 100644 index 000000000..65dabf200 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Commands/AccessPolicies/UpdateProjectServiceAccountsAccessPoliciesCommandTests.cs @@ -0,0 +1,42 @@ +using Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Commands.AccessPolicies; + +[SutProviderCustomize] +[ProjectCustomize] +public class UpdateProjectServiceAccountsAccessPoliciesCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateAsync_NoUpdates_DoesNotCallRepository( + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates data) + { + data.ServiceAccountAccessPolicyUpdates = []; + await sutProvider.Sut.UpdateAsync(data); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpdateProjectServiceAccountsAccessPoliciesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_HasUpdates_CallsRepository( + SutProvider sutProvider, + ProjectServiceAccountsAccessPoliciesUpdates data) + { + await sutProvider.Sut.UpdateAsync(data); + + await sutProvider.GetDependency() + .Received(1) + .UpdateProjectServiceAccountsAccessPoliciesAsync(Arg.Any()); + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQueryTests.cs new file mode 100644 index 000000000..ce11cde2b --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/AccessPolicies/ProjectServiceAccountsAccessPoliciesUpdatesQueryTests.cs @@ -0,0 +1,86 @@ +#nullable enable +using Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Enums.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture; +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.AccessPolicies; + +[SutProviderCustomize] +[ProjectCustomize] +public class ProjectServiceAccountsAccessPoliciesUpdatesQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetAsync_NoCurrentAccessPolicies_ReturnsAllCreates( + SutProvider sutProvider, + ProjectServiceAccountsAccessPolicies data) + { + sutProvider.GetDependency() + .GetProjectServiceAccountsAccessPoliciesAsync(data.ProjectId) + .ReturnsNullForAnyArgs(); + + var result = await sutProvider.Sut.GetAsync(data); + + Assert.Equal(data.ProjectId, result.ProjectId); + Assert.Equal(data.OrganizationId, result.OrganizationId); + Assert.Equal(data.ServiceAccountAccessPolicies.Count(), result.ServiceAccountAccessPolicyUpdates.Count()); + Assert.All(result.ServiceAccountAccessPolicyUpdates, p => + { + Assert.Equal(AccessPolicyOperation.Create, p.Operation); + Assert.Contains(data.ServiceAccountAccessPolicies, x => x == p.AccessPolicy); + }); + } + + [Theory] + [BitAutoData] + public async Task GetAsync_CurrentAccessPolicies_ReturnsChanges( + SutProvider sutProvider, + ProjectServiceAccountsAccessPolicies data, ServiceAccountProjectAccessPolicy currentPolicyToDelete) + { + foreach (var policy in data.ServiceAccountAccessPolicies) + { + policy.GrantedProjectId = data.ProjectId; + } + + currentPolicyToDelete.GrantedProjectId = data.ProjectId; + + var updatePolicy = new ServiceAccountProjectAccessPolicy + { + ServiceAccountId = data.ServiceAccountAccessPolicies.First().ServiceAccountId, + GrantedProjectId = data.ProjectId, + Read = !data.ServiceAccountAccessPolicies.First().Read, + Write = !data.ServiceAccountAccessPolicies.First().Write + }; + + var currentPolicies = new ProjectServiceAccountsAccessPolicies + { + ProjectId = data.ProjectId, + OrganizationId = data.OrganizationId, + ServiceAccountAccessPolicies = [updatePolicy, currentPolicyToDelete] + }; + + sutProvider.GetDependency() + .GetProjectServiceAccountsAccessPoliciesAsync(data.ProjectId) + .ReturnsForAnyArgs(currentPolicies); + + var result = await sutProvider.Sut.GetAsync(data); + + Assert.Equal(data.ProjectId, result.ProjectId); + Assert.Equal(data.OrganizationId, result.OrganizationId); + Assert.Single(result.ServiceAccountAccessPolicyUpdates.Where(x => + x.Operation == AccessPolicyOperation.Delete && x.AccessPolicy == currentPolicyToDelete)); + Assert.Single(result.ServiceAccountAccessPolicyUpdates.Where(x => + x.Operation == AccessPolicyOperation.Update && + x.AccessPolicy.GrantedProjectId == updatePolicy.GrantedProjectId)); + Assert.Equal(result.ServiceAccountAccessPolicyUpdates.Count() - 2, + result.ServiceAccountAccessPolicyUpdates.Count(x => x.Operation == AccessPolicyOperation.Create)); + } +} diff --git a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs index 59e178a9b..54f699e05 100644 --- a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs +++ b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs @@ -29,8 +29,10 @@ public class AccessPoliciesController : Controller private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IUpdateAccessPolicyCommand _updateAccessPolicyCommand; private readonly IUpdateServiceAccountGrantedPoliciesCommand _updateServiceAccountGrantedPoliciesCommand; + private readonly IUpdateProjectServiceAccountsAccessPoliciesCommand _updateProjectServiceAccountsAccessPoliciesCommand; private readonly IAccessClientQuery _accessClientQuery; private readonly IServiceAccountGrantedPolicyUpdatesQuery _serviceAccountGrantedPolicyUpdatesQuery; + private readonly IProjectServiceAccountsAccessPoliciesUpdatesQuery _projectServiceAccountsAccessPoliciesUpdatesQuery; private readonly IUserService _userService; private readonly IAuthorizationService _authorizationService; @@ -43,9 +45,11 @@ public class AccessPoliciesController : Controller IProjectRepository projectRepository, IAccessClientQuery accessClientQuery, IServiceAccountGrantedPolicyUpdatesQuery serviceAccountGrantedPolicyUpdatesQuery, + IProjectServiceAccountsAccessPoliciesUpdatesQuery projectServiceAccountsAccessPoliciesUpdatesQuery, IUpdateServiceAccountGrantedPoliciesCommand updateServiceAccountGrantedPoliciesCommand, ICreateAccessPoliciesCommand createAccessPoliciesCommand, IDeleteAccessPolicyCommand deleteAccessPolicyCommand, + IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand, IUpdateAccessPolicyCommand updateAccessPolicyCommand) { _authorizationService = authorizationService; @@ -60,6 +64,8 @@ public class AccessPoliciesController : Controller _updateServiceAccountGrantedPoliciesCommand = updateServiceAccountGrantedPoliciesCommand; _accessClientQuery = accessClientQuery; _serviceAccountGrantedPolicyUpdatesQuery = serviceAccountGrantedPolicyUpdatesQuery; + _projectServiceAccountsAccessPoliciesUpdatesQuery = projectServiceAccountsAccessPoliciesUpdatesQuery; + _updateProjectServiceAccountsAccessPoliciesCommand = updateProjectServiceAccountsAccessPoliciesCommand; } [HttpPost("/projects/{id}/access-policies")] @@ -296,6 +302,41 @@ public class AccessPoliciesController : Controller return await GetServiceAccountGrantedPoliciesAsync(serviceAccount); } + [HttpGet("/projects/{id}/access-policies/service-accounts")] + public async Task + GetProjectServiceAccountsAccessPoliciesAsync( + [FromRoute] Guid id) + { + var project = await _projectRepository.GetByIdAsync(id); + await CheckUserHasWriteAccessToProjectAsync(project); + var results = + await _accessPolicyRepository.GetProjectServiceAccountsAccessPoliciesAsync(id); + return new ProjectServiceAccountsAccessPoliciesResponseModel(results); + } + + [HttpPut("/projects/{id}/access-policies/service-accounts")] + public async Task + PutProjectServiceAccountsAccessPoliciesAsync([FromRoute] Guid id, + [FromBody] ProjectServiceAccountsAccessPoliciesRequestModel request) + { + var project = await _projectRepository.GetByIdAsync(id) ?? throw new NotFoundException(); + var accessPoliciesUpdates = + await _projectServiceAccountsAccessPoliciesUpdatesQuery.GetAsync( + request.ToProjectServiceAccountsAccessPolicies(project)); + + var authorizationResult = await _authorizationService.AuthorizeAsync(User, accessPoliciesUpdates, + ProjectServiceAccountsAccessPoliciesOperations.Updates); + if (!authorizationResult.Succeeded) + { + throw new NotFoundException(); + } + + await _updateProjectServiceAccountsAccessPoliciesCommand.UpdateAsync(accessPoliciesUpdates); + + var results = await _accessPolicyRepository.GetProjectServiceAccountsAccessPoliciesAsync(id); + return new ProjectServiceAccountsAccessPoliciesResponseModel(results); + } + private async Task<(AccessClientType AccessClientType, Guid UserId)> CheckUserHasWriteAccessToProjectAsync(Project project) { if (project == null) diff --git a/src/Api/SecretsManager/Models/Request/ProjectServiceAccountsAccessPoliciesRequestModel.cs b/src/Api/SecretsManager/Models/Request/ProjectServiceAccountsAccessPoliciesRequestModel.cs new file mode 100644 index 000000000..14961ddce --- /dev/null +++ b/src/Api/SecretsManager/Models/Request/ProjectServiceAccountsAccessPoliciesRequestModel.cs @@ -0,0 +1,28 @@ +#nullable enable +using Bit.Api.SecretsManager.Utilities; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; + +namespace Bit.Api.SecretsManager.Models.Request; + +public class ProjectServiceAccountsAccessPoliciesRequestModel +{ + public required IEnumerable ServiceAccountAccessPolicyRequests { get; set; } + + public ProjectServiceAccountsAccessPolicies ToProjectServiceAccountsAccessPolicies(Project project) + { + var serviceAccountAccessPolicies = ServiceAccountAccessPolicyRequests + .Select(x => x.ToServiceAccountProjectAccessPolicy(project.Id, project.OrganizationId)) + .ToList(); + + AccessPolicyHelpers.CheckForDistinctAccessPolicies(serviceAccountAccessPolicies); + AccessPolicyHelpers.CheckAccessPoliciesHaveReadPermission(serviceAccountAccessPolicies); + + return new ProjectServiceAccountsAccessPolicies + { + ProjectId = project.Id, + OrganizationId = project.OrganizationId, + ServiceAccountAccessPolicies = serviceAccountAccessPolicies + }; + } +} diff --git a/src/Api/SecretsManager/Models/Response/ProjectServiceAccountsAccessPoliciesResponseModel.cs b/src/Api/SecretsManager/Models/Response/ProjectServiceAccountsAccessPoliciesResponseModel.cs new file mode 100644 index 000000000..4242eedf1 --- /dev/null +++ b/src/Api/SecretsManager/Models/Response/ProjectServiceAccountsAccessPoliciesResponseModel.cs @@ -0,0 +1,29 @@ +#nullable enable +using Bit.Core.Models.Api; +using Bit.Core.SecretsManager.Models.Data; + +namespace Bit.Api.SecretsManager.Models.Response; + +public class ProjectServiceAccountsAccessPoliciesResponseModel : ResponseModel +{ + private const string _objectName = "ProjectServiceAccountsAccessPolicies"; + + public ProjectServiceAccountsAccessPoliciesResponseModel( + ProjectServiceAccountsAccessPolicies? projectServiceAccountsAccessPolicies) + : base(_objectName) + { + if (projectServiceAccountsAccessPolicies == null) + { + return; + } + + ServiceAccountAccessPolicies = projectServiceAccountsAccessPolicies.ServiceAccountAccessPolicies + .Select(x => new ServiceAccountProjectAccessPolicyResponseModel(x)).ToList(); + } + + public ProjectServiceAccountsAccessPoliciesResponseModel() : base(_objectName) + { + } + + public List ServiceAccountAccessPolicies { get; set; } = []; +} diff --git a/src/Core/SecretsManager/AuthorizationRequirements/ProjectServiceAccountsAccessPoliciesOperationRequirement.cs b/src/Core/SecretsManager/AuthorizationRequirements/ProjectServiceAccountsAccessPoliciesOperationRequirement.cs new file mode 100644 index 000000000..4251002af --- /dev/null +++ b/src/Core/SecretsManager/AuthorizationRequirements/ProjectServiceAccountsAccessPoliciesOperationRequirement.cs @@ -0,0 +1,14 @@ +#nullable enable +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.SecretsManager.AuthorizationRequirements; + +public class ProjectServiceAccountsAccessPoliciesOperationRequirement : OperationAuthorizationRequirement +{ + +} + +public static class ProjectServiceAccountsAccessPoliciesOperations +{ + public static readonly ProjectServiceAccountsAccessPoliciesOperationRequirement Updates = new() { Name = nameof(Updates) }; +} diff --git a/src/Core/SecretsManager/Commands/AccessPolicies/Interfaces/IUpdateProjectServiceAccountsAccessPoliciesCommand.cs b/src/Core/SecretsManager/Commands/AccessPolicies/Interfaces/IUpdateProjectServiceAccountsAccessPoliciesCommand.cs new file mode 100644 index 000000000..7307e877c --- /dev/null +++ b/src/Core/SecretsManager/Commands/AccessPolicies/Interfaces/IUpdateProjectServiceAccountsAccessPoliciesCommand.cs @@ -0,0 +1,9 @@ +#nullable enable +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; + +namespace Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; + +public interface IUpdateProjectServiceAccountsAccessPoliciesCommand +{ + Task UpdateAsync(ProjectServiceAccountsAccessPoliciesUpdates accessPoliciesUpdates); +} diff --git a/src/Core/SecretsManager/Models/Data/AccessPolicyUpdates/ProjectServiceAccountsAccessPoliciesUpdates.cs b/src/Core/SecretsManager/Models/Data/AccessPolicyUpdates/ProjectServiceAccountsAccessPoliciesUpdates.cs new file mode 100644 index 000000000..8993b6f68 --- /dev/null +++ b/src/Core/SecretsManager/Models/Data/AccessPolicyUpdates/ProjectServiceAccountsAccessPoliciesUpdates.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; + +public class ProjectServiceAccountsAccessPoliciesUpdates +{ + public Guid ProjectId { get; set; } + public Guid OrganizationId { get; set; } + public IEnumerable ServiceAccountAccessPolicyUpdates { get; set; } = []; +} diff --git a/src/Core/SecretsManager/Models/Data/ProjectServiceAccountsAccessPolicies.cs b/src/Core/SecretsManager/Models/Data/ProjectServiceAccountsAccessPolicies.cs new file mode 100644 index 000000000..52ea20f1c --- /dev/null +++ b/src/Core/SecretsManager/Models/Data/ProjectServiceAccountsAccessPolicies.cs @@ -0,0 +1,80 @@ +#nullable enable +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Enums.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; + +namespace Bit.Core.SecretsManager.Models.Data; + +public class ProjectServiceAccountsAccessPolicies +{ + public ProjectServiceAccountsAccessPolicies() + { + } + + public ProjectServiceAccountsAccessPolicies(Guid projectId, + IEnumerable policies) + { + ProjectId = projectId; + ServiceAccountAccessPolicies = policies + .OfType() + .ToList(); + + var project = ServiceAccountAccessPolicies.FirstOrDefault()?.GrantedProject; + if (project != null) + { + OrganizationId = project.OrganizationId; + } + } + + public Guid ProjectId { get; set; } + public Guid OrganizationId { get; set; } + public IEnumerable ServiceAccountAccessPolicies { get; set; } = []; + + public ProjectServiceAccountsAccessPoliciesUpdates GetPolicyUpdates(ProjectServiceAccountsAccessPolicies requested) + { + var currentServiceAccountIds = GetServiceAccountIds(ServiceAccountAccessPolicies); + var requestedServiceAccountIds = GetServiceAccountIds(requested.ServiceAccountAccessPolicies); + + var serviceAccountIdsToBeDeleted = currentServiceAccountIds.Except(requestedServiceAccountIds).ToList(); + var serviceAccountIdsToBeCreated = requestedServiceAccountIds.Except(currentServiceAccountIds).ToList(); + var serviceAccountIdsToBeUpdated = GetServiceAccountIdsToBeUpdated(requested); + + var policiesToBeDeleted = + CreatePolicyUpdates(ServiceAccountAccessPolicies, serviceAccountIdsToBeDeleted, + AccessPolicyOperation.Delete); + var policiesToBeCreated = CreatePolicyUpdates(requested.ServiceAccountAccessPolicies, + serviceAccountIdsToBeCreated, + AccessPolicyOperation.Create); + var policiesToBeUpdated = CreatePolicyUpdates(requested.ServiceAccountAccessPolicies, + serviceAccountIdsToBeUpdated, + AccessPolicyOperation.Update); + + return new ProjectServiceAccountsAccessPoliciesUpdates + { + OrganizationId = OrganizationId, + ProjectId = ProjectId, + ServiceAccountAccessPolicyUpdates = + policiesToBeDeleted.Concat(policiesToBeCreated).Concat(policiesToBeUpdated) + }; + } + + private static List CreatePolicyUpdates( + IEnumerable policies, List serviceAccountIds, + AccessPolicyOperation operation) => + policies + .Where(ap => serviceAccountIds.Contains(ap.ServiceAccountId!.Value)) + .Select(ap => new ServiceAccountProjectAccessPolicyUpdate { Operation = operation, AccessPolicy = ap }) + .ToList(); + + private List GetServiceAccountIdsToBeUpdated(ProjectServiceAccountsAccessPolicies requested) => + ServiceAccountAccessPolicies + .Where(currentAp => requested.ServiceAccountAccessPolicies.Any(requestedAp => + requestedAp.GrantedProjectId == currentAp.GrantedProjectId && + requestedAp.ServiceAccountId == currentAp.ServiceAccountId && + (requestedAp.Write != currentAp.Write || requestedAp.Read != currentAp.Read))) + .Select(ap => ap.ServiceAccountId!.Value) + .ToList(); + + private static List GetServiceAccountIds(IEnumerable policies) => + policies.Select(ap => ap.ServiceAccountId!.Value).ToList(); +} diff --git a/src/Core/SecretsManager/Queries/AccessPolicies/Interfaces/IProjectServiceAccountsAccessPoliciesUpdatesQuery.cs b/src/Core/SecretsManager/Queries/AccessPolicies/Interfaces/IProjectServiceAccountsAccessPoliciesUpdatesQuery.cs new file mode 100644 index 000000000..e38e64b09 --- /dev/null +++ b/src/Core/SecretsManager/Queries/AccessPolicies/Interfaces/IProjectServiceAccountsAccessPoliciesUpdatesQuery.cs @@ -0,0 +1,10 @@ +#nullable enable +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; + +namespace Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces; + +public interface IProjectServiceAccountsAccessPoliciesUpdatesQuery +{ + Task GetAsync(ProjectServiceAccountsAccessPolicies grantedPolicies); +} diff --git a/src/Core/SecretsManager/Repositories/IAccessPolicyRepository.cs b/src/Core/SecretsManager/Repositories/IAccessPolicyRepository.cs index e8ef99d49..ed20f754e 100644 --- a/src/Core/SecretsManager/Repositories/IAccessPolicyRepository.cs +++ b/src/Core/SecretsManager/Repositories/IAccessPolicyRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; namespace Bit.Core.SecretsManager.Repositories; @@ -22,4 +23,6 @@ public interface IAccessPolicyRepository Task GetServiceAccountGrantedPoliciesPermissionDetailsAsync( Guid serviceAccountId, Guid userId, AccessClientType accessClientType); Task UpdateServiceAccountGrantedPoliciesAsync(ServiceAccountGrantedPoliciesUpdates policyUpdates); + Task GetProjectServiceAccountsAccessPoliciesAsync(Guid projectId); + Task UpdateProjectServiceAccountsAccessPoliciesAsync(ProjectServiceAccountsAccessPoliciesUpdates updates); } diff --git a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs index 40f9cbfdd..5c871fbf8 100644 --- a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs @@ -16,6 +16,9 @@ public interface IServiceAccountRepository Task UserHasWriteAccessToServiceAccount(Guid id, Guid userId); Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType); + Task> AccessToServiceAccountsAsync(IEnumerable ids, Guid userId, + AccessClientType accessType); Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId); Task> GetManyByOrganizationIdWithSecretsDetailsAsync(Guid organizationId, Guid userId, AccessClientType accessType); + Task ServiceAccountsAreInOrganizationAsync(List serviceAccountIds, Guid organizationId); } diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs index dc5c5e209..8b5ece931 100644 --- a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs @@ -53,10 +53,25 @@ public class NoopServiceAccountRepository : IServiceAccountRepository return Task.FromResult((false, false)); } + public Task> AccessToServiceAccountsAsync(IEnumerable ids, + Guid userId, AccessClientType accessType) + { + return Task.FromResult(null as Dictionary); + } + public Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId) { return Task.FromResult(0); } - public Task> GetManyByOrganizationIdWithSecretsDetailsAsync(Guid organizationId, Guid userId, AccessClientType accessType) => throw new NotImplementedException(); + public Task> GetManyByOrganizationIdWithSecretsDetailsAsync( + Guid organizationId, Guid userId, AccessClientType accessType) + { + return Task.FromResult(null as IEnumerable); + } + + public Task ServiceAccountsAreInOrganizationAsync(List serviceAccountIds, Guid organizationId) + { + return Task.FromResult(false); + } } diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs index 8927f546c..9a7db1876 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/AccessPoliciesControllerTests.cs @@ -1169,6 +1169,209 @@ public class AccessPoliciesControllerTests : IClassFixture(); + + Assert.NotNull(result); + Assert.Empty(result.ServiceAccountAccessPolicies); + } + + [Fact] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_UserDoesntHavePermission_ReturnsNotFound() + { + // Create a new account as a user + await _organizationHelper.Initialize(true, true, true); + var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await _loginHelper.LoginAsync(email); + + var initData = await SetupAccessPolicyRequest(orgUser.OrganizationId); + + var response = await _client.GetAsync($"/projects/{initData.ProjectId}/access-policies/service-accounts"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData(PermissionType.RunAsAdmin)] + [InlineData(PermissionType.RunAsUserWithPermission)] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_Success(PermissionType permissionType) + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + var initData = await SetupAccessPolicyRequest(org.Id); + + if (permissionType == PermissionType.RunAsUserWithPermission) + { + var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await _loginHelper.LoginAsync(email); + var accessPolicies = new List + { + new UserProjectAccessPolicy + { + GrantedProjectId = initData.ProjectId, OrganizationUserId = orgUser.Id, Read = true, Write = true, + } + }; + await _accessPolicyRepository.CreateManyAsync(accessPolicies); + } + + var response = await _client.GetAsync($"/projects/{initData.ProjectId}/access-policies/service-accounts"); + response.EnsureSuccessStatusCode(); + + var result = await response.Content + .ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.NotEmpty(result.ServiceAccountAccessPolicies); + Assert.Equal(initData.ServiceAccountId, result.ServiceAccountAccessPolicies.First().ServiceAccountId); + Assert.NotNull(result.ServiceAccountAccessPolicies.First().ServiceAccountName); + Assert.NotNull(result.ServiceAccountAccessPolicies.First().GrantedProjectName); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_SmNotEnabled_NotFound(bool useSecrets, + bool accessSecrets, bool organizationEnabled) + { + var (_, organizationUser) = + await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled); + await _loginHelper.LoginAsync(_email); + + var (projectId, serviceAccountId) = await CreateProjectAndServiceAccountAsync(organizationUser.OrganizationId); + + var request = new ProjectServiceAccountsAccessPoliciesRequestModel + { + ServiceAccountAccessPolicyRequests = + [ + new AccessPolicyRequest { GranteeId = serviceAccountId, Read = true, Write = true } + ] + }; + + var response = await _client.PutAsJsonAsync($"/projects/{projectId}/access-policies/service-accounts", request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_UserHasNoPermission_ReturnsNotFound() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + var (email, _) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await _loginHelper.LoginAsync(email); + + var (projectId, serviceAccountId) = await CreateProjectAndServiceAccountAsync(org.Id); + + var request = new ProjectServiceAccountsAccessPoliciesRequestModel + { + ServiceAccountAccessPolicyRequests = + [ + new AccessPolicyRequest { GranteeId = serviceAccountId, Read = true, Write = true } + ] + }; + + var response = await _client.PutAsJsonAsync($"/projects/{projectId}/access-policies/service-accounts", request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData(PermissionType.RunAsAdmin)] + [InlineData(PermissionType.RunAsUserWithPermission)] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_MismatchedOrgIds_ReturnsNotFound( + PermissionType permissionType) + { + var (_, organizationUser) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var (project, request) = + await SetupProjectServiceAccountsAccessPoliciesRequestAsync(permissionType, organizationUser, + false); + + var newOrg = await _organizationHelper.CreateSmOrganizationAsync(); + + var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount + { + Name = _mockEncryptedString, + OrganizationId = newOrg.Id + }); + request.ServiceAccountAccessPolicyRequests = + [ + new AccessPolicyRequest { GranteeId = serviceAccount.Id, Read = true, Write = true } + ]; + + var response = + await _client.PutAsJsonAsync($"/projects/{project.Id}/access-policies/service-accounts", request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData(PermissionType.RunAsAdmin, false)] + [InlineData(PermissionType.RunAsAdmin, true)] + [InlineData(PermissionType.RunAsUserWithPermission, false)] + [InlineData(PermissionType.RunAsUserWithPermission, true)] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_Success(PermissionType permissionType, + bool createPreviousAccessPolicy) + { + var (_, organizationUser) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var (project, request) = + await SetupProjectServiceAccountsAccessPoliciesRequestAsync(permissionType, organizationUser, + createPreviousAccessPolicy); + + var response = + await _client.PutAsJsonAsync($"/projects/{project.Id}/access-policies/service-accounts", request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content + .ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.Equal(request.ServiceAccountAccessPolicyRequests.First().GranteeId, + result.ServiceAccountAccessPolicies.First().ServiceAccountId); + Assert.True(result.ServiceAccountAccessPolicies.First().Read); + Assert.True(result.ServiceAccountAccessPolicies.First().Write); + Assert.Single(result.ServiceAccountAccessPolicies); + } + private async Task SetupAccessPolicyRequest(Guid organizationId) { var project = await _projectRepository.CreateAsync(new Project @@ -1184,13 +1387,15 @@ public class AccessPoliciesControllerTests : IClassFixture + [ + new ServiceAccountProjectAccessPolicy { - new ServiceAccountProjectAccessPolicy - { - Read = true, Write = true, ServiceAccountId = serviceAccount.Id, GrantedProjectId = project.Id, - }, - }); + Read = true, + Write = true, + ServiceAccountId = serviceAccount.Id, + GrantedProjectId = project.Id, + } + ]); return new RequestSetupData { @@ -1395,6 +1600,65 @@ public class AccessPoliciesControllerTests : IClassFixture + SetupProjectServiceAccountsAccessPoliciesRequestAsync( + PermissionType permissionType, OrganizationUser organizationUser, bool createPreviousAccessPolicy) + { + var (project, currentUser) = await SetupProjectPeoplePermissionAsync(permissionType, organizationUser); + var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount + { + Name = _mockEncryptedString, + OrganizationId = currentUser.OrganizationId + }); + + var accessPolicies = new List + { + new UserServiceAccountAccessPolicy + { + GrantedServiceAccountId = serviceAccount.Id, + OrganizationUserId = currentUser.Id, + Read = true, + Write = true + } + }; + + var request = new ProjectServiceAccountsAccessPoliciesRequestModel + { + ServiceAccountAccessPolicyRequests = + [ + new() { GranteeId = serviceAccount.Id, Read = true, Write = true } + ] + }; + + if (createPreviousAccessPolicy) + { + var anotherServiceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount + { + Name = _mockEncryptedString, + OrganizationId = currentUser.OrganizationId + }); + + accessPolicies.Add(new UserServiceAccountAccessPolicy + { + GrantedServiceAccountId = anotherServiceAccount.Id, + OrganizationUserId = currentUser.Id, + Read = true, + Write = true + }); + accessPolicies.Add(new ServiceAccountProjectAccessPolicy + { + GrantedProjectId = project.Id, + ServiceAccountId = anotherServiceAccount.Id, + Read = true, + Write = true + }); + } + + await _accessPolicyRepository.CreateManyAsync(accessPolicies); + + return (project, request); + } + private class RequestSetupData { public Guid ProjectId { get; set; } diff --git a/test/Api.Test/SecretsManager/Controllers/AccessPoliciesControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/AccessPoliciesControllerTests.cs index dba6bc1d6..7b4224f15 100644 --- a/test/Api.Test/SecretsManager/Controllers/AccessPoliciesControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/AccessPoliciesControllerTests.cs @@ -6,9 +6,11 @@ using Bit.Api.Test.SecretsManager.Enums; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Identity; using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; using Bit.Core.SecretsManager.Queries.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; @@ -1082,6 +1084,195 @@ public class AccessPoliciesControllerTests .UpdateAsync(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_ProjectDoesntExist_ThrowsNotFound( + SutProvider sutProvider, + ServiceAccount data) + { + sutProvider.GetDependency().GetByIdAsync(data.Id).ReturnsNull(); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetProjectServiceAccountsAccessPoliciesAsync(data.Id)); + + await sutProvider.GetDependency().Received(0) + .GetProjectServiceAccountsAccessPoliciesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_NoAccess_ThrowsNotFound( + SutProvider sutProvider, + Project data) + { + SetupUserWithoutPermission(sutProvider, data.OrganizationId); + sutProvider.GetDependency().GetByIdAsync(data.Id).Returns(data); + sutProvider.GetDependency().AccessToProjectAsync(default, default, default) + .ReturnsForAnyArgs((false, false)); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetProjectServiceAccountsAccessPoliciesAsync(data.Id)); + + await sutProvider.GetDependency().Received(0) + .GetProjectServiceAccountsAccessPoliciesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_ClientIsServiceAccount_ThrowsNotFound( + SutProvider sutProvider, + Project data) + { + SetupUserWithoutPermission(sutProvider, data.OrganizationId); + sutProvider.GetDependency().GetByIdAsync(data.Id).Returns(data); + sutProvider.GetDependency().ClientType = ClientType.ServiceAccount; + sutProvider.GetDependency().AccessToProjectAsync(default, default, default) + .ReturnsForAnyArgs((true, true)); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetProjectServiceAccountsAccessPoliciesAsync(data.Id)); + + await sutProvider.GetDependency().Received(0) + .GetProjectServiceAccountsAccessPoliciesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_HasAccessNoPolicies_ReturnsEmptyList( + SutProvider sutProvider, + Project data) + { + SetupUserWithoutPermission(sutProvider, data.OrganizationId); + sutProvider.GetDependency().GetByIdAsync(data.Id).Returns(data); + sutProvider.GetDependency().AccessToProjectAsync(default, default, default) + .ReturnsForAnyArgs((true, true)); + + + sutProvider.GetDependency() + .GetProjectServiceAccountsAccessPoliciesAsync(Arg.Any()) + .ReturnsNullForAnyArgs(); + + var result = await sutProvider.Sut.GetProjectServiceAccountsAccessPoliciesAsync(data.Id); + + Assert.Empty(result.ServiceAccountAccessPolicies); + } + + [Theory] + [BitAutoData] + public async Task GetProjectServiceAccountsAccessPoliciesAsync_HasAccess_Success( + SutProvider sutProvider, + ProjectServiceAccountsAccessPolicies policies, + Project data) + { + SetupUserWithoutPermission(sutProvider, data.OrganizationId); + sutProvider.GetDependency().GetByIdAsync(data.Id).Returns(data); + sutProvider.GetDependency().AccessToProjectAsync(default, default, default) + .ReturnsForAnyArgs((true, true)); + + sutProvider.GetDependency() + .GetProjectServiceAccountsAccessPoliciesAsync(Arg.Any()) + .ReturnsForAnyArgs(policies); + + var result = await sutProvider.Sut.GetProjectServiceAccountsAccessPoliciesAsync(data.Id); + + Assert.NotEmpty(result.ServiceAccountAccessPolicies); + Assert.Equal(policies.ServiceAccountAccessPolicies.Count(), result.ServiceAccountAccessPolicies.Count); + } + + [Theory] + [BitAutoData] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_ProjectDoesNotExist_Throws( + SutProvider sutProvider, + Project data, + ProjectServiceAccountsAccessPoliciesRequestModel request) + { + await Assert.ThrowsAsync(() => + sutProvider.Sut.PutProjectServiceAccountsAccessPoliciesAsync(data.Id, request)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpdateAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_DuplicatePolicyRequest_ThrowsBadRequestException( + SutProvider sutProvider, + Project data, + ProjectServiceAccountsAccessPoliciesRequestModel request) + { + var dup = new AccessPolicyRequest { GranteeId = Guid.NewGuid(), Read = true, Write = true }; + request.ServiceAccountAccessPolicyRequests = [dup, dup]; + + sutProvider.GetDependency().GetByIdAsync(data.Id).ReturnsForAnyArgs(data); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.PutProjectServiceAccountsAccessPoliciesAsync(data.Id, request)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpdateAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_InvalidPolicyRequest_ThrowsBadRequestException( + SutProvider sutProvider, + Project data, + ProjectServiceAccountsAccessPoliciesRequestModel request) + { + var policyRequest = new AccessPolicyRequest { GranteeId = Guid.NewGuid(), Read = false, Write = true }; + request.ServiceAccountAccessPolicyRequests = [policyRequest]; + + sutProvider.GetDependency().GetByIdAsync(data.Id).ReturnsForAnyArgs(data); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.PutProjectServiceAccountsAccessPoliciesAsync(data.Id, request)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpdateAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_UserHasNoAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Project data, + ProjectServiceAccountsAccessPoliciesRequestModel request) + { + request = SetupValidRequest(request); + sutProvider.GetDependency().GetByIdAsync(data.Id).ReturnsForAnyArgs(data); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()).Returns(AuthorizationResult.Failed()); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.PutProjectServiceAccountsAccessPoliciesAsync(data.Id, request)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .UpdateAsync(Arg.Any()); + ; + } + + [Theory] + [BitAutoData] + public async Task PutProjectServiceAccountsAccessPoliciesAsync_Success( + SutProvider sutProvider, + Project data, + ProjectServiceAccountsAccessPoliciesRequestModel request) + { + request = SetupValidRequest(request); + sutProvider.GetDependency().GetByIdAsync(data.Id).ReturnsForAnyArgs(data); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), Arg.Any(), + Arg.Any>()).Returns(AuthorizationResult.Success()); + + await sutProvider.Sut.PutProjectServiceAccountsAccessPoliciesAsync(data.Id, request); + + await sutProvider.GetDependency().Received(1) + .UpdateAsync(Arg.Any()); + } + private static AccessPoliciesCreateRequest AddRequestsOverMax(AccessPoliciesCreateRequest request) { var newRequests = new List(); @@ -1168,4 +1359,14 @@ public class AccessPoliciesControllerTests return request; } + + private static ProjectServiceAccountsAccessPoliciesRequestModel SetupValidRequest(ProjectServiceAccountsAccessPoliciesRequestModel request) + { + foreach (var policyRequest in request.ServiceAccountAccessPolicyRequests) + { + policyRequest.Read = true; + } + + return request; + } } diff --git a/test/Core.Test/SecretsManager/Models/ProjectServiceAccountsAccessPoliciesTests.cs b/test/Core.Test/SecretsManager/Models/ProjectServiceAccountsAccessPoliciesTests.cs new file mode 100644 index 000000000..48c625fc4 --- /dev/null +++ b/test/Core.Test/SecretsManager/Models/ProjectServiceAccountsAccessPoliciesTests.cs @@ -0,0 +1,79 @@ +#nullable enable +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Enums.AccessPolicies; +using Bit.Core.SecretsManager.Models.Data; +using Xunit; + +namespace Bit.Core.Test.SecretsManager.Models; + +public class ProjectServiceAccountsAccessPoliciesTests +{ + [Fact] + public void GetPolicyUpdates_NoChanges_ReturnsEmptyList() + { + var serviceAccountId1 = Guid.NewGuid(); + var serviceAccountId2 = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + var existing = new ProjectServiceAccountsAccessPolicies + { + ServiceAccountAccessPolicies = new List + { + new() { ServiceAccountId = serviceAccountId1, GrantedProjectId = projectId, Read = true, Write = true }, + new() { ServiceAccountId = serviceAccountId2, GrantedProjectId = projectId, Read = false, Write = true } + } + }; + + var result = existing.GetPolicyUpdates(existing); + + Assert.Empty(result.ServiceAccountAccessPolicyUpdates); + } + + [Fact] + public void GetPolicyUpdates_ReturnsCorrectPolicyChanges() + { + var serviceAccountId1 = Guid.NewGuid(); + var serviceAccountId2 = Guid.NewGuid(); + var serviceAccountId3 = Guid.NewGuid(); + var serviceAccountId4 = Guid.NewGuid(); + var projectId = Guid.NewGuid(); + + var existing = new ProjectServiceAccountsAccessPolicies + { + ServiceAccountAccessPolicies = new List + { + new() { ServiceAccountId = serviceAccountId1, GrantedProjectId = projectId, Read = true, Write = true }, + new() { ServiceAccountId = serviceAccountId3, GrantedProjectId = projectId, Read = true, Write = true }, + new() { ServiceAccountId = serviceAccountId4, GrantedProjectId = projectId, Read = true, Write = true } + } + }; + + var requested = new ProjectServiceAccountsAccessPolicies + { + ServiceAccountAccessPolicies = new List + { + new() { ServiceAccountId = serviceAccountId1, GrantedProjectId = projectId, Read = true, Write = false }, + new() { ServiceAccountId = serviceAccountId2, GrantedProjectId = projectId, Read = false, Write = true }, + new() { ServiceAccountId = serviceAccountId3, GrantedProjectId = projectId, Read = true, Write = true } + } + }; + + + var result = existing.GetPolicyUpdates(requested); + + Assert.Contains(serviceAccountId2, result.ServiceAccountAccessPolicyUpdates + .Where(pu => pu.Operation == AccessPolicyOperation.Create) + .Select(pu => pu.AccessPolicy.ServiceAccountId!.Value)); + + Assert.Contains(serviceAccountId4, result.ServiceAccountAccessPolicyUpdates + .Where(pu => pu.Operation == AccessPolicyOperation.Delete) + .Select(pu => pu.AccessPolicy.ServiceAccountId!.Value)); + + Assert.Contains(serviceAccountId1, result.ServiceAccountAccessPolicyUpdates + .Where(pu => pu.Operation == AccessPolicyOperation.Update) + .Select(pu => pu.AccessPolicy.ServiceAccountId!.Value)); + + Assert.DoesNotContain(serviceAccountId3, result.ServiceAccountAccessPolicyUpdates + .Select(pu => pu.AccessPolicy.ServiceAccountId!.Value)); + } +}