UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14

Merged
dmolinari merged 14 commits from feature/UDT-010 into main 2026-04-16 20:30:17 +00:00
Owner

Resumen

UDT-010 (Fase 0.5 — transversal) implementa la infra de auditoría y trazabilidad para SIG-CM 2.0. Cierra el follow-up #6 (admin creador en alta de usuarios) y prepara la base para que toda UDT futura nazca con trazabilidad completa.

Source of truth del diseño: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md (re-escrito en esta UDT — arquitectura escalable, automatizada y auto-documentada para el modelo).

Closes #6

Arquitectura (2 niveles)

Nivel Responsabilidad Almacén
1 — Temporal Tables "¿Cómo estaba X en T?" — historia del dato dbo.Usuario_History, dbo.Rol_History, dbo.Permiso_History, dbo.RolPermiso_History
2 — AuditEvent "¿Quién hizo qué, cuándo?" — eventos de dominio dbo.AuditEvent particionada mensual, retención 10 años
2bis — SecurityEvent Login/logout/refresh/permission.denied dbo.SecurityEvent particionada mensual, retención 5 años

Batches implementados (14 commits)

# Commit Descripción
B0 2d1d187 Bootstrap + spike anti-MSDTC (validated design #D-1)
B1 1c79dfa V010 migration — filegroups, partition functions/schemes, SYSTEM_VERSIONING
B1.3 c95bc7f Fix Respawn configs + Collection attrs (cross-assembly)
B2 68f96b9 Audit abstractions (IAuditContext/IAuditLogger/ISecurityEventLogger + DTOs)
B3 08d6622 JsonSanitizer + AuditOptions binding
B4 0b4af4c Middleware: CorrelationIdMiddleware + AuditActorMiddleware + AuditContext scoped
B5 300badd Repositories (AuditEventRepository + SecurityEventRepository + cursor pagination)
B6 a3d6214 AuditLogger + SecurityEventLogger impl (fail-closed)
B7 26efb74 Enganche 7 handlers Usuario — Closes #6
B8 a3f01bc Enganche 4 handlers Rol + AssignPermisos
B9 b619c05 SecurityEvent en Auth (Login/Logout/Refresh + PermissionAuthorizationHandler)
B10 2bb9011 GET /api/v1/audit/events + /health/audit
B11 9eac044 3 jobs Quartz.NET (Partition Manager / Retention Enforcer / Integrity Check)
B12 b526df2 Frontend /admin/audit — DataTable + filtros + tests

Seguridad y convenciones

  • Fail-closed: IAuditLogger lanza AuditContextMissingException si falta ActorUserId — mejor romper el comando que perder trazabilidad.
  • Transaccional: el logger participa del TransactionScope del comando. Si el insert en AuditEvent falla → rollback completo.
  • Sanitización PII: JsonSanitizer strippea keys blacklisted (password, token, cvv, etc.) antes de persistir metadata. Configurable vía Audit:SanitizedKeys.
  • Reuso de permiso: consulta protegida por administracion:auditoria:ver (ya existía en V005/V006 asignado a admin — no se crea permiso nuevo).
  • Soft FK en ActorUserId → Usuario.Id: sin CASCADE. La auditoría sobrevive al borrado de usuario (requisito legal).

Follow-up #6 cerrado

Antes: CreateUsuarioCommandHandler.cs:40 tenía // TODO: audit — record which admin created this user. Hoy: el handler emite usuario.create con ActorUserId = admin.Id del JWT sub en cada creación. Verificable vía:

SELECT TOP 10 ActorUserId, Action, TargetId, OccurredAt, Metadata
FROM dbo.AuditEvent
WHERE Action = 'usuario.create'
ORDER BY OccurredAt DESC;

Test plan

  • Backend suite — dotnet test debe pasar 528/528 (381 Application + 147 Api)
  • Frontend suite — cd src/web && npm test debe pasar 161/161
  • Smoke E2E: login admin → POST /api/v1/users crea usuario → consultar GET /api/v1/audit/events?targetType=Usuario devuelve el evento
  • Smoke health: GET /health/audit devuelve 200 Healthy
  • Jobs manualmente: arrancar la API, verificar en logs que los triggers cron están registrados
  • Regression: los 141 tests previos de Api.Tests siguen pasando
  • Smoke UI: navegar a /admin/audit, verificar que el DataTable lista eventos con filtros operativos

Docs actualizadas (Obsidian — gitignored, local)

  • Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md — re-escrito completo (source of truth de la arquitectura)
  • Obsidian/INSTRUCCIONES_IA.md — regla "🔍 REGLA DE AUDITORÍA Y TRAZABILIDAD" agregada
  • Obsidian/STATUS.md — UDT-010 marcado [x]
  • engram sig-cm2/audit-architecture + sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress} persistidos

