FactuGES_ERP/docs/architecture/audit-system-design.md
david 7033d02a69 feat(audit): implement audit page state management and API integration
- Add utility functions for managing audit page state including parsing, serialization, and updates.
- Introduce audit period resolution logic to handle various time frames.
- Create API functions for finding and searching audit entries with appropriate DTOs and mappers.
- Implement hooks for fetching audit entries and lists, integrating with the data source.
- Add tests for utility functions, API calls, and mappers to ensure correctness.
- Establish shared entities and DTOs for consistent data handling across the module.
- Set up TypeScript configuration for the audit module.
2026-08-25 16:23:46 +02:00

19 KiB

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:

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:

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:

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:

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.