@kilnjs/audit
v0.1.3
Published
An audit trail for KilnJS: every write recorded with the caller that made it, from the driver rather than from a call site
Readme
@kilnjs/audit
An audit trail for KilnJS, as a module core is given rather than one it contains. Every write is recorded with the caller that made it, from inside the database driver.
yarn add @kilnjs/audit// app.config.ts
import { audit } from "@kilnjs/audit";
export default {
modules: [audit()],
} satisfies AppConfig;// src/audit.ts
import database from "@kilnjs/core/database";
import type { AuditDelegate } from "@kilnjs/audit";
export default {
models: ["User", "Membership", "Grant"],
record: async (entry) => {
await database.auditEntry.create({
data: {
model: entry.model,
operation: entry.operation,
actor_id: entry.actorId ?? null,
at: entry.at,
},
});
},
} satisfies AuditDelegate;Why it is a module and not a recipe
Because the two hard parts cannot be done at a call site.
Nothing can be forgotten. The recording happens in the database driver, so a write through a model method, a job, a seed or the Prisma CLI is recorded the same way. A trail that each endpoint had to add to has its gaps exactly where somebody was in a hurry, and those are the writes worth having a trail of.
The caller arrives on its own. A write three layers inside a model method knows nothing about who asked for it. This reads the principal out of the request context core opens, so no signature changes and nothing is threaded through.
A write with no request behind it - a job, a seed, a migration - is recorded with no actor at all. That is a fact rather than a gap: inventing one would make the trail say something untrue.
What a delegate decides
| | |
| --- | --- |
| record | where an entry goes. The only thing it must have |
| models | which models to record. Everything, unless it says |
| operations | which operations. Every write, unless it says. Reads are never recorded |
A server that audits everything records its own sessions table on every request, which is noise rather than evidence, so most applications name the models that matter.
What audit() takes
The same two questions the delegate answers, plus three the delegate cannot. What is passed here wins over the delegate, so a deployment can narrow a delegate it did not write.
audit({
skipModels: ["Session", "RequestLog"],
actorId: (context) => context?.user?.id ?? "system",
});| | |
| --- | --- |
| models | which models to record, over the delegate's own list |
| operations | which operations, over the delegate's |
| skipModels | never recorded, whatever either of the above said |
| unrecorded | refuse or report: what a write whose entry could not be kept does. AUDIT_UNRECORDED |
| actorId | who a write is recorded against, where it is not the caller's own id |
| delegate | one passed rather than discovered, for a server that assembles it |
skipModels is there because narrowing by inclusion means listing every model
that matters and remembering the next one; a server that audits everything
except two tables says that instead.
actorId is handed the request-scoped context and is the only way to record
something other than context.user.id: an impersonated caller, a service
account, or the word system for a write with no request behind it.
Two decisions worth knowing about
A write whose entry could not be recorded fails. A trail is only as good as
its gaps, so the caller is told rather than quietly left with an untracked
write. unrecorded: "report" is the other answer, for a server that would
rather lose the entry than the write, and the failure is reported to telemetry
either way.
Recording cannot record itself. The delegate writes its entry through the
same client this wraps. The guard is async storage rather than a flag, because
record is awaited and two requests overlap: a boolean would be switched off
by whichever finished first.
A server with this module declared and no src/audit.ts does not boot. It
is not a module with a missing option: it is a server that believes it is
keeping a trail and is not, which is the worst of the three states.
Verifying
yarn verify # ts:check, lint, build, exports:check, testNo database: what the real client does with an extension is core's business and the reference application's. What this module hands over is what these tests cover, including that the delegate's own writes stay out of the trail however deep they go.
