From a7b992d424ec96265c426b3cb659409cdbd4aca9 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:34:08 -0500 Subject: [PATCH] [SM-1150] Add secret sync endpoint (#3906) * Add SecretsSyncQuery * Add SecretsSync to controller * Add unit tests * Add integration tests * update repo layer --- .../Queries/Secrets/SecretsSyncQuery.cs | 50 ++++ .../SecretsManagerCollectionExtensions.cs | 3 + .../Repositories/AccessPolicyRepository.cs | 28 +- .../Repositories/ProjectRepository.cs | 50 ++-- .../Repositories/SecretRepository.cs | 267 +++++++++++------- .../Queries/Secrets/SecretsSyncQueryTests.cs | 96 +++++++ .../Controllers/SecretsController.cs | 44 ++- .../SecretsManagerPortingController.cs | 2 +- .../Controllers/SecretsTrashController.cs | 2 +- .../Response/SecretsSyncResponseModel.cs | 27 ++ .../Models/Data/SecretsSyncRequest.cs | 12 + .../Secrets/Interfaces/ISecretsSyncQuery.cs | 10 + .../Repositories/ISecretRepository.cs | 8 +- .../Repositories/Noop/NoopSecretRepository.cs | 17 +- .../Controllers/SecretsControllerTests.cs | 127 ++++++++- .../Controllers/SecretsControllerTests.cs | 106 ++++++- 16 files changed, 711 insertions(+), 138 deletions(-) create mode 100644 bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Secrets/SecretsSyncQuery.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Secrets/SecretsSyncQueryTests.cs create mode 100644 src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs create mode 100644 src/Core/SecretsManager/Models/Data/SecretsSyncRequest.cs create mode 100644 src/Core/SecretsManager/Queries/Secrets/Interfaces/ISecretsSyncQuery.cs diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Secrets/SecretsSyncQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Secrets/SecretsSyncQuery.cs new file mode 100644 index 0000000000..d32eaa4a03 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Secrets/SecretsSyncQuery.cs @@ -0,0 +1,50 @@ +#nullable enable +using Bit.Core.Exceptions; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Commercial.Core.SecretsManager.Queries.Secrets; + +public class SecretsSyncQuery : ISecretsSyncQuery +{ + private readonly ISecretRepository _secretRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; + + public SecretsSyncQuery( + ISecretRepository secretRepository, + IServiceAccountRepository serviceAccountRepository) + { + _secretRepository = secretRepository; + _serviceAccountRepository = serviceAccountRepository; + } + + public async Task<(bool HasChanges, IEnumerable? Secrets)> GetAsync(SecretsSyncRequest syncRequest) + { + if (syncRequest.LastSyncedDate == null) + { + return await GetSecretsAsync(syncRequest); + } + + var serviceAccount = await _serviceAccountRepository.GetByIdAsync(syncRequest.ServiceAccountId); + if (serviceAccount == null) + { + throw new NotFoundException(); + } + + if (syncRequest.LastSyncedDate.Value <= serviceAccount.RevisionDate) + { + return await GetSecretsAsync(syncRequest); + } + + return (HasChanges: false, null); + } + + private async Task<(bool HasChanges, IEnumerable? Secrets)> GetSecretsAsync(SecretsSyncRequest syncRequest) + { + var secrets = await _secretRepository.GetManyByOrganizationIdAsync(syncRequest.OrganizationId, + syncRequest.ServiceAccountId, syncRequest.AccessClientType); + return (HasChanges: true, Secrets: secrets); + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs index 239f3c0984..1fbb21f4b9 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs @@ -12,6 +12,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.Trash; using Bit.Commercial.Core.SecretsManager.Queries; using Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies; using Bit.Commercial.Core.SecretsManager.Queries.Projects; +using Bit.Commercial.Core.SecretsManager.Queries.Secrets; using Bit.Commercial.Core.SecretsManager.Queries.ServiceAccounts; using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces; @@ -23,6 +24,7 @@ using Bit.Core.SecretsManager.Commands.Trash.Interfaces; using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces; using Bit.Core.SecretsManager.Queries.Interfaces; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; +using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -43,6 +45,7 @@ public static class SecretsManagerCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + 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 8a11cdc618..691c188aa9 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/AccessPolicyRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/AccessPolicyRepository.cs @@ -25,10 +25,14 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli policy.GrantedProject.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)); - public async Task> CreateManyAsync(List baseAccessPolicies) + public async Task> CreateManyAsync( + List baseAccessPolicies) { - using var scope = ServiceScopeFactory.CreateScope(); + await using var scope = ServiceScopeFactory.CreateAsyncScope(); var dbContext = GetDatabaseContext(scope); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var serviceAccountIds = new List(); foreach (var baseAccessPolicy in baseAccessPolicies) { baseAccessPolicy.SetNewId(); @@ -64,12 +68,22 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli { var entity = Mapper.Map(accessPolicy); await dbContext.AddAsync(entity); + serviceAccountIds.Add(entity.ServiceAccountId!.Value); break; } } } + if (serviceAccountIds.Count > 0) + { + var utcNow = DateTime.UtcNow; + await dbContext.ServiceAccount + .Where(sa => serviceAccountIds.Contains(sa.Id)) + .ExecuteUpdateAsync(setters => setters.SetProperty(sa => sa.RevisionDate, utcNow)); + } + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); return baseAccessPolicies; } @@ -190,6 +204,16 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli var entity = await dbContext.AccessPolicies.FindAsync(id); if (entity != null) { + if (entity is ServiceAccountProjectAccessPolicy serviceAccountProjectAccessPolicy) + { + var serviceAccount = + await dbContext.ServiceAccount.FindAsync(serviceAccountProjectAccessPolicy.ServiceAccountId); + if (serviceAccount != null) + { + serviceAccount.RevisionDate = DateTime.UtcNow; + } + } + dbContext.Remove(entity); await dbContext.SaveChangesAsync(); } diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs index 9fdbe42814..51d6a88785 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs @@ -70,23 +70,43 @@ public class ProjectRepository : Repository ids) { - using var scope = ServiceScopeFactory.CreateScope(); - var utcNow = DateTime.UtcNow; + await using var scope = ServiceScopeFactory.CreateAsyncScope(); var dbContext = GetDatabaseContext(scope); - var projects = dbContext.Project - .Where(c => ids.Contains(c.Id)) - .Include(p => p.Secrets); - await projects.ForEachAsync(project => + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var serviceAccountIds = await dbContext.Project + .Where(p => ids.Contains(p.Id)) + .Include(p => p.ServiceAccountAccessPolicies) + .SelectMany(p => p.ServiceAccountAccessPolicies.Select(ap => ap.ServiceAccountId!.Value)) + .Distinct() + .ToListAsync(); + + var secretIds = await dbContext.Project + .Where(p => ids.Contains(p.Id)) + .Include(p => p.Secrets) + .SelectMany(p => p.Secrets.Select(s => s.Id)) + .Distinct() + .ToListAsync(); + + var utcNow = DateTime.UtcNow; + if (serviceAccountIds.Count > 0) { - foreach (var projectSecret in project.Secrets) - { - projectSecret.RevisionDate = utcNow; - } + await dbContext.ServiceAccount + .Where(sa => serviceAccountIds.Contains(sa.Id)) + .ExecuteUpdateAsync(setters => + setters.SetProperty(sa => sa.RevisionDate, utcNow)); + } - dbContext.Remove(project); - }); + if (secretIds.Count > 0) + { + await dbContext.Secret + .Where(s => secretIds.Contains(s.Id)) + .ExecuteUpdateAsync(setters => + setters.SetProperty(s => s.RevisionDate, utcNow)); + } - await dbContext.SaveChangesAsync(); + await dbContext.Project.Where(p => ids.Contains(p.Id)).ExecuteDeleteAsync(); + await transaction.CommitAsync(); } public async Task> GetManyWithSecretsByIds(IEnumerable ids) @@ -199,8 +219,4 @@ public class ProjectRepository : Repository> ServiceAccountHasReadAccessToProject(Guid serviceAccountId) => p => p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read); - - private static Expression> ServiceAccountHasWriteAccessToProject(Guid serviceAccountId) => p => - p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Write); - } diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs index 7d8a429a6e..cb69db5e2d 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs @@ -43,7 +43,28 @@ public class SecretRepository : Repository> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) + public async Task> GetManyByOrganizationIdAsync( + Guid organizationId, Guid userId, AccessClientType accessType) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + var query = dbContext.Secret + .Include(c => c.Projects) + .Where(c => c.OrganizationId == organizationId && c.DeletedDate == null); + + query = accessType switch + { + AccessClientType.NoAccessCheck => query, + AccessClientType.User => query.Where(UserHasReadAccessToSecret(userId)), + AccessClientType.ServiceAccount => query.Where(ServiceAccountHasReadAccessToSecret(userId)), + _ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null) + }; + + var secrets = await query.OrderBy(c => c.RevisionDate).ToListAsync(); + return Mapper.Map>(secrets); + } + + public async Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) { using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); @@ -82,7 +103,7 @@ public class SecretRepository : Repository> GetManyByOrganizationIdInTrashAsync(Guid organizationId) + public async Task> GetManyDetailsByOrganizationIdInTrashAsync(Guid organizationId) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -103,7 +124,7 @@ public class SecretRepository : Repository> GetManyByProjectIdAsync(Guid projectId, Guid userId, AccessClientType accessType) + public async Task> GetManyDetailsByProjectIdAsync(Guid projectId, Guid userId, AccessClientType accessType) { using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); @@ -115,106 +136,124 @@ public class SecretRepository : Repository CreateAsync(Core.SecretsManager.Entities.Secret secret) + public override async Task CreateAsync( + Core.SecretsManager.Entities.Secret secret) { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - secret.SetNewId(); - var entity = Mapper.Map(secret); + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + secret.SetNewId(); + var entity = Mapper.Map(secret); - if (secret.Projects?.Count > 0) + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + if (secret.Projects?.Count > 0) + { + foreach (var project in entity.Projects) { - foreach (var p in entity.Projects) - { - dbContext.Attach(p); - } + dbContext.Attach(project); } - await dbContext.AddAsync(entity); - await dbContext.SaveChangesAsync(); - secret.Id = entity.Id; - return secret; + var projectIds = entity.Projects.Select(p => p.Id).ToList(); + await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds); } + + await dbContext.AddAsync(entity); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + secret.Id = entity.Id; + return secret; } public async Task UpdateAsync(Core.SecretsManager.Entities.Secret secret) { - using (var scope = ServiceScopeFactory.CreateScope()) + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + var mappedEntity = Mapper.Map(secret); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var entity = await dbContext.Secret + .Include(s => s.Projects) + .FirstAsync(s => s.Id == secret.Id); + + var projectsToRemove = entity.Projects.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id)).ToList(); + var projectsToAdd = mappedEntity.Projects.Where(p => entity.Projects.All(ep => ep.Id != p.Id)).ToList(); + + foreach (var p in projectsToRemove) { - var dbContext = GetDatabaseContext(scope); - var mappedEntity = Mapper.Map(secret); - - var entity = await dbContext.Secret - .Include("Projects") - .FirstAsync(s => s.Id == secret.Id); - - foreach (var p in entity.Projects.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id))) - { - entity.Projects.Remove(p); - } - - // Add new relationships - foreach (var project in mappedEntity.Projects.Where(p => entity.Projects.All(ep => ep.Id != p.Id))) - { - var p = dbContext.AttachToOrGet(_ => _.Id == project.Id, () => project); - entity.Projects.Add(p); - } - - dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity); - await dbContext.SaveChangesAsync(); + entity.Projects.Remove(p); } + foreach (var project in projectsToAdd) + { + var p = dbContext.AttachToOrGet(x => x.Id == project.Id, () => project); + entity.Projects.Add(p); + } + + var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList(); + if (projectIds.Count > 0) + { + await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds); + } + + await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, [entity.Id]); + dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + return secret; } public async Task SoftDeleteManyByIdAsync(IEnumerable ids) { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var utcNow = DateTime.UtcNow; - var secrets = dbContext.Secret.Where(c => ids.Contains(c.Id)); - await secrets.ForEachAsync(secret => - { - dbContext.Attach(secret); - secret.DeletedDate = utcNow; - secret.RevisionDate = utcNow; - }); - await dbContext.SaveChangesAsync(); - } + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var secretIds = ids.ToList(); + await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, secretIds); + + var utcNow = DateTime.UtcNow; + + await dbContext.Secret.Where(c => secretIds.Contains(c.Id)) + .ExecuteUpdateAsync(setters => + setters.SetProperty(s => s.RevisionDate, utcNow) + .SetProperty(s => s.DeletedDate, utcNow)); + + await transaction.CommitAsync(); } public async Task HardDeleteManyByIdAsync(IEnumerable ids) { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var secrets = dbContext.Secret.Where(c => ids.Contains(c.Id)); - await secrets.ForEachAsync(secret => - { - dbContext.Attach(secret); - dbContext.Remove(secret); - }); - await dbContext.SaveChangesAsync(); - } + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var secretIds = ids.ToList(); + await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, secretIds); + + await dbContext.Secret.Where(c => secretIds.Contains(c.Id)) + .ExecuteDeleteAsync(); + + await transaction.CommitAsync(); } public async Task RestoreManyByIdAsync(IEnumerable ids) { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var utcNow = DateTime.UtcNow; - var secrets = dbContext.Secret.Where(c => ids.Contains(c.Id)); - await secrets.ForEachAsync(secret => - { - dbContext.Attach(secret); - secret.DeletedDate = null; - secret.RevisionDate = utcNow; - }); - await dbContext.SaveChangesAsync(); - } + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + var secretIds = ids.ToList(); + await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, secretIds); + + var utcNow = DateTime.UtcNow; + + await dbContext.Secret.Where(c => secretIds.Contains(c.Id)) + .ExecuteUpdateAsync(setters => + setters.SetProperty(s => s.RevisionDate, utcNow) + .SetProperty(s => s.DeletedDate, (DateTime?)null)); + + await transaction.CommitAsync(); } public async Task> ImportAsync(IEnumerable secrets) @@ -248,24 +287,6 @@ public class SecretRepository : Repository ids) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var utcNow = DateTime.UtcNow; - var secrets = dbContext.Secret.Where(s => ids.Contains(s.Id)); - - await secrets.ForEachAsync(secret => - { - dbContext.Attach(secret); - secret.RevisionDate = utcNow; - }); - - await dbContext.SaveChangesAsync(); - } - } - public async Task<(bool Read, bool Write)> AccessToSecretAsync(Guid id, Guid userId, AccessClientType accessType) { using var scope = ServiceScopeFactory.CreateScope(); @@ -357,4 +378,60 @@ public class SecretRepository : Repository ap.OrganizationUser.UserId == userId && ap.Read) || p.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.UserId == userId && ap.Read))); + + private static async Task UpdateServiceAccountRevisionsByProjectIdsAsync(DatabaseContext dbContext, + List projectIds) + { + if (projectIds.Count == 0) + { + return; + } + + var serviceAccountIds = await dbContext.Project.Where(p => projectIds.Contains(p.Id)) + .Include(p => p.ServiceAccountAccessPolicies) + .SelectMany(p => p.ServiceAccountAccessPolicies.Select(ap => ap.ServiceAccountId!.Value)) + .Distinct() + .ToListAsync(); + + await UpdateServiceAccountRevisionsAsync(dbContext, serviceAccountIds); + } + + private static async Task UpdateServiceAccountRevisionsBySecretIdsAsync(DatabaseContext dbContext, + List secretIds) + { + if (secretIds.Count == 0) + { + return; + } + + var projectAccessServiceAccountIds = await dbContext.Secret + .Where(s => secretIds.Contains(s.Id)) + .SelectMany(s => + s.Projects.SelectMany(p => p.ServiceAccountAccessPolicies.Select(ap => ap.ServiceAccountId!.Value))) + .Distinct() + .ToListAsync(); + + var directAccessServiceAccountIds = await dbContext.Secret + .Where(s => secretIds.Contains(s.Id)) + .SelectMany(s => s.ServiceAccountAccessPolicies.Select(ap => ap.ServiceAccountId!.Value)) + .Distinct() + .ToListAsync(); + + var serviceAccountIds = + directAccessServiceAccountIds.Concat(projectAccessServiceAccountIds).Distinct().ToList(); + + await UpdateServiceAccountRevisionsAsync(dbContext, serviceAccountIds); + } + + private static async Task UpdateServiceAccountRevisionsAsync(DatabaseContext dbContext, + List serviceAccountIds) + { + if (serviceAccountIds.Count > 0) + { + var utcNow = DateTime.UtcNow; + await dbContext.ServiceAccount + .Where(sa => serviceAccountIds.Contains(sa.Id)) + .ExecuteUpdateAsync(setters => setters.SetProperty(b => b.RevisionDate, utcNow)); + } + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Secrets/SecretsSyncQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Secrets/SecretsSyncQueryTests.cs new file mode 100644 index 0000000000..affecfdb6c --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Secrets/SecretsSyncQueryTests.cs @@ -0,0 +1,96 @@ +#nullable enable +using Bit.Commercial.Core.SecretsManager.Queries.Secrets; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Queries.Secrets; + +[SutProviderCustomize] +public class SecretsSyncQueryTests +{ + [Theory, BitAutoData] + public async Task GetAsync_NullLastSyncedDate_ReturnsHasChanges( + SutProvider sutProvider, + SecretsSyncRequest data) + { + data.LastSyncedDate = null; + + var result = await sutProvider.Sut.GetAsync(data); + + Assert.True(result.HasChanges); + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdAsync(Arg.Is(data.OrganizationId), + Arg.Is(data.ServiceAccountId), + Arg.Is(data.AccessClientType)); + } + + [Theory, BitAutoData] + public async Task GetAsync_HasLastSyncedDateServiceAccountNotFound_Throws( + SutProvider sutProvider, + SecretsSyncRequest data) + { + data.LastSyncedDate = DateTime.UtcNow; + sutProvider.GetDependency().GetByIdAsync(data.ServiceAccountId) + .Returns((ServiceAccount?)null); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(data)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyByOrganizationIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task GetAsync_HasLastSyncedDateServiceAccountWithLaterOrEqualRevisionDate_ReturnsChanges( + bool datesEqual, + SutProvider sutProvider, + SecretsSyncRequest data, + ServiceAccount serviceAccount) + { + data.LastSyncedDate = DateTime.UtcNow.AddDays(-1); + serviceAccount.Id = data.ServiceAccountId; + serviceAccount.RevisionDate = datesEqual ? data.LastSyncedDate.Value : data.LastSyncedDate.Value.AddSeconds(600); + + sutProvider.GetDependency().GetByIdAsync(data.ServiceAccountId) + .Returns(serviceAccount); + + var result = await sutProvider.Sut.GetAsync(data); + + Assert.True(result.HasChanges); + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdAsync(Arg.Is(data.OrganizationId), + Arg.Is(data.ServiceAccountId), + Arg.Is(data.AccessClientType)); + } + + [Theory, BitAutoData] + public async Task GetAsync_HasLastSyncedDateServiceAccountWithEarlierRevisionDate_ReturnsNoChanges( + SutProvider sutProvider, + SecretsSyncRequest data, + ServiceAccount serviceAccount) + { + data.LastSyncedDate = DateTime.UtcNow.AddDays(-1); + serviceAccount.Id = data.ServiceAccountId; + serviceAccount.RevisionDate = data.LastSyncedDate.Value.AddDays(-2); + + sutProvider.GetDependency().GetByIdAsync(data.ServiceAccountId) + .Returns(serviceAccount); + + var result = await sutProvider.Sut.GetAsync(data); + + Assert.False(result.HasChanges); + Assert.Null(result.Secrets); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyByOrganizationIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index d8e09fe17b..dcaa1886be 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -9,6 +9,9 @@ using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Queries.Interfaces; +using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Enums; @@ -29,6 +32,8 @@ public class SecretsController : Controller private readonly ICreateSecretCommand _createSecretCommand; private readonly IUpdateSecretCommand _updateSecretCommand; private readonly IDeleteSecretCommand _deleteSecretCommand; + private readonly IAccessClientQuery _accessClientQuery; + private readonly ISecretsSyncQuery _secretsSyncQuery; private readonly IUserService _userService; private readonly IEventService _eventService; private readonly IReferenceEventService _referenceEventService; @@ -42,6 +47,8 @@ public class SecretsController : Controller ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand, IDeleteSecretCommand deleteSecretCommand, + IAccessClientQuery accessClientQuery, + ISecretsSyncQuery secretsSyncQuery, IUserService userService, IEventService eventService, IReferenceEventService referenceEventService, @@ -54,6 +61,8 @@ public class SecretsController : Controller _createSecretCommand = createSecretCommand; _updateSecretCommand = updateSecretCommand; _deleteSecretCommand = deleteSecretCommand; + _accessClientQuery = accessClientQuery; + _secretsSyncQuery = secretsSyncQuery; _userService = userService; _eventService = eventService; _referenceEventService = referenceEventService; @@ -73,7 +82,7 @@ public class SecretsController : Controller var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin); - var secrets = await _secretRepository.GetManyByOrganizationIdAsync(organizationId, userId, accessClient); + var secrets = await _secretRepository.GetManyDetailsByOrganizationIdAsync(organizationId, userId, accessClient); return new SecretWithProjectsListResponseModel(secrets); } @@ -139,7 +148,7 @@ public class SecretsController : Controller var orgAdmin = await _currentContext.OrganizationAdmin(project.OrganizationId); var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin); - var secrets = await _secretRepository.GetManyByProjectIdAsync(projectId, userId, accessClient); + var secrets = await _secretRepository.GetManyDetailsByProjectIdAsync(projectId, userId, accessClient); return new SecretWithProjectsListResponseModel(secrets); } @@ -246,4 +255,35 @@ public class SecretsController : Controller var responses = secrets.Select(s => new BaseSecretResponseModel(s)); return new ListResponseModel(responses); } + + [HttpGet("/organizations/{organizationId}/secrets/sync")] + public async Task GetSecretsSyncAsync([FromRoute] Guid organizationId, + [FromQuery] DateTime? lastSyncedDate = null) + { + if (lastSyncedDate.HasValue && lastSyncedDate.Value > DateTime.UtcNow) + { + throw new BadRequestException("Last synced date must be in the past."); + } + + if (!_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + var (accessClient, serviceAccountId) = await _accessClientQuery.GetAccessClientAsync(User, organizationId); + if (accessClient != AccessClientType.ServiceAccount) + { + throw new BadRequestException("Only service accounts can sync secrets."); + } + + var syncRequest = new SecretsSyncRequest + { + AccessClientType = accessClient, + OrganizationId = organizationId, + ServiceAccountId = serviceAccountId, + LastSyncedDate = lastSyncedDate + }; + var (hasChanges, secrets) = await _secretsSyncQuery.GetAsync(syncRequest); + return new SecretsSyncResponseModel(hasChanges, secrets); + } } diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs index 19ef56e565..7599bd262b 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs @@ -44,7 +44,7 @@ public class SecretsManagerPortingController : Controller var userId = _userService.GetProperUserId(User).Value; var projects = await _projectRepository.GetManyByOrganizationIdAsync(organizationId, userId, AccessClientType.NoAccessCheck); - var secrets = await _secretRepository.GetManyByOrganizationIdAsync(organizationId, userId, AccessClientType.NoAccessCheck); + var secrets = await _secretRepository.GetManyDetailsByOrganizationIdAsync(organizationId, userId, AccessClientType.NoAccessCheck); if (projects == null && secrets == null) { diff --git a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs index 1bf9d3135a..19a84755d8 100644 --- a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs @@ -41,7 +41,7 @@ public class TrashController : Controller throw new UnauthorizedAccessException(); } - var secrets = await _secretRepository.GetManyByOrganizationIdInTrashAsync(organizationId); + var secrets = await _secretRepository.GetManyDetailsByOrganizationIdInTrashAsync(organizationId); return new SecretWithProjectsListResponseModel(secrets); } diff --git a/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs new file mode 100644 index 0000000000..56b8811361 --- /dev/null +++ b/src/Api/SecretsManager/Models/Response/SecretsSyncResponseModel.cs @@ -0,0 +1,27 @@ +#nullable enable +using Bit.Api.Models.Response; +using Bit.Core.Models.Api; +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Api.SecretsManager.Models.Response; + +public class SecretsSyncResponseModel : ResponseModel +{ + private const string _objectName = "secretsSync"; + + public bool HasChanges { get; set; } + public ListResponseModel? Secrets { get; set; } + + public SecretsSyncResponseModel(bool hasChanges, IEnumerable? secrets, string obj = _objectName) + : base(obj) + { + Secrets = secrets != null + ? new ListResponseModel(secrets.Select(s => new BaseSecretResponseModel(s))) + : null; + HasChanges = hasChanges; + } + + public SecretsSyncResponseModel() : base(_objectName) + { + } +} diff --git a/src/Core/SecretsManager/Models/Data/SecretsSyncRequest.cs b/src/Core/SecretsManager/Models/Data/SecretsSyncRequest.cs new file mode 100644 index 0000000000..0b3642d8eb --- /dev/null +++ b/src/Core/SecretsManager/Models/Data/SecretsSyncRequest.cs @@ -0,0 +1,12 @@ +#nullable enable +using Bit.Core.Enums; + +namespace Bit.Core.SecretsManager.Models.Data; + +public class SecretsSyncRequest +{ + public AccessClientType AccessClientType { get; set; } + public Guid OrganizationId { get; set; } + public Guid ServiceAccountId { get; set; } + public DateTime? LastSyncedDate { get; set; } +} diff --git a/src/Core/SecretsManager/Queries/Secrets/Interfaces/ISecretsSyncQuery.cs b/src/Core/SecretsManager/Queries/Secrets/Interfaces/ISecretsSyncQuery.cs new file mode 100644 index 0000000000..1318ec97fc --- /dev/null +++ b/src/Core/SecretsManager/Queries/Secrets/Interfaces/ISecretsSyncQuery.cs @@ -0,0 +1,10 @@ +#nullable enable +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; + +namespace Bit.Core.SecretsManager.Queries.Secrets.Interfaces; + +public interface ISecretsSyncQuery +{ + Task<(bool HasChanges, IEnumerable? Secrets)> GetAsync(SecretsSyncRequest syncRequest); +} diff --git a/src/Core/SecretsManager/Repositories/ISecretRepository.cs b/src/Core/SecretsManager/Repositories/ISecretRepository.cs index e8f0673d1e..b16ff8dfc4 100644 --- a/src/Core/SecretsManager/Repositories/ISecretRepository.cs +++ b/src/Core/SecretsManager/Repositories/ISecretRepository.cs @@ -6,11 +6,12 @@ namespace Bit.Core.SecretsManager.Repositories; public interface ISecretRepository { - Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType); - Task> GetManyByOrganizationIdInTrashAsync(Guid organizationId); + Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType); + Task> GetManyDetailsByOrganizationIdInTrashAsync(Guid organizationId); + Task> GetManyDetailsByProjectIdAsync(Guid projectId, Guid userId, AccessClientType accessType); + Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task> GetManyByOrganizationIdInTrashByIdsAsync(Guid organizationId, IEnumerable ids); Task> GetManyByIds(IEnumerable ids); - Task> GetManyByProjectIdAsync(Guid projectId, Guid userId, AccessClientType accessType); Task GetByIdAsync(Guid id); Task CreateAsync(Secret secret); Task UpdateAsync(Secret secret); @@ -18,7 +19,6 @@ public interface ISecretRepository Task HardDeleteManyByIdAsync(IEnumerable ids); Task RestoreManyByIdAsync(IEnumerable ids); Task> ImportAsync(IEnumerable secrets); - Task UpdateRevisionDates(IEnumerable ids); Task<(bool Read, bool Write)> AccessToSecretAsync(Guid id, Guid userId, AccessClientType accessType); Task EmptyTrash(DateTime nowTime, uint deleteAfterThisNumberOfDays); Task GetSecretsCountByOrganizationIdAsync(Guid organizationId); diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs index 8f65322ac1..ddec1efb20 100644 --- a/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs +++ b/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs @@ -6,17 +6,23 @@ namespace Bit.Core.SecretsManager.Repositories.Noop; public class NoopSecretRepository : ISecretRepository { - public Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, + public Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) { return Task.FromResult(null as IEnumerable); } - public Task> GetManyByOrganizationIdInTrashAsync(Guid organizationId) + public Task> GetManyDetailsByOrganizationIdInTrashAsync(Guid organizationId) { return Task.FromResult(null as IEnumerable); } + public Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, + AccessClientType accessType) + { + return Task.FromResult(null as IEnumerable); + } + public Task> GetManyByOrganizationIdInTrashByIdsAsync(Guid organizationId, IEnumerable ids) { @@ -28,7 +34,7 @@ public class NoopSecretRepository : ISecretRepository return Task.FromResult(null as IEnumerable); } - public Task> GetManyByProjectIdAsync(Guid projectId, Guid userId, + public Task> GetManyDetailsByProjectIdAsync(Guid projectId, Guid userId, AccessClientType accessType) { return Task.FromResult(null as IEnumerable); @@ -69,11 +75,6 @@ public class NoopSecretRepository : ISecretRepository return Task.FromResult(null as IEnumerable); } - public Task UpdateRevisionDates(IEnumerable ids) - { - return Task.FromResult(0); - } - public Task<(bool Read, bool Write)> AccessToSecretAsync(Guid id, Guid userId, AccessClientType accessType) { return Task.FromResult((false, false)); diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs index 0ff7396eda..afe6ddeac9 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretsControllerTests.cs @@ -22,6 +22,7 @@ public class SecretsControllerTests : IClassFixture, IAsy private readonly ApiApplicationFactory _factory; private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IAccessPolicyRepository _accessPolicyRepository; private readonly LoginHelper _loginHelper; @@ -35,6 +36,7 @@ public class SecretsControllerTests : IClassFixture, IAsy _secretRepository = _factory.GetService(); _projectRepository = _factory.GetService(); _accessPolicyRepository = _factory.GetService(); + _serviceAccountRepository = _factory.GetService(); _loginHelper = new LoginHelper(_factory, _client); } @@ -264,6 +266,7 @@ public class SecretsControllerTests : IClassFixture, IAsy { var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); await _loginHelper.LoginAsync(email); + await _loginHelper.LoginAsync(email); accessType = AccessClientType.User; var accessPolicies = new List @@ -288,7 +291,7 @@ public class SecretsControllerTests : IClassFixture, IAsy secretResponse.EnsureSuccessStatusCode(); var secretResult = await secretResponse.Content.ReadFromJsonAsync(); - var result = (await _secretRepository.GetManyByProjectIdAsync(project.Id, orgUserId, accessType)).First(); + var result = (await _secretRepository.GetManyDetailsByProjectIdAsync(project.Id, orgUserId, accessType)).First(); var secret = result.Secret; Assert.NotNull(secretResult); @@ -392,6 +395,7 @@ public class SecretsControllerTests : IClassFixture, IAsy { var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled); await _loginHelper.LoginAsync(_email); + await _loginHelper.LoginAsync(_email); var project = await _projectRepository.CreateAsync(new Project { @@ -784,6 +788,106 @@ public class SecretsControllerTests : IClassFixture, IAsy Assert.Equal(secretIds.Count, result.Data.Count()); } + [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 GetSecretsSyncAsync_SmAccessDenied_NotFound(bool useSecrets, bool accessSecrets, + bool organizationEnabled) + { + var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled); + await _loginHelper.LoginAsync(_email); + + var response = await _client.GetAsync($"/organizations/{org.Id}/secrets/sync"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSecretsSyncAsync_UserClient_BadRequest() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var response = await _client.GetAsync($"/organizations/{org.Id}/secrets/sync"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetSecretsSyncAsync_NoSecrets_ReturnsEmptyList(bool useLastSyncedDate) + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + var apiKeyDetails = await _organizationHelper.CreateNewServiceAccountApiKeyAsync(); + await _loginHelper.LoginWithApiKeyAsync(apiKeyDetails); + + var requestUrl = $"/organizations/{org.Id}/secrets/sync"; + if (useLastSyncedDate) + { + requestUrl = $"/organizations/{org.Id}/secrets/sync?lastSyncedDate={DateTime.UtcNow.AddDays(-1)}"; + } + + var response = await _client.GetAsync(requestUrl); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.True(result.HasChanges); + Assert.NotNull(result.Secrets); + Assert.Empty(result.Secrets.Data); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetSecretsSyncAsync_HasSecrets_ReturnsAll(bool useLastSyncedDate) + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + var apiKeyDetails = await _organizationHelper.CreateNewServiceAccountApiKeyAsync(); + await _loginHelper.LoginWithApiKeyAsync(apiKeyDetails); + var secretIds = await SetupSecretsSyncRequestAsync(org.Id, apiKeyDetails.ApiKey.ServiceAccountId!.Value); + + var requestUrl = $"/organizations/{org.Id}/secrets/sync"; + if (useLastSyncedDate) + { + requestUrl = $"/organizations/{org.Id}/secrets/sync?lastSyncedDate={DateTime.UtcNow.AddDays(-1)}"; + } + + var response = await _client.GetAsync(requestUrl); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.True(result.HasChanges); + Assert.NotNull(result.Secrets); + Assert.NotEmpty(result.Secrets.Data); + Assert.Equal(secretIds.Count, result.Secrets.Data.Count()); + Assert.All(result.Secrets.Data, item => Assert.Contains(item.Id, secretIds)); + } + + [Fact] + public async Task GetSecretsSyncAsync_ServiceAccountNotRevised_ReturnsNoChanges() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + var apiKeyDetails = await _organizationHelper.CreateNewServiceAccountApiKeyAsync(); + var serviceAccountId = apiKeyDetails.ApiKey.ServiceAccountId!.Value; + await _loginHelper.LoginWithApiKeyAsync(apiKeyDetails); + await SetupSecretsSyncRequestAsync(org.Id, serviceAccountId); + await UpdateServiceAccountRevisionAsync(serviceAccountId, DateTime.UtcNow.AddDays(-1)); + + var response = await _client.GetAsync($"/organizations/{org.Id}/secrets/sync?lastSyncedDate={DateTime.UtcNow}"); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.False(result.HasChanges); + Assert.Null(result.Secrets); + } + private async Task<(Project Project, List secretIds)> CreateSecretsAsync(Guid orgId, int numberToCreate = 3) { var project = await _projectRepository.CreateAsync(new Project @@ -853,4 +957,25 @@ public class SecretsControllerTests : IClassFixture, IAsy throw new ArgumentOutOfRangeException(nameof(permissionType), permissionType, null); } } + + private async Task> SetupSecretsSyncRequestAsync(Guid organizationId, Guid serviceAccountId) + { + var (project, secretIds) = await CreateSecretsAsync(organizationId); + var accessPolicies = new List + { + new ServiceAccountProjectAccessPolicy + { + GrantedProjectId = project.Id, ServiceAccountId = serviceAccountId, Read = true, Write = true + } + }; + await _accessPolicyRepository.CreateManyAsync(accessPolicies); + return secretIds; + } + + private async Task UpdateServiceAccountRevisionAsync(Guid serviceAccountId, DateTime revisionDate) + { + var sa = await _serviceAccountRepository.GetByIdAsync(serviceAccountId); + sa.RevisionDate = revisionDate; + await _serviceAccountRepository.ReplaceAsync(sa); + } } diff --git a/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs index 241bf724bf..097ee27d4a 100644 --- a/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs @@ -8,6 +8,8 @@ using Bit.Core.Exceptions; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Queries.Interfaces; +using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture; @@ -37,7 +39,7 @@ public class SecretsControllerTests var result = await sutProvider.Sut.ListByOrganizationAsync(id); await sutProvider.GetDependency().Received(1) - .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(id)), userId, accessType); + .GetManyDetailsByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(id)), userId, accessType); Assert.Empty(result.Secrets); } @@ -45,10 +47,10 @@ public class SecretsControllerTests [Theory] [BitAutoData(PermissionType.RunAsAdmin)] [BitAutoData(PermissionType.RunAsUserWithPermission)] - public async Task GetSecretsByOrganization_Success(PermissionType permissionType, SutProvider sutProvider, Core.SecretsManager.Entities.Secret resultSecret, Guid organizationId, Guid userId, Core.SecretsManager.Entities.Project mockProject, AccessClientType accessType) + public async Task GetSecretsByOrganization_Success(PermissionType permissionType, SutProvider sutProvider, Secret resultSecret, Guid organizationId, Guid userId, Project mockProject, AccessClientType accessType) { sutProvider.GetDependency().AccessSecretsManager(default).ReturnsForAnyArgs(true); - sutProvider.GetDependency().GetManyByOrganizationIdAsync(default, default, default) + sutProvider.GetDependency().GetManyDetailsByOrganizationIdAsync(default, default, default) .ReturnsForAnyArgs(new List { new() { Secret = resultSecret, Read = true, Write = true }, @@ -61,22 +63,22 @@ public class SecretsControllerTests } else { - resultSecret.Projects = new List() { mockProject }; + resultSecret.Projects = new List() { mockProject }; sutProvider.GetDependency().OrganizationAdmin(organizationId).Returns(false); sutProvider.GetDependency().AccessToProjectAsync(default, default, default) .Returns((true, true)); } - var result = await sutProvider.Sut.ListByOrganizationAsync(resultSecret.OrganizationId); + await sutProvider.Sut.ListByOrganizationAsync(resultSecret.OrganizationId); await sutProvider.GetDependency().Received(1) - .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(resultSecret.OrganizationId)), userId, accessType); + .GetManyDetailsByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(resultSecret.OrganizationId)), userId, accessType); } [Theory] [BitAutoData] - public async Task GetSecretsByOrganization_AccessDenied_Throws(SutProvider sutProvider, Core.SecretsManager.Entities.Secret resultSecret) + public async Task GetSecretsByOrganization_AccessDenied_Throws(SutProvider sutProvider, Secret resultSecret) { sutProvider.GetDependency().AccessSecretsManager(default).ReturnsForAnyArgs(false); @@ -429,6 +431,76 @@ public class SecretsControllerTests Assert.Equal(data.Count, results.Data.Count()); } + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task GetSecretsSyncAsync_AccessSecretsManagerFalse_ThrowsNotFound( + bool nullLastSyncedDate, + SutProvider sutProvider, Guid organizationId) + { + var lastSyncedDate = GetLastSyncedDate(nullLastSyncedDate); + + sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)) + .ReturnsForAnyArgs(false); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetSecretsSyncAsync(organizationId, lastSyncedDate)); + } + + [Theory] + [BitAutoData(true, AccessClientType.NoAccessCheck)] + [BitAutoData(true, AccessClientType.User)] + [BitAutoData(true, AccessClientType.Organization)] + [BitAutoData(false, AccessClientType.NoAccessCheck)] + [BitAutoData(false, AccessClientType.User)] + [BitAutoData(false, AccessClientType.Organization)] + public async Task GetSecretsSyncAsync_AccessClientIsNotAServiceAccount_ThrowsBadRequest( + bool nullLastSyncedDate, + AccessClientType accessClientType, + SutProvider sutProvider, Guid organizationId) + { + var lastSyncedDate = GetLastSyncedDate(nullLastSyncedDate); + + sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .GetAccessClientAsync(Arg.Any(), Arg.Any()) + .Returns((accessClientType, new Guid())); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetSecretsSyncAsync(organizationId, lastSyncedDate)); + } + + [Theory] + [BitAutoData] + public async Task GetSecretsSyncAsync_LastSyncedInFuture_ThrowsBadRequest( + List secrets, + SutProvider sutProvider, Guid organizationId) + { + DateTime? lastSyncedDate = DateTime.UtcNow.AddDays(3); + + SetupSecretsSyncRequest(false, secrets, sutProvider, organizationId); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetSecretsSyncAsync(organizationId, lastSyncedDate)); + } + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task GetSecretsSyncAsync_AccessClientIsAServiceAccount_Success( + bool nullLastSyncedDate, + List secrets, + SutProvider sutProvider, Guid organizationId) + { + var lastSyncedDate = SetupSecretsSyncRequest(nullLastSyncedDate, secrets, sutProvider, organizationId); + + var result = await sutProvider.Sut.GetSecretsSyncAsync(organizationId, lastSyncedDate); + Assert.True(result.HasChanges); + Assert.NotNull(result.Secrets); + Assert.NotEmpty(result.Secrets.Data); + } + private static (List Ids, GetSecretsRequestModel request) BuildGetSecretsRequestModel( IEnumerable data) { @@ -447,4 +519,24 @@ public class SecretsControllerTests return organizationId; } + + private static DateTime? SetupSecretsSyncRequest(bool nullLastSyncedDate, List secrets, + SutProvider sutProvider, Guid organizationId) + { + var lastSyncedDate = GetLastSyncedDate(nullLastSyncedDate); + + sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)) + .ReturnsForAnyArgs(true); + sutProvider.GetDependency() + .GetAccessClientAsync(Arg.Any(), Arg.Any()) + .Returns((AccessClientType.ServiceAccount, new Guid())); + sutProvider.GetDependency().GetAsync(Arg.Any()) + .Returns((true, secrets)); + return lastSyncedDate; + } + + private static DateTime? GetLastSyncedDate(bool nullLastSyncedDate) + { + return nullLastSyncedDate ? null : DateTime.UtcNow.AddDays(-1); + } }