diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index a0b18cb8476f..14ef0e5e4e7c 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -19,15 +19,18 @@ public class SecurityTaskController : Controller private readonly IUserService _userService; private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery; private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand; + private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; public SecurityTaskController( IUserService userService, IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery, - IMarkTaskAsCompleteCommand markTaskAsCompleteCommand) + IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, + IGetTasksForOrganizationQuery getTasksForOrganizationQuery) { _userService = userService; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; _markTaskAsCompleteCommand = markTaskAsCompleteCommand; + _getTasksForOrganizationQuery = getTasksForOrganizationQuery; } /// @@ -54,4 +57,18 @@ public async Task Complete(Guid taskId) await _markTaskAsCompleteCommand.CompleteAsync(taskId); return NoContent(); } + + /// + /// Retrieves security tasks for an organization. Restricted to organization administrators. + /// + /// The organization Id + /// Optional filter for task status. If not provided, returns tasks of all statuses. + [HttpGet("organization")] + public async Task> ListForOrganization( + [FromQuery] Guid organizationId, [FromQuery] SecurityTaskStatus? status) + { + var securityTasks = await _getTasksForOrganizationQuery.GetTasksAsync(organizationId, status); + var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); + return new ListResponseModel(response); + } } diff --git a/src/Core/Vault/Queries/GetTasksForOrganizationQuery.cs b/src/Core/Vault/Queries/GetTasksForOrganizationQuery.cs new file mode 100644 index 000000000000..8f71f3cc3bb1 --- /dev/null +++ b/src/Core/Vault/Queries/GetTasksForOrganizationQuery.cs @@ -0,0 +1,44 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Bit.Core.Vault.Authorization.SecurityTasks; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Queries; + +public class GetTasksForOrganizationQuery : IGetTasksForOrganizationQuery +{ + private readonly ISecurityTaskRepository _securityTaskRepository; + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + + public GetTasksForOrganizationQuery( + ISecurityTaskRepository securityTaskRepository, + IAuthorizationService authorizationService, + ICurrentContext currentContext + ) + { + _securityTaskRepository = securityTaskRepository; + _authorizationService = authorizationService; + _currentContext = currentContext; + } + + public async Task> GetTasksAsync(Guid organizationId, + SecurityTaskStatus? status = null) + { + var organization = _currentContext.GetOrganization(organizationId); + var userId = _currentContext.UserId; + + if (organization == null || !userId.HasValue) + { + throw new NotFoundException(); + } + + await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, organization, SecurityTaskOperations.ListAllForOrganization); + + return (await _securityTaskRepository.GetManyByOrganizationIdStatusAsync(organizationId, status)).ToList(); + } +} diff --git a/src/Core/Vault/Queries/IGetTasksForOrganizationQuery.cs b/src/Core/Vault/Queries/IGetTasksForOrganizationQuery.cs new file mode 100644 index 000000000000..c61f379008a7 --- /dev/null +++ b/src/Core/Vault/Queries/IGetTasksForOrganizationQuery.cs @@ -0,0 +1,15 @@ +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; + +namespace Bit.Core.Vault.Queries; + +public interface IGetTasksForOrganizationQuery +{ + /// + /// Retrieves all security tasks for an organization. + /// + /// The Id of the organization + /// Optional filter for task status. If not provided, returns tasks of all statuses + /// A collection of security tasks + Task> GetTasksAsync(Guid organizationId, SecurityTaskStatus? status = null); +} diff --git a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs index 34f1f2ee6411..c236172533ef 100644 --- a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs +++ b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs @@ -13,4 +13,12 @@ public interface ISecurityTaskRepository : IRepository /// Optional filter for task status. If not provided, returns tasks of all statuses /// Task> GetManyByUserIdStatusAsync(Guid userId, SecurityTaskStatus? status = null); + + /// + /// Retrieves all security tasks for an organization. + /// + /// The id of the organization + /// Optional filter for task status. If not provided, returns tasks of all statuses + /// + Task> GetManyByOrganizationIdStatusAsync(Guid organizationId, SecurityTaskStatus? status = null); } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs index dfe8a04814cc..35dace9a9ef4 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs @@ -32,4 +32,18 @@ public async Task> GetManyByUserIdStatusAsync(Guid use return results.ToList(); } + + /// + public async Task> GetManyByOrganizationIdStatusAsync(Guid organizationId, + SecurityTaskStatus? status = null) + { + await using var connection = new SqlConnection(ConnectionString); + + var results = await connection.QueryAsync( + $"[{Schema}].[SecurityTask_ReadByOrganizationIdStatus]", + new { OrganizationId = organizationId, Status = status }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs index bd56df1bcfca..5adfdc4c762b 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs @@ -25,4 +25,31 @@ public SecurityTaskRepository(IServiceScopeFactory serviceScopeFactory, IMapper var data = await query.Run(dbContext).ToListAsync(); return data; } + + /// + public async Task> GetManyByOrganizationIdStatusAsync(Guid organizationId, + SecurityTaskStatus? status = null) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var query = from st in dbContext.SecurityTasks + join o in dbContext.Organizations + on st.OrganizationId equals o.Id + where + o.Enabled && + st.OrganizationId == organizationId && + (status == null || st.Status == status) + select new Core.Vault.Entities.SecurityTask + { + Id = st.Id, + OrganizationId = st.OrganizationId, + CipherId = st.CipherId, + Status = st.Status, + Type = st.Type, + CreationDate = st.CreationDate, + RevisionDate = st.RevisionDate, + }; + + return await query.OrderByDescending(st => st.CreationDate).ToListAsync(); + } } diff --git a/src/Sql/Vault/dbo/Stored Procedures/SecurityTask/SecurityTask_ReadByOrganizationIdStatus.sql b/src/Sql/Vault/dbo/Stored Procedures/SecurityTask/SecurityTask_ReadByOrganizationIdStatus.sql new file mode 100644 index 000000000000..19e436e71d18 --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/SecurityTask/SecurityTask_ReadByOrganizationIdStatus.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[SecurityTask_ReadByOrganizationIdStatus] + @OrganizationId UNIQUEIDENTIFIER, + @Status TINYINT = NULL +AS +BEGIN + SET NOCOUNT ON + + SELECT + ST.* + FROM + [dbo].[SecurityTaskView] ST + INNER JOIN + [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId] + WHERE + ST.[OrganizationId] = @OrganizationId + AND O.[Enabled] = 1 + AND ST.[Status] = COALESCE(@Status, ST.[Status]) + ORDER BY ST.[CreationDate] DESC +END diff --git a/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs b/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs new file mode 100644 index 000000000000..59ec7350daa5 --- /dev/null +++ b/test/Core.Test/Vault/Queries/GetTasksForOrganizationQueryTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Vault.Authorization.SecurityTasks; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Queries; +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.Queries; + +[SutProviderCustomize] +public class GetTasksForOrganizationQueryTests +{ + [Theory, BitAutoData] + public async Task GetTasksAsync_Success( + Guid userId, CurrentContextOrganization org, + SutProvider sutProvider) + { + var status = SecurityTaskStatus.Pending; + sutProvider.GetDependency().HttpContext.User.Returns(new ClaimsPrincipal()); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(org.Id).Returns(org); + sutProvider.GetDependency().AuthorizeAsync( + Arg.Any(), org, Arg.Is>( + e => e.Contains(SecurityTaskOperations.ListAllForOrganization) + ) + ).Returns(AuthorizationResult.Success()); + sutProvider.GetDependency().GetManyByOrganizationIdStatusAsync(org.Id, status).Returns(new List() + { + new() { Id = Guid.NewGuid() }, + new() { Id = Guid.NewGuid() }, + }); + + var result = await sutProvider.Sut.GetTasksAsync(org.Id, status); + + Assert.Equal(2, result.Count); + sutProvider.GetDependency().Received(1).AuthorizeAsync( + Arg.Any(), org, Arg.Is>( + e => e.Contains(SecurityTaskOperations.ListAllForOrganization) + ) + ); + sutProvider.GetDependency().Received(1).GetManyByOrganizationIdStatusAsync(org.Id, SecurityTaskStatus.Pending); + } + + [Theory, BitAutoData] + public async Task GetTaskAsync_MissingOrg_Failure(Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetTasksAsync(Guid.NewGuid())); + } + + [Theory, BitAutoData] + public async Task GetTaskAsync_MissingUser_Failure(CurrentContextOrganization org, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(null as Guid?); + sutProvider.GetDependency().GetOrganization(org.Id).Returns(org); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetTasksAsync(org.Id)); + } + + [Theory, BitAutoData] + public async Task GetTasksAsync_Unauthorized_Failure( + Guid userId, CurrentContextOrganization org, + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.User.Returns(new ClaimsPrincipal()); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetOrganization(org.Id).Returns(org); + sutProvider.GetDependency().AuthorizeAsync( + Arg.Any(), org, Arg.Is>( + e => e.Contains(SecurityTaskOperations.ListAllForOrganization) + ) + ).Returns(AuthorizationResult.Failed()); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetTasksAsync(org.Id)); + + sutProvider.GetDependency().Received(1).AuthorizeAsync( + Arg.Any(), org, Arg.Is>( + e => e.Contains(SecurityTaskOperations.ListAllForOrganization) + ) + ); + sutProvider.GetDependency().Received(0).GetManyByOrganizationIdStatusAsync(org.Id, SecurityTaskStatus.Pending); + } +} diff --git a/util/Migrator/DbScripts/2025-01-09_00_SecurityTaskReadByOrganization.sql b/util/Migrator/DbScripts/2025-01-09_00_SecurityTaskReadByOrganization.sql new file mode 100644 index 000000000000..11774e20920e --- /dev/null +++ b/util/Migrator/DbScripts/2025-01-09_00_SecurityTaskReadByOrganization.sql @@ -0,0 +1,20 @@ +CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_ReadByOrganizationIdStatus] + @OrganizationId UNIQUEIDENTIFIER, + @Status TINYINT = NULL +AS +BEGIN + SET NOCOUNT ON + + SELECT + ST.* + FROM + [dbo].[SecurityTaskView] ST + INNER JOIN + [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId] + WHERE + ST.[OrganizationId] = @OrganizationId + AND O.[Enabled] = 1 + AND ST.[Status] = COALESCE(@Status, ST.[Status]) + ORDER BY ST.[CreationDate] DESC +END +GO