diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/ServiceAccounts/ServiceAccountSecretsDetailsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/ServiceAccounts/ServiceAccountSecretsDetailsQuery.cs new file mode 100644 index 000000000..5bf3ba354 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/ServiceAccounts/ServiceAccountSecretsDetailsQuery.cs @@ -0,0 +1,36 @@ +using Bit.Core.Enums; +using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Commercial.Core.SecretsManager.Queries.ServiceAccounts; + +public class ServiceAccountSecretsDetailsQuery : IServiceAccountSecretsDetailsQuery +{ + private readonly IServiceAccountRepository _serviceAccountRepository; + + public ServiceAccountSecretsDetailsQuery(IServiceAccountRepository serviceAccountRepository) + { + _serviceAccountRepository = serviceAccountRepository; + } + + public async Task> GetManyByOrganizationIdAsync( + Guid organizationId, Guid userId, AccessClientType accessClient, bool includeAccessToSecrets) + { + if (includeAccessToSecrets) + { + return await _serviceAccountRepository.GetManyByOrganizationIdWithSecretsDetailsAsync(organizationId, + userId, + accessClient); + } + + var serviceAccounts = + await _serviceAccountRepository.GetManyByOrganizationIdAsync(organizationId, userId, accessClient); + + return serviceAccounts.Select(sa => new ServiceAccountSecretsDetails + { + ServiceAccount = sa, + AccessToSecrets = 0, + }); + } +} diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs index 614bd4610..5858ef9b5 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs @@ -10,6 +10,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.Secrets; using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; using Bit.Commercial.Core.SecretsManager.Commands.Trash; using Bit.Commercial.Core.SecretsManager.Queries; +using Bit.Commercial.Core.SecretsManager.Queries.ServiceAccounts; using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces; using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces; using Bit.Core.SecretsManager.Commands.Porting.Interfaces; @@ -18,6 +19,7 @@ using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Commands.Trash.Interfaces; using Bit.Core.SecretsManager.Queries.Interfaces; +using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +34,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/ServiceAccountRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs index d880f5d02..e9a7c183b 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using AutoMapper; using Bit.Core.Enums; +using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.SecretsManager.Models; @@ -140,6 +141,44 @@ public class ServiceAccountRepository : Repository> GetManyByOrganizationIdWithSecretsDetailsAsync( + Guid organizationId, Guid userId, AccessClientType accessType) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var query = from sa in dbContext.ServiceAccount + join ap in dbContext.ServiceAccountProjectAccessPolicy + on sa.Id equals ap.ServiceAccountId into grouping + from ap in grouping.DefaultIfEmpty() + where sa.OrganizationId == organizationId + select new + { + ServiceAccount = sa, + AccessToSecrets = ap.GrantedProject.Secrets.Count(s => s.DeletedDate == null) + }; + + query = accessType switch + { + AccessClientType.NoAccessCheck => query, + AccessClientType.User => query.Where(c => + c.ServiceAccount.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || + c.ServiceAccount.GroupAccessPolicies.Any(ap => + ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))), + _ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null), + }; + + var results = (await query.ToListAsync()) + .GroupBy(g => g.ServiceAccount) + .Select(g => + new ServiceAccountSecretsDetails + { + ServiceAccount = Mapper.Map(g.Key), + AccessToSecrets = g.Sum(x => x.AccessToSecrets), + }).OrderBy(c => c.ServiceAccount.RevisionDate).ToList(); + + return results; + } + 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/Queries/ServiceAccounts/ServiceAccountSecretsDetailsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/ServiceAccounts/ServiceAccountSecretsDetailsQueryTests.cs new file mode 100644 index 000000000..0f926ceae --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/ServiceAccounts/ServiceAccountSecretsDetailsQueryTests.cs @@ -0,0 +1,52 @@ +using Bit.Commercial.Core.SecretsManager.Queries.ServiceAccounts; +using Bit.Core.Enums; +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 Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Queries.ServiceAccounts; + +[SutProviderCustomize] +public class ServiceAccountSecretsDetailsQueryTests +{ + [Theory] + [BitAutoData(false)] + [BitAutoData(true)] + public async Task GetManyByOrganizationId_CallsDifferentRepoMethods( + bool includeAccessToSecrets, + SutProvider sutProvider, + Guid organizationId, + Guid userId, + AccessClientType accessClient, + ServiceAccount mockSa, + ServiceAccountSecretsDetails mockSaDetails) + { + sutProvider.GetDependency().GetManyByOrganizationIdAsync(default, default, default) + .ReturnsForAnyArgs(new List { mockSa }); + + sutProvider.GetDependency().GetManyByOrganizationIdWithSecretsDetailsAsync(default, default, default) + .ReturnsForAnyArgs(new List { mockSaDetails }); + + + var result = await sutProvider.Sut.GetManyByOrganizationIdAsync(organizationId, userId, accessClient, includeAccessToSecrets); + + if (includeAccessToSecrets) + { + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdWithSecretsDetailsAsync(Arg.Is(AssertHelper.AssertPropertyEqual(mockSaDetails.ServiceAccount.OrganizationId)), + Arg.Any(), Arg.Any()); + } + else + { + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(mockSa.OrganizationId)), + Arg.Any(), Arg.Any()); + Assert.Equal(0, result.First().AccessToSecrets); + } + } +} diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 1b7307b8a..4a7993f0b 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -8,6 +8,7 @@ using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -26,6 +27,7 @@ public class ServiceAccountsController : Controller private readonly IAuthorizationService _authorizationService; private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IApiKeyRepository _apiKeyRepository; + private readonly IServiceAccountSecretsDetailsQuery _serviceAccountSecretsDetailsQuery; private readonly ICreateAccessTokenCommand _createAccessTokenCommand; private readonly ICreateServiceAccountCommand _createServiceAccountCommand; private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand; @@ -38,6 +40,7 @@ public class ServiceAccountsController : Controller IAuthorizationService authorizationService, IServiceAccountRepository serviceAccountRepository, IApiKeyRepository apiKeyRepository, + IServiceAccountSecretsDetailsQuery serviceAccountSecretsDetailsQuery, ICreateAccessTokenCommand createAccessTokenCommand, ICreateServiceAccountCommand createServiceAccountCommand, IUpdateServiceAccountCommand updateServiceAccountCommand, @@ -49,6 +52,7 @@ public class ServiceAccountsController : Controller _authorizationService = authorizationService; _serviceAccountRepository = serviceAccountRepository; _apiKeyRepository = apiKeyRepository; + _serviceAccountSecretsDetailsQuery = serviceAccountSecretsDetailsQuery; _createServiceAccountCommand = createServiceAccountCommand; _updateServiceAccountCommand = updateServiceAccountCommand; _deleteServiceAccountsCommand = deleteServiceAccountsCommand; @@ -57,8 +61,8 @@ public class ServiceAccountsController : Controller } [HttpGet("/organizations/{organizationId}/service-accounts")] - public async Task> ListByOrganizationAsync( - [FromRoute] Guid organizationId) + public async Task> ListByOrganizationAsync( + [FromRoute] Guid organizationId, [FromQuery] bool includeAccessToSecrets = false) { if (!_currentContext.AccessSecretsManager(organizationId)) { @@ -69,11 +73,11 @@ public class ServiceAccountsController : Controller var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin); - var serviceAccounts = - await _serviceAccountRepository.GetManyByOrganizationIdAsync(organizationId, userId, accessClient); - - var responses = serviceAccounts.Select(serviceAccount => new ServiceAccountResponseModel(serviceAccount)); - return new ListResponseModel(responses); + var results = + await _serviceAccountSecretsDetailsQuery.GetManyByOrganizationIdAsync(organizationId, userId, accessClient, + includeAccessToSecrets); + var responses = results.Select(r => new ServiceAccountSecretsDetailsResponseModel(r)); + return new ListResponseModel(responses); } [HttpGet("{id}")] diff --git a/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs b/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs index c8306b61e..868c241ec 100644 --- a/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs @@ -1,5 +1,6 @@ using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; namespace Bit.Api.SecretsManager.Models.Response; @@ -35,3 +36,18 @@ public class ServiceAccountResponseModel : ResponseModel public DateTime RevisionDate { get; set; } } + +public class ServiceAccountSecretsDetailsResponseModel : ServiceAccountResponseModel +{ + public ServiceAccountSecretsDetailsResponseModel(ServiceAccountSecretsDetails serviceAccountDetails) : base(serviceAccountDetails.ServiceAccount) + { + if (serviceAccountDetails == null) + { + throw new ArgumentNullException(nameof(serviceAccountDetails)); + } + + AccessToSecrets = serviceAccountDetails.AccessToSecrets; + } + + public int AccessToSecrets { get; set; } +} diff --git a/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs b/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs new file mode 100644 index 000000000..67a369f02 --- /dev/null +++ b/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs @@ -0,0 +1,9 @@ +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Core.SecretsManager.Models.Data; + +public class ServiceAccountSecretsDetails +{ + public ServiceAccount ServiceAccount { get; set; } + public int AccessToSecrets { get; set; } +} diff --git a/src/Core/SecretsManager/Queries/ServiceAccounts/Interfaces/IServiceAccountSecretsDetailsQuery.cs b/src/Core/SecretsManager/Queries/ServiceAccounts/Interfaces/IServiceAccountSecretsDetailsQuery.cs new file mode 100644 index 000000000..5af9f4d56 --- /dev/null +++ b/src/Core/SecretsManager/Queries/ServiceAccounts/Interfaces/IServiceAccountSecretsDetailsQuery.cs @@ -0,0 +1,10 @@ +using Bit.Core.Enums; +using Bit.Core.SecretsManager.Models.Data; + +namespace Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; + +public interface IServiceAccountSecretsDetailsQuery +{ + public Task> GetManyByOrganizationIdAsync( + Guid organizationId, Guid userId, AccessClientType accessClient, bool includeAccessToSecrets); +} diff --git a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs index b362a5676..40f9cbfdd 100644 --- a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs @@ -1,5 +1,6 @@ using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; namespace Bit.Core.SecretsManager.Repositories; @@ -16,4 +17,5 @@ public interface IServiceAccountRepository Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType); Task GetServiceAccountCountByOrganizationIdAsync(Guid organizationId); + Task> GetManyByOrganizationIdWithSecretsDetailsAsync(Guid organizationId, Guid userId, AccessClientType accessType); } diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs index eab0b069b..dc5c5e209 100644 --- a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs @@ -1,5 +1,6 @@ using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Models.Data; namespace Bit.Core.SecretsManager.Repositories.Noop; @@ -56,4 +57,6 @@ public class NoopServiceAccountRepository : IServiceAccountRepository { return Task.FromResult(0); } + + public Task> GetManyByOrganizationIdWithSecretsDetailsAsync(Guid organizationId, Guid userId, AccessClientType accessType) => throw new NotImplementedException(); } diff --git a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index f075d0ec3..0847c5c37 100644 --- a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; +using Bit.Core.SecretsManager.Queries.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -33,9 +34,9 @@ public class ServiceAccountsControllerTests sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(Guid.NewGuid()); var result = await sutProvider.Sut.ListByOrganizationAsync(id); - await sutProvider.GetDependency().Received(1) - .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(id)), Arg.Any(), - Arg.Any()); + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(id)), + Arg.Any(), Arg.Any(), Arg.Any()); Assert.Empty(result.Data); } @@ -43,18 +44,18 @@ public class ServiceAccountsControllerTests [Theory] [BitAutoData] public async void GetServiceAccountsByOrganization_Success(SutProvider sutProvider, - ServiceAccount resultServiceAccount) + ServiceAccountSecretsDetails resultServiceAccount) { sutProvider.GetDependency().AccessSecretsManager(default).ReturnsForAnyArgs(true); sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(Guid.NewGuid()); - sutProvider.GetDependency().GetManyByOrganizationIdAsync(default, default, default) - .ReturnsForAnyArgs(new List { resultServiceAccount }); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(default, default, default, default) + .ReturnsForAnyArgs(new List { resultServiceAccount }); - var result = await sutProvider.Sut.ListByOrganizationAsync(resultServiceAccount.OrganizationId); + var result = await sutProvider.Sut.ListByOrganizationAsync(resultServiceAccount.ServiceAccount.OrganizationId); - await sutProvider.GetDependency().Received(1) - .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(resultServiceAccount.OrganizationId)), - Arg.Any(), Arg.Any()); + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(resultServiceAccount.ServiceAccount.OrganizationId)), + Arg.Any(), Arg.Any(), Arg.Any()); Assert.NotEmpty(result.Data); Assert.Single(result.Data); }