mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[SM-1592] API for Secret Versioning, adding controller, repository and tests (#6444)
* Adding SecretVersion table to server * making the names singular not plural for new table * removing migration * fixing migration * Adding indexes for serviceacct and orguserId * indexes for sqllite * fixing migrations * adding indexes to secretVeriosn.sql * tests * removing tests * adding GO * api repository and controller additions for SecretVersion table, as well as tests * test fix sqllite * improvements * removing comments * making files nullable safe * Justin Baurs suggested changes * claude suggestions * Claude fixes * test fixes
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
|
||||
|
||||
public class SecretVersionRepository : Repository<Core.SecretsManager.Entities.SecretVersion, SecretVersion, Guid>, ISecretVersionRepository
|
||||
{
|
||||
public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, db => db.SecretVersion)
|
||||
{ }
|
||||
|
||||
public override async Task<Core.SecretsManager.Entities.SecretVersion?> GetByIdAsync(Guid id)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var secretVersion = await dbContext.SecretVersion
|
||||
.Where(sv => sv.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
return Mapper.Map<Core.SecretsManager.Entities.SecretVersion>(secretVersion);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyBySecretIdAsync(Guid secretId)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var secretVersions = await dbContext.SecretVersion
|
||||
.Where(sv => sv.SecretId == secretId)
|
||||
.OrderByDescending(sv => sv.VersionDate)
|
||||
.ToListAsync();
|
||||
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var versionIds = ids.ToList();
|
||||
var secretVersions = await dbContext.SecretVersion
|
||||
.Where(sv => versionIds.Contains(sv.Id))
|
||||
.OrderByDescending(sv => sv.VersionDate)
|
||||
.ToListAsync();
|
||||
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
|
||||
}
|
||||
|
||||
public override async Task<Core.SecretsManager.Entities.SecretVersion> CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion)
|
||||
{
|
||||
const int maxVersionsToKeep = 10;
|
||||
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync();
|
||||
|
||||
// Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep
|
||||
var versionsToKeepIds = await dbContext.SecretVersion
|
||||
.Where(sv => sv.SecretId == secretVersion.SecretId)
|
||||
.OrderByDescending(sv => sv.VersionDate)
|
||||
.Take(maxVersionsToKeep - 1)
|
||||
.Select(sv => sv.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// Delete all versions for this secret that are not in the "keep" list
|
||||
if (versionsToKeepIds.Any())
|
||||
{
|
||||
await dbContext.SecretVersion
|
||||
.Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
secretVersion.SetNewId();
|
||||
var entity = Mapper.Map<SecretVersion>(secretVersion);
|
||||
|
||||
await dbContext.AddAsync(entity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return secretVersion;
|
||||
}
|
||||
|
||||
public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var secretVersionIds = ids.ToList();
|
||||
await dbContext.SecretVersion
|
||||
.Where(sv => secretVersionIds.Contains(sv.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions
|
||||
{
|
||||
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
|
||||
services.AddSingleton<ISecretRepository, SecretRepository>();
|
||||
services.AddSingleton<ISecretVersionRepository, SecretVersionRepository>();
|
||||
services.AddSingleton<IProjectRepository, ProjectRepository>();
|
||||
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.SecretsManager.Repositories;
|
||||
|
||||
public class SecretVersionRepositoryTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_EntityCreation_Success(SecretVersion secretVersion)
|
||||
{
|
||||
// Arrange & Act
|
||||
secretVersion.SetNewId();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, secretVersion.Id);
|
||||
Assert.NotEqual(Guid.Empty, secretVersion.SecretId);
|
||||
Assert.NotNull(secretVersion.Value);
|
||||
Assert.NotEqual(default, secretVersion.VersionDate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_WithServiceAccountEditor_Success(SecretVersion secretVersion, Guid serviceAccountId)
|
||||
{
|
||||
// Arrange & Act
|
||||
secretVersion.EditorServiceAccountId = serviceAccountId;
|
||||
secretVersion.EditorOrganizationUserId = null;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(serviceAccountId, secretVersion.EditorServiceAccountId);
|
||||
Assert.Null(secretVersion.EditorOrganizationUserId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_WithOrganizationUserEditor_Success(SecretVersion secretVersion, Guid organizationUserId)
|
||||
{
|
||||
// Arrange & Act
|
||||
secretVersion.EditorOrganizationUserId = organizationUserId;
|
||||
secretVersion.EditorServiceAccountId = null;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(organizationUserId, secretVersion.EditorOrganizationUserId);
|
||||
Assert.Null(secretVersion.EditorServiceAccountId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_NullableEditors_Success(SecretVersion secretVersion)
|
||||
{
|
||||
// Arrange & Act
|
||||
secretVersion.EditorServiceAccountId = null;
|
||||
secretVersion.EditorOrganizationUserId = null;
|
||||
|
||||
// Assert
|
||||
Assert.Null(secretVersion.EditorServiceAccountId);
|
||||
Assert.Null(secretVersion.EditorOrganizationUserId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_VersionDateSet_Success(SecretVersion secretVersion)
|
||||
{
|
||||
// Arrange
|
||||
var versionDate = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
secretVersion.VersionDate = versionDate;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(versionDate, secretVersion.VersionDate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_ValueEncrypted_Success(SecretVersion secretVersion, string encryptedValue)
|
||||
{
|
||||
// Arrange & Act
|
||||
secretVersion.Value = encryptedValue;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(encryptedValue, secretVersion.Value);
|
||||
Assert.NotEmpty(secretVersion.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_MultipleVersions_DifferentIds(List<SecretVersion> secretVersions, Guid secretId)
|
||||
{
|
||||
// Arrange & Act
|
||||
foreach (var version in secretVersions)
|
||||
{
|
||||
version.SecretId = secretId;
|
||||
version.SetNewId();
|
||||
}
|
||||
|
||||
// Assert
|
||||
var distinctIds = secretVersions.Select(v => v.Id).Distinct();
|
||||
Assert.Equal(secretVersions.Count, distinctIds.Count());
|
||||
Assert.All(secretVersions, v => Assert.Equal(secretId, v.SecretId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void SecretVersion_VersionDateOrdering_Success(SecretVersion version1, SecretVersion version2, SecretVersion version3, Guid secretId)
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTime.UtcNow;
|
||||
version1.SecretId = secretId;
|
||||
version1.VersionDate = now.AddDays(-2);
|
||||
|
||||
version2.SecretId = secretId;
|
||||
version2.VersionDate = now.AddDays(-1);
|
||||
|
||||
version3.SecretId = secretId;
|
||||
version3.VersionDate = now;
|
||||
|
||||
var versions = new List<SecretVersion> { version2, version3, version1 };
|
||||
|
||||
// Act
|
||||
var orderedVersions = versions.OrderByDescending(v => v.VersionDate).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(version3.Id, orderedVersions[0].Id); // Most recent
|
||||
Assert.Equal(version2.Id, orderedVersions[1].Id);
|
||||
Assert.Equal(version1.Id, orderedVersions[2].Id); // Oldest
|
||||
}
|
||||
}
|
||||
337
src/Api/SecretsManager/Controllers/SecretVersionsController.cs
Normal file
337
src/Api/SecretsManager/Controllers/SecretVersionsController.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.SecretsManager.Models.Request;
|
||||
using Bit.Api.SecretsManager.Models.Response;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Controllers;
|
||||
|
||||
[Authorize("secrets")]
|
||||
public class SecretVersionsController : Controller
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ISecretVersionRepository _secretVersionRepository;
|
||||
private readonly ISecretRepository _secretRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
public SecretVersionsController(
|
||||
ICurrentContext currentContext,
|
||||
ISecretVersionRepository secretVersionRepository,
|
||||
ISecretRepository secretRepository,
|
||||
IUserService userService,
|
||||
IOrganizationUserRepository organizationUserRepository)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_secretVersionRepository = secretVersionRepository;
|
||||
_secretRepository = secretRepository;
|
||||
_userService = userService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
}
|
||||
|
||||
[HttpGet("secrets/{secretId}/versions")]
|
||||
public async Task<ListResponseModel<SecretVersionResponseModel>> GetVersionsBySecretIdAsync([FromRoute] Guid secretId)
|
||||
{
|
||||
var secret = await _secretRepository.GetByIdAsync(secretId);
|
||||
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// For service accounts and organization API, skip user-level access checks
|
||||
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
|
||||
_currentContext.IdentityClientType == IdentityClientType.Organization)
|
||||
{
|
||||
// Already verified Secrets Manager access above
|
||||
var versionList = await _secretVersionRepository.GetManyBySecretIdAsync(secretId);
|
||||
var responseList = versionList.Select(v => new SecretVersionResponseModel(v));
|
||||
return new ListResponseModel<SecretVersionResponseModel>(responseList);
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
|
||||
|
||||
var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient);
|
||||
if (!access.Read)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secretId);
|
||||
var responses = versions.Select(v => new SecretVersionResponseModel(v));
|
||||
|
||||
return new ListResponseModel<SecretVersionResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpGet("secret-versions/{id}")]
|
||||
public async Task<SecretVersionResponseModel> GetByIdAsync([FromRoute] Guid id)
|
||||
{
|
||||
var secretVersion = await _secretVersionRepository.GetByIdAsync(id);
|
||||
if (secretVersion == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var secret = await _secretRepository.GetByIdAsync(secretVersion.SecretId);
|
||||
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// For service accounts and organization API, skip user-level access checks
|
||||
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
|
||||
_currentContext.IdentityClientType == IdentityClientType.Organization)
|
||||
{
|
||||
// Already verified Secrets Manager access above
|
||||
return new SecretVersionResponseModel(secretVersion);
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
|
||||
|
||||
var access = await _secretRepository.AccessToSecretAsync(secretVersion.SecretId, userId.Value, accessClient);
|
||||
if (!access.Read)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new SecretVersionResponseModel(secretVersion);
|
||||
}
|
||||
|
||||
[HttpPost("secret-versions/get-by-ids")]
|
||||
public async Task<ListResponseModel<SecretVersionResponseModel>> GetManyByIdsAsync([FromBody] List<Guid> ids)
|
||||
{
|
||||
if (!ids.Any())
|
||||
{
|
||||
throw new BadRequestException("No version IDs provided.");
|
||||
}
|
||||
|
||||
// Get all versions
|
||||
var versions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList();
|
||||
if (!versions.Any())
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Get all associated secrets and check permissions
|
||||
var secretIds = versions.Select(v => v.SecretId).Distinct().ToList();
|
||||
var secrets = (await _secretRepository.GetManyByIds(secretIds)).ToList();
|
||||
|
||||
if (!secrets.Any())
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Ensure all secrets belong to the same organization
|
||||
var organizationId = secrets.First().OrganizationId;
|
||||
if (secrets.Any(s => s.OrganizationId != organizationId) ||
|
||||
!_currentContext.AccessSecretsManager(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// For service accounts and organization API, skip user-level access checks
|
||||
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
|
||||
_currentContext.IdentityClientType == IdentityClientType.Organization)
|
||||
{
|
||||
// Already verified Secrets Manager access and organization ownership above
|
||||
var serviceAccountResponses = versions.Select(v => new SecretVersionResponseModel(v));
|
||||
return new ListResponseModel<SecretVersionResponseModel>(serviceAccountResponses);
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var isAdmin = await _currentContext.OrganizationAdmin(organizationId);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin);
|
||||
|
||||
// Verify read access to all associated secrets
|
||||
var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient);
|
||||
if (accessResults.Values.Any(access => !access.Read))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var responses = versions.Select(v => new SecretVersionResponseModel(v));
|
||||
return new ListResponseModel<SecretVersionResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpPut("secrets/{secretId}/versions/restore")]
|
||||
public async Task<SecretResponseModel> RestoreVersionAsync([FromRoute] Guid secretId, [FromBody] RestoreSecretVersionRequestModel request)
|
||||
{
|
||||
if (!(_currentContext.IdentityClientType == IdentityClientType.User || _currentContext.IdentityClientType == IdentityClientType.ServiceAccount))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var secret = await _secretRepository.GetByIdAsync(secretId);
|
||||
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Get the version first to validate it belongs to this secret
|
||||
var version = await _secretVersionRepository.GetByIdAsync(request.VersionId);
|
||||
if (version == null || version.SecretId != secretId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Store the current value before restoration
|
||||
var currentValue = secret.Value;
|
||||
|
||||
// For service accounts and organization API, skip user-level access checks
|
||||
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount)
|
||||
{
|
||||
// Save current value as a version before restoring
|
||||
if (currentValue != version.Value)
|
||||
{
|
||||
var editorUserId = _userService.GetProperUserId(User);
|
||||
if (editorUserId.HasValue)
|
||||
{
|
||||
var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion
|
||||
{
|
||||
SecretId = secretId,
|
||||
Value = currentValue!,
|
||||
VersionDate = DateTime.UtcNow,
|
||||
EditorServiceAccountId = editorUserId.Value
|
||||
};
|
||||
|
||||
await _secretVersionRepository.CreateAsync(currentVersionSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
// Already verified Secrets Manager access above
|
||||
secret.Value = version.Value;
|
||||
secret.RevisionDate = DateTime.UtcNow;
|
||||
var updatedSec = await _secretRepository.UpdateAsync(secret);
|
||||
return new SecretResponseModel(updatedSec, true, true);
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
|
||||
|
||||
var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient);
|
||||
if (!access.Write)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Save current value as a version before restoring
|
||||
if (currentValue != version.Value)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId.Value);
|
||||
if (orgUser == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion
|
||||
{
|
||||
SecretId = secretId,
|
||||
Value = currentValue!,
|
||||
VersionDate = DateTime.UtcNow,
|
||||
EditorOrganizationUserId = orgUser.Id
|
||||
};
|
||||
|
||||
await _secretVersionRepository.CreateAsync(currentVersionSnapshot);
|
||||
}
|
||||
|
||||
// Update the secret with the version's value
|
||||
secret.Value = version.Value;
|
||||
secret.RevisionDate = DateTime.UtcNow;
|
||||
|
||||
var updatedSecret = await _secretRepository.UpdateAsync(secret);
|
||||
|
||||
return new SecretResponseModel(updatedSecret, true, true);
|
||||
}
|
||||
|
||||
[HttpPost("secret-versions/delete")]
|
||||
public async Task<IActionResult> BulkDeleteAsync([FromBody] List<Guid> ids)
|
||||
{
|
||||
if (!ids.Any())
|
||||
{
|
||||
throw new BadRequestException("No version IDs provided.");
|
||||
}
|
||||
|
||||
var secretVersions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList();
|
||||
if (secretVersions.Count != ids.Count)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Ensure all versions belong to secrets in the same organization
|
||||
var secretIds = secretVersions.Select(v => v.SecretId).Distinct().ToList();
|
||||
var secrets = await _secretRepository.GetManyByIds(secretIds);
|
||||
var secretsList = secrets.ToList();
|
||||
|
||||
if (!secretsList.Any())
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organizationId = secretsList.First().OrganizationId;
|
||||
if (secretsList.Any(s => s.OrganizationId != organizationId) ||
|
||||
!_currentContext.AccessSecretsManager(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// For service accounts and organization API, skip user-level access checks
|
||||
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
|
||||
_currentContext.IdentityClientType == IdentityClientType.Organization)
|
||||
{
|
||||
// Already verified Secrets Manager access and organization ownership above
|
||||
await _secretVersionRepository.DeleteManyByIdAsync(ids);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
|
||||
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
|
||||
|
||||
// Verify write access to all associated secrets
|
||||
var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient);
|
||||
if (accessResults.Values.Any(access => !access.Write))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _secretVersionRepository.DeleteManyByIdAsync(ids);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.AuthorizationRequirements;
|
||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
@@ -29,6 +30,7 @@ public class SecretsController : Controller
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ISecretRepository _secretRepository;
|
||||
private readonly ISecretVersionRepository _secretVersionRepository;
|
||||
private readonly ICreateSecretCommand _createSecretCommand;
|
||||
private readonly IUpdateSecretCommand _updateSecretCommand;
|
||||
private readonly IDeleteSecretCommand _deleteSecretCommand;
|
||||
@@ -38,11 +40,13 @@ public class SecretsController : Controller
|
||||
private readonly IUserService _userService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
public SecretsController(
|
||||
ICurrentContext currentContext,
|
||||
IProjectRepository projectRepository,
|
||||
ISecretRepository secretRepository,
|
||||
ISecretVersionRepository secretVersionRepository,
|
||||
ICreateSecretCommand createSecretCommand,
|
||||
IUpdateSecretCommand updateSecretCommand,
|
||||
IDeleteSecretCommand deleteSecretCommand,
|
||||
@@ -51,11 +55,13 @@ public class SecretsController : Controller
|
||||
ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery,
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IAuthorizationService authorizationService)
|
||||
IAuthorizationService authorizationService,
|
||||
IOrganizationUserRepository organizationUserRepository)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_projectRepository = projectRepository;
|
||||
_secretRepository = secretRepository;
|
||||
_secretVersionRepository = secretVersionRepository;
|
||||
_createSecretCommand = createSecretCommand;
|
||||
_updateSecretCommand = updateSecretCommand;
|
||||
_deleteSecretCommand = deleteSecretCommand;
|
||||
@@ -65,6 +71,7 @@ public class SecretsController : Controller
|
||||
_userService = userService;
|
||||
_eventService = eventService;
|
||||
_authorizationService = authorizationService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
|
||||
}
|
||||
|
||||
@@ -190,6 +197,44 @@ public class SecretsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Create a version record if the value changed
|
||||
if (updateRequest.ValueChanged)
|
||||
{
|
||||
// Store the old value before updating
|
||||
var oldValue = secret.Value;
|
||||
var userId = _userService.GetProperUserId(User)!.Value;
|
||||
Guid? editorServiceAccountId = null;
|
||||
Guid? editorOrganizationUserId = null;
|
||||
|
||||
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount)
|
||||
{
|
||||
editorServiceAccountId = userId;
|
||||
}
|
||||
else if (_currentContext.IdentityClientType == IdentityClientType.User)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId);
|
||||
if (orgUser != null)
|
||||
{
|
||||
editorOrganizationUserId = orgUser.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
var secretVersion = new SecretVersion
|
||||
{
|
||||
SecretId = id,
|
||||
Value = oldValue,
|
||||
VersionDate = DateTime.UtcNow,
|
||||
EditorServiceAccountId = editorServiceAccountId,
|
||||
EditorOrganizationUserId = editorOrganizationUserId
|
||||
};
|
||||
|
||||
await _secretVersionRepository.CreateAsync(secretVersion);
|
||||
}
|
||||
|
||||
var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates);
|
||||
await LogSecretEventAsync(secret, EventType.Secret_Edited);
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Request;
|
||||
|
||||
public class RestoreSecretVersionRequestModel
|
||||
{
|
||||
[Required]
|
||||
public Guid VersionId { get; set; }
|
||||
}
|
||||
@@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject
|
||||
|
||||
public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; }
|
||||
|
||||
public bool ValueChanged { get; set; } = false;
|
||||
|
||||
public Secret ToSecret(Secret secret)
|
||||
{
|
||||
secret.Key = Key;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Response;
|
||||
|
||||
public class SecretVersionResponseModel : ResponseModel
|
||||
{
|
||||
private const string _objectName = "secretVersion";
|
||||
|
||||
public Guid Id { get; set; }
|
||||
public Guid SecretId { get; set; }
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public DateTime VersionDate { get; set; }
|
||||
public Guid? EditorServiceAccountId { get; set; }
|
||||
public Guid? EditorOrganizationUserId { get; set; }
|
||||
|
||||
public SecretVersionResponseModel() : base(_objectName) { }
|
||||
|
||||
public SecretVersionResponseModel(SecretVersion secretVersion) : base(_objectName)
|
||||
{
|
||||
Id = secretVersion.Id;
|
||||
SecretId = secretVersion.SecretId;
|
||||
Value = secretVersion.Value;
|
||||
VersionDate = secretVersion.VersionDate;
|
||||
EditorServiceAccountId = secretVersion.EditorServiceAccountId;
|
||||
EditorOrganizationUserId = secretVersion.EditorOrganizationUserId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
|
||||
namespace Bit.Core.SecretsManager.Repositories;
|
||||
|
||||
public interface ISecretVersionRepository
|
||||
{
|
||||
Task<SecretVersion?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<SecretVersion>> GetManyBySecretIdAsync(Guid secretId);
|
||||
Task<IEnumerable<SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids);
|
||||
Task<SecretVersion> CreateAsync(SecretVersion secretVersion);
|
||||
Task DeleteManyByIdAsync(IEnumerable<Guid> ids);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
|
||||
namespace Bit.Core.SecretsManager.Repositories.Noop;
|
||||
|
||||
public class NoopSecretVersionRepository : ISecretVersionRepository
|
||||
{
|
||||
public Task<SecretVersion?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return Task.FromResult(null as SecretVersion);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<SecretVersion>> GetManyBySecretIdAsync(Guid secretId)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<SecretVersion>());
|
||||
}
|
||||
|
||||
public Task<SecretVersion> CreateAsync(SecretVersion secretVersion)
|
||||
{
|
||||
return Task.FromResult(secretVersion);
|
||||
}
|
||||
|
||||
public Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
return Task.FromResult(Enumerable.Empty<SecretVersion>());
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IProviderService, NoopProviderService>();
|
||||
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
|
||||
services.AddScoped<ISecretRepository, NoopSecretRepository>();
|
||||
services.AddScoped<ISecretVersionRepository, NoopSecretVersionRepository>();
|
||||
services.AddScoped<IProjectRepository, NoopProjectRepository>();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Net;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.SecretsManager.Enums;
|
||||
using Bit.Api.IntegrationTest.SecretsManager.Helpers;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.SecretsManager.Models.Request;
|
||||
using Bit.Api.SecretsManager.Models.Response;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.SecretsManager.Controllers;
|
||||
|
||||
public class SecretVersionsControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly string _mockEncryptedString =
|
||||
"2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiApplicationFactory _factory;
|
||||
private readonly ISecretRepository _secretRepository;
|
||||
private readonly ISecretVersionRepository _secretVersionRepository;
|
||||
private readonly IAccessPolicyRepository _accessPolicyRepository;
|
||||
private readonly LoginHelper _loginHelper;
|
||||
|
||||
private string _email = null!;
|
||||
private SecretsManagerOrganizationHelper _organizationHelper = null!;
|
||||
|
||||
public SecretVersionsControllerTests(ApiApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = _factory.CreateClient();
|
||||
_secretRepository = _factory.GetService<ISecretRepository>();
|
||||
_secretVersionRepository = _factory.GetService<ISecretVersionRepository>();
|
||||
_accessPolicyRepository = _factory.GetService<IAccessPolicyRepository>();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(_email);
|
||||
_organizationHelper = new SecretsManagerOrganizationHelper(_factory, _email);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[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 GetVersionsBySecretId_SmAccessDenied_NotFound(bool useSecrets, bool accessSecrets, bool organizationEnabled)
|
||||
{
|
||||
var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled);
|
||||
await _loginHelper.LoginAsync(_email);
|
||||
|
||||
var secret = await _secretRepository.CreateAsync(new Secret
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
Key = _mockEncryptedString,
|
||||
Value = _mockEncryptedString,
|
||||
Note = _mockEncryptedString
|
||||
});
|
||||
|
||||
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PermissionType.RunAsAdmin)]
|
||||
[InlineData(PermissionType.RunAsUserWithPermission)]
|
||||
public async Task GetVersionsBySecretId_Success(PermissionType permissionType)
|
||||
{
|
||||
var (org, _) = await _organizationHelper.Initialize(true, true, true);
|
||||
await _loginHelper.LoginAsync(_email);
|
||||
|
||||
var secret = await _secretRepository.CreateAsync(new Secret
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
Key = _mockEncryptedString,
|
||||
Value = _mockEncryptedString,
|
||||
Note = _mockEncryptedString
|
||||
});
|
||||
|
||||
// Create some versions
|
||||
var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = _mockEncryptedString,
|
||||
VersionDate = DateTime.UtcNow.AddDays(-2)
|
||||
});
|
||||
|
||||
var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = _mockEncryptedString,
|
||||
VersionDate = DateTime.UtcNow.AddDays(-1)
|
||||
});
|
||||
|
||||
if (permissionType == PermissionType.RunAsUserWithPermission)
|
||||
{
|
||||
var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true);
|
||||
await _loginHelper.LoginAsync(email);
|
||||
|
||||
var accessPolicies = new List<BaseAccessPolicy>
|
||||
{
|
||||
new UserSecretAccessPolicy
|
||||
{
|
||||
GrantedSecretId = secret.Id,
|
||||
OrganizationUserId = orgUser.Id,
|
||||
Read = true,
|
||||
Write = true
|
||||
}
|
||||
};
|
||||
await _accessPolicyRepository.CreateManyAsync(accessPolicies);
|
||||
}
|
||||
|
||||
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<SecretVersionResponseModel>>();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Data.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVersionById_Success()
|
||||
{
|
||||
var (org, _) = await _organizationHelper.Initialize(true, true, true);
|
||||
await _loginHelper.LoginAsync(_email);
|
||||
|
||||
var secret = await _secretRepository.CreateAsync(new Secret
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
Key = _mockEncryptedString,
|
||||
Value = _mockEncryptedString,
|
||||
Note = _mockEncryptedString
|
||||
});
|
||||
|
||||
var version = await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = _mockEncryptedString,
|
||||
VersionDate = DateTime.UtcNow
|
||||
});
|
||||
|
||||
var response = await _client.GetAsync($"/secret-versions/{version.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SecretVersionResponseModel>();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(version.Id, result.Id);
|
||||
Assert.Equal(secret.Id, result.SecretId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreVersion_Success()
|
||||
{
|
||||
var (org, _) = await _organizationHelper.Initialize(true, true, true);
|
||||
await _loginHelper.LoginAsync(_email);
|
||||
|
||||
var secret = await _secretRepository.CreateAsync(new Secret
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
Key = _mockEncryptedString,
|
||||
Value = "OriginalValue",
|
||||
Note = _mockEncryptedString
|
||||
});
|
||||
|
||||
var version = await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = "OldValue",
|
||||
VersionDate = DateTime.UtcNow.AddDays(-1)
|
||||
});
|
||||
|
||||
var request = new RestoreSecretVersionRequestModel
|
||||
{
|
||||
VersionId = version.Id
|
||||
};
|
||||
|
||||
var response = await _client.PutAsJsonAsync($"/secrets/{secret.Id}/versions/restore", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SecretResponseModel>();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("OldValue", result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkDelete_Success()
|
||||
{
|
||||
var (org, _) = await _organizationHelper.Initialize(true, true, true);
|
||||
await _loginHelper.LoginAsync(_email);
|
||||
|
||||
var secret = await _secretRepository.CreateAsync(new Secret
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
Key = _mockEncryptedString,
|
||||
Value = _mockEncryptedString,
|
||||
Note = _mockEncryptedString
|
||||
});
|
||||
|
||||
var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = _mockEncryptedString,
|
||||
VersionDate = DateTime.UtcNow.AddDays(-2)
|
||||
});
|
||||
|
||||
var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = _mockEncryptedString,
|
||||
VersionDate = DateTime.UtcNow.AddDays(-1)
|
||||
});
|
||||
|
||||
var ids = new List<Guid> { version1.Id, version2.Id };
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/secret-versions/delete", ids);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secret.Id);
|
||||
Assert.Empty(versions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVersionsBySecretId_ReturnsOrderedByVersionDate()
|
||||
{
|
||||
var (org, _) = await _organizationHelper.Initialize(true, true, true);
|
||||
await _loginHelper.LoginAsync(_email);
|
||||
|
||||
var secret = await _secretRepository.CreateAsync(new Secret
|
||||
{
|
||||
OrganizationId = org.Id,
|
||||
Key = _mockEncryptedString,
|
||||
Value = _mockEncryptedString,
|
||||
Note = _mockEncryptedString
|
||||
});
|
||||
|
||||
// Create versions in random order
|
||||
await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = "Version2",
|
||||
VersionDate = DateTime.UtcNow.AddDays(-1)
|
||||
});
|
||||
|
||||
await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = "Version3",
|
||||
VersionDate = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _secretVersionRepository.CreateAsync(new SecretVersion
|
||||
{
|
||||
SecretId = secret.Id,
|
||||
Value = "Version1",
|
||||
VersionDate = DateTime.UtcNow.AddDays(-2)
|
||||
});
|
||||
|
||||
var response = await _client.GetAsync($"/secrets/{secret.Id}/versions");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<SecretVersionResponseModel>>();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Data.Count());
|
||||
|
||||
var versions = result.Data.ToList();
|
||||
// Should be ordered by VersionDate descending (newest first)
|
||||
Assert.Equal("Version3", versions[0].Value);
|
||||
Assert.Equal("Version2", versions[1].Value);
|
||||
Assert.Equal("Version1", versions[2].Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Bit.Api.SecretsManager.Controllers;
|
||||
using Bit.Api.SecretsManager.Models.Request;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.SecretsManager.Controllers;
|
||||
|
||||
[ControllerCustomize(typeof(SecretVersionsController))]
|
||||
[SutProviderCustomize]
|
||||
[SecretCustomize]
|
||||
public class SecretVersionsControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetVersionsBySecretId_SecretNotFound_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Guid secretId)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secretId).Returns((Secret?)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.GetVersionsBySecretIdAsync(secretId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetVersionsBySecretId_NoAccess_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(false);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetVersionsBySecretId_NoReadAccess_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret,
|
||||
Guid userId)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((false, false));
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetVersionsBySecretId_Success(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret,
|
||||
List<SecretVersion> versions,
|
||||
Guid userId)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, false));
|
||||
|
||||
foreach (var version in versions)
|
||||
{
|
||||
version.SecretId = secret.Id;
|
||||
}
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetManyBySecretIdAsync(secret.Id).Returns(versions);
|
||||
|
||||
var result = await sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id);
|
||||
|
||||
Assert.Equal(versions.Count, result.Data.Count());
|
||||
await sutProvider.GetDependency<ISecretVersionRepository>().Received(1)
|
||||
.GetManyBySecretIdAsync(Arg.Is(secret.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetById_VersionNotFound_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Guid versionId)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(versionId).Returns((SecretVersion?)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.GetByIdAsync(versionId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetById_Success(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
SecretVersion version,
|
||||
Secret secret,
|
||||
Guid userId)
|
||||
{
|
||||
version.SecretId = secret.Id;
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(version.Id).Returns(version);
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, false));
|
||||
|
||||
var result = await sutProvider.Sut.GetByIdAsync(version.Id);
|
||||
|
||||
Assert.Equal(version.Id, result.Id);
|
||||
Assert.Equal(version.SecretId, result.SecretId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreVersion_NoWriteAccess_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret,
|
||||
SecretVersion version,
|
||||
RestoreSecretVersionRequestModel request,
|
||||
Guid userId)
|
||||
{
|
||||
version.SecretId = secret.Id;
|
||||
request.VersionId = version.Id;
|
||||
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, false));
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreVersion_VersionNotFound_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret,
|
||||
RestoreSecretVersionRequestModel request,
|
||||
Guid userId)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, true));
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns((SecretVersion?)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreVersion_VersionBelongsToDifferentSecret_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret,
|
||||
SecretVersion version,
|
||||
RestoreSecretVersionRequestModel request,
|
||||
Guid userId)
|
||||
{
|
||||
version.SecretId = Guid.NewGuid(); // Different secret
|
||||
request.VersionId = version.Id;
|
||||
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, true));
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns(version);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.RestoreVersionAsync(secret.Id, request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreVersion_Success(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
Secret secret,
|
||||
SecretVersion version,
|
||||
RestoreSecretVersionRequestModel request,
|
||||
Guid userId,
|
||||
OrganizationUser organizationUser)
|
||||
{
|
||||
version.SecretId = secret.Id;
|
||||
request.VersionId = version.Id;
|
||||
var versionValue = version.Value;
|
||||
organizationUser.OrganizationId = secret.OrganizationId;
|
||||
organizationUser.UserId = userId;
|
||||
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(secret.Id).Returns(secret);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, true));
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(request.VersionId).Returns(version);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(secret.OrganizationId, userId).Returns(organizationUser);
|
||||
sutProvider.GetDependency<ISecretRepository>().UpdateAsync(Arg.Any<Secret>()).Returns(x => x.Arg<Secret>());
|
||||
|
||||
var result = await sutProvider.Sut.RestoreVersionAsync(secret.Id, request);
|
||||
|
||||
await sutProvider.GetDependency<ISecretRepository>().Received(1)
|
||||
.UpdateAsync(Arg.Is<Secret>(s => s.Value == versionValue));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkDelete_EmptyIds_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider)
|
||||
{
|
||||
await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.BulkDeleteAsync(new List<Guid>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkDelete_VersionNotFound_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
List<Guid> ids)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(ids[0]).Returns((SecretVersion?)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.BulkDeleteAsync(ids));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkDelete_NoWriteAccess_Throws(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
List<SecretVersion> versions,
|
||||
Secret secret,
|
||||
Guid userId)
|
||||
{
|
||||
var ids = versions.Select(v => v.Id).ToList();
|
||||
foreach (var version in versions)
|
||||
{
|
||||
version.SecretId = secret.Id;
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetByIdAsync(version.Id).Returns(version);
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<ISecretRepository>().GetManyByIds(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(new List<Secret> { secret });
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(false);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, false));
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.BulkDeleteAsync(ids));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task BulkDelete_Success(
|
||||
SutProvider<SecretVersionsController> sutProvider,
|
||||
List<SecretVersion> versions,
|
||||
Secret secret,
|
||||
Guid userId)
|
||||
{
|
||||
var ids = versions.Select(v => v.Id).ToList();
|
||||
foreach (var version in versions)
|
||||
{
|
||||
version.SecretId = secret.Id;
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<ISecretVersionRepository>().GetManyByIdsAsync(ids).Returns(versions);
|
||||
sutProvider.GetDependency<ISecretRepository>().GetManyByIds(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns(new List<Secret> { secret });
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
|
||||
sutProvider.GetDependency<IUserService>().GetProperUserId(default).ReturnsForAnyArgs(userId);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(secret.OrganizationId).Returns(true);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, userId, default)
|
||||
.ReturnsForAnyArgs((true, true));
|
||||
|
||||
await sutProvider.Sut.BulkDeleteAsync(ids);
|
||||
|
||||
await sutProvider.GetDependency<ISecretVersionRepository>().Received(1)
|
||||
.DeleteManyByIdAsync(Arg.Is<IEnumerable<Guid>>(x => x.SequenceEqual(ids)));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using Bit.Api.SecretsManager.Controllers;
|
||||
using Bit.Api.SecretsManager.Models.Request;
|
||||
using Bit.Api.Test.SecretsManager.Enums;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -244,6 +245,7 @@ public class SecretsControllerTests
|
||||
{
|
||||
data = SetupSecretUpdateRequest(data);
|
||||
SetControllerUser(sutProvider, new Guid());
|
||||
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
|
||||
sutProvider.GetDependency<IAuthorizationService>()
|
||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<Secret>(),
|
||||
Arg.Any<IEnumerable<IAuthorizationRequirement>>()).ReturnsForAnyArgs(AuthorizationResult.Success());
|
||||
@@ -602,6 +604,7 @@ public class SecretsControllerTests
|
||||
{
|
||||
data = SetupSecretUpdateRequest(data, true);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().IdentityClientType.Returns(IdentityClientType.ServiceAccount);
|
||||
sutProvider.GetDependency<IAuthorizationService>()
|
||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<Secret>(),
|
||||
Arg.Any<IEnumerable<IAuthorizationRequirement>>()).Returns(AuthorizationResult.Success());
|
||||
|
||||
Reference in New Issue
Block a user