Commit Graph

21 Commits

Author SHA1 Message Date
2f0da2d720 feat(infra): MedioRepository + SeccionRepository + integration tests — ADM-001 B5 2026-04-16 19:04:09 -03:00
9eac044752 feat(jobs): 3 audit maintenance jobs (Quartz.NET, UDT-010 B11)
Agrega Quartz.Extensions.Hosting 3.13.1 al catálogo central.

SIGCM2.Infrastructure/Audit/Jobs/:
- AuditPartitionManagerJob — mensual (cron '0 0 2 1 * ?', UTC). Extiende
  pf_AuditEvent_Monthly y pf_SecurityEvent_Monthly con SPLIT RANGE para el
  mes+2 (mantiene +1 de buffer). Idempotente: verifica existencia antes.
- AuditRetentionEnforcerJob — anual (cron '0 0 3 1 1 ?', UTC). DELETE rows
  > 10 años en AuditEvent y > 5 años en SecurityEvent. Temporal history se
  purga solo vía HISTORY_RETENTION_PERIOD del engine.
- AuditIntegrityCheckJob — semanal domingos (cron '0 0 1 ? * SUN', UTC).
  Valida SYSTEM_VERSIONING=ON + partitions próximos 3 meses. Emite
  SecurityEvent 'system.integrity_alert' failure via ISecurityEventLogger
  cuando detecta inconsistencias.

AuditMaintenanceRegistration.cs:
- services.AddAuditMaintenance(configuration) wraps AddQuartz + AddQuartzHostedService
  con los 3 triggers crónicos.

Program.cs:
- builder.Services.AddAuditMaintenance(configuration) wired ONLY en entornos
  productivos — skipeado en 'Testing' para que los integration tests no
  disparen los triggers cron durante el ciclo de vida del TestWebAppFactory.

Row-based DELETE en RetentionEnforcerJob es la opción conservadora para la
primera generación — cuando los volúmenes lo justifiquen (>200M filas), se
upgradea a SWITCH OUT + DROP para partition-level drop. Documentado en
comentario de la clase.

Tests (Strict TDD, integration):
- AuditJobsTests (3): PartitionManager crea target boundary + idempotencia,
  RetentionEnforcer purga > threshold (10y audit, 5y security), IntegrityCheck
  all-OK no emite alert.

Suite: 381/381 Application.Tests + 147/147 Api.Tests = 528/528 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-6 #REQ-SEC-5, design, tasks#B11}
2026-04-16 17:10:43 -03:00
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
300badda73 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
0b4af4c332 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
08d6622e43 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
5fd88b5a9d feat(infra): IUsuarioRepository.UpdatePermisosJsonAsync + impl Dapper [UDT-009] 2026-04-15 21:33:39 -03:00
86310de286 feat(security): remover claim permisos del JWT post-UDT-009 [UDT-009] 2026-04-15 21:28:26 -03:00
9dcd63543e feat(auth): extend LoginResponse with username + mustChangePassword + ultimoLogin [UDT-008] 2026-04-15 17:39:48 -03:00
be2257a9bf feat(infra): BATCH 4 - Permiso/RolPermiso repos Dapper + tests integracion [UDT-005] 2026-04-15 15:39:25 -03:00
6f999b8fcd feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico
- RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409)
- ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409)
- DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure)
- CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository)
- Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests
- Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes
- tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test
2026-04-15 12:50:24 -03:00
34b714750a feat(api): UDT-004 dominio + repositorio + application roles (tdd)
- Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol)
- Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse)
- Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios
- Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$)
- Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1)
- Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset.
- Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos.
2026-04-15 12:31:29 -03:00
bce591e63c fix(auth): preserve JWT claim names in bearer middleware
JwtBearerOptions.MapInboundClaims defaulted to true, which mapped the
'sub' claim to ClaimTypes.NameIdentifier in HttpContext.User. Logout
endpoint read User.FindFirst("sub") and got null, returning 401 for
any authenticated caller.

Fix: set MapInboundClaims=false and pin NameClaimType="name" so the
JWT claims land in the principal with their original names, aligning
with how JwtService.GetPrincipalFromExpiredToken (used by refresh)
already consumes them.

Unblocks Login_Refresh_Logout_FullFlow integration test (15/15 green).
2026-04-15 11:03:15 -03:00
3d598faffc feat(api): UDT-003 registro de usuarios — backend completo (Phases 1-6)
- Domain: Usuario.ForCreation factory, UsernameAlreadyExistsException, IUsuarioRepository extendido
- Application: CreateUsuarioCommand/Validator/Handler, UsuarioCreatedDto, AuthOptions password policy
- Infrastructure: UsuarioRepository.ExistsByUsernameAsync + AddAsync (INSERT OUTPUT INSERTED.Id), RoleClaimType="rol" en TokenValidationParameters
- Api: UsuariosController POST api/v1/users [Authorize(Roles="admin")], ExceptionFilter mapea UsernameAlreadyExistsException + SqlException 2627 → 409
- Tests (unit): 43 tests — 33 validator + 10 handler (107 total, green)
- Tests (integration): 7 tests CreateUsuarioEndpoint — 401/403/400/201/409/race/e2e (green)
- Fix: TestWebAppFactory.ConfigureTestServices reemplaza SqlConnectionFactory singleton con CS de test correcto
2026-04-15 10:47:48 -03:00
aed26e3de9 feat(infra): register RefreshTokenRepository, RefreshTokenGenerator, ClientContext and handlers in DI 2026-04-14 13:28:36 -03:00
cb4250f7b3 feat(infra): implement ClientContext for IP and UserAgent from IHttpContextAccessor 2026-04-14 13:28:35 -03:00
19ac807500 feat(infra): add RefreshTokenDays to JwtOptions and AuthOptions config 2026-04-14 13:28:35 -03:00
0c809da633 feat(infra): implement RefreshTokenRepository with Dapper and add GetByIdAsync to UsuarioRepository 2026-04-14 13:28:29 -03:00
d326dd87e0 feat(infra): implement RefreshTokenGenerator with cryptographic random bytes 2026-04-14 13:28:24 -03:00
c910ff2fc5 feat(infra): implement GetPrincipalFromExpiredToken in JwtService 2026-04-14 13:28:20 -03:00
ca57ce33b5 feat(udt-001): infrastructure (Dapper, BCrypt, JWT RS256, dispatcher) 2026-04-13 21:36:02 -03:00