From b619c0576235da1e342bbe0228d22baa4edf96dc Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:59:27 -0300 Subject: [PATCH] feat(audit): security events en Auth + authorization handlers (UDT-010 B9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instruments auth pipeline with ISecurityEventLogger per #REQ-AUTH-SEC: LoginCommandHandler: - login success → action=login result=success actorUserId=user.Id - login failure disaggregated internally (client still sees 401 unified): user_not_found / user_inactive / invalid_password — attempts captured with attemptedUsername + FailureReason LogoutCommandHandler: - action=logout result=success actorUserId=cmd.UsuarioId RefreshCommandHandler: - refresh.issue success on successful rotation - refresh.reuse_detected failure when revoked token is presented (chain revoke already happens; we add the security event with metadata.familyId) - refresh.issue failure for: token_expired / sub_mismatch / user_not_found / user_inactive PermissionAuthorizationHandler: - permission.denied failure on require-permission rejection, with metadata { permissionRequired, endpoint, method }. ActorUserId from JWT sub. DI: ISecurityEventLogger was already registered by B6 (AddInfrastructure). Test updates: 4 test classes now inject ISecurityEventLogger mock: - LoginCommandHandlerTests, LogoutCommandHandlerTests, RefreshCommandHandlerTests - PermissionAuthorizationHandlerTests (Api.Tests) Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-SEC-2/3/4/5 #REQ-AUTH-SEC, design, tasks#B9} --- .../PermissionAuthorizationHandler.cs | 16 ++++++++- .../Auth/Login/LoginCommandHandler.cs | 28 +++++++++++++-- .../Auth/Logout/LogoutCommandHandler.cs | 6 +++- .../Auth/Refresh/RefreshCommandHandler.cs | 34 +++++++++++++++++-- .../PermissionAuthorizationHandlerTests.cs | 3 ++ .../Auth/Login/LoginCommandHandlerTests.cs | 4 ++- .../Auth/Logout/LogoutCommandHandlerTests.cs | 4 ++- .../Refresh/RefreshCommandHandlerTests.cs | 4 ++- 8 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs index 43551a9..ca89103 100644 --- a/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs +++ b/src/api/SIGCM2.Api/Authorization/PermissionAuthorizationHandler.cs @@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; namespace SIGCM2.Api.Authorization; @@ -12,21 +13,25 @@ namespace SIGCM2.Api.Authorization; /// and IUsuarioRepository, resolves effective permissions via PermisoResolver, /// and succeeds if at least one required permission matches (OR semantics). /// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3). +/// UDT-010: emits SecurityEvent 'permission.denied' on rejection. /// public sealed class PermissionAuthorizationHandler : AuthorizationHandler { private readonly IRolPermisoRepository _rolPermisoRepo; private readonly IUsuarioRepository _usuarioRepo; + private readonly ISecurityEventLogger _security; private readonly ILogger _logger; public PermissionAuthorizationHandler( IRolPermisoRepository rolPermisoRepo, IUsuarioRepository usuarioRepo, + ISecurityEventLogger security, ILogger logger) { _rolPermisoRepo = rolPermisoRepo; _usuarioRepo = usuarioRepo; + _security = security; _logger = logger; } @@ -83,8 +88,17 @@ public sealed class PermissionAuthorizationHandler } // 8. Stash required permission for ForbiddenProblemDetailsHandler + var requiredPermission = requirement.PermissionCodes[0]; if (context.Resource is HttpContext httpContext) - httpContext.Items["RequiredPermission"] = requirement.PermissionCodes[0]; + httpContext.Items["RequiredPermission"] = requiredPermission; + + // 9. Emit SecurityEvent for the denial + var endpoint = (context.Resource as HttpContext)?.Request?.Path.Value; + var method = (context.Resource as HttpContext)?.Request?.Method; + await _security.LogAsync("permission.denied", "failure", + actorUserId: userId, + failureReason: $"missing_permission:{requiredPermission}", + metadata: new { permissionRequired = requiredPermission, endpoint, method }); context.Fail(new AuthorizationFailureReason(this, $"missing_permission:{string.Join('|', requirement.PermissionCodes)}")); diff --git a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs index fc9a06e..e74a07f 100644 --- a/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Login/LoginCommandHandler.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Common; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; @@ -19,6 +20,7 @@ public sealed class LoginCommandHandler : ICommandHandler _logger; public LoginCommandHandler( @@ -30,6 +32,7 @@ public sealed class LoginCommandHandler : ICommandHandler logger) { _repository = repository; @@ -40,6 +43,7 @@ public sealed class LoginCommandHandler : ICommandHandler p, StringComparer.Ordinal).ToArray(); + await _security.LogAsync("login", "success", actorUserId: usuario.Id); + return new LoginResponseDto( AccessToken: accessToken, RefreshToken: rawRefresh, // raw to client — never stored diff --git a/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs b/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs index 9103a08..daf0272 100644 --- a/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs +++ b/src/api/SIGCM2.Application/Auth/Logout/LogoutCommandHandler.cs @@ -1,15 +1,18 @@ using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; namespace SIGCM2.Application.Auth.Logout; public sealed class LogoutCommandHandler : ICommandHandler { private readonly IRefreshTokenRepository _refreshRepo; + private readonly ISecurityEventLogger _security; - public LogoutCommandHandler(IRefreshTokenRepository refreshRepo) + public LogoutCommandHandler(IRefreshTokenRepository refreshRepo, ISecurityEventLogger security) { _refreshRepo = refreshRepo; + _security = security; } public async Task Handle(LogoutCommand command) @@ -17,6 +20,7 @@ public sealed class LogoutCommandHandler : ICommandHandler Handle(RefreshCommand command) @@ -62,23 +66,44 @@ public sealed class RefreshCommandHandler : ICommandHandler(); private readonly IUsuarioRepository _usuarioRepo = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly PermissionAuthorizationHandler _handler; public PermissionAuthorizationHandlerTests() @@ -29,6 +31,7 @@ public sealed class PermissionAuthorizationHandlerTests _handler = new PermissionAuthorizationHandler( _rolPermisoRepo, _usuarioRepo, + _security, NullLogger.Instance); } diff --git a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs index e009f00..103300b 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Login/LoginCommandHandlerTests.cs @@ -3,6 +3,7 @@ using NSubstitute; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Auth; using SIGCM2.Application.Auth.Login; using SIGCM2.Domain.Entities; @@ -20,6 +21,7 @@ public class LoginCommandHandlerTests private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 }; private readonly LoginCommandHandler _handler; @@ -42,7 +44,7 @@ public class LoginCommandHandlerTests _handler = new LoginCommandHandler( _repository, _hasher, _jwtService, _refreshRepo, _refreshGenerator, _clientCtx, _authOptions, - _rolPermisoRepo, _logger); + _rolPermisoRepo, _security, _logger); } // Scenario: valid credentials → returns token response with usuario populated diff --git a/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs index 2237bc4..ed07064 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Logout/LogoutCommandHandlerTests.cs @@ -1,5 +1,6 @@ using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; using SIGCM2.Application.Auth.Logout; namespace SIGCM2.Application.Tests.Auth.Logout; @@ -7,11 +8,12 @@ namespace SIGCM2.Application.Tests.Auth.Logout; public class LogoutCommandHandlerTests { private readonly IRefreshTokenRepository _refreshRepo = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly LogoutCommandHandler _handler; public LogoutCommandHandlerTests() { - _handler = new LogoutCommandHandler(_refreshRepo); + _handler = new LogoutCommandHandler(_refreshRepo, _security); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs index b0907eb..7423cf0 100644 --- a/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs +++ b/tests/SIGCM2.Application.Tests/Auth/Refresh/RefreshCommandHandlerTests.cs @@ -4,6 +4,7 @@ using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Auth; using SIGCM2.Application.Auth.Refresh; using SIGCM2.Domain.Entities; @@ -19,6 +20,7 @@ public class RefreshCommandHandlerTests private readonly IJwtService _jwtService = Substitute.For(); private readonly IRefreshTokenGenerator _generator = Substitute.For(); private readonly IClientContext _clientCtx = Substitute.For(); + private readonly ISecurityEventLogger _security = Substitute.For(); private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 }; private readonly RefreshCommandHandler _handler; @@ -34,7 +36,7 @@ public class RefreshCommandHandlerTests _generator.Generate().Returns("new_raw_token_value_xyz"); _handler = new RefreshCommandHandler( - _refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions); + _refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security); } // Helper: build an active stored RefreshToken with a matching principal