@speles7172/audit-client
v0.3.3
Published
Generic entity audit trail over Postgres — one table for every entity type, with field-level diffs, the acting user, and their device and location.
Downloads
1,260
Readme
@speles7172/audit-client
One audit table for every entity type — who changed what, from what to what, when, and from where.
Postgres, over an executor you supply. There is no pg dependency here and no
dependency on any other client: whatever your project already uses to reach the
database can drive this.
npm install @speles7172/audit-clientPair it with @speles7172/audit-console for the UI.
The table
One row per event, for every kind of entity:
| | |
|---|---|
| entity_type, entity_id, entity_name | what it is about. entity_id is text, so integer, UUID and ULID keys all work. entity_name is denormalised so a deleted record still says what it was called. |
| action | create, update, status_change, delete, login, view, export, upload, link, unlink, bulk_create, alert — plus whatever you add. No CHECK constraint, so a new verb is a constant, not a migration. |
| changes | the diff: [{ "field": "amount", "from": "100", "to": "250" }] |
| message | a sentence for a person, for what the diff cannot say |
| request_payload | the request body, redacted |
| actor_id, actor_name, actor_email | who. Denormalised, and with no foreign key — a trail that DELETE FROM users can cascade away is not a trail. |
| ip_address, user_agent, browser, location | the device and where it was |
| occurred_at | timestamptz |
Get it into your schema by pasting the DDL into a migration:
import { AUDIT_TABLE_SQL } from '@speles7172/audit-client';or, if you have no migration runner, call ensureAuditTable(execute) on boot —
every statement it emits is IF NOT EXISTS.
Writing
import { createAuditWriter } from '@speles7172/audit-client';
const writer = createAuditWriter(pool.query.bind(pool));pool.query.bind(pool) from pg satisfies the executor. So does
@speles7172/sql-client, and so does a Lambda that proxies into a VPC — the
signature is (sql, params) => Promise<{ rows }> and nothing more.
await writer.recordCreate({
entityType: 'invoice',
entityId: invoice.id,
entityName: invoice.number,
actor: user, // a user id, or a JWT claim set, or a row
requestPayload: body,
});
// Diffs the row against the body. Writes one record carrying every change,
// and returns null when the update changed nothing.
await writer.recordUpdate({
entityType: 'invoice',
entityId: invoice.id,
entityName: invoice.number,
previous, // the row as it was
body, // what the request asked for
fields: ['amount', 'status', 'due_date', { field: 'tags', format: (v) => (v as string[]).join(', ') }],
});Only fields named in fields are considered, and only those actually present in
body — a PATCH that omits a field is not a request to clear it. A field
whose name looks like a credential (password, api_key, token, …) is
recorded as having changed, with both sides replaced by [redacted]; the same
net runs over requestPayload.
Actions are checked against the known vocabulary, so a typo fails at the write
rather than quietly creating a filter bucket of one. An application whose actions
are open-ended and named by convention — entity.verb, say — passes
actionPolicy: 'shape' instead and enumerates nothing; the grammar check still
catches the spellings the rule exists for.
A change to whatever you call the status field is recorded as status_change
rather than update. Set statusField if yours is called something else.
For a repository module, bind the type once:
const invoices = writer.for('invoice');
await invoices.recordDelete({ entityId: id, entityName: number, actor: user });Who and where, without plumbing
Wrap the request once and every record written inside it is stamped with the acting user and their device — no threading a user agent through five layers to reach the function that actually writes:
import { extractClientInfo, runWithAuditContext, updateAuditContext } from '@speles7172/audit-client';
export const handler = (event) =>
runWithAuditContext({ client: extractClientInfo(event) }, async () => {
const user = await authenticate(event);
updateAuditContext({ actor: user }); // known only after the token is verified
return route(event);
});extractClientInfo reads an API Gateway event, a Node IncomingMessage
(so, an Express req) or a WHATWG Request, and picks up viewer geo headers
from CloudFront, Cloudflare or Vercel. An explicit client or actor on a
write always wins, for the cases with no request behind them — a scheduled job,
a bridge Lambda, a backfill.
The address is taken from CloudFront-Viewer-Address first, then
X-Forwarded-For, then X-Real-IP, then the socket. That order is the point:
X-Forwarded-For is a client-supplied header, so anyone who can reach your
origin directly can put whatever they like in it, while
CloudFront-Viewer-Address is written by the edge and overwrites whatever
arrived. Behind a CDN that overwrites it the address is reliable; directly
exposed it is a claim. Treat it as evidence in a trail, never as an access
decision.
Atomically
An audit trail whose accuracy depends on nothing going wrong is not evidence of
anything. auditedUpdate puts the read, the write and the record in one
transaction:
import { auditedUpdate } from '@speles7172/audit-client';
const updated = await auditedUpdate(writer, {
pool,
entityType: 'invoice',
entityId: id,
fields: ['amount', 'status'],
body,
lock: { table: 'invoices' },
load: (c) => c.query('SELECT * FROM invoices WHERE id = $1', [id]).then((r) => r.rows[0]),
update: (c) => c.query(updateSql, updateParams).then((r) => r.rows[0]),
nameFrom: (_previous, row) => String(row.number),
});lock is required and has no default. A transaction alone is not enough: at
Postgres' default READ COMMITTED, two concurrent calls can both read
amount = 100 before either writes. The second UPDATE blocks, then proceeds
against the row the first one left — so it really overwrites 250, while its
audit record claims it overwrote 100, and nothing about the trail looks wrong.
Pass { table, column? } to have the lock taken for you, or
'locked-in-load' if your own load ends in FOR UPDATE. There is no value
meaning "do not lock", because a mistake with no symptom is not one to leave to
memory.
By default a failed audit insert throws, which rolls the operation back with it.
Pass onError if your application would rather lose a record than a payment.
Reading
import { createAuditReader, auditFilterFromQuery } from '@speles7172/audit-client';
const reader = createAuditReader(pool.query.bind(pool));
// The cross-entity log. Administrators only.
const page = await reader.list(auditFilterFromQuery(event.queryStringParameters));
// One record's trail. Both arguments are required and the filter cannot widen
// them, so this endpoint can never be coaxed into dumping the whole log.
const trail = await reader.listForEntity('invoice', id, { limit: 100 });auditFilterFromQuery accepts multi-value parameters either repeated
(?action=a&action=b) or comma-separated, and rejects anything out of range
rather than clamping it quietly.
This package applies no access control. Who may read which entity's trail is
a question only your application can answer. Put the check in front of the
endpoint, and use listForEntity for anything a non-administrator can reach.
facets() and actorFacets() populate the console's filter pickers.
Named places
Rows store the IP as written. If you keep a table of known networks, hand the reader a resolver and every row gets a place name attached:
createAuditReader(execute, {
resolveLocations: async (ips) => new Map(await lookupNetworks(ips)),
});Called once per page with the distinct addresses on it. A failure there is swallowed — a trail without place names is still a trail.
Entry points
| | |
|---|---|
| @speles7172/audit-client | Node: the writer, the reader, the schema, the request adapters, the AsyncLocalStorage context |
| @speles7172/audit-client/core | dependency-free: the types, the diff engine, the filter grammar, the user-agent and geo parsing. What a browser bundle can import — and what @speles7172/audit-console uses. |
Both are published as ESM and CommonJS. The CommonJS build is not decoration:
Jest resolves a package through its require condition, so an ESM-only
exports map cannot be imported by a ts-jest suite at all — the package would
be unusable in a large class of Node backends. import and require both
work, and CI require()s the CommonJS entries on every run so they cannot
quietly stop loading.
MIT.
