From f88baba66b0444c0e1c6a5e53634b5b936b63ed0 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:23:22 -0500 Subject: [PATCH] [PM-23580] Security Task Metrics (#6164) * add metrics endpoint for an organization to return completed and total security tasks * refactor metrics fetch to use sql sproc for efficiency rather than having to pull all security task data * add separate response model for security task metrics endpoint * Pascal Case to match existing implementations * refactor org to organization for consistency with other methods * alter security task endpoint: - remove "count" from variable naming - update sproc naming * remove enablement check * replace orgId with organizationId --- .../Controllers/SecurityTaskController.cs | 17 +++- .../SecurityTaskMetricsResponseModel.cs | 21 +++++ .../Vault/Entities/SecurityTaskMetrics.cs | 13 +++ .../GetTaskMetricsForOrganizationQuery.cs | 42 ++++++++++ .../IGetTaskMetricsForOrganizationQuery.cs | 13 +++ .../Repositories/ISecurityTaskRepository.cs | 7 ++ .../Vault/VaultServiceCollectionExtensions.cs | 1 + .../Repositories/SecurityTaskRepository.cs | 13 +++ .../Repositories/SecurityTaskRepository.cs | 20 +++++ ...curityTask_ReadMetricsByOrganizationId.sql | 14 ++++ .../SecurityTaskRepositoryTests.cs | 79 +++++++++++++++++++ .../2025-08-12_00_SecurityTaskMetrics.sql | 15 ++++ 12 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs create mode 100644 src/Core/Vault/Entities/SecurityTaskMetrics.cs create mode 100644 src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs create mode 100644 src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs create mode 100644 src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql create mode 100644 util/Migrator/DbScripts/2025-08-12_00_SecurityTaskMetrics.sql diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index 7f61271ab2..efff200e86 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -24,6 +24,7 @@ public class SecurityTaskController : Controller private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; private readonly ICreateManyTasksCommand _createManyTasksCommand; private readonly ICreateManyTaskNotificationsCommand _createManyTaskNotificationsCommand; + private readonly IGetTaskMetricsForOrganizationQuery _getTaskMetricsForOrganizationQuery; public SecurityTaskController( IUserService userService, @@ -31,7 +32,8 @@ public class SecurityTaskController : Controller IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, IGetTasksForOrganizationQuery getTasksForOrganizationQuery, ICreateManyTasksCommand createManyTasksCommand, - ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand) + ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand, + IGetTaskMetricsForOrganizationQuery getTaskMetricsForOrganizationQuery) { _userService = userService; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; @@ -39,6 +41,7 @@ public class SecurityTaskController : Controller _getTasksForOrganizationQuery = getTasksForOrganizationQuery; _createManyTasksCommand = createManyTasksCommand; _createManyTaskNotificationsCommand = createManyTaskNotificationsCommand; + _getTaskMetricsForOrganizationQuery = getTaskMetricsForOrganizationQuery; } /// @@ -80,6 +83,18 @@ public class SecurityTaskController : Controller return new ListResponseModel(response); } + /// + /// Retrieves security task metrics for an organization. + /// + /// The organization Id + [HttpGet("{organizationId:guid}/metrics")] + public async Task GetTaskMetricsForOrganization([FromRoute] Guid organizationId) + { + var metrics = await _getTaskMetricsForOrganizationQuery.GetTaskMetrics(organizationId); + + return new SecurityTaskMetricsResponseModel(metrics.CompletedTasks, metrics.TotalTasks); + } + /// /// Bulk create security tasks for an organization. /// diff --git a/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs b/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs new file mode 100644 index 0000000000..502e90ddea --- /dev/null +++ b/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs @@ -0,0 +1,21 @@ +namespace Bit.Api.Vault.Models.Response; + +public class SecurityTaskMetricsResponseModel +{ + + public SecurityTaskMetricsResponseModel(int completedTasks, int totalTasks) + { + CompletedTasks = completedTasks; + TotalTasks = totalTasks; + } + + /// + /// Number of tasks that have been completed in the organization. + /// + public int CompletedTasks { get; set; } + + /// + /// Total number of tasks in the organization, regardless of their status. + /// + public int TotalTasks { get; set; } +} diff --git a/src/Core/Vault/Entities/SecurityTaskMetrics.cs b/src/Core/Vault/Entities/SecurityTaskMetrics.cs new file mode 100644 index 0000000000..c4172f6af9 --- /dev/null +++ b/src/Core/Vault/Entities/SecurityTaskMetrics.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Vault.Entities; + +public class SecurityTaskMetrics +{ + public SecurityTaskMetrics(int completedTasks, int totalTasks) + { + CompletedTasks = completedTasks; + TotalTasks = totalTasks; + } + + public int CompletedTasks { get; set; } + public int TotalTasks { get; set; } +} diff --git a/src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs b/src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs new file mode 100644 index 0000000000..f51efe6274 --- /dev/null +++ b/src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs @@ -0,0 +1,42 @@ +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.Repositories; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Queries; + +public class GetTaskMetricsForOrganizationQuery : IGetTaskMetricsForOrganizationQuery +{ + private readonly ISecurityTaskRepository _securityTaskRepository; + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + + public GetTaskMetricsForOrganizationQuery( + ISecurityTaskRepository securityTaskRepository, + IAuthorizationService authorizationService, + ICurrentContext currentContext + ) + { + _securityTaskRepository = securityTaskRepository; + _authorizationService = authorizationService; + _currentContext = currentContext; + } + + public async Task GetTaskMetrics(Guid organizationId) + { + 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.GetTaskMetricsAsync(organizationId); + } +} diff --git a/src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs b/src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs new file mode 100644 index 0000000000..49054e484d --- /dev/null +++ b/src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs @@ -0,0 +1,13 @@ +using Bit.Core.Vault.Entities; + +namespace Bit.Core.Vault.Queries; + +public interface IGetTaskMetricsForOrganizationQuery +{ + /// + /// Retrieves security task metrics for an organization. + /// + /// The Id of the organization + /// Metrics for all security tasks within an organization. + Task GetTaskMetrics(Guid organizationId); +} diff --git a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs index cc8303345d..4b88f1c0e8 100644 --- a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs +++ b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs @@ -28,4 +28,11 @@ public interface ISecurityTaskRepository : IRepository /// Collection of tasks to create /// Collection of created security tasks Task> CreateManyAsync(IEnumerable tasks); + + /// + /// Retrieves security task metrics for an organization. + /// + /// The id of the organization + /// A collection of security task metrics + Task GetTaskMetricsAsync(Guid organizationId); } diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index 9efa1ea379..1acc74959d 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -25,5 +25,6 @@ public static class VaultServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs index f7a5f3b878..292e99d6ad 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs @@ -48,6 +48,19 @@ public class SecurityTaskRepository : Repository, ISecurityT return results.ToList(); } + /// + public async Task GetTaskMetricsAsync(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + + var result = await connection.QueryAsync( + $"[{Schema}].[SecurityTask_ReadMetricsByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result.FirstOrDefault() ?? new SecurityTaskMetrics(0, 0); + } + /// public async Task> CreateManyAsync(IEnumerable tasks) { diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs index a3ba2632fe..d4f9424d40 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs @@ -76,4 +76,24 @@ public class SecurityTaskRepository : Repository + public async Task GetTaskMetricsAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var metrics = await (from st in dbContext.SecurityTasks + join o in dbContext.Organizations on st.OrganizationId equals o.Id + where st.OrganizationId == organizationId && o.Enabled + select st) + .GroupBy(x => 1) + .Select(g => new Core.Vault.Entities.SecurityTaskMetrics( + g.Count(x => x.Status == SecurityTaskStatus.Completed), + g.Count() + )) + .FirstOrDefaultAsync(); + + return metrics ?? new Core.Vault.Entities.SecurityTaskMetrics(0, 0); + } } diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql new file mode 100644 index 0000000000..0d9d076a98 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[SecurityTask_ReadMetricsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(CASE WHEN st.[Status] = 1 THEN 1 END) AS CompletedTasks, + COUNT(*) AS TotalTasks + FROM + [dbo].[SecurityTaskView] st + WHERE + st.[OrganizationId] = @OrganizationId +END diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs index eb5a310db3..f17950c04d 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs @@ -266,4 +266,83 @@ public class SecurityTaskRepositoryTests Assert.Equal(2, taskIds.Count); } + + [DatabaseTheory, DatabaseData] + public async Task GetTaskMetricsAsync( + IOrganizationRepository organizationRepository, + ICipherRepository cipherRepository, + ISecurityTaskRepository securityTaskRepository) + { + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "" + }); + + var cipher1 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", }; + await cipherRepository.CreateAsync(cipher1); + + var cipher2 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", }; + await cipherRepository.CreateAsync(cipher2); + + var tasks = new List + { + new() + { + OrganizationId = organization.Id, + CipherId = cipher1.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }, + new() + { + OrganizationId = organization.Id, + CipherId = cipher1.Id, + Status = SecurityTaskStatus.Completed, + Type = SecurityTaskType.UpdateAtRiskCredential, + }, + new() + { + OrganizationId = organization.Id, + CipherId = cipher2.Id, + Status = SecurityTaskStatus.Completed, + Type = SecurityTaskType.UpdateAtRiskCredential, + }, + new() + { + OrganizationId = organization.Id, + CipherId = cipher2.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + } + }; + + await securityTaskRepository.CreateManyAsync(tasks); + + var metrics = await securityTaskRepository.GetTaskMetricsAsync(organization.Id); + + Assert.Equal(2, metrics.CompletedTasks); + Assert.Equal(4, metrics.TotalTasks); + } + + [DatabaseTheory, DatabaseData] + public async Task GetZeroTaskMetricsAsync( + IOrganizationRepository organizationRepository, + ISecurityTaskRepository securityTaskRepository) + { + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "" + }); + + var metrics = await securityTaskRepository.GetTaskMetricsAsync(organization.Id); + + Assert.Equal(0, metrics.CompletedTasks); + Assert.Equal(0, metrics.TotalTasks); + } } diff --git a/util/Migrator/DbScripts/2025-08-12_00_SecurityTaskMetrics.sql b/util/Migrator/DbScripts/2025-08-12_00_SecurityTaskMetrics.sql new file mode 100644 index 0000000000..81d5c267a7 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-12_00_SecurityTaskMetrics.sql @@ -0,0 +1,15 @@ +CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_ReadMetricsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(CASE WHEN st.[Status] = 1 THEN 1 END) AS CompletedTasks, + COUNT(*) AS TotalTasks + FROM + [dbo].[SecurityTaskView] st + WHERE + st.[OrganizationId] = @OrganizationId +END +GO