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}
shadcn MCP server registrado globalmente (claude mcp add -s user shadcn -- npx -y shadcn@latest mcp). Disponible en cualquier sesion/proyecto.
8 componentes shadcn nuevos via CLI (cd src/web && npx shadcn@latest add ...):
- table, select, popover, pagination, breadcrumb, alert-dialog
- command, dialog (deps de combobox y alert-dialog)
Total instalados ahora: 22
Fix gotcha shadcn CLI: agregado compilerOptions.paths al root tsconfig.json
(sino crea folder literal '@/' en lugar de resolver el alias). Antes solo
estaba en tsconfig.app.json que el CLI no lee.
@tanstack/react-table 8.21 instalado.
Nuevo componente <DataTable> generico (src/web/src/components/ui/data-table.tsx):
- Wrapper sobre TanStack Table
- Priority columns: meta { priority: 'high' | 'medium' | 'low' }
→ hidden md:table-cell / hidden lg:table-cell automatico
- Tap-to-expand row mobile (chevron auto-aparece cuando hay cols hidden,
click despliega panel con hidden cells como dl/dt/dd)
- Loading state con DataTableSkeleton
- Empty state customizable
- onRowClick callback con stop-propagation correcto en chevron
- 14 tests cubriendo todas las features
Refactor UsersTable a DataTable como dogfood (mismo output visual,
columnas con priority alta/media/baja). 150 tests frontend totales verde.
Doc Obsidian 2.14 v2.4 actualizado con seccion DataTable completa,
componentes ampliados a 22, MCP global, y gotcha del tsconfig.
Engram sig-cm2/design-system actualizado a v2.4.
Skill registry actualizado con compact rules de DataTable y MCP.
Cambios:
- Instalado @radix-ui/react-tooltip 1.2.8 (componente faltante de
shadcn/ui que faltaba en el set inicial).
- Nuevo src/web/src/components/ui/tooltip.tsx (shadcn pattern):
TooltipProvider, Tooltip, TooltipTrigger, TooltipContent con
animaciones data-state (fade + zoom + slide direccional).
- App.tsx: TooltipProvider envuelve toda la app con delayDuration 150ms.
- AppSidebar refactorizado:
- Toggle button MOVIDO al header (top), al lado izquierdo del nombre
'SIG-CM 2.0'. Eliminado boton bottom (era redundante).
- Cuando collapsed: solo el toggle visible centrado (68px width).
- Cuando expanded: [Toggle] [SIG-CM 2.0] aligned left.
- Quitado overflow-hidden del aside (era lo que impedia que los
tooltips fueran visibles — los clipping containers padres tampoco
importan ahora porque Radix portalea el tooltip a body).
- Tooltips en TODOS los items collapsed (incluido el toggle) y en
items disabled muestra 'Label · Próximamente'.
- Eliminado el componente CSS-only SidebarTooltip (reemplazado por
Radix que se renderiza fuera del DOM tree con Portal).
El bug original era que tanto el aside con overflow-hidden como el
ProtectedLayout con overflow-hidden clipean cualquier elemento que
intente escapar via absolute positioning. Radix Portal soluciona
eso renderizando el tooltip en document.body.
Tests 136/136 verde.
Cambios:
- Nuevo hook useSidebar() con persistencia en localStorage
('sidebar-collapsed' = '1'/'0').
- SidebarNav refactorizado:
- Width controlled internamente (w-60 expanded, w-[68px] collapsed)
- Toggle button al pie con PanelLeftClose/Open icon
- Brand mark con gradient brand+violet (consistencia con login)
- Active indicator: barra vertical sutil a la izquierda cuando expanded,
bg accent cuando collapsed
- SectionLabel se reemplaza por divider sutil cuando collapsed
- Custom SidebarTooltip puro CSS (sin radix dep nuevo) que aparece
a la derecha del item con animacion fade + slide al hover. Funciona
con group-hover/item y group-hover/toggle (Tailwind named groups).
- Items disabled muestran badge 'Próx.' chico (era 'Próximamente' largo)
y en tooltip cuando collapsed: 'Label · Próximamente'.
- Fix scroll horizontal: overflow-x-hidden en nav, truncate en spans,
shrink-0 en iconos y badges. Layout robusto a labels largos.
- ProtectedLayout deja de hardcodear lg:w-60 — sidebar controla su width.
- AppHeader Sheet (mobile) usa <SidebarNav forceExpanded /> para que
en mobile siempre se vea expanded sin importar el state desktop.
Tests 136/136 verde.
Cambios de tokens:
- Light --background: 0.988 -> 0.962 (slate cool, hace pop el card white)
- Dark --background: 0.135 -> 0.130 (mas oscuro)
- Dark --card: 0.180 -> 0.220 (salto +0.090 vs bg, antes solo +0.045)
- Dark --popover: 0.200 -> 0.245 (popovers/dropdowns aun mas elevados)
- Dark --secondary/muted/accent/input: bumpeados al nivel correspondiente
para que la jerarquia visual mantenga proporciones
Card variants:
- default: shadow-sm -> shadow-md (mas elevacion default)
- nuevo variant 'flat' (sin shadow) para cuando se necesite
Nueva utility .surface:
- Mismo treatment visual que <Card variant='default'> pero como clase
CSS — para contenedores que no usan el componente Card (ej: tablas,
listas custom). bg-card + border + radius + shadow-md.
UsersTable refactorizado para usar .surface en lugar de border manual.
Cualquier futura tabla/lista usa .surface por consistencia.
Tests 136/136 verde.
Cambios:
- index.css: fix de browser autofill (Chrome/Safari forzaba bg amarillo +
texto blanco que rompia contraste). Override -webkit-text-fill-color
+ box-shadow inset para mantener tokens del DS. Esta era la causa real
de las 'letras blancas en gris' que se veian en login.
- index.css: utility .grid-bg global (7% opacity light, 10% dark) — para
usar como fondo cuadriculado en todos los layouts.
- PublicLayout: agrego .grid-bg layer + bg-background explicito + glow
blobs mas intensos (25%/20% en light vs 10% antes). Light ahora
tiene la misma profundidad visual que dark.
- ProtectedLayout: agrego .grid-bg + glow blobs sutiles en corners para
dar profundidad al dashboard y todas las secciones internas. Resalta
futuros componentes glass.
Tests: 136/136 verde.
El ThemeToggle solo vivia en AppHeader (ProtectedLayout), por lo que
desde /login era imposible cambiar el tema. Movido a esquina superior
derecha con z-index 20 sobre el gradient mesh.
useTheme defaultea a system preference, pero el usuario tiene que poder
override desde cualquier pantalla — incluido el login.
Cambios principales:
- Agregado violet accent (oklch 0.62 0.20 280) para combo tech con brand cyan
- Neutrals con shift sutil hacia hue 250 (slate-violet)
- Dark mode con bg oklch(0.135 0.018 252) — no pure black, feel mas tech
- Inputs con token --input propio (white en light, elevado en dark) y --input-border mas prominente. Fixea problema de input gris feo
- Card soporta variant glass/elevated/default
- Multi-layer shadows reales (shadow-sm/md/lg/xl/glow)
- Gradient mesh utility (.gradient-mesh + token --gradient-mesh)
- Clase .glass para glassmorphism (backdrop-blur 20px + saturate 180%)
- Border radius default 10px (era 8px) — mas moderno
- Headings con tracking-tight -0.015em
LoginPage redesigned:
- PublicLayout con gradient mesh + 2 glow blobs (brand+violet) + grid sutil
- Card variant glass para el form
- Logo mark con bg-gradient-to-br from-brand-500 to-violet-500
- Inputs con bg propio + ring brand glow al focus
Tests: 136/136 verde.
Doc Obsidian 2.14 actualizado v2.0. Engram sig-cm2/design-system actualizado.
- Reemplazo de tokens HSL por OKLCH (Tailwind 4 native)
- Brand color #008fbe escalado a brand-50..950
- Neutral cool slate (complementa brand)
- Semantic: success/warning/destructive como tokens
- Background tinted off-white (no pure white) para warmth
- Dark mode usa neutral-950 (no pure black)
- Brand utilities expuestos via @theme inline (bg-brand-500, etc)
- Focus rings con ring brand color
- Selection con brand-200/800
Skill registry actualizado con compact rules de design system para auto-inyeccion en sub-agents.
Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.14 Design System.md
Engram: sig-cm2/design-system
Sonner estaba como dependencia pero el componente Toaster nunca se monto
en el arbol de la app. ChangeMyPasswordPage ya usaba toast() pero no
mostraba nada visualmente. Agregado <Toaster richColors closeButton /> en
App.tsx (top-right) y toasts de exito/error en PermisosEditor.handleSave
para confirmar al usuario que el cambio se persistio.
El backend devuelve { rolPermisos, overrides: {grant, deny}, effective }
(nested) segun spec, pero el frontend lo lee como {grant, deny} planos.
Causaba TypeError: permisoData.grant is not iterable al abrir tab Permisos.
Tests del frontend actualizados con el shape correcto.
El row click de UsersListPage navega directo a /usuarios/:id/editar,
por lo que el modal montado solo en UserDetailPage no era alcanzable
desde el flujo real. Ahora tambien esta en el header del EditPage,
al lado del boton Volver, oculto cuando el target es el user logueado.
La seccion Mi cuenta en el sidebar quedaba desprolija con un unico item.
Se movio Cambiar contraseña al dropdown del avatar en AppHeader donde
pertenece semanticamente.
El componente ResetPasswordModal estaba implementado pero nunca montado en una pagina.
Ahora se renderiza en UserDetailPage, oculto cuando el target es el usuario logueado
(evita hit de cannot-self-reset en backend).