diff --git a/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/SecretManagerCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/SecretManagerCollectionExtensions.cs new file mode 100644 index 000000000000..ee0c9e06d969 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/SecretManagerCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Bit.Commercial.Core.SecretManagerFeatures.Secrets; +using Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Commercial.Core.SecretManagerFeatures +{ + public static class SecretManagerCollectionExtensions + { + public static void AddSecretManagerServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } +} + diff --git a/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/CreateSecretCommand.cs b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/CreateSecretCommand.cs new file mode 100644 index 000000000000..7bf271061c83 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/CreateSecretCommand.cs @@ -0,0 +1,22 @@ +using Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Repositories; + +namespace Bit.Commercial.Core.SecretManagerFeatures.Secrets +{ + public class CreateSecretCommand : ICreateSecretCommand + { + private readonly ISecretRepository _secretRepository; + + public CreateSecretCommand(ISecretRepository secretRepository) + { + _secretRepository = secretRepository; + } + + public async Task CreateAsync(Secret secret) + { + return await _secretRepository.CreateAsync(secret); + } + } +} + diff --git a/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/Interfaces/ICreateSecretCommand.cs b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/Interfaces/ICreateSecretCommand.cs new file mode 100644 index 000000000000..6b6e9c2c09fe --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/Interfaces/ICreateSecretCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Entities; + +namespace Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces +{ + public interface ICreateSecretCommand + { + Task CreateAsync(Secret secret); + } +} + diff --git a/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/Interfaces/IUpdateSecretCommand.cs b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/Interfaces/IUpdateSecretCommand.cs new file mode 100644 index 000000000000..536bcba684d6 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/Interfaces/IUpdateSecretCommand.cs @@ -0,0 +1,10 @@ +using Bit.Core.Entities; + +namespace Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces +{ + public interface IUpdateSecretCommand + { + Task UpdateAsync(Secret secret); + } +} + diff --git a/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/UpdateSecretCommand.cs b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/UpdateSecretCommand.cs new file mode 100644 index 000000000000..4a42776dd772 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/SecretManagerFeatures/Secrets/UpdateSecretCommand.cs @@ -0,0 +1,40 @@ +using Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Commercial.Core.SecretManagerFeatures.Secrets +{ + public class UpdateSecretCommand : IUpdateSecretCommand + { + private readonly ISecretRepository _secretRepository; + + public UpdateSecretCommand(ISecretRepository secretRepository) + { + _secretRepository = secretRepository; + } + + public async Task UpdateAsync(Secret secret) + { + if (secret.Id == default(Guid)) + { + throw new BadRequestException("Cannot update secret, secret does not exist."); + } + + var existingSecret = await _secretRepository.GetByIdAsync(secret.Id); + if (existingSecret == null) + { + throw new NotFoundException(); + } + + secret.OrganizationId = existingSecret.OrganizationId; + secret.CreationDate = existingSecret.CreationDate; + secret.DeletedDate = existingSecret.DeletedDate; + secret.RevisionDate = DateTime.UtcNow; + + await _secretRepository.ReplaceAsync(secret); + return secret; + } + } +} + diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 4074fe5f748b..3ed3bfce8107 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Commercial.Core.Services; +using Bit.Commercial.Core.SecretManagerFeatures; +using Bit.Commercial.Core.Services; using Bit.Core.Services; using Microsoft.Extensions.DependencyInjection; @@ -9,6 +10,7 @@ public static class ServiceCollectionExtensions public static void AddCommCoreServices(this IServiceCollection services) { services.AddScoped(); + services.AddSecretManagerServices(); } } } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretManagerFeatures/Secrets/CreateSecretCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretManagerFeatures/Secrets/CreateSecretCommandTests.cs new file mode 100644 index 000000000000..ec47ad398293 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretManagerFeatures/Secrets/CreateSecretCommandTests.cs @@ -0,0 +1,27 @@ +using Bit.Commercial.Core.SecretManagerFeatures.Secrets; +using Bit.Core.Entities; +using Bit.Core.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.SecretManagerFeatures.Secrets +{ + [SutProviderCustomize] + public class CreateSecretCommandTests + { + [Theory] + [BitAutoData] + public async Task CreateAsync_CallsCreate(Secret data, + SutProvider sutProvider) + { + await sutProvider.Sut.CreateAsync(data); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(AssertHelper.AssertPropertyEqual(data))); + } + } +} + diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretManagerFeatures/Secrets/UpdateSecretCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretManagerFeatures/Secrets/UpdateSecretCommandTests.cs new file mode 100644 index 000000000000..da5e9cc8d48c --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretManagerFeatures/Secrets/UpdateSecretCommandTests.cs @@ -0,0 +1,130 @@ +using Bit.Commercial.Core.SecretManagerFeatures.Secrets; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.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.SecretManagerFeatures.Secrets +{ + [SutProviderCustomize] + public class UpdateSecretCommandTests + { + [Theory] + [BitAutoData] + public async Task UpdateAsync_DefaultGuidId_ThrowsNotFound(Secret data, SutProvider sutProvider) + { + data.Id = new Guid(); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(data)); + + Assert.Contains("Cannot update secret, secret does not exist.", exception.Message); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_SecretDoesNotExist_ThrowsNotFound(Secret data, SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateAsync(data)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_CallsReplaceAsync(Secret data, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(data.Id).Returns(data); + await sutProvider.Sut.UpdateAsync(data); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(AssertHelper.AssertPropertyEqual(data))); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_DoesNotModifyOrganizationId(Secret existingSecret, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(existingSecret.Id).Returns(existingSecret); + + var updatedOrgId = Guid.NewGuid(); + var secretUpdate = new Secret() + { + OrganizationId = updatedOrgId, + Id = existingSecret.Id, + Key = existingSecret.Key, + }; + + var result = await sutProvider.Sut.UpdateAsync(secretUpdate); + + Assert.Equal(existingSecret.OrganizationId, result.OrganizationId); + Assert.NotEqual(existingSecret.OrganizationId, updatedOrgId); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_DoesNotModifyCreationDate(Secret existingSecret, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(existingSecret.Id).Returns(existingSecret); + + var updatedCreationDate = DateTime.UtcNow; + var secretUpdate = new Secret() + { + CreationDate = updatedCreationDate, + Id = existingSecret.Id, + Key = existingSecret.Key, + }; + + var result = await sutProvider.Sut.UpdateAsync(secretUpdate); + + Assert.Equal(existingSecret.CreationDate, result.CreationDate); + Assert.NotEqual(existingSecret.CreationDate, updatedCreationDate); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_DoesNotModifyDeletionDate(Secret existingSecret, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(existingSecret.Id).Returns(existingSecret); + + var updatedDeletionDate = DateTime.UtcNow; + var secretUpdate = new Secret() + { + DeletedDate = updatedDeletionDate, + Id = existingSecret.Id, + Key = existingSecret.Key, + }; + + var result = await sutProvider.Sut.UpdateAsync(secretUpdate); + + Assert.Equal(existingSecret.DeletedDate, result.DeletedDate); + Assert.NotEqual(existingSecret.DeletedDate, updatedDeletionDate); + } + + + [Theory] + [BitAutoData] + public async Task UpdateAsync_RevisionDateIsUpdatedToUtcNow(Secret existingSecret, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(existingSecret.Id).Returns(existingSecret); + + var updatedRevisionDate = DateTime.UtcNow.AddDays(10); + var secretUpdate = new Secret() + { + RevisionDate = updatedRevisionDate, + Id = existingSecret.Id, + Key = existingSecret.Key, + }; + + var result = await sutProvider.Sut.UpdateAsync(secretUpdate); + + Assert.NotEqual(existingSecret.RevisionDate, result.RevisionDate); + AssertHelper.AssertRecent(result.RevisionDate); + } + } +} + diff --git a/src/Api/Controllers/SecretsController.cs b/src/Api/Controllers/SecretsController.cs index 0063f97859ba..55dd8195add5 100644 --- a/src/Api/Controllers/SecretsController.cs +++ b/src/Api/Controllers/SecretsController.cs @@ -1,6 +1,9 @@ using Bit.Api.Models.Response; +using Bit.Api.SecretManagerFeatures.Models.Request; using Bit.Api.SecretManagerFeatures.Models.Response; using Bit.Api.Utilities; +using Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Microsoft.AspNetCore.Mvc; @@ -10,18 +13,57 @@ namespace Bit.Api.Controllers public class SecretsController : Controller { private readonly ISecretRepository _secretRepository; + private readonly ICreateSecretCommand _createSecretCommand; + private readonly IUpdateSecretCommand _updateSecretCommand; - public SecretsController(ISecretRepository secretRepository) + public SecretsController(ISecretRepository secretRepository, ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand) { _secretRepository = secretRepository; + _createSecretCommand = createSecretCommand; + _updateSecretCommand = updateSecretCommand; } - [HttpGet("organizations/{orgId}/secrets")] - public async Task> GetAsync([FromRoute] Guid orgId) + [HttpGet("organizations/{organizationId}/secrets")] + public async Task> GetSecretsByOrganizationAsync([FromRoute] Guid organizationId) { - var results = await _secretRepository.GetManyByOrganizationIdAsync(orgId); - var responses = results.Select(secret => new SecretResponseModel(secret)); - return new ListResponseModel(responses); + var secrets = await _secretRepository.GetManyByOrganizationIdAsync(organizationId); + if (secrets == null || !secrets.Any()) + { + throw new NotFoundException(); + } + var responses = secrets.Select(secret => new SecretIdentifierResponseModel(secret)); + return new ListResponseModel(responses); + } + + + [HttpGet("secrets/{id}")] + public async Task GetSecretAsync([FromRoute] Guid id) + { + var secret = await _secretRepository.GetByIdAsync(id); + if (secret == null) + { + throw new NotFoundException(); + } + return new SecretResponseModel(secret); + } + + [HttpPost("organizations/{organizationId}/secrets")] + public async Task CreateSecretAsync([FromRoute] Guid organizationId, [FromBody] SecretCreateRequestModel createRequest) + { + if (organizationId != createRequest.OrganizationId) + { + throw new BadRequestException("Organization ID does not match."); + } + + var result = await _createSecretCommand.CreateAsync(createRequest.ToSecret()); + return new SecretResponseModel(result); + } + + [HttpPut("secrets/{id}")] + public async Task UpdateSecretAsync([FromRoute] Guid id, [FromBody] SecretUpdateRequestModel updateRequest) + { + var result = await _updateSecretCommand.UpdateAsync(updateRequest.ToSecret(id)); + return new SecretResponseModel(result); } } } diff --git a/src/Api/SecretManagerFeatures/Models/Request/SecretCreateRequestModel.cs b/src/Api/SecretManagerFeatures/Models/Request/SecretCreateRequestModel.cs new file mode 100644 index 000000000000..4b19b31b62c8 --- /dev/null +++ b/src/Api/SecretManagerFeatures/Models/Request/SecretCreateRequestModel.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Api.SecretManagerFeatures.Models.Request +{ + public class SecretCreateRequestModel : IValidatableObject + { + [Required] + public Guid OrganizationId { get; set; } + + [Required] + [EncryptedString] + public string Key { get; set; } + + [Required] + [EncryptedString] + public string Value { get; set; } + + [Required] + [EncryptedString] + public string Note { get; set; } + + public Secret ToSecret() + { + return new Secret() + { + OrganizationId = this.OrganizationId, + Key = this.Key, + Value = this.Value, + Note = this.Note, + DeletedDate = null, + }; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (OrganizationId == default(Guid)) + { + yield return new ValidationResult("Organization ID is required."); + } + } + } +} + diff --git a/src/Api/SecretManagerFeatures/Models/Request/SecretUpdateRequestModel.cs b/src/Api/SecretManagerFeatures/Models/Request/SecretUpdateRequestModel.cs new file mode 100644 index 000000000000..9adc8bf06e4c --- /dev/null +++ b/src/Api/SecretManagerFeatures/Models/Request/SecretUpdateRequestModel.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Api.SecretManagerFeatures.Models.Request +{ + public class SecretUpdateRequestModel + { + [Required] + [EncryptedString] + public string Key { get; set; } + + [Required] + [EncryptedString] + public string Value { get; set; } + + [Required] + [EncryptedString] + public string Note { get; set; } + + public Secret ToSecret(Guid id) + { + return new Secret() + { + Id = id, + Key = this.Key, + Value = this.Value, + Note = this.Note, + DeletedDate = null, + }; + } + } +} + diff --git a/src/Api/SecretManagerFeatures/Models/Response/SecretIdentifierResponseModel.cs b/src/Api/SecretManagerFeatures/Models/Response/SecretIdentifierResponseModel.cs new file mode 100644 index 000000000000..f90c76da7515 --- /dev/null +++ b/src/Api/SecretManagerFeatures/Models/Response/SecretIdentifierResponseModel.cs @@ -0,0 +1,34 @@ +using Bit.Core.Entities; +using Bit.Core.Models.Api; + +namespace Bit.Api.SecretManagerFeatures.Models.Response +{ + public class SecretIdentifierResponseModel : ResponseModel + { + public SecretIdentifierResponseModel(Secret secret, string obj = "secret") + : base(obj) + { + if (secret == null) + { + throw new ArgumentNullException(nameof(secret)); + } + + Id = secret.Id.ToString(); + OrganizationId = secret.OrganizationId.ToString(); + Key = secret.Key; + CreationDate = secret.CreationDate; + RevisionDate = secret.RevisionDate; + } + + public string Id { get; set; } + + public string OrganizationId { get; set; } + + public string Key { get; set; } + + public DateTime CreationDate { get; set; } + + public DateTime RevisionDate { get; set; } + } +} + diff --git a/src/Api/SecretManagerFeatures/Models/Response/SecretResponseModel.cs b/src/Api/SecretManagerFeatures/Models/Response/SecretResponseModel.cs index 31255007d636..5a961e76fbf5 100644 --- a/src/Api/SecretManagerFeatures/Models/Response/SecretResponseModel.cs +++ b/src/Api/SecretManagerFeatures/Models/Response/SecretResponseModel.cs @@ -16,6 +16,8 @@ public SecretResponseModel(Secret secret, string obj = "secret") Id = secret.Id.ToString(); OrganizationId = secret.OrganizationId.ToString(); Key = secret.Key; + Value = secret.Value; + Note = secret.Note; CreationDate = secret.CreationDate; RevisionDate = secret.RevisionDate; } @@ -26,6 +28,10 @@ public SecretResponseModel(Secret secret, string obj = "secret") public string Key { get; set; } + public string Value { get; set; } + + public string Note { get; set; } + public DateTime CreationDate { get; set; } public DateTime RevisionDate { get; set; } diff --git a/test/Api.Test/Controllers/SecretsControllerTests.cs b/test/Api.Test/Controllers/SecretsControllerTests.cs new file mode 100644 index 000000000000..9276bcfd2f0d --- /dev/null +++ b/test/Api.Test/Controllers/SecretsControllerTests.cs @@ -0,0 +1,91 @@ +using Bit.Api.Controllers; +using Bit.Api.SecretManagerFeatures.Models.Request; +using Bit.Commercial.Core.SecretManagerFeatures.Secrets.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Controllers +{ + [ControllerCustomize(typeof(SecretsController))] + [SutProviderCustomize] + [JsonDocumentCustomize] + public class SecretsControllerTests + { + [Theory] + [BitAutoData] + public async void GetSecretsByOrganization_ThrowsNotFound(SutProvider sutProvider) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.GetSecretsByOrganizationAsync(Guid.NewGuid())); + } + + [Theory] + [BitAutoData] + public async void GetSecret_NotFound(SutProvider sutProvider) + { + await Assert.ThrowsAsync(() => sutProvider.Sut.GetSecretAsync(Guid.NewGuid())); + } + + [Theory] + [BitAutoData] + public async void CreateSecret_MismatchedOrgId_Throws(SutProvider sutProvider, SecretCreateRequestModel data) + { + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateSecretAsync(Guid.NewGuid(), data)); + Assert.Contains("Organization ID does not match.", exception.Message); + } + + [Theory] + [BitAutoData] + public async void GetSecret_Success(SutProvider sutProvider, Secret resultSecret) + { + sutProvider.GetDependency().GetByIdAsync(default).ReturnsForAnyArgs(resultSecret); + + var result = await sutProvider.Sut.GetSecretAsync(resultSecret.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(resultSecret.Id))); + } + + [Theory] + [BitAutoData] + public async void GetSecretsByOrganization_Success(SutProvider sutProvider, Secret resultSecret) + { + sutProvider.GetDependency().GetManyByOrganizationIdAsync(default).ReturnsForAnyArgs(new List() { resultSecret }); + + var result = await sutProvider.Sut.GetSecretsByOrganizationAsync(resultSecret.OrganizationId); + + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationIdAsync(Arg.Is(AssertHelper.AssertPropertyEqual(resultSecret.OrganizationId))); + } + + [Theory] + [BitAutoData] + public async void CreateSecret_Success(SutProvider sutProvider, SecretCreateRequestModel data) + { + var resultSecret = data.ToSecret(); + + sutProvider.GetDependency().CreateAsync(default).ReturnsForAnyArgs(resultSecret); + + var result = await sutProvider.Sut.CreateSecretAsync(data.OrganizationId, data); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async void UpdateSecret_Success(SutProvider sutProvider, SecretUpdateRequestModel data, Guid secretId) + { + var resultSecret = data.ToSecret(secretId); + sutProvider.GetDependency().UpdateAsync(default).ReturnsForAnyArgs(resultSecret); + + var result = await sutProvider.Sut.UpdateSecretAsync(secretId, data); + await sutProvider.GetDependency().Received(1) + .UpdateAsync(Arg.Any()); + } + } +}