Files
SIG-CM2.0/src/api/SIGCM2.Infrastructure/DependencyInjection.cs
dmolinari a3d6214d09 feat(infra): AuditLogger + SecurityEventLogger impl (UDT-010 B6)
Composes the audit emission layer per design #D-8:

SIGCM2.Infrastructure/Audit/AuditLogger.cs (IAuditLogger):
- Enriches from IAuditContext (ActorUserId/ActorRoleId/Ip/UserAgent/CorrelationId).
- Sanitizes metadata via JsonSanitizer + AuditOptions.SanitizedKeys.
- Persists via IAuditEventRepository.InsertAsync.
- Fail-closed: throws AuditContextMissingException when ActorUserId is null.
- Translates Guid.Empty correlation id to null (DB column is nullable; Empty
  indicates 'no middleware ran').
- Uses System.DateTime.UtcNow for occurredAt.

SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs (ISecurityEventLogger):
- NOT fail-closed: null ActorUserId is valid (login failures, anonymous
  permission.denied events).
- Ip/UserAgent pulled from IAuditContext; metadata sanitized the same way.
- Persists via ISecurityEventRepository.

DI: AddScoped for both loggers in AddInfrastructure.

Tests (Strict TDD, mocks for IAuditContext/IAuditEventRepository/
ISecurityEventRepository):
- AuditLoggerTests (6): happy path with full context, fail-closed null actor,
  metadata sanitization, null metadata pass-through, repo-throws-bubbles-up
  (critical for TransactionScope rollback), custom SanitizedKeys from options.
- SecurityEventLoggerTests (4): login.success with context, login.failure
  with null actor + attemptedUsername, metadata sanitization,
  permission.denied with both actor and attemptedUsername null.

Two initial failures were fixed by replacing 'null' literal arguments in
NSubstitute Received(...) assertions with Arg.Is<T?>(x => x == null) —
NSubstitute does not always match null literals when mixed with Arg.Any<T>().

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-4 #REQ-SEC-2/3, design#D-8, tasks#B6}
2026-04-16 13:41:10 -03:00

112 lines
5.1 KiB
C#

using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth;
using SIGCM2.Infrastructure.Http;
using SIGCM2.Infrastructure.Messaging;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.Infrastructure.Security;
namespace SIGCM2.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Database
var connectionString = configuration.GetConnectionString("SqlServer")
?? throw new InvalidOperationException("Missing ConnectionStrings:SqlServer");
services.AddSingleton(new SqlConnectionFactory(connectionString));
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
services.AddScoped<IRolRepository, RolRepository>();
services.AddScoped<IPermisoRepository, PermisoRepository>();
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
// Also expose as JwtOptions directly for convenience (resolves via IOptions<JwtOptions>)
services.AddSingleton<JwtOptions>(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
// AuthOptions (Application layer) — populated from the same Jwt config section
services.AddSingleton<AuthOptions>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return new AuthOptions
{
AccessTokenMinutes = opts.AccessTokenMinutes,
RefreshTokenDays = opts.RefreshTokenDays,
};
});
// RSA key pair — loaded lazily as singletons from the fully-resolved JwtOptions
services.AddSingleton<RSA>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return RsaKeyLoader.LoadPrivateKey(opts);
});
services.AddSingleton<RsaSecurityKey>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return new RsaSecurityKey(RsaKeyLoader.LoadPublicKey(opts));
});
services.AddScoped<IJwtService>(sp =>
new JwtService(sp.GetRequiredService<RSA>(), sp.GetRequiredService<JwtOptions>()));
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
services.AddSingleton<IRefreshTokenGenerator, RefreshTokenGenerator>();
services.AddHttpContextAccessor();
services.AddScoped<IClientContext, ClientContext>();
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();
// JWT Bearer authentication
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
// Post-configure JWT Bearer — wire RSA public key + validation params from resolved options.
// MapInboundClaims=false: preserve JWT claim names as-is ("sub", "rol", etc.).
// Without this, the middleware maps "sub" → ClaimTypes.NameIdentifier and breaks User.FindFirst("sub").
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.PostConfigure<RsaSecurityKey, JwtOptions>((jwtBearerOpts, rsaKey, jwtOpts) =>
{
jwtBearerOpts.MapInboundClaims = false;
jwtBearerOpts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = rsaKey,
ValidateIssuer = true,
ValidIssuer = jwtOpts.Issuer,
ValidateAudience = true,
ValidAudience = jwtOpts.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
RoleClaimType = "rol",
NameClaimType = "name"
};
});
return services;
}
}