Migración V010

Aplicada en dev (SIGCM2 + SIGCM2_Test). Para prod futuro, ver database/README.md — requiere ventana de mantenimiento corta (ALTER con SYSTEM_VERSIONING toma Sch-M lock brevemente) y backup previo. Rollback script incluido (V010_ROLLBACK.sql) — advertencia: pierde toda la historia auditada.

OUT of scope (diferido a ADM-004)

  • UI rica: drilldown modal, export CSV, timeline-per-entity
  • Derecho al olvido automatizado (Ley 25.326)
  • Columnstore en particiones COLD
  • Alertas externas (PagerDuty/Slack)
  • Outbox pattern (solo si AuditEvent > 200M filas)
## Resumen UDT-010 (Fase 0.5 — transversal) implementa la infra de auditoría y trazabilidad para SIG-CM 2.0. Cierra el follow-up #6 (admin creador en alta de usuarios) y prepara la base para que toda UDT futura nazca con trazabilidad completa. Source of truth del diseño: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md` (re-escrito en esta UDT — arquitectura escalable, automatizada y auto-documentada para el modelo). **Closes #6** ## Arquitectura (2 niveles) | Nivel | Responsabilidad | Almacén | |---|---|---| | 1 — Temporal Tables | "¿Cómo estaba X en T?" — historia del dato | `dbo.Usuario_History`, `dbo.Rol_History`, `dbo.Permiso_History`, `dbo.RolPermiso_History` | | 2 — `AuditEvent` | "¿Quién hizo qué, cuándo?" — eventos de dominio | `dbo.AuditEvent` particionada mensual, retención 10 años | | 2bis — `SecurityEvent` | Login/logout/refresh/permission.denied | `dbo.SecurityEvent` particionada mensual, retención 5 años | ## Batches implementados (14 commits) | # | Commit | Descripción | |---|---|---| | B0 | `2d1d187` | Bootstrap + spike anti-MSDTC (validated design #D-1) | | B1 | `1c79dfa` | V010 migration — filegroups, partition functions/schemes, SYSTEM_VERSIONING | | B1.3 | `c95bc7f` | Fix Respawn configs + Collection attrs (cross-assembly) | | B2 | `68f96b9` | Audit abstractions (IAuditContext/IAuditLogger/ISecurityEventLogger + DTOs) | | B3 | `08d6622` | JsonSanitizer + AuditOptions binding | | B4 | `0b4af4c` | Middleware: CorrelationIdMiddleware + AuditActorMiddleware + AuditContext scoped | | B5 | `300badd` | Repositories (AuditEventRepository + SecurityEventRepository + cursor pagination) | | B6 | `a3d6214` | AuditLogger + SecurityEventLogger impl (fail-closed) | | **B7** | **`26efb74`** | **Enganche 7 handlers Usuario — Closes #6** | | B8 | `a3f01bc` | Enganche 4 handlers Rol + AssignPermisos | | B9 | `b619c05` | SecurityEvent en Auth (Login/Logout/Refresh + PermissionAuthorizationHandler) | | B10 | `2bb9011` | `GET /api/v1/audit/events` + `/health/audit` | | B11 | `9eac044` | 3 jobs Quartz.NET (Partition Manager / Retention Enforcer / Integrity Check) | | B12 | `b526df2` | Frontend `/admin/audit` — DataTable + filtros + tests | ## Seguridad y convenciones - **Fail-closed**: `IAuditLogger` lanza `AuditContextMissingException` si falta `ActorUserId` — mejor romper el comando que perder trazabilidad. - **Transaccional**: el logger participa del `TransactionScope` del comando. Si el insert en `AuditEvent` falla → rollback completo. - **Sanitización PII**: `JsonSanitizer` strippea keys blacklisted (password, token, cvv, etc.) antes de persistir metadata. Configurable vía `Audit:SanitizedKeys`. - **Reuso de permiso**: consulta protegida por `administracion:auditoria:ver` (ya existía en V005/V006 asignado a admin — no se crea permiso nuevo). - **Soft FK** en `ActorUserId → Usuario.Id`: sin CASCADE. La auditoría sobrevive al borrado de usuario (requisito legal). ## Follow-up #6 cerrado Antes: `CreateUsuarioCommandHandler.cs:40` tenía `// TODO: audit — record which admin created this user`. Hoy: el handler emite `usuario.create` con `ActorUserId = admin.Id` del JWT sub en cada creación. Verificable vía: ```sql SELECT TOP 10 ActorUserId, Action, TargetId, OccurredAt, Metadata FROM dbo.AuditEvent WHERE Action = 'usuario.create' ORDER BY OccurredAt DESC; ``` ## Test plan - [x] Backend suite — `dotnet test` debe pasar 528/528 (381 Application + 147 Api) - [x] Frontend suite — `cd src/web && npm test` debe pasar 161/161 - [x] Smoke E2E: login admin → POST /api/v1/users crea usuario → consultar `GET /api/v1/audit/events?targetType=Usuario` devuelve el evento - [x] Smoke health: `GET /health/audit` devuelve 200 Healthy - [x] Jobs manualmente: arrancar la API, verificar en logs que los triggers cron están registrados - [x] Regression: los 141 tests previos de Api.Tests siguen pasando - [x] Smoke UI: navegar a `/admin/audit`, verificar que el DataTable lista eventos con filtros operativos ## Docs actualizadas (Obsidian — gitignored, local) - `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md` — re-escrito completo (source of truth de la arquitectura) - `Obsidian/INSTRUCCIONES_IA.md` — regla "🔍 REGLA DE AUDITORÍA Y TRAZABILIDAD" agregada - `Obsidian/STATUS.md` — UDT-010 marcado `[x]` - engram `sig-cm2/audit-architecture` + `sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress}` persistidos ## Migración V010 **Aplicada en dev** (`SIGCM2` + `SIGCM2_Test`). Para prod futuro, ver `database/README.md` — requiere ventana de mantenimiento corta (ALTER con SYSTEM_VERSIONING toma Sch-M lock brevemente) y backup previo. Rollback script incluido (`V010_ROLLBACK.sql`) — advertencia: pierde toda la historia auditada. ## OUT of scope (diferido a ADM-004) - UI rica: drilldown modal, export CSV, timeline-per-entity - Derecho al olvido automatizado (Ley 25.326) - Columnstore en particiones COLD - Alertas externas (PagerDuty/Slack) - Outbox pattern (solo si AuditEvent > 200M filas)
dmolinari added 14 commits 2026-04-16 20:13:24 +00:00
Validates design decision #D-1 (TransactionScope ambient over IUnitOfWork):
TransactionScope with TransactionScopeAsyncFlowOption.Enabled does NOT
escalate to MSDTC when multiple SqlConnections share the same connection
string. Test passes (DistributedIdentifier == Guid.Empty).

