Files
SIG-CM2.0/src/api/SIGCM2.Infrastructure/DependencyInjection.cs

120 lines
5.5 KiB
C#
Raw Normal View History

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;
feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3) Adds the metadata sanitization layer per #REQ-AUD-5: SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class): - Sanitize(object?, IReadOnlyCollection<string>) -> string? - Serializes via System.Text.Json + JsonNode recursive traversal. - Strips blacklisted keys at every nesting level (objects + arrays). - Case-insensitive match (ToLowerInvariant on both sides). - Null input -> null output (never throws). - Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK). SIGCM2.Application/Audit/AuditOptions.cs: - Documented the IConfiguration array-binding quirk: config is ADDITIVE (append at higher indices), not REPLACE. Intentional for security — defaults like 'password'/'token'/'cvv' must not be silently dropped. SIGCM2.Infrastructure/DependencyInjection.cs: - services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName)) wired in AddInfrastructure(). Tests (Strict TDD, RED -> GREEN): - JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/ primitives/round-trip-valid-json/string-as-value/default-keys-effective. - AuditOptionsBindingTests (2): defaults when section absent + additive override. One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally asserted against an encoding-specific literal; rewrote to use JsonDocument round-trip (validates behavior without coupling to encoder quirks). Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}
2026-04-16 13:28:37 -03:00
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>();
services.AddScoped<IMedioRepository, MedioRepository>();
services.AddScoped<ISeccionRepository, SeccionRepository>();
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
// 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>(),
sp.GetRequiredService<TimeProvider>()));
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
services.AddSingleton<IRefreshTokenGenerator, RefreshTokenGenerator>();
services.AddHttpContextAccessor();
services.AddScoped<IClientContext, ClientContext>();
feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3) Adds the metadata sanitization layer per #REQ-AUD-5: SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class): - Sanitize(object?, IReadOnlyCollection<string>) -> string? - Serializes via System.Text.Json + JsonNode recursive traversal. - Strips blacklisted keys at every nesting level (objects + arrays). - Case-insensitive match (ToLowerInvariant on both sides). - Null input -> null output (never throws). - Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK). SIGCM2.Application/Audit/AuditOptions.cs: - Documented the IConfiguration array-binding quirk: config is ADDITIVE (append at higher indices), not REPLACE. Intentional for security — defaults like 'password'/'token'/'cvv' must not be silently dropped. SIGCM2.Infrastructure/DependencyInjection.cs: - services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName)) wired in AddInfrastructure(). Tests (Strict TDD, RED -> GREEN): - JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/ primitives/round-trip-valid-json/string-as-value/default-keys-effective. - AuditOptionsBindingTests (2): defaults when section absent + additive override. One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally asserted against an encoding-specific literal; rewrote to use JsonDocument round-trip (validates behavior without coupling to encoder quirks). Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}
2026-04-16 13:28:37 -03:00
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
feat(api): audit context middleware + scoped impl (UDT-010 B4) Wires the request-scoped audit context per design #D-2: Middleware pipeline in Program.cs: app.UseCors() app.UseMiddleware<CorrelationIdMiddleware>() // PRE-AUTH app.UseAuthentication() app.UseMiddleware<AuditActorMiddleware>() // POST-AUTH app.UseAuthorization() app.MapControllers() SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs: - Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId). - Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items. - Echoes the correlation id back via response header (OnStarting + immediate set — immediate set makes unit testing against DefaultHttpContext reliable). SIGCM2.Api/Middleware/AuditActorMiddleware.cs: - Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int, stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset. SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl): - Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty when no HttpContext is available (jobs, tests without middleware). - ActorRoleId intentionally null for now — rol code → id resolution is deferred; the logger may resolve it at persist time in a later batch. DI registration (Infrastructure/DependencyInjection.cs): - services.AddScoped<IAuditContext, AuditContext>() Tests (Strict TDD): - CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed correlation id, sets response header, captures ip/ua, calls next. - AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/ calls-next. - AuditContextTests (7): reads from Items, null-http-context defaults, ActorRoleId currently null. Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4}
2026-04-16 13:32:13 -03:00
services.AddScoped<IAuditContext, SIGCM2.Infrastructure.Audit.AuditContext>();
feat(infra): audit + security event repositories (UDT-010 B5) Introduces persistence layer for audit and security events per design #D-6: SIGCM2.Application/Audit/: - IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination - ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is queried only from an admin dashboard deferred to ADM-004) - AuditEventQueryResult: (Items, NextCursor) record SIGCM2.Infrastructure/Audit/: - AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for DESC pagination. TryDecode is fail-open — malformed cursor returns null and the query starts from the top. - AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic WHERE composition with parameterized filters (zero SQL injection risk). LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto. Pagination fetches Limit+1 rows to detect "more pages"; emits cursor from the Nth row when overflow observed. - SecurityEventRepository: straight INSERT for login/logout/refresh/ permission.denied events. DI: AddScoped for both repos in AddInfrastructure. Integration tests (Strict TDD): 13 total, all against SIGCM2_Test. - AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor, filter-by-target, filter-by-date-range, cursor pagination across 3 pages (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario populates username, cursor encode/decode roundtrip, cursor malformed variants. - SecurityEventRepositoryTests (3): insert success, insert failure with null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection. Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1, design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
services.AddScoped<IAuditEventRepository, SIGCM2.Infrastructure.Audit.AuditEventRepository>();
services.AddScoped<ISecurityEventRepository, SIGCM2.Infrastructure.Audit.SecurityEventRepository>();
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
services.AddScoped<IAuditLogger, SIGCM2.Infrastructure.Audit.AuditLogger>();
services.AddScoped<ISecurityEventLogger, SIGCM2.Infrastructure.Audit.SecurityEventLogger>();
feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3) Adds the metadata sanitization layer per #REQ-AUD-5: SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class): - Sanitize(object?, IReadOnlyCollection<string>) -> string? - Serializes via System.Text.Json + JsonNode recursive traversal. - Strips blacklisted keys at every nesting level (objects + arrays). - Case-insensitive match (ToLowerInvariant on both sides). - Null input -> null output (never throws). - Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK). SIGCM2.Application/Audit/AuditOptions.cs: - Documented the IConfiguration array-binding quirk: config is ADDITIVE (append at higher indices), not REPLACE. Intentional for security — defaults like 'password'/'token'/'cvv' must not be silently dropped. SIGCM2.Infrastructure/DependencyInjection.cs: - services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName)) wired in AddInfrastructure(). Tests (Strict TDD, RED -> GREEN): - JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/ primitives/round-trip-valid-json/string-as-value/default-keys-effective. - AuditOptionsBindingTests (2): defaults when section absent + additive override. One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally asserted against an encoding-specific literal; rewrote to use JsonDocument round-trip (validates behavior without coupling to encoder quirks). Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing. Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}
2026-04-16 13:28:37 -03:00
// 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;
}
}