@jantstack/adonis-audit
v1.0.0
Published
Audit trail engine for AdonisJS + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where auditable ⊆ serializable — fields hidden from your API never reach the trail.
Maintainers
Readme
@jantstack/adonis-audit
Motor de auditoría para AdonisJS 7 + Lucid: un store transaccional, N sinks best-effort, un mixin CRUD para modelos, actores y entidades polimórficos, y un snapshot seguro por defecto donde lo que ocultas de tu API tampoco entra en el rastro.
npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:runLa idea en una línea
auditable ⊆ serializable. Un campo marcado con @column({ serializeAs: null }) —el equivalente
en Lucid al $hidden de Eloquent— no sale por tu API y tampoco llega al log de auditoría. Una
declaración, hecha donde ya estabas pensando en qué es sensible, con dos efectos.
Suena a detalle y no lo es. La implementación ingenua de un mixin de auditoría lee model.$attributes,
que es la copia que va hacia la base de datos — una capa por debajo de donde serializeAs actúa.
El resultado es un modelo protegido en su JSON y desprotegido en su rastro, que es justo donde el dato
dura más y donde más gente puede leerlo. Y el caso peor no es crear, es actualizar: un cambio de
contraseña deja registrados el hash viejo y el nuevo, permanentemente, sobreviviendo incluso al borrado
de la cuenta.
Este paquete aplica la misma frontera a las dos rutas, y encima añade una segunda red por nombre
(password, *_token, *_secret, *_hash…) que se aplica incluso sobre un toLog() explícito —
porque el fallo realista no es olvidar toLog(), es añadir una columna refresh_token seis meses
después y no acordarse.
La segunda red recorre en profundidad, y eso importa más de lo que parece: el agujero real no
está en la primera capa. Una columna json llamada settings es legítimamente serializable —el
campo en sí no es secreto, así que nadie le pone serializeAs: null— y su nombre no casa con
ninguna regla. Sin recorrer, un settings.api_key atraviesa las dos redes.
El recorrido tiene tres topes —profundidad 12, ciclos y un techo de 10 000 nodos— y los tres fallan
cerrados: lo que no se puede inspeccionar se sustituye por [audit: no inspeccionado] en vez de
emitirse tal cual. Rendirse devolviendo el valor sería fallar abierto justo donde alguien escondería
algo, y el marcador deja constancia del recorte en lugar de truncar en silencio.
Lo que no cubre, dicho claramente: la redacción es por nombre de campo. Un secreto interpolado
dentro de un texto libre —la description de un evento, el describe() de una transición— pasa sin
tocarse. Esos campos los escribes tú; trátalos como salida pública.
Arquitectura: un store + N sinks
No son drivers intercambiables. La auditoría es un flujo append-only y en la práctica quieres varios destinos a la vez: la tabla para consultar, un SIEM para cumplimiento, stdout para el colector.
audit.log(...)
├─→ store exactamente uno · el consultable · honra tu transacción
└─→ sinks[] cero o más · best-effort · nunca reciben trxLos dos niveles tienen criticidad distinta, y esa es toda la arquitectura:
| | si falla | |---|---| | store | el error propaga. Dentro de una transacción, revierte el negocio con él: una operación cuyo rastro no pudo escribirse no debería darse por buena. | | sink | se registra y la vida sigue. Un colector de logs caído no puede tumbar un login. |
Mezclar ambos niveles es cómo se pierden rastros sin que nadie se entere.
Dos asimetrías explícitas en el contrato
Transaccionalidad. AuditStore.supportsTransactions es parte del contrato. Pasarle una trx a un
store que no la soporta lanza en vez de descartarla en silencio — devolver un "ok" sin dar la
atomicidad que se pidió es la forma más dañina de fallar en auditoría, porque el rastro parece existir.
Lectura. Append lo cumplen todos; query no. Por eso AuditReader es una capacidad opcional y
separada, y el provider avisa al arrancar si tu store no sabe leer — no en la primera petición al
endpoint de historial.
Uso
El mixin
import { compose } from '@adonisjs/core/helpers'
import { auditable } from '@jantstack/adonis-audit'
export default class Invoice extends compose(BaseModel, auditable({ eventPrefix: 'invoice' })) {
@column() declare total: number
// No sale por la API ⇒ no entra en el rastro. Sin más ceremonia.
@column({ serializeAs: null }) declare internalMargin: number
}Emite invoice.created, invoice.updated (solo con los campos que cambiaron) e invoice.deleted.
Si el modelo está en una transacción, el rastro participa de ella.
Transiciones para no quedarte con diffs anónimos:
auditable({
eventPrefix: 'invoice',
transitions: [{
when: (m) => m.$original.status === 'draft' && m.status === 'issued',
event: 'invoice.issued',
}],
})Eventos a mano
import audit from '@jantstack/adonis-audit/services/main'
await audit.log('org.archived', organization, {
previous: { archived: false },
trx, // participa de tu transacción
})
// `actor: null` explícito ≠ omitirlo. Un login fallido no tiene actor, y eso ES el dato.
await audit.log('auth.login_failed', null, { actor: null, description: email })config/audit.ts
export default defineAuditConfig({
store: new DatabaseAuditStore(),
sinks: [new StdoutAuditSink()],
redact: ['salaryBand', /^interno_/],
resolveActor: () => {
const user = HttpContext.get()?.auth?.user
return user ? { type: 'users', uuid: user.uuid } : null
},
})resolveActor lo aportas tú porque los guards son tuyos: el paquete no sabe si tienes users,
admins, integrations o cualquier otra cosa.
Escribir tu propio store
Implementa AuditStore y, si sabes consultar, también AuditReader. Después júzgalo con el mismo
juez que los que trae el paquete:
import { runAuditStoreContract } from '@jantstack/adonis-audit/testing'
test.group('mi store de ClickHouse', () => {
runAuditStoreContract({
test,
makeStore: () => new ClickHouseAuditStore(),
reset: async () => { /* ... */ },
})
})La suite es deliberadamente asimétrica: los casos de lectura se saltan solos si tu store no implementa
AuditReader, y ese salto se anuncia — "no falló" no debe confundirse con "cumple".
Incluidos: DatabaseAuditStore (transaccional y consultable) y NullAuditStore. El segundo no es
relleno: que la suite de contrato pase contra él es lo que demuestra que el contrato es real y no la
implementación de la base de datos con otro nombre.
El rastro es de solo-inserción
ActivityLog rechaza save() y delete() sobre una fila existente: una auditoría que se puede
reescribir no prueba nada.
Eso es resistencia dentro de la aplicación, no una garantía. El query builder no dispara hooks
de modelo, así que ActivityLog.query().delete() sigue funcionando — a propósito, es como se purga
y como limpian los tests. La garantía de verdad se pone en la base de datos:
REVOKE UPDATE, DELETE ON activity_logs FROM mi_rol_de_aplicacion;Si necesitas anular un evento, registra uno nuevo que lo compense. Esa es la forma correcta en un libro de solo-inserción, y deja constancia de la anulación.
Compatibilidad
Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL y SQLite.
Las columnas current/previous se serializan a mano porque los motores no coinciden: Postgres
devuelve el json ya parseado, SQLite y MySQL devuelven la cadena. Sin eso el paquete "funcionaría"
en Postgres y devolvería strings en los otros dos.
Licencia
MIT © Jose Tenorio