Unblocks UDT-010 batches B1-B14.

Refs: sdd/udt-010-auditoria-trazabilidad/{design,tasks}
Applied to SIGCM2 (dev) and SIGCM2_Test.

V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
  via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
  DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
  forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
  (Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
  PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).

V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).

database/README.md: migration order + V010 prod-apply notes.

tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
  clear message if not — migration itself requires ALTER DATABASE privileges
  and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
  on system-versioned history tables).

tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
  pre-update state + Usuario_History populated.

Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
Follow-up of B1 (V010 migration). Issues found when running the full suite
cross-assembly:

1. Respawn 'Cannot delete rows from a temporal history table' error:
   4 per-class Respawner configs in SIGCM2.Application.Tests did not
   include the newly-created *_History tables introduced by V010
   (Usuario_History / Rol_History / Permiso_History / RolPermiso_History).
   The engine rejects direct DELETE on system-versioned history tables.
   Extended TablesToIgnore in all 4 configs.

2. FK_RefreshToken_Usuario violation in RolRepositoryTests.InitializeAsync:
   Manual 'DELETE FROM Usuario' failed when residual RefreshTokens from
   prior suites existed. Added 'DELETE FROM RefreshToken' before the
   Usuario cleanup to respect FK order. Latent bug surfaced by a new
   test-run ordering — not UDT-010 specific, but fixed in scope.

