FactuGES_ERP/docs/architecture/audit-system-design.md

233 lines
19 KiB
Markdown
Raw Normal View History

# DOCUMENTO DE DISENO - SISTEMA TRANSVERSAL DE AUDITORIA
**Estado:** consolidado actual.
**Fecha:** 25 de agosto de 2026.
**Alcance:** Writer portable, composición server-side, lectura HTTP y frontend de `/audit` actualmente implementados.
## Resumen ejecutivo
Audit registra **quién** realizó una operación, **qué** ocurrió, **cuándo**, sobre qué **target**, bajo qué **scope** y, para una mutación, los cambios históricos exactos. Es un Audit Trail con Change History append-only.
No es Event Sourcing, logging técnico general, un sistema genérico de versionado, `BusinessVersion` ni `DocumentVersion`. El estado actual implementa Audit Trail y Change History. BusinessVersion y DocumentVersion son evoluciones distintas y no están implementadas.
## Separación conceptual
| Concepto | Responsabilidad | Estado |
| --- | --- | --- |
| Audit Trail | Quién, qué, cuándo, target, contexto y operación realizada | Implementado |
| Change History | Diff histórico de una mutation | Implementado |
| BusinessVersion | Versionado funcional de entidades/documentos de negocio | Futuro |
| DocumentVersion | Versionado de binarios o documentos | Futuro |
## Principios y límites del paquete portable
`@repo/rdx-audit` proporciona el Writer portable. No conoce Sequelize, MariaDB, Express, Company, tenant, Criteria, Identity, roles, permisos, transacciones concretas ni el `AsyncLocalStorage` del host. El dominio funcional tampoco conoce Audit: los application services reciben `IAudit` por composición.
Principios normativos:
- Store append-only y actions históricas estables.
- La mutación de negocio y su AuditEntry comparten transacción.
- La autorización de lectura está separada de los filtros solicitados.
- El Reader está separado del Writer y no pertenece al paquete portable.
- Cada módulo proyecta snapshots históricos controlados; Audit no inspecciona aggregates ni ORM.
- Audit no depende de módulos funcionales concretos.
## Contratos Writer
`IAudit.record()` es la operación canónica. Los atajos sólo fijan el `kind` y delegan en ella:
```ts
interface IAudit {
record(input: RecordAuditInput): Promise<Result<void, AuditError>>;
recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
}
```
Un `event` representa una acción ocurrida y siempre persiste `changes: []`; su target puede no tener ID. Una `mutation` exige target identificado, recibe `before` y `after`, calcula el diff y sólo se persiste si hay al menos un cambio. Una mutation no-op devuelve éxito y no crea AuditEntry.
La estructura base contiene `id`, `occurredAt` ISO, `source`, `operation`, `actor`, `scope`, `action`, `kind`, `target` y `changes`. El ID se genera con `randomUUID()` salvo generador inyectado; el timestamp procede de `now().toISOString()`.
### AuditState y snapshots
`AuditState` admite objetos planos readonly cuyos valores son `string`, número finito, `boolean`, `null`, objeto plano readonly o array readonly. Se rechazan `undefined`, `Date`, `Buffer`, `bigint`, funciones, `Map`, `Set`, instancias de clase, Value Objects, `Maybe`, `Collection`, símbolos y ciclos. Las claves no pueden contener `.`.
Los snapshots son funcionales, no serializaciones ORM. No se debe aplicar `aggregate.toPrimitives()` automáticamente. Normalmente excluyen timestamps técnicos y `companyId` cuando el scope ya expresa compañía. PII puede incluirse si la proyección histórica lo decide; referencias de catálogo se guardan como IDs históricos y no se resuelven con labels actuales. Los arrays son sensibles al orden; si el orden no tiene semántica, el módulo debe normalizarlo antes de auditar.
### Diff
`diffAuditState()` compara objetos recursivamente, ordena paths de forma determinista y es independiente del orden de inserción de keys. Los arrays son atómicos; `atomicPaths` permite tratar un objeto como atómico. La ausencia de clave no equivale a `null`:
```ts
interface AuditChange {
readonly path: string;
readonly kind: "set" | "unset" | "change";
readonly before: AuditValue | null;
readonly after: AuditValue | null;
}
```
- `set`: clave ausente antes y presente después.
- `unset`: presente antes y ausente después.
- `change`: presente en ambos, con valor distinto o cambio atómico/tipo incompatible.
## Contexto de operación y transacción
`OperationContext` contiene `operationId`, `correlationId`, `origin`, `actor` y `scope`. `AsyncLocalStorageOperationContextStore` lo aísla por operación asíncrona y restaura correctamente scopes anidados. Operation/correlation/origin son inmutables; sólo se enriquecen actor y scope. Un actor concreto no puede sustituirse por otro, y un scope concreto no puede sustituirse por otro; el actor anónimo y el scope nulo pueden enriquecerse una vez.
Pipeline HTTP actual:
```text
request -> OperationContext middleware -> authenticateUser -> requireCompanyContext -> controller
```
El middleware genera `operationId`, adopta `X-Correlation-Id` si es válido o genera uno, responde `X-Request-Id` y `X-Correlation-Id`, y parte de `origin: "http"`, actor anónimo y scope nulo. Autenticación enriquece actor como `user`; el contexto de compañía autorizado enriquece scope como `company`.
`OperationAuditContextProvider` adapta `OperationContext` al puerto `IAuditContextProvider`, sin invertir la dependencia del paquete portable. `scope: null` sólo significa que la operación histórica no tuvo scope; nunca significa visibilidad global.
`SequelizeTransactionContext` usa otro `AsyncLocalStorage` exclusivamente para la transacción Sequelize activa. No reemplaza el paso explícito de `Transaction` entre casos de uso y repositorios. `SequelizeTransactionManager.complete()` prohíbe transacciones anidadas, abre T1, publica T1, ejecuta el callback y hace rollback si lanza o devuelve `Result.fail`; en éxito hace commit.
Para una mutation la propiedad requerida es:
```text
BEGIN T1
mutación y persistencia funcional
Audit.recordMutation
audit_events + audit_changes
COMMIT
```
Si Audit falla, el resultado debe propagarse y T1 revierte negocio y auditoría. Los tests MariaDB de `sequelize-audit-store.integration.test.ts` comprueban commit, rollback, cambios JSON y fallo de mutation sin T.
## Store y persistencia MariaDB
`SequelizeAuditStore` implementa `IAuditStore.append(entry)`. No hay upsert: un ID duplicado falla. Un event puede insertarse sin transacción; una mutation sin transacción devuelve `AuditStoreError` controlado. Con T activa crea el event y hace `bulkCreate` de los changes en la misma T.
`audit_events` tiene PK `id` y columnas `kind`, `occurred_at`, source, operation/correlation/origin, actor, scope, action y target. Índices: `(occurred_at,id)`, scope+fecha+id, target+fecha+id, actor+fecha+id, `operation_id` y `correlation_id`.
`audit_changes` tiene PK compuesta `(audit_event_id, position)`, FK a `audit_events(id)` con `ON DELETE CASCADE`, además de `path`, `kind`, `before_value` y `after_value` JSON. MariaDB puede devolver JSON como texto; los getters del modelo lo parsean para mantener el contrato al leer. `position` preserva el orden persistido.
La composición productiva crea una única instancia: `AsyncLocalStorageOperationContextStore -> OperationAuditContextProvider`, `SequelizeTransactionContext -> SequelizeAuditStore`, y `Audit({ source: { type: "application", id: "uecko-erp" }, ... })`. Esa instancia se entrega como `ModuleParams.audit`; los módulos reciben el puerto, no el store concreto ni una instancia por request.
## Patrón de instrumentación funcional
Para actualizar: cargar agregado, proyectar `before`, mutar, terminar sin persistencia/audit si es no-op, persistir con T, proyectar `after`, `recordMutation`, y propagar cualquier fallo para rollback. Para create: crear, persistir, proyectar estado y auditar dentro de T. Para delete: cargar, proyectar before, borrar y auditar dentro de T.
Normalmente no se auditan validation failure, not found, rechazo de dominio, cross-company, duplicados ni no-ops: Audit describe operaciones realizadas, no intentos fallidos.
## Módulos instrumentados y actions
| Target | Action | Kind | Estado |
| --- | --- | --- | --- |
| payment_method | `payment_method.created` | mutation | Implementada |
| payment_method | `payment_method.updated` | mutation | Implementada |
| payment_method | `payment_method.enabled` | mutation | Implementada |
| payment_method | `payment_method.disabled` | mutation | Implementada |
| payment_method | `payment_method.deleted` | mutation | Implementada |
| payment_method | `payment_method.default_set` | mutation | Implementada |
| payment_method | `payment_method.default_unset` | mutation | Implementada |
| payment_term | `payment_term.created` | mutation | Implementada |
| payment_term | `payment_term.updated` | mutation | Implementada |
| payment_term | `payment_term.enabled` | mutation | Implementada |
| payment_term | `payment_term.disabled` | mutation | Implementada |
| payment_term | `payment_term.deleted` | mutation | Implementada |
| customer | `customer.created` | mutation | Implementada |
| customer | `customer.updated` | mutation | Implementada |
| company | `company.created` | mutation | Implementada |
| company | `company.updated` | mutation | Implementada |
| company | `company.enabled` | mutation | Implementada |
| company | `company.disabled` | mutation | Implementada |
| supplier | `supplier.created` | mutation | Implementada |
| supplier | `supplier.updated` | mutation | Implementada |
| supplier | `supplier.activated` | mutation | Implementada |
| supplier | `supplier.deactivated` | mutation | Implementada |
Payment Methods proyecta `name`, `description`, flags `isActive/isSystem/isDefault`; cambiar el default puede producir dos AuditEntries (`default_unset` y `default_set`) bajo la misma operación y T. Payment Terms añade `dueRules` como array atómico ordenado por el dominio, con porcentajes serializados como strings.
Customers y Suppliers proyectan estado, referencia, identidad comercial/fiscal, dirección, contactos, idioma y moneda. Customers añade método/plazo de pago, régimen fiscal y flags de recargo/retención; incluye PII y excluye deliberadamente `legalRecord`. No hay actions lifecycle de Customer. Suppliers separa status del PATCH mediante lifecycle explícito; los inactivos siguen siendo consultables. Companies proyecta identidad, contacto, slug, plazo por defecto y estado; create es unscoped y update/status son scoped en las rutas actuales.
Una `action` es un string jerárquico validado en minúsculas, por ejemplo `customer.updated`. Tras registrarse se convierte en contrato histórico: no debe renombrarse arbitrariamente ni centralizarse en un enum global inexistente.
## Reader, autorización y HTTP
`IAuditReader` y `SequelizeAuditReader` viven en `apps/server` porque dependen de Sequelize. `AuditReadAccess` puede ser `unrestricted` o `scoped { scopes, includeUnscoped }`. La ruta HTTP V1 sólo crea acceso scoped con la compañía activa y `includeUnscoped: false`; no expone unrestricted ni RBAC funcional. Por tanto, cualquier usuario autenticado con membership activa de la compañía puede leer todo su Audit, incluidos snapshots con TIN, email, teléfonos y direcciones. Es una limitación y riesgo de PII explícito.
Regla normativa de lectura: `AUTHORIZED_WHERE AND REQUESTED_FILTERS`. El test prueba que autorizado en A y filtro `scopeId=B` produce `[]`. Detail fuera de scope devuelve 404, no 403, para no revelar existencia.
Endpoints actuales:
- `GET /audit`
- `GET /audit/:audit_entry_id`
Ambos pasan por `requireIdentityTenant()`. Zod usa objetos estrictos; una query/params inválida devuelve 422 mediante `ValidationApiError`, no 400. Query soporta `pageNumber`, `pageSize`, `q`, `from`, `to`, `kind`, `action`, `targetType`, `targetId`, `actorType`, `actorId`, `scopeType`, `scopeId`, `operationId`, `correlationId`, `origin`, `orderBy` y `order`.
El Reader aplica una whitelist propia incluso cuando se invoca directamente. Rechaza `unknownField`, `beforeValue`, `afterValue` y `some_sql_column` antes de SQL. Quick search busca `action`, labels de target/actor/scope y coincidencias exactas de target/operation/correlation ID; escapa `%`, `_` y `\\`, y no busca dentro del JSON histórico.
Los periodos son normativos `[from,to)`: `occurred_at >= from` y `< to`. El backend usa paginación zero-based, `pageSize` máximo 100, y responde `page`, `per_page`, `total_pages`, `total_items`. Sorting acepta `occurredAt`, `action`, `targetType` y `actorLabel`; el default y tie-breaker son `occurred_at DESC, id DESC`.
Cada AuditEntry es una fila de list. La list no carga changes: resuelve `changes_count` con `COUNT + SELECT` y subquery correlacionada, por lo que es O(1) queries respecto a N entries. Detail primero autoriza y carga el event, después carga changes en consulta adicional por `position ASC`; un event devuelve `changes: []`.
## Frontend `/audit`
El módulo es `modules/audit`; la ruta protegida y con compañía requerida es `/audit`. La URL es la fuente de verdad para `q`, `period`, `from`, `to`, `kind`, `targetType`, `action`, `pageNumber`, `pageSize`, `orderBy`, `order` y `entry`. `pageNumber` URL es one-based; controller y backend son zero-based. El default es `last7Days`.
Filtros visibles V1: búsqueda, periodo, kind, recurso y action. No hay filtro Actor ni Company. Actor no se expone porque no existe endpoint productivo de company users ni selector reutilizable; `/users` es legacy/desconectado, `actor.label` es histórico y hay actores no humanos. El backend sí soporta actorId/actorType.
Presets: `today` usa inicio local del día a inicio del siguiente; `last24Hours`, `last7Days` y `last30Days` terminan en now; `custom` conserva sus ISO. Registries de targets/actions/paths son metadata de presentación frontend, no contratos backend, y usan fallback raw para actions, paths o targets desconocidos.
`entry=<id>` abre un Sheet lateral y permite deep-link, reload y Back/Forward. `GET /audit/:id` se mapea de DTO snake_case a modelo camelCase. El Sheet muestra action/target/actor, fecha local, changes (`set`: nuevo valor; `unset`: valor eliminado; `change`: antes/después), evento sin cambios, y metadata técnica: Audit Entry ID, action raw, kind, target/actor/scope ID, operation/correlation ID, origin, source type/id y timestamp ISO. El renderer genérico cubre null, boolean, scalar, array y objeto; no hay renderers de dominio especializados ni resolución actual de catálogos.
Estados UX: skeleton inicial, datos conservados durante refetch, empty y filtered empty, PageError inicial, error inline de refresh, skeleton/error/retry en detail y 404 neutro.
### Refresh y Live
Actualizar llama al `refetch()` de la query actual. Live es una preferencia local no persistida. React Query hace polling de 5 segundos sólo cuando Live es efectivo: primera página y periodo que incluye now. Para custom se usa `from <= now < to`; fuera de primera página o periodo incompatible Live permanece solicitado pero pausado y explica el motivo. El detalle no hace polling.
Los rangos relativos se recalculan dentro del request de polling sin modificar la URL. La query key mantiene el estado semántico ya renderizado, evitando crear una entrada de caché por cada `now`. Las keys de list y detail incluyen `companyId`; al cambiar compañía se limpia `entry` y Live solicitado continúa sobre la key de la nueva compañía.
## Validación, regresiones y evidencia
La cobertura existente incluye Writer/diff/state, OperationContext y transaction context, store y rollback MariaDB, integrations de Payment Methods/Terms, Customer, Company y Supplier, Reader scoped/unrestricted/whitelist/rango/búsqueda/paginación/detail, HTTP tenant A/B/404/422, y frontend mapper/URL/periodos/filtros/detail/renderer/live. La ejecución actual de `pnpm --filter @erp/audit test` valida 32 tests frontend.
Lecciones verificadas en código y pruebas:
- Los operadores de Criteria `GREATER_THAN_OR_EQUAL` y `LOWER_THAN` se traducen expresamente a `Op.gte` y `Op.lt`; fue un bug de implementación del Reader, no una regla opcional.
- `from` y `to` deben combinarse sobre la misma columna `occurred_at`; sobrescribir el where habría roto `[from,to)`. La integración Reader cubre el rango.
- El parser frontend convierte URL one-based a estado/backend zero-based y serializa a la inversa; fue una regresión de integración UI, cubierta por test.
- El contrato HTTP de login es `access_token`; la divergencia `accessToken` corresponde al mapeo interno/frontend o a harnesses y no a un campo HTTP alternativo productivo.
## Limitaciones V1 y evolución prudente
Limitaciones actuales: sin RBAC granular, filtro Actor, filtro Company, facets, export, saved searches, búsqueda en JSON before/after, SSE/WebSocket, cursor pagination, contador de nuevos eventos, navegación al target ni resolución histórica de labels de catálogo. Los events unscoped no son visibles por HTTP normal.
Posibles evoluciones, no implementadas ni obligatorias: RBAC y endpoint unrestricted de administrador, endpoint company-users y actor filter, facets/export/cursor pagination, SSE/WebSocket si polling no basta, renderers especializados, navegación a recursos, BusinessVersion y DocumentVersion.
## Guía para instrumentar un módulo nuevo
1. Identificar operaciones efectivamente realizadas.
2. Definir actions estables y target.
3. Diseñar AuditState histórico y decidir PII.
4. Decidir arrays, normalización y `atomicPaths`.
5. Detectar no-ops.
6. Instrumentar el application service, no controller, repository ni domain.
7. Compartir la misma T y propagar el fallo de Audit.
8. Añadir tests unitarios, integración funcional y rollback MariaDB.
9. Actualizar la tabla de actions y registries/traducciones frontend si procede.
Anti-patrones: auditar desde controllers/repositorios/domain, registrar no-ops o mutaciones fuera de T, usar `toPrimitives()` automático, reescribir historia con labels actuales, usar scope solicitado como autorización, tratar `scope=null` como global, renombrar actions históricas, usar Audit como Event Sourcing o como directorio de usuarios.
## Decisiones normativas e implementativas
Son normativas: actions estables, snapshots explícitos, mutation atómica con negocio, no auditar no-ops, separación entre scope y autorización, y una AuditEntry por hecho auditado. Una operación de negocio puede producir más de un hecho, como el cambio de método de pago predeterminado.
Son implementativas actuales y reemplazables: MariaDB, Sequelize, modelos `audit_events/audit_changes`, subquery correlacionada de `changes_count`, paginación offset, React Query y polling Live de cinco segundos. Cambiar esa infraestructura no debe reinterpretar las reglas históricas.
## Referencias de implementación
- Writer y diff: `packages/rdx-audit/src/audit.ts`, `audit.types.ts`, `internal/audit-diff.ts`, `internal/audit-state-validator.ts`.
- Contexto y T: `modules/core/src/api/infrastructure/context/`, `.../persistence/sequelize/sequelize-transaction-*.ts`.
- Store/modelos/Reader/rutas: `apps/server/src/infrastructure/audit/`.
- Evidencia server: `apps/server/src/testing/http/*audit*.test.ts` y `sequelize-audit-store.integration.test.ts`.
- Instrumentaciones: los directorios `audit/` de Catalogs, Customers, Companies y Suppliers.
- Frontend: `modules/audit/src/web/` y sus tests de utils/shared.