diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index 7b0bfa0bfb..a0b18cb847 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -3,6 +3,7 @@ using Bit.Api.Vault.Models.Response; using Bit.Core; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Queries; using Microsoft.AspNetCore.Authorization; @@ -17,11 +18,16 @@ public class SecurityTaskController : Controller { private readonly IUserService _userService; private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery; + private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand; - public SecurityTaskController(IUserService userService, IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery) + public SecurityTaskController( + IUserService userService, + IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery, + IMarkTaskAsCompleteCommand markTaskAsCompleteCommand) { _userService = userService; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; + _markTaskAsCompleteCommand = markTaskAsCompleteCommand; } /// @@ -37,4 +43,15 @@ public class SecurityTaskController : Controller var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); return new ListResponseModel(response); } + + /// + /// Marks a task as complete. The user must have edit permission on the cipher associated with the task. + /// + /// The unique identifier of the task to complete + [HttpPatch("{taskId:guid}/complete")] + public async Task Complete(Guid taskId) + { + await _markTaskAsCompleteCommand.CompleteAsync(taskId); + return NoContent(); + } } diff --git a/src/Core/Vault/Authorization/SecurityTaskOperations.cs b/src/Core/Vault/Authorization/SecurityTaskOperations.cs new file mode 100644 index 0000000000..77b504723f --- /dev/null +++ b/src/Core/Vault/Authorization/SecurityTaskOperations.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.Vault.Authorization; + +public class SecurityTaskOperationRequirement : OperationAuthorizationRequirement +{ + public SecurityTaskOperationRequirement(string name) + { + Name = name; + } +} + +public static class SecurityTaskOperations +{ + public static readonly SecurityTaskOperationRequirement Update = new(nameof(Update)); +} diff --git a/src/Core/Vault/Commands/Interfaces/IMarkTaskAsCompleteCommand.cs b/src/Core/Vault/Commands/Interfaces/IMarkTaskAsCompleteCommand.cs new file mode 100644 index 0000000000..1b745b8d07 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IMarkTaskAsCompleteCommand.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IMarkTaskAsCompleteCommand +{ + /// + /// Marks a task as complete. + /// + /// The unique identifier of the task to complete + /// A task representing the async operation + Task CompleteAsync(Guid taskId); +} diff --git a/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs b/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs new file mode 100644 index 0000000000..b46fb0cecb --- /dev/null +++ b/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs @@ -0,0 +1,50 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Commands; + +public class MarkTaskAsCompletedCommand : IMarkTaskAsCompleteCommand +{ + private readonly ISecurityTaskRepository _securityTaskRepository; + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + + public MarkTaskAsCompletedCommand( + ISecurityTaskRepository securityTaskRepository, + IAuthorizationService authorizationService, + ICurrentContext currentContext) + { + _securityTaskRepository = securityTaskRepository; + _authorizationService = authorizationService; + _currentContext = currentContext; + } + + /// + public async Task CompleteAsync(Guid taskId) + { + if (!_currentContext.UserId.HasValue) + { + throw new NotFoundException(); + } + + var task = await _securityTaskRepository.GetByIdAsync(taskId); + if (task is null) + { + throw new NotFoundException(); + } + + await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, task, + SecurityTaskOperations.Update); + + task.Status = SecurityTaskStatus.Completed; + task.RevisionDate = DateTime.UtcNow; + + await _securityTaskRepository.ReplaceAsync(task); + } +} diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index d3c9dd9648..15cb01f1a0 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Bit.Core.Vault.Queries; +using Bit.Core.Vault.Commands; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Queries; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Vault; @@ -16,5 +18,6 @@ public static class VaultServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/test/Core.Test/Vault/AutoFixture/SecurityTaskFixtures.cs b/test/Core.Test/Vault/AutoFixture/SecurityTaskFixtures.cs new file mode 100644 index 0000000000..eb0a29421a --- /dev/null +++ b/test/Core.Test/Vault/AutoFixture/SecurityTaskFixtures.cs @@ -0,0 +1,25 @@ +using AutoFixture; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Test.Common.AutoFixture.Attributes; + +namespace Bit.Core.Test.Vault.AutoFixture; + +public class SecurityTaskFixtures : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => + composer + .With(task => task.Id, Guid.NewGuid()) + .With(task => task.OrganizationId, Guid.NewGuid()) + .With(task => task.Status, SecurityTaskStatus.Pending) + .Without(x => x.CipherId) + ); + } +} + +public class SecurityTaskCustomizeAttribute : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new SecurityTaskFixtures(); +} diff --git a/test/Core.Test/Vault/Commands/MarkTaskAsCompletedCommandTest.cs b/test/Core.Test/Vault/Commands/MarkTaskAsCompletedCommandTest.cs new file mode 100644 index 0000000000..82550df48d --- /dev/null +++ b/test/Core.Test/Vault/Commands/MarkTaskAsCompletedCommandTest.cs @@ -0,0 +1,83 @@ +#nullable enable +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Test.Vault.AutoFixture; +using Bit.Core.Vault.Authorization; +using Bit.Core.Vault.Commands; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Commands; + +[SutProviderCustomize] +[SecurityTaskCustomize] +public class MarkTaskAsCompletedCommandTest +{ + private static void Setup(SutProvider sutProvider, Guid taskId, SecurityTask? securityTask, Guid? userId, bool authorizedUpdate = false) + { + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency() + .GetByIdAsync(taskId) + .Returns(securityTask); + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), securityTask ?? Arg.Any(), + Arg.Is>(reqs => + reqs.Contains(SecurityTaskOperations.Update))) + .Returns(authorizedUpdate ? AuthorizationResult.Success() : AuthorizationResult.Failed()); + } + + [Theory] + [BitAutoData] + public async Task CompleteAsync_NotLoggedIn_NotFoundException( + SutProvider sutProvider, + Guid taskId, + SecurityTask securityTask) + { + Setup(sutProvider, taskId, securityTask, null, true); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CompleteAsync(taskId)); + } + + [Theory] + [BitAutoData] + public async Task CompleteAsync_TaskNotFound_NotFoundException( + SutProvider sutProvider, + Guid taskId) + { + Setup(sutProvider, taskId, null, Guid.NewGuid(), true); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CompleteAsync(taskId)); + } + + [Theory] + [BitAutoData] + public async Task CompleteAsync_AuthorizationFailed_NotFoundException( + SutProvider sutProvider, + Guid taskId, + SecurityTask securityTask) + { + Setup(sutProvider, taskId, securityTask, Guid.NewGuid()); + + await Assert.ThrowsAsync(() => sutProvider.Sut.CompleteAsync(taskId)); + } + + [Theory] + [BitAutoData] + public async Task CompleteAsync_Success( + SutProvider sutProvider, + Guid taskId, + SecurityTask securityTask) + { + Setup(sutProvider, taskId, securityTask, Guid.NewGuid(), true); + + await sutProvider.Sut.CompleteAsync(taskId); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(securityTask); + } +}