3. UQ_Usuario_Username duplicate admin race:
   TransactionScopeSpikeTests (B0) and V010MigrationTests (B1) were
   missing [Collection("ApiIntegration")], causing them to run in
   parallel with the rest of SIGCM2.Api.Tests and race on SeedAdmin.
   Serialized by adding the Collection attribute.

Suite now passes cross-assembly: 130/130 Api.Tests + 336/336 Application.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/apply-progress (B1 follow-up)
Introduces the contract layer for audit logging per design #D-8:

SIGCM2.Application/Audit/:
- IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/
  Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware +
  AuditActorMiddleware (B4).
- IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) —
  domain-level audit emitter. Enlists in ambient TransactionScope
  (fail-closed per #REQ-AUD-4).
- ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?,
  sessionId?, failureReason?, metadata?, ct) — security-events emitter
  separate from IAuditLogger (different retention, no transaction scope,
  captures login/logout/refresh/permission.denied).
- AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by
  JsonSanitizer in B3).
- AuditEventDto — read projection for GET /api/v1/audit/events (B10).
- AuditEventFilter — query filter record with default Limit=50.

SIGCM2.Domain/Exceptions/:
- AuditContextMissingException : DomainException — fail-closed sentinel
  thrown when IAuditLogger is called without ActorUserId in a user-scoped
  command (#REQ-AUD-4).

Tests (Strict TDD — shape contract, RED → GREEN):
- tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests
  covering nullability, signatures, default options, record equality.

Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2}
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}
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}
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}
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}
7 command handlers del módulo Usuarios ahora auditan via IAuditLogger:

| Handler                                 | Action                  |
|-----------------------------------------|-------------------------|
| CreateUsuarioCommandHandler             | usuario.create          |
| UpdateUsuarioCommandHandler             | usuario.update          |
| DeactivateUsuarioCommandHandler         | usuario.deactivate      |
| ReactivateUsuarioCommandHandler         | usuario.reactivate      |
| ChangeMyPasswordCommandHandler          | usuario.password_change |
| ResetUsuarioPasswordCommandHandler      | usuario.password_reset  |
| UpdateUsuarioPermisosOverridesHandler   | usuario.permisos_update |

Patrón por handler (per design #D-1):
  using (var tx = new TransactionScope(Required, ReadCommitted, AsyncFlowEnabled))
  {
      await repo.UpdateAsync(...);
      await audit.LogAsync(...);
      tx.Complete();
  }
  // post-commit reads OUTSIDE the using block
  var updated = await repo.GetDetailAsync(...);

Metadata captured:
- usuario.create: after={username, nombre, apellido, email, rol} — NO password.
- usuario.update: {before, after} diff of editable fields.
- usuario.password_reset: {targetId} only — tempPassword is NEVER persisted to
  audit (returned to caller once, never stored).
- usuario.permisos_update: {before, after} of grant/deny override lists.

Key fix during implementation: initially used 'using var tx = ...' (bare
declaration). This kept the TransactionScope active for the rest of the method,
causing 'The current TransactionScope is already complete' when post-commit
reads (GetDetailAsync) tried to enlist. Solution: explicit 'using (var tx = ...)
{ ... }' block that disposes the scope before post-commit reads.

AuditContextMissingException surfaces from AuditLogger when IAuditContext
lacks ActorUserId — fail-closed per #REQ-AUD-4. In integration tests, the
middleware populates ActorUserId from the JWT sub of the authenticated admin.

Test updates: 6 existing unit test classes now inject IAuditLogger mock:
- CreateUsuarioCommandHandlerTests
- UpdateUsuarioCommandHandlerTests
- DeactivateUsuarioCommandHandlerTests
- ReactivateUsuarioCommandHandlerTests
- ChangeMyPasswordCommandHandlerTests
- ResetUsuarioPasswordCommandHandlerTests

Follow-up #6 ([Auditoría] Registrar admin creador en alta de usuarios) is
closed: CreateUsuarioCommandHandler now records ActorUserId = admin JWT sub
on every user creation. TODO comment removed.

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

Closes #6
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-UM-AUD, design, tasks#B7}
4 command handlers del módulo Roles + Permisos ahora auditan:

| Handler                              | Action                 |
|--------------------------------------|------------------------|
| CreateRolCommandHandler              | rol.create             |
| UpdateRolCommandHandler              | rol.update             |
| DeactivateRolCommandHandler          | rol.deactivate         |
| AssignPermisosToRolCommandHandler    | rol.permisos_update    |

Mismo patrón que B7 (using block + post-commit reads outside scope).

Metadata:
- rol.create: after={Codigo, Nombre, Descripcion}
- rol.update: {before, after} diff
- rol.permisos_update: {before, after} con arrays de codigos ordenados

AssignPermisosToRolCommandHandler captura 'before' leyendo
GetByRolCodigoAsync antes del TransactionScope para poder emitir el diff.

4 test classes actualizados con mock de IAuditLogger.

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

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-RM-AUD, design, tasks#B8}
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}
AuditController:
- GET /api/v1/audit/events?actorUserId&targetType&targetId&from&to&cursor&limit
- Protected by [RequirePermission("administracion:auditoria:ver")] — reuses
  the existing permission (V005/V006 seed assigns it to admin).
