diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/DeleteServiceAccountsCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/DeleteServiceAccountsCommand.cs new file mode 100644 index 000000000..39d340d75 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/DeleteServiceAccountsCommand.cs @@ -0,0 +1,83 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; + +namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; + +public class DeleteServiceAccountsCommand : IDeleteServiceAccountsCommand +{ + private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly ICurrentContext _currentContext; + + public DeleteServiceAccountsCommand( + IServiceAccountRepository serviceAccountRepository, + ICurrentContext currentContext) + { + _serviceAccountRepository = serviceAccountRepository; + _currentContext = currentContext; + } + + public async Task>> DeleteServiceAccounts(List ids, Guid userId) + { + if (ids.Any() != true || userId == new Guid()) + { + throw new ArgumentNullException(); + } + + var serviceAccounts = (await _serviceAccountRepository.GetManyByIds(ids))?.ToList(); + + if (serviceAccounts?.Any() != true || serviceAccounts.Count != ids.Count) + { + throw new NotFoundException(); + } + + // Ensure all service accounts belongs to the same organization + var organizationId = serviceAccounts.First().OrganizationId; + if (serviceAccounts.Any(p => p.OrganizationId != organizationId)) + { + throw new BadRequestException(); + } + + if (!_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin); + + var results = new List>(serviceAccounts.Count); + var deleteIds = new List(); + + foreach (var sa in serviceAccounts) + { + var hasAccess = accessClient switch + { + AccessClientType.NoAccessCheck => true, + AccessClientType.User => await _serviceAccountRepository.UserHasWriteAccessToServiceAccount(sa.Id, userId), + _ => false, + }; + + if (!hasAccess) + { + results.Add(new Tuple(sa, "access denied")); + } + else + { + results.Add(new Tuple(sa, "")); + deleteIds.Add(sa.Id); + } + } + + if (deleteIds.Count > 0) + { + await _serviceAccountRepository.DeleteManyByIdAsync(deleteIds); + } + + return results; + } +} + diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs index 3f871d853..7922dd034 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/SecretsManagerCollectionExtensions.cs @@ -28,6 +28,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 5dd560c45..9b2bfd54f 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs @@ -32,6 +32,16 @@ public class ServiceAccountRepository : Repository>(serviceAccounts); } + public async Task> GetManyByIds(IEnumerable ids) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var serviceAccounts = await dbContext.ServiceAccount + .Where(c => ids.Contains(c.Id)) + .ToListAsync(); + return Mapper.Map>(serviceAccounts); + } + public async Task UserHasReadAccessToServiceAccount(Guid id, Guid userId) { using var scope = ServiceScopeFactory.CreateScope(); @@ -71,6 +81,26 @@ public class ServiceAccountRepository : Repository>(serviceAccounts); } + public async Task DeleteManyByIdAsync(IEnumerable ids) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + // Policies can't have a cascade delete, so we need to delete them manually. + var policies = dbContext.AccessPolicies.Where(ap => + ((ServiceAccountProjectAccessPolicy)ap).ServiceAccountId.HasValue && ids.Contains(((ServiceAccountProjectAccessPolicy)ap).ServiceAccountId!.Value) || + ((GroupServiceAccountAccessPolicy)ap).GrantedServiceAccountId.HasValue && ids.Contains(((GroupServiceAccountAccessPolicy)ap).GrantedServiceAccountId!.Value) || + ((UserServiceAccountAccessPolicy)ap).GrantedServiceAccountId.HasValue && ids.Contains(((UserServiceAccountAccessPolicy)ap).GrantedServiceAccountId!.Value)); + dbContext.RemoveRange(policies); + + var apiKeys = dbContext.ApiKeys.Where(a => a.ServiceAccountId.HasValue && ids.Contains(a.ServiceAccountId!.Value)); + dbContext.RemoveRange(apiKeys); + + var serviceAccounts = dbContext.ServiceAccount.Where(c => ids.Contains(c.Id)); + dbContext.RemoveRange(serviceAccounts); + await dbContext.SaveChangesAsync(); + } + 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/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index ccc851c8a..fd7da0a0c 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -25,6 +25,7 @@ public class ServiceAccountsController : Controller private readonly ICreateAccessTokenCommand _createAccessTokenCommand; private readonly ICreateServiceAccountCommand _createServiceAccountCommand; private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand; + private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand; public ServiceAccountsController( @@ -35,6 +36,7 @@ public class ServiceAccountsController : Controller ICreateAccessTokenCommand createAccessTokenCommand, ICreateServiceAccountCommand createServiceAccountCommand, IUpdateServiceAccountCommand updateServiceAccountCommand, + IDeleteServiceAccountsCommand deleteServiceAccountsCommand, IRevokeAccessTokensCommand revokeAccessTokensCommand) { _currentContext = currentContext; @@ -43,6 +45,7 @@ public class ServiceAccountsController : Controller _apiKeyRepository = apiKeyRepository; _createServiceAccountCommand = createServiceAccountCommand; _updateServiceAccountCommand = updateServiceAccountCommand; + _deleteServiceAccountsCommand = deleteServiceAccountsCommand; _revokeAccessTokensCommand = revokeAccessTokensCommand; _createAccessTokenCommand = createAccessTokenCommand; } @@ -90,6 +93,16 @@ public class ServiceAccountsController : Controller return new ServiceAccountResponseModel(result); } + [HttpPost("delete")] + public async Task> BulkDeleteAsync([FromBody] List ids) + { + var userId = _userService.GetProperUserId(User).Value; + + var results = await _deleteServiceAccountsCommand.DeleteServiceAccounts(ids, userId); + var responses = results.Select(r => new BulkDeleteResponseModel(r.Item1.Id, r.Item2)); + return new ListResponseModel(responses); + } + [HttpGet("{id}/access-tokens")] public async Task> GetAccessTokens([FromRoute] Guid id) { diff --git a/src/Core/SecretsManager/Commands/ServiceAccounts/Interfaces/IDeleteServiceAccountsCommand.cs b/src/Core/SecretsManager/Commands/ServiceAccounts/Interfaces/IDeleteServiceAccountsCommand.cs new file mode 100644 index 000000000..23260b06b --- /dev/null +++ b/src/Core/SecretsManager/Commands/ServiceAccounts/Interfaces/IDeleteServiceAccountsCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; + +public interface IDeleteServiceAccountsCommand +{ + Task>> DeleteServiceAccounts(List ids, Guid userId); +} + diff --git a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs index 740d597ec..194df493f 100644 --- a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs @@ -7,8 +7,10 @@ public interface IServiceAccountRepository { Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task GetByIdAsync(Guid id); + Task> GetManyByIds(IEnumerable ids); Task CreateAsync(ServiceAccount serviceAccount); Task ReplaceAsync(ServiceAccount serviceAccount); + Task DeleteManyByIdAsync(IEnumerable ids); Task UserHasReadAccessToServiceAccount(Guid id, Guid userId); Task UserHasWriteAccessToServiceAccount(Guid id, Guid userId); Task> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType); diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index b2501161b..bf7a83704 100644 --- a/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -275,6 +275,92 @@ public class ServiceAccountsControllerTest : IClassFixture { initialServiceAccount.Id }; + + var response = await _client.PutAsJsonAsync("/service-accounts/delete", request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Delete_MissingAccessPolicy_AccessDenied() + { + var (org, _) = await _organizationHelper.Initialize(true, true); + var (email, _) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await LoginAsync(email); + + var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount + { + OrganizationId = org.Id, + Name = _mockEncryptedString, + }); + + var ids = new List { serviceAccount.Id }; + + var response = await _client.PostAsJsonAsync("/service-accounts/delete", ids); + + var results = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(results); + } + + [Theory] + [InlineData(PermissionType.RunAsAdmin)] + [InlineData(PermissionType.RunAsUserWithPermission)] + public async Task Delete_Success(PermissionType permissionType) + { + var (org, _) = await _organizationHelper.Initialize(true, true); + + var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount + { + OrganizationId = org.Id, + Name = _mockEncryptedString, + }); + + await _apiKeyRepository.CreateAsync(new ApiKey { ServiceAccountId = serviceAccount.Id }); + + if (permissionType == PermissionType.RunAsAdmin) + { + await LoginAsync(_email); + } + else + { + var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await LoginAsync(email); + + await _accessPolicyRepository.CreateManyAsync(new List { + new UserServiceAccountAccessPolicy + { + GrantedServiceAccountId = serviceAccount.Id, + OrganizationUserId = orgUser.Id, + Write = true, + Read = true, + }, + }); + } + + var ids = new List { serviceAccount.Id }; + + var response = await _client.PostAsJsonAsync("/service-accounts/delete", ids); + response.EnsureSuccessStatusCode(); + + var sa = await _serviceAccountRepository.GetManyByIds(ids); + Assert.Empty(sa); + } + [Theory] [InlineData(false, false)] [InlineData(true, false)]