Casi todo sistema tiene una tabla audit_log con una columna JSONB donde cabe todo: login, error, cambio de configuración, concesión de role, apertura de documento. Funciona hasta que alguien hace una pregunta de gobernanza de verdad, y entonces consultar se vuelve arqueología.
Yo separo el rastro en tres planos, porque responden a preguntas distintas:
| Plano | Pregunta | Ejemplos |
|---|---|---|
| Operativo | ¿Qué pasó en el sistema? | login, error, configuración, procesamiento |
| Concesión | ¿Quién dio qué poder a quién, y cuándo? | roles, grants directos, shares, revocaciones |
| Acceso | ¿Quién vio o consultó qué contenido? | apertura, descarga, impresión, consulta de microdatos |
Cada plano tiene volumen, retención, índices y criticidad propios. La concesión es poco volumen y mucho valor probatorio. El acceso es volumen alto y pide particionado por tiempo. El operativo es ruido útil. Mezclarlo todo hace que un rastro importante dependa de un writer pensado para otra finalidad.
Estado no es evento
Una tabla user_role dice quién tiene el role hoy. No dice quién lo tenía hace tres meses. Para eso existe una tabla append-only de eventos. Las dos son necesarias, y una no sustituye a la otra.
Una tabla de estado dice quién tiene acceso. Solo el rastro de eventos dice cómo llegó ahí.
Asimetría de fallo
No todo fallo de auditoría debe tener la misma consecuencia:
| Naturaleza | Si el rastro falla | Por qué |
|---|---|---|
| operativo | best-effort | el hecho puede haber ocurrido ya |
| concesión | fallo cerrado | un poder invisible es inaceptable |
| acceso sensible | fallo cerrado | un acceso sin evidencia rompe la accountability |
Es una decisión regulatoria y operativa, y tiene un coste: fail-closed pone el storage del rastro en el camino crítico. Antes de adoptarlo, mide el p50 y el p99 del insert, la tasa de error causada por el rastro, el crecimiento diario, el bloat y el comportamiento del pool. La política no puede exigir una disponibilidad que la infraestructura no se dimensionó para sostener.
Concesión y evento en la misma transacción
BEGIN;
INSERT INTO grant_event (...);
INSERT INTO user_role (...);
UPDATE app_user
SET permission_version = permission_version + 1
WHERE id = ...;
COMMIT;Si cualquier paso falla, rollback de todo. Un evento que afirma una concesión que no existe es tan malo como una concesión sin evento. Sin un Unit of Work global, un helper estrecho lo resuelve:
security_transaction(operation):
begin
operation(shared_session)
commit_or_rollbackY la regla que le da dientes al helper: cualquier call site de concesión fuera de él es un bug estructural, y el CI lo busca.
Registrar antes de servir
Cuando el acceso sensible es fail-closed, el orden es autorizar, registrar el evento y solo entonces serializar y devolver el contenido. El error residual es registrar un acceso que no llegó al usuario porque algo falló después. Ese error “por exceso” es auditable. Lo inverso, contenido entregado sin evento, es exactamente lo que la política quiere impedir.
Registra la vía
Cuando alguien abre un recurso, vale la pena guardar por qué camino se satisfizo la autorización:
access_via = own_partition | share | all_partitions | platform_bypass | legacySi el share se revoca mañana, el rastro sigue explicando el acceso de hoy sin necesidad de reconstruir el estado pasado. Cuando más de una vía es verdadera a la vez, define una precedencia y mantenla, o guárdalas todas. Lo importante es no dejar ambigüedad histórica.
Qué no registrar
El polling de la interfaz no es acceso. Listar metadatos y abrir contenido son eventos distintos, y guardar cada refresh automático genera un volumen enorme con poco valor probatorio. Define qué cuenta como acceso antes de encender el writer.
Y recuerda que el propio rastro es dato personal: identidad, IP, hora, objeto accedido, patrón de uso. Necesita control de acceso, retención explícita y, en los entornos más exigentes, un usuario de aplicación sin UPDATE ni DELETE sobre él.