- 400 on limit out of [1,100] or from > to.
- Cursor-based DESC pagination via AuditEventRepository.QueryAsync.

AuditHealthCheck (IHealthCheck):
- Validates SYSTEM_VERSIONING ON on Usuario/Rol/Permiso/RolPermiso.
- Validates partition boundaries exist for next 3 months (both AuditEvent and
  SecurityEvent functions).
- Reports last audit event age (lenient 24h to accommodate dev/test quiet envs).
- Validates HISTORY_RETENTION_PERIOD == 10 YEARS on all 4 tables.
  Key fix during impl: sys.tables.history_retention_period is stored in UNITS
  (1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR), NOT seconds. Assertion: period=10
  AND unit=6 (10 YEARS).
- Mapped at /health/audit via app.MapHealthChecks with tag 'audit'.

Tests (Strict TDD, integration against SIGCM2_Test):
- AuditControllerTests (5): without-auth 401, without-permission 403 (cajero),
  admin with filter returns events, invalid limit 400, from>to 400.
- AuditHealthCheckTests (1): returns Healthy with V010 applied.

Suite: 378/378 Application.Tests + 147/147 Api.Tests = 525/525 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-7/8, design, tasks#B10}
Read-only audit timeline per design #D-9. Delegated to sub-agent, completed
before rate limit cutoff; verified with vitest 161/161 passing.

New files:
- src/web/src/api/audit.ts — axios client: listAuditEvents(filter)
- src/web/src/features/admin/audit/useAuditEvents.ts — TanStack Query hook
- src/web/src/pages/admin/audit/AuditPage.tsx — DataTable + 4 filters + cursor
  pagination 'Cargar más' button. Columns: OccurredAt (local time formatted),
  ActorUsername, Action (badge), TargetType + TargetId, IpAddress, CorrelationId
  (copy button with toast).
- src/web/src/pages/admin/audit/AuditFilters.tsx — 4 filters form.
- src/web/src/tests/features/admin/audit/useAuditEvents.test.ts — hook unit.
- src/web/src/tests/features/admin/audit/AuditPage.test.tsx — component test
  with MSW handler mock.

Modified:
- src/web/src/router.tsx — /admin/audit route, protected by auth + permission
  'administracion:auditoria:ver'.
- src/web/src/components/layout/AppSidebar.tsx — sidebar entry (icon, visible
  only with the required permission, uses existing permission-filtering pattern).

OUT of scope (deferred to ADM-004):
- Row drilldown modal with full metadata JSON formatted.
- CSV export.
- Timeline-per-entity visualization.

Design System v2.4 conventions respected: DataTable component from
@/components/ui/data-table (no raw <table>), tokens only (no hex inline),
density compact, Radix tooltip for copy button, sonner toast on copy.

Vitest run: 29 test files / 161 tests passing. No regressions in existing
frontend tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec, design#D-9, tasks#B12}
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}
dmolinari merged commit 7c0646be0d into main 2026-04-16 20:30:17 +00:00
dmolinari deleted branch feature/UDT-010 2026-04-16 20:30:17 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dmolinari/SIG-CM2.0#14