Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

@jantstack/adonis-audit

Audit trail engine for AdonisJS 7 + Lucid: one transactional store, N best-effort sinks, a Lucid CRUD mixin, polymorphic actors and entities, and a safe-by-default snapshot where what you hide from your API never reaches the trail.

npm i @jantstack/adonis-audit
node ace configure @jantstack/adonis-audit
node ace migration:run

The idea in one line

auditable ⊆ serializable. A field marked @column({ serializeAs: null }) — Lucid's equivalent of Eloquent's $hidden — stays out of your API and out of the audit log. One declaration, made where you were already thinking about what is sensitive, with two effects.

That sounds like a detail and isn't. The naive audit mixin reads model.$attributes, which is the copy headed towards the database — one layer below where serializeAs acts. The result is a model protected in its JSON and unprotected in its trail, which is exactly where the data lives longest and where the most people can read it. And the worst case isn't creating, it's updating: a password change records both the old hash and the new one, permanently, surviving even the deletion of the account.

This package applies the same boundary to both paths, and adds a second net by field name (password, *_token, *_secret, *_hash…) that applies even on top of an explicit toLog() — because the realistic failure isn't forgetting toLog(), it's adding a refresh_token column six months later and not remembering.

That second net walks deep, and this matters more than it appears: the real hole isn't in the first layer. A json column named settings is legitimately serializable — the field itself isn't a secret, so nobody marks it serializeAs: null — and its name matches no rule. Without walking, a settings.api_key slips through both nets.

The walk has three ceilings — depth 12, cycles, and a 10,000-node budget — and all three fail closed: whatever cannot be inspected is replaced by [audit: not inspected] rather than emitted as-is. Giving up by returning the value would mean failing open exactly where someone would hide something, and the marker records the truncation instead of silently dropping it.

What it does not cover, stated plainly: redaction is by field name. A secret interpolated into free text — an event's description, a transition's describe() — passes through untouched. You write those fields; treat them as public output.


Architecture: one store + N sinks

These are not interchangeable drivers. Auditing is an append-only stream and in practice you want several destinations at once: the table to query, a SIEM for compliance, stdout for the collector.

audit.log(...)
├─→ store exactly one · the queryable one · honours your transaction
└─→ sinks[] zero or more · best-effort · never receive a trx

The two levels have different criticality, and that is the whole architecture:

on failure
storethe error propagates. Inside a transaction it rolls the business back with it: an operation whose trail could not be written should not be taken as done.
sinklogged, and life goes on. A downed log collector must not take down a login.

Blurring the two levels is how trails get lost without anyone noticing.

Two asymmetries made explicit in the contract

Transactionality.AuditStore.supportsTransactions is part of the contract. Handing a trx to a store that doesn't support it throws instead of silently discarding it — returning an "ok" without delivering the atomicity that was asked for is the most damaging way to fail at auditing, because the trail appears to exist.

Reading. Every store can append; not every store can query. That's why AuditReader is a separate, optional capability, and the provider warns at boot if your store cannot read — not on the first request to your history endpoint.


Usage

The mixin

import{compose}from'@adonisjs/core/helpers'import{auditable}from'@jantstack/adonis-audit'exportdefaultclassInvoiceextendscompose(BaseModel,auditable({eventPrefix: 'invoice'})){
@column()declaretotal: number// Not in the API ⇒ not in the trail. No further ceremony.
@column({serializeAs: null})declareinternalMargin: number}

Emits invoice.created, invoice.updated (only the fields that changed) and invoice.deleted. If the model is inside a transaction, the trail takes part in it.

Transitions, so you don't end up with anonymous diffs:

auditable({eventPrefix: 'invoice',transitions: [{when: (m)=>m.$original.status==='draft'&&m.status==='issued',event: 'invoice.issued',}],})

Manual events

importauditfrom'@jantstack/adonis-audit/services/main'awaitaudit.log('org.archived',organization,{previous: {archived: false},
trx,// takes part in your transaction})// An explicit `actor: null` ≠ omitting it. A failed login has no actor, and that IS the data.awaitaudit.log('auth.login_failed',null,{actor: null,description: email})

config/audit.ts

import{AuditContext,defineAuditConfig,DatabaseAuditStore}from'@jantstack/adonis-audit'exportdefaultdefineAuditConfig({store: newDatabaseAuditStore(),sinks: [newStdoutAuditSink()],redact: ['salaryBand',/^internal_/],resolveActor: ()=>{constctx: any=AuditContext.current()?.ctxif(!ctx)returnnull// jobs, ace commands, seedersconstuser=ctx.auth?.userif(!user)returnnullconstguard=ctx.auth?.authenticatedViaGuardreturn{type: guard==='admins' ? 'admins' : 'users',uuid: user.uuid}},})

resolveActor is yours to supply because the guards are yours: the package has no idea whether you have users, admins, integrations or something else entirely.

Use AuditContext.current() and not AdonisJS's HttpContext.get(). The latter depends on useAsyncLocalStorage, which ships disabled — and without it, it returns null with no warning, so every event would end up with no actor and nothing would tell you. The package's own context is populated by AuditContextMiddleware, which configure registers in the router stack for you.


Writing your own store

Implement AuditStore and, if it can query, AuditReader too. Then judge it with the same judge as the ones that ship with the package:

import{runAuditStoreContract}from'@jantstack/adonis-audit/testing'test.group('my ClickHouse store',()=>{runAuditStoreContract({
test,makeStore: ()=>newClickHouseAuditStore(),reset: async()=>{/* ... */},})})

The suite is deliberately asymmetric: the read cases skip themselves if your store doesn't implement AuditReader, and that skip is announced — "didn't fail" must not be mistaken for "complies".

Included: DatabaseAuditStore (transactional and queryable) and NullAuditStore. The second is not filler: the contract suite passing against it is what proves the contract is real, and not the database implementation wearing a different name.


The trail is append-only

ActivityLog rejects save() and delete() on an existing row: an audit trail you can rewrite proves nothing.

That is resistance inside the application, not a guarantee. The query builder doesn't fire model hooks, so ActivityLog.query().delete() still works — deliberately, since that's how you purge and how the tests clean up. The real guarantee is set in the database:

REVOKEUPDATE, DELETEON activity_logs FROM my_application_role;

If you need to void an event, record a new one that compensates for it. That is the correct move in an append-only ledger, and it leaves a record of the voiding too.

Compatibility

Node ≥ 20.6 · AdonisJS ^7 · Lucid ^22 · PostgreSQL, MySQL and SQLite.

The current/previous columns are serialized by hand because the engines disagree: Postgres returns json already parsed, SQLite and MySQL return the string. Without that the package would "work" on Postgres and hand you strings on the other two.

License

MIT

About

Audit trail engine for AdonisJS 7 + Lucid: one transactional store plus fan-out sinks, a Lucid CRUD mixin, and a safe-by-default snapshot where auditable ⊆ serializable.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages