V011 crea dbo.Medio y dbo.Seccion con SYSTEM_VERSIONING ON (retention 10
anios) y PAGE compression en history; siembra el permiso
'administracion:secciones:gestionar' y lo asigna a rol admin. El permiso
'administracion:medios:gestionar' ya existia desde V005.
V012 siembra Medios fundacionales ELDIA y ELPLATA (MERGE idempotente).
Rollbacks V011/V012 validados estructuralmente; aplicacion y
reaplicacion verificadas en SIGCM2_Test y SIGCM2. Fixture de tests
actualizado: EnsureV011SchemaAsync, SeedMediosCanonicalAsync, ignora
Medio_History y Seccion_History en Respawner.
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}
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}
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}
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}
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}
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}
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}
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)
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}
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}
Transaction-scoped tests conflicted with the repository opening its own connection,
blocking on FK locks for the uncommitted seeded user and causing timeouts.
Switched to the Respawn pattern used by UsuarioRepositoryTests ([Collection("Database")])
which commits seed data and resets between test classes.