@wtfalch/audit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/bin/copy.d.ts +25 -0
- package/dist/bin/copy.js +55 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +16 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/ledger.d.ts +98 -0
- package/dist/ledger.js +114 -0
- package/dist/migrations/0001_audit.sql +203 -0
- package/dist/schema.d.ts +58 -0
- package/dist/schema.js +120 -0
- package/dist/tables.d.ts +841 -0
- package/dist/tables.js +46 -0
- package/dist/vocabulary.d.ts +63 -0
- package/dist/vocabulary.js +106 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @wtfalch/audit
|
|
2
|
+
|
|
3
|
+
The estate's append-only audit ledger, as a package: one table's shape and
|
|
4
|
+
walls, a signer, readers, erasure and export. Per-app Postgres, a vocabulary
|
|
5
|
+
the host supplies, no framework.
|
|
6
|
+
|
|
7
|
+
It is **not** a service you call from outside. A host constructs the ledger
|
|
8
|
+
once inside its trusted base, keeps the signer there, and hands each module
|
|
9
|
+
that wants auditing a writer already bound to the actor the base resolved
|
|
10
|
+
and the event names that module may use. The guarantee that a row's actor is
|
|
11
|
+
real is the host's, and it holds only while the signer stays private.
|
|
12
|
+
|
|
13
|
+
## What the package enforces, and what the host does
|
|
14
|
+
|
|
15
|
+
| Concern | Where |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Column bounds, JSON size, `namespace.name` action shape, subject pair, break-glass shape | `migrations/0001_audit.sql` CHECKs, and `rowSchema` before the insert |
|
|
18
|
+
| Append-only: no DELETE, no TRUNCATE, UPDATE limited to what erasure touches | `audit_events_guard` trigger, plus a revoke from `<database>_rt` |
|
|
19
|
+
| The closed sets: which events, actor classes, contexts, outcomes, reason codes | `LedgerVocabulary` in TypeScript at write time; the host's own CHECKs in the database when it has them |
|
|
20
|
+
| Who may sign what | The host. The package checks no permission and reads no session |
|
|
21
|
+
| Tenant visibility | Decided per event in the vocabulary, never per write |
|
|
22
|
+
| Erasure | `audit_erase_person(subject, pseudonym, subject_email)`, ledger-only, `SECURITY DEFINER`; the host cleans its own tables in the same transaction |
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm add @wtfalch/audit
|
|
28
|
+
pnpm exec audit-migrations # copies migrations/*.sql into drizzle/ as the next numbers
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The copy is recorded in `drizzle/.audit-migrations.json`; running it again
|
|
32
|
+
copies nothing. Apply the copied file with the host's own migrate script.
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { core } from '@wtfalch/authz';
|
|
38
|
+
import { createLedger, ledgerVocabularyFromCore } from '@wtfalch/audit';
|
|
39
|
+
|
|
40
|
+
// Once, inside the trusted base.
|
|
41
|
+
const ledger = createLedger({
|
|
42
|
+
vocabulary: ledgerVocabularyFromCore(core, {
|
|
43
|
+
'invoice.paid': { tenantVisible: true },
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// In a server action, inside the transaction that makes the change.
|
|
48
|
+
await db.transaction(async (tx) => {
|
|
49
|
+
await tx.update(invoices).set({ paidAt: now }).where(eq(invoices.id, id));
|
|
50
|
+
await ledger.sign(tx, {
|
|
51
|
+
action: 'invoice.paid',
|
|
52
|
+
tenantId,
|
|
53
|
+
actor: { class: 'human', id: access.principal.id, display: access.principal.display },
|
|
54
|
+
context: 'standard',
|
|
55
|
+
target: { type: 'invoice', id },
|
|
56
|
+
after: { paidAt: now },
|
|
57
|
+
request: requestContext(),
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Readers. The host gates; a tenant-facing page always passes tenantVisibleOnly.
|
|
62
|
+
const page = await ledger.page(db, { tenantId, tenantVisibleOnly: true, limit: 50 });
|
|
63
|
+
const rows = await ledger.exportRows(db, tenantId);
|
|
64
|
+
|
|
65
|
+
// Erasure, inside the host's own erasure transaction.
|
|
66
|
+
const touched = await ledger.erase(tx, { subject: personId, pseudonym, email });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`ledgerVocabularyFromCore` refuses a host event in a namespace the core
|
|
70
|
+
uses: `tenant.invoice_paid` is out, `invoice.paid` is in. The core's
|
|
71
|
+
namespaces are the trusted base's.
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
pnpm test # PGlite, in memory
|
|
77
|
+
TEST_DATABASE_URL=postgres://... pnpm test # a real Postgres; drops its public schema first
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The real run adds the runtime-role test: `<database>_rt` may insert and
|
|
81
|
+
call `audit_erase_person`, and may not update, delete or truncate.
|
|
82
|
+
|
|
83
|
+
## Release
|
|
84
|
+
|
|
85
|
+
Tag `v*`. The workflow builds, tests and publishes with npm trusted
|
|
86
|
+
publishing; the first publish of a new package is done from a laptop.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations in this estate are hand-written SQL, numbered per app, applied
|
|
3
|
+
* by psql on boot, additive. A package cannot own a number in an app's
|
|
4
|
+
* sequence, so this copies any migration the app has not yet copied into the
|
|
5
|
+
* app's `drizzle/` as the next numbers and records which in a manifest. The
|
|
6
|
+
* same mechanism `@wtfalch/threads` and `@wtfalch/reporting` use; the manifest name differs.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MANIFEST = ".audit-migrations.json";
|
|
9
|
+
export interface Manifest {
|
|
10
|
+
/** package file name -> the name it was copied to in the app */
|
|
11
|
+
copied: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export interface CopyResult {
|
|
14
|
+
copied: Array<{
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
}>;
|
|
18
|
+
manifest: Manifest;
|
|
19
|
+
}
|
|
20
|
+
export declare function copyMigrations(opts: {
|
|
21
|
+
from: string;
|
|
22
|
+
to: string;
|
|
23
|
+
version?: string;
|
|
24
|
+
}): CopyResult;
|
|
25
|
+
export declare function describeCopy(result: CopyResult): string;
|
package/dist/bin/copy.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Migrations in this estate are hand-written SQL, numbered per app, applied
|
|
5
|
+
* by psql on boot, additive. A package cannot own a number in an app's
|
|
6
|
+
* sequence, so this copies any migration the app has not yet copied into the
|
|
7
|
+
* app's `drizzle/` as the next numbers and records which in a manifest. The
|
|
8
|
+
* same mechanism `@wtfalch/threads` and `@wtfalch/reporting` use; the manifest name differs.
|
|
9
|
+
*/
|
|
10
|
+
export const MANIFEST = '.audit-migrations.json';
|
|
11
|
+
const NUMBERED = /^(\d{4})_(.+\.sql)$/;
|
|
12
|
+
function readManifest(dir) {
|
|
13
|
+
const file = join(dir, MANIFEST);
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
return { copied: {} };
|
|
16
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
17
|
+
return { copied: parsed.copied ?? {} };
|
|
18
|
+
}
|
|
19
|
+
function nextNumber(dir) {
|
|
20
|
+
let max = -1;
|
|
21
|
+
for (const name of readdirSync(dir)) {
|
|
22
|
+
const m = NUMBERED.exec(name);
|
|
23
|
+
if (m?.[1])
|
|
24
|
+
max = Math.max(max, Number(m[1]));
|
|
25
|
+
}
|
|
26
|
+
return max + 1;
|
|
27
|
+
}
|
|
28
|
+
export function copyMigrations(opts) {
|
|
29
|
+
mkdirSync(opts.to, { recursive: true });
|
|
30
|
+
const manifest = readManifest(opts.to);
|
|
31
|
+
const copied = [];
|
|
32
|
+
const sources = readdirSync(opts.from)
|
|
33
|
+
.filter((n) => NUMBERED.test(n))
|
|
34
|
+
.sort();
|
|
35
|
+
let next = nextNumber(opts.to);
|
|
36
|
+
for (const name of sources) {
|
|
37
|
+
if (manifest.copied[name])
|
|
38
|
+
continue;
|
|
39
|
+
const rest = NUMBERED.exec(name)?.[2] ?? name;
|
|
40
|
+
const target = `${String(next).padStart(4, '0')}_${rest}`;
|
|
41
|
+
const body = readFileSync(join(opts.from, name), 'utf8');
|
|
42
|
+
const header = `-- Copied from @wtfalch/audit${opts.version ? ` ${opts.version}` : ''} (migrations/${name}) by audit-migrations.\n-- Do not edit here; the next package version ships the next file.\n\n`;
|
|
43
|
+
writeFileSync(join(opts.to, target), header + body);
|
|
44
|
+
manifest.copied[name] = target;
|
|
45
|
+
copied.push({ from: name, to: target });
|
|
46
|
+
next += 1;
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(join(opts.to, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
49
|
+
return { copied, manifest };
|
|
50
|
+
}
|
|
51
|
+
export function describeCopy(result) {
|
|
52
|
+
if (result.copied.length === 0)
|
|
53
|
+
return 'audit-migrations: nothing to copy';
|
|
54
|
+
return result.copied.map((c) => `audit-migrations: ${c.from} -> ${basename(c.to)}`).join('\n');
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { copyMigrations, describeCopy } from './copy.js';
|
|
6
|
+
/**
|
|
7
|
+
* `audit-migrations [dir]`: copy this package's migrations the app has
|
|
8
|
+
* not yet copied into `dir` (default `drizzle`) as the next numbers.
|
|
9
|
+
* Idempotent; run it after every upgrade of @wtfalch/audit, then commit
|
|
10
|
+
* what it wrote.
|
|
11
|
+
*/
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const from = join(here, '..', 'migrations');
|
|
14
|
+
const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
|
|
15
|
+
const { version } = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
|
|
16
|
+
console.log(describeCopy(copyMigrations({ from, to, version })));
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/ledger.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { type AuditEventRow, auditEvents } from './tables.js';
|
|
3
|
+
import type { LedgerVocabulary } from './vocabulary.js';
|
|
4
|
+
/**
|
|
5
|
+
* The host's drizzle handle or a transaction open on it. postgres-js in the
|
|
6
|
+
* apps, PGlite in this package's own tests; the queries use nothing
|
|
7
|
+
* driver-specific.
|
|
8
|
+
*/
|
|
9
|
+
export type Handle = PgDatabase<PgQueryResultHKT, any, any>;
|
|
10
|
+
/** Who did it. The host's trusted base fills this from the access it resolved; nothing else may. */
|
|
11
|
+
export interface Actor {
|
|
12
|
+
readonly class: string;
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly display: string;
|
|
15
|
+
}
|
|
16
|
+
/** What a caller of the signer supplies for one row. Everything else is filled in here. */
|
|
17
|
+
export interface SignInput {
|
|
18
|
+
readonly action: string;
|
|
19
|
+
readonly tenantId: string | null;
|
|
20
|
+
readonly actor: Actor;
|
|
21
|
+
readonly context: string;
|
|
22
|
+
readonly target: {
|
|
23
|
+
readonly type: string;
|
|
24
|
+
readonly id: string;
|
|
25
|
+
};
|
|
26
|
+
readonly outcome?: string;
|
|
27
|
+
readonly sessionId?: string | null;
|
|
28
|
+
readonly reason?: string | null;
|
|
29
|
+
readonly reference?: string | null;
|
|
30
|
+
readonly request?: {
|
|
31
|
+
readonly id?: string | null;
|
|
32
|
+
readonly ip?: string | null;
|
|
33
|
+
readonly userAgent?: string | null;
|
|
34
|
+
};
|
|
35
|
+
/** Defaults to the vocabulary's decision for this event. A host may override for a single row and should rarely need to. */
|
|
36
|
+
readonly tenantVisible?: boolean;
|
|
37
|
+
readonly before?: unknown;
|
|
38
|
+
readonly after?: unknown;
|
|
39
|
+
/** The principal the event is about, when it is not the actor. */
|
|
40
|
+
readonly subject?: {
|
|
41
|
+
readonly class: string;
|
|
42
|
+
readonly id: string;
|
|
43
|
+
} | null;
|
|
44
|
+
readonly occurredAt?: Date;
|
|
45
|
+
}
|
|
46
|
+
export interface PageOptions {
|
|
47
|
+
/** One tenant's rows; `null` for rows with no tenant; omit for every tenant. */
|
|
48
|
+
readonly tenantId?: string | null;
|
|
49
|
+
/** Only rows the tenant's own log may show. A tenant-facing reader passes `true` and never lets a caller choose. */
|
|
50
|
+
readonly tenantVisibleOnly?: boolean;
|
|
51
|
+
readonly actorId?: string;
|
|
52
|
+
readonly subjectId?: string;
|
|
53
|
+
readonly action?: string;
|
|
54
|
+
readonly requestId?: string;
|
|
55
|
+
readonly after?: {
|
|
56
|
+
readonly occurredAt: Date;
|
|
57
|
+
readonly id: number;
|
|
58
|
+
};
|
|
59
|
+
readonly limit?: number;
|
|
60
|
+
}
|
|
61
|
+
export interface Page {
|
|
62
|
+
readonly items: readonly AuditEventRow[];
|
|
63
|
+
readonly next: {
|
|
64
|
+
readonly occurredAt: Date;
|
|
65
|
+
readonly id: number;
|
|
66
|
+
} | null;
|
|
67
|
+
}
|
|
68
|
+
export interface EraseInput {
|
|
69
|
+
readonly subject: string;
|
|
70
|
+
readonly pseudonym: string;
|
|
71
|
+
/** The person's address, so rows whose payloads name them and nothing else are swept too. */
|
|
72
|
+
readonly email?: string | null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* What `createLedger` returns. **`sign` is the signer.** A host constructs the
|
|
76
|
+
* ledger exactly once inside its trusted base, keeps `sign` there, and hands
|
|
77
|
+
* each service that wants auditing a writer already bound to the actor it
|
|
78
|
+
* resolved and the event names it may use. Nothing here checks a permission
|
|
79
|
+
* or reads a session: the guarantee that a row's actor is real is the host's,
|
|
80
|
+
* and it holds only while the signer stays private.
|
|
81
|
+
*/
|
|
82
|
+
export interface Ledger {
|
|
83
|
+
readonly vocabulary: LedgerVocabulary;
|
|
84
|
+
readonly tables: {
|
|
85
|
+
readonly events: typeof auditEvents;
|
|
86
|
+
};
|
|
87
|
+
/** Validates against the vocabulary and inserts, on the handle given: a transaction when the row must commit with the change it records. */
|
|
88
|
+
sign(handle: Handle, input: SignInput): Promise<void>;
|
|
89
|
+
/** Newest first, keyset on `(occurred_at, id)`. Applies no permission; the host gates and decides `tenantVisibleOnly`. */
|
|
90
|
+
page(handle: Handle, options?: PageOptions): Promise<Page>;
|
|
91
|
+
/** The one sanctioned write: `audit_erase_person`. Returns how many rows it touched. Call inside the host's erasure transaction. */
|
|
92
|
+
erase(handle: Handle, input: EraseInput): Promise<number>;
|
|
93
|
+
/** One tenant's rows, oldest first, for a tenant's export. Deterministic order; no secrets are in this table to omit. */
|
|
94
|
+
exportRows(handle: Handle, tenantId: string): Promise<readonly AuditEventRow[]>;
|
|
95
|
+
}
|
|
96
|
+
export declare function createLedger(options: {
|
|
97
|
+
vocabulary: LedgerVocabulary;
|
|
98
|
+
}): Ledger;
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { and, asc, desc, eq, lt, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { rowSchema } from './schema.js';
|
|
3
|
+
import { auditEvents } from './tables.js';
|
|
4
|
+
export function createLedger(options) {
|
|
5
|
+
const { vocabulary } = options;
|
|
6
|
+
const schema = rowSchema(vocabulary);
|
|
7
|
+
async function sign(handle, input) {
|
|
8
|
+
const meta = vocabulary.events[input.action];
|
|
9
|
+
if (!meta) {
|
|
10
|
+
throw new Error(`audit: "${input.action}" is not an event this ledger's vocabulary declares`);
|
|
11
|
+
}
|
|
12
|
+
const occurredAt = input.occurredAt ?? new Date();
|
|
13
|
+
const row = schema.parse({
|
|
14
|
+
occurred_at: occurredAt.toISOString(),
|
|
15
|
+
tenant_id: input.tenantId,
|
|
16
|
+
actor_class: input.actor.class,
|
|
17
|
+
actor_id: input.actor.id,
|
|
18
|
+
actor_display: input.actor.display,
|
|
19
|
+
action: input.action,
|
|
20
|
+
target_type: input.target.type,
|
|
21
|
+
target_id: input.target.id,
|
|
22
|
+
outcome: input.outcome ?? vocabulary.outcomes[0],
|
|
23
|
+
context: input.context,
|
|
24
|
+
session_id: input.sessionId ?? null,
|
|
25
|
+
reason: input.reason ?? null,
|
|
26
|
+
reference: input.reference ?? null,
|
|
27
|
+
request_id: input.request?.id ?? null,
|
|
28
|
+
ip: input.request?.ip ?? null,
|
|
29
|
+
user_agent: input.request?.userAgent ?? null,
|
|
30
|
+
tenant_visible: input.tenantVisible ?? meta.tenantVisible,
|
|
31
|
+
before: input.before ?? null,
|
|
32
|
+
after: input.after ?? null,
|
|
33
|
+
erased_at: null,
|
|
34
|
+
schema_version: 1,
|
|
35
|
+
subject_class: input.subject?.class ?? null,
|
|
36
|
+
subject_id: input.subject?.id ?? null,
|
|
37
|
+
});
|
|
38
|
+
await handle.insert(auditEvents).values({
|
|
39
|
+
occurredAt,
|
|
40
|
+
tenantId: row.tenant_id,
|
|
41
|
+
actorClass: row.actor_class,
|
|
42
|
+
actorId: row.actor_id,
|
|
43
|
+
actorDisplay: row.actor_display,
|
|
44
|
+
action: row.action,
|
|
45
|
+
targetType: row.target_type,
|
|
46
|
+
targetId: row.target_id,
|
|
47
|
+
outcome: row.outcome,
|
|
48
|
+
context: row.context,
|
|
49
|
+
sessionId: row.session_id,
|
|
50
|
+
reason: row.reason,
|
|
51
|
+
reference: row.reference,
|
|
52
|
+
requestId: row.request_id,
|
|
53
|
+
ip: row.ip,
|
|
54
|
+
userAgent: row.user_agent,
|
|
55
|
+
tenantVisible: row.tenant_visible,
|
|
56
|
+
before: row.before,
|
|
57
|
+
after: row.after,
|
|
58
|
+
schemaVersion: row.schema_version,
|
|
59
|
+
subjectClass: row.subject_class,
|
|
60
|
+
subjectId: row.subject_id,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function page(handle, options = {}) {
|
|
64
|
+
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
65
|
+
const conditions = [];
|
|
66
|
+
if (options.tenantId === null)
|
|
67
|
+
conditions.push(sql `${auditEvents.tenantId} is null`);
|
|
68
|
+
else if (options.tenantId !== undefined)
|
|
69
|
+
conditions.push(eq(auditEvents.tenantId, options.tenantId));
|
|
70
|
+
if (options.tenantVisibleOnly)
|
|
71
|
+
conditions.push(eq(auditEvents.tenantVisible, true));
|
|
72
|
+
if (options.actorId)
|
|
73
|
+
conditions.push(eq(auditEvents.actorId, options.actorId));
|
|
74
|
+
if (options.subjectId)
|
|
75
|
+
conditions.push(eq(auditEvents.subjectId, options.subjectId));
|
|
76
|
+
if (options.action)
|
|
77
|
+
conditions.push(eq(auditEvents.action, options.action));
|
|
78
|
+
if (options.requestId)
|
|
79
|
+
conditions.push(eq(auditEvents.requestId, options.requestId));
|
|
80
|
+
if (options.after) {
|
|
81
|
+
const { occurredAt, id } = options.after;
|
|
82
|
+
const keyset = or(lt(auditEvents.occurredAt, occurredAt), and(eq(auditEvents.occurredAt, occurredAt), lt(auditEvents.id, id)));
|
|
83
|
+
if (keyset)
|
|
84
|
+
conditions.push(keyset);
|
|
85
|
+
}
|
|
86
|
+
const rows = await handle
|
|
87
|
+
.select()
|
|
88
|
+
.from(auditEvents)
|
|
89
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
90
|
+
.orderBy(desc(auditEvents.occurredAt), desc(auditEvents.id))
|
|
91
|
+
.limit(limit + 1);
|
|
92
|
+
const items = rows.slice(0, limit);
|
|
93
|
+
const last = items.at(-1);
|
|
94
|
+
return {
|
|
95
|
+
items,
|
|
96
|
+
next: rows.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function erase(handle, input) {
|
|
100
|
+
const result = await handle.execute(sql `select audit_erase_person(${input.subject}, ${input.pseudonym}, ${input.email ?? null}) as n`);
|
|
101
|
+
const rows = Array.isArray(result)
|
|
102
|
+
? result
|
|
103
|
+
: (result.rows ?? []);
|
|
104
|
+
return Number(rows[0]?.n ?? 0);
|
|
105
|
+
}
|
|
106
|
+
async function exportRows(handle, tenantId) {
|
|
107
|
+
return handle
|
|
108
|
+
.select()
|
|
109
|
+
.from(auditEvents)
|
|
110
|
+
.where(eq(auditEvents.tenantId, tenantId))
|
|
111
|
+
.orderBy(asc(auditEvents.occurredAt), asc(auditEvents.id));
|
|
112
|
+
}
|
|
113
|
+
return { vocabulary, tables: { events: auditEvents }, sign, page, erase, exportRows };
|
|
114
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
-- @wtfalch/audit: the append-only audit ledger. Mirrors src/tables.ts.
|
|
2
|
+
--
|
|
3
|
+
-- Every statement is idempotent. Applied by the host's own migrate script
|
|
4
|
+
-- after the host copies this file into its drizzle/ directory as the next
|
|
5
|
+
-- number (audit-migrations); never edited there.
|
|
6
|
+
--
|
|
7
|
+
-- What this file enforces is the ledger's SHAPE and its WALLS: bounded
|
|
8
|
+
-- columns, JSON under 64 KB, the pairs that must appear together, the
|
|
9
|
+
-- break-glass rule that a session row carries its session, reason and
|
|
10
|
+
-- reference, and the three triggers plus the runtime-role revoke that make
|
|
11
|
+
-- "append-only" true for a role that is not a superuser. The CLOSED SETS
|
|
12
|
+
-- (which event names, actor classes, contexts, outcomes and reason codes a
|
|
13
|
+
-- host admits) are the host's own vocabulary: enforced in TypeScript by the
|
|
14
|
+
-- ledger at write time, and in the database by the host's own CHECKs when it
|
|
15
|
+
-- has them (wtfalch/app-template's 0003 and 0007 do). A host with only this
|
|
16
|
+
-- file gets the shape and the walls.
|
|
17
|
+
--
|
|
18
|
+
-- Estate-shaped: the DO block assumes a runtime role named <database>_rt that
|
|
19
|
+
-- owns nothing and serves the app. Without it, the tables and the function
|
|
20
|
+
-- exist and no revoke takes effect.
|
|
21
|
+
--
|
|
22
|
+
-- No foreign keys, on purpose: the trail outlives the tenant, the person and
|
|
23
|
+
-- the credential it describes. `tenant_id` is a uuid because the estate's
|
|
24
|
+
-- tenants are; a host with other ids alters the column in its own migration.
|
|
25
|
+
|
|
26
|
+
create table if not exists audit_events (
|
|
27
|
+
id bigint generated always as identity primary key,
|
|
28
|
+
occurred_at timestamptz not null default now(),
|
|
29
|
+
tenant_id uuid,
|
|
30
|
+
actor_class text not null,
|
|
31
|
+
actor_id text not null,
|
|
32
|
+
actor_display text not null,
|
|
33
|
+
action text not null,
|
|
34
|
+
target_type text not null,
|
|
35
|
+
target_id text not null,
|
|
36
|
+
outcome text not null,
|
|
37
|
+
context text not null,
|
|
38
|
+
session_id text,
|
|
39
|
+
reason text,
|
|
40
|
+
reference text,
|
|
41
|
+
request_id text,
|
|
42
|
+
ip text,
|
|
43
|
+
user_agent text,
|
|
44
|
+
tenant_visible boolean not null,
|
|
45
|
+
before jsonb,
|
|
46
|
+
after jsonb,
|
|
47
|
+
-- Set by audit_erase_person. The row and actor_id survive; who that was
|
|
48
|
+
-- does not, once this is set.
|
|
49
|
+
erased_at timestamptz,
|
|
50
|
+
schema_version smallint not null default 1,
|
|
51
|
+
-- The principal the event was ABOUT, when that is not the actor: an
|
|
52
|
+
-- invitation's invitee, a membership's holder. What lets "events affecting
|
|
53
|
+
-- me" be answered without guessing from payloads.
|
|
54
|
+
subject_class text,
|
|
55
|
+
subject_id text,
|
|
56
|
+
constraint audit_events_actor_class_check check (length(actor_class) between 1 and 64),
|
|
57
|
+
constraint audit_events_actor_id_check check (length(actor_id) between 1 and 256),
|
|
58
|
+
constraint audit_events_actor_display_check check (length(actor_display) between 1 and 256),
|
|
59
|
+
constraint audit_events_action_shape_check check (action ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
|
|
60
|
+
constraint audit_events_target_type_check check (length(target_type) between 1 and 64),
|
|
61
|
+
constraint audit_events_target_id_check check (length(target_id) between 1 and 256),
|
|
62
|
+
constraint audit_events_outcome_check check (length(outcome) between 1 and 32),
|
|
63
|
+
constraint audit_events_context_check check (length(context) between 1 and 32),
|
|
64
|
+
constraint audit_events_session_id_check check (session_id is null or length(session_id) <= 256),
|
|
65
|
+
constraint audit_events_reason_check check (reason is null or length(reason) <= 512),
|
|
66
|
+
constraint audit_events_reference_check check (reference is null or length(reference) <= 512),
|
|
67
|
+
constraint audit_events_request_id_check check (request_id is null or length(request_id) <= 128),
|
|
68
|
+
constraint audit_events_ip_check check (ip is null or length(ip) <= 64),
|
|
69
|
+
constraint audit_events_user_agent_check check (user_agent is null or length(user_agent) <= 1024),
|
|
70
|
+
constraint audit_events_before_check check (before is null or octet_length(before::text) <= 65536),
|
|
71
|
+
constraint audit_events_after_check check (after is null or octet_length(after::text) <= 65536),
|
|
72
|
+
-- A support-session row names its session, its reason and its reference.
|
|
73
|
+
-- Which reasons are admissible is the host's closed set.
|
|
74
|
+
constraint audit_events_break_glass_shape_check check (
|
|
75
|
+
context <> 'break_glass'
|
|
76
|
+
or (session_id is not null and reason is not null and reference is not null)
|
|
77
|
+
),
|
|
78
|
+
constraint audit_events_subject_pair_check check ((subject_id is null) = (subject_class is null)),
|
|
79
|
+
constraint audit_events_subject_class_check check (subject_class is null or length(subject_class) between 1 and 64),
|
|
80
|
+
constraint audit_events_subject_id_check check (subject_id is null or length(subject_id) between 1 and 256)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
-- (occurred_at, id) is the ordering; every reader pages on it.
|
|
84
|
+
create index if not exists audit_events_tenant_time_idx
|
|
85
|
+
on audit_events (tenant_id, occurred_at desc, id desc);
|
|
86
|
+
create index if not exists audit_events_actor_time_idx
|
|
87
|
+
on audit_events (actor_id, occurred_at desc);
|
|
88
|
+
create index if not exists audit_events_action_time_idx
|
|
89
|
+
on audit_events (action, occurred_at desc);
|
|
90
|
+
create index if not exists audit_events_subject_time_idx
|
|
91
|
+
on audit_events (subject_class, subject_id, occurred_at desc, id desc) where subject_id is not null;
|
|
92
|
+
create index if not exists audit_events_request_idx
|
|
93
|
+
on audit_events (request_id, occurred_at desc) where request_id is not null;
|
|
94
|
+
|
|
95
|
+
-- The second wall. The first is the revoke below, which stops the server; this
|
|
96
|
+
-- stops the owner's own mistakes at a psql prompt. An UPDATE may change only
|
|
97
|
+
-- the four columns erasure touches, and DELETE and TRUNCATE are refused
|
|
98
|
+
-- outright. audit_erase_person passes through this trigger, not around it.
|
|
99
|
+
create or replace function audit_events_guard() returns trigger
|
|
100
|
+
language plpgsql as $$
|
|
101
|
+
begin
|
|
102
|
+
if tg_op = 'DELETE' then
|
|
103
|
+
raise exception 'audit_events is append-only: delete refused';
|
|
104
|
+
elsif tg_op = 'TRUNCATE' then
|
|
105
|
+
raise exception 'audit_events is append-only: truncate refused';
|
|
106
|
+
elsif tg_op = 'UPDATE' then
|
|
107
|
+
if new.id is distinct from old.id
|
|
108
|
+
or new.occurred_at is distinct from old.occurred_at
|
|
109
|
+
or new.tenant_id is distinct from old.tenant_id
|
|
110
|
+
or new.actor_class is distinct from old.actor_class
|
|
111
|
+
or new.actor_id is distinct from old.actor_id
|
|
112
|
+
or new.action is distinct from old.action
|
|
113
|
+
or new.target_type is distinct from old.target_type
|
|
114
|
+
or new.target_id is distinct from old.target_id
|
|
115
|
+
or new.outcome is distinct from old.outcome
|
|
116
|
+
or new.context is distinct from old.context
|
|
117
|
+
or new.session_id is distinct from old.session_id
|
|
118
|
+
or new.reason is distinct from old.reason
|
|
119
|
+
or new.reference is distinct from old.reference
|
|
120
|
+
or new.request_id is distinct from old.request_id
|
|
121
|
+
or new.ip is distinct from old.ip
|
|
122
|
+
or new.user_agent is distinct from old.user_agent
|
|
123
|
+
or new.tenant_visible is distinct from old.tenant_visible
|
|
124
|
+
or new.schema_version is distinct from old.schema_version
|
|
125
|
+
or new.subject_class is distinct from old.subject_class
|
|
126
|
+
or new.subject_id is distinct from old.subject_id
|
|
127
|
+
then
|
|
128
|
+
raise exception 'audit_events is append-only: only actor_display, before, after and erased_at may change';
|
|
129
|
+
end if;
|
|
130
|
+
end if;
|
|
131
|
+
return new;
|
|
132
|
+
end
|
|
133
|
+
$$;
|
|
134
|
+
drop trigger if exists audit_events_guarded_update on audit_events;
|
|
135
|
+
create trigger audit_events_guarded_update
|
|
136
|
+
before update on audit_events
|
|
137
|
+
for each row execute function audit_events_guard();
|
|
138
|
+
drop trigger if exists audit_events_no_delete on audit_events;
|
|
139
|
+
create trigger audit_events_no_delete
|
|
140
|
+
before delete on audit_events
|
|
141
|
+
for each row execute function audit_events_guard();
|
|
142
|
+
drop trigger if exists audit_events_no_truncate on audit_events;
|
|
143
|
+
create trigger audit_events_no_truncate
|
|
144
|
+
before truncate on audit_events
|
|
145
|
+
for each statement execute function audit_events_guard();
|
|
146
|
+
|
|
147
|
+
-- The one sanctioned write. Rows the person wrote (actor_id), rows written
|
|
148
|
+
-- about them (subject_id), and rows whose payload carries their address:
|
|
149
|
+
-- actor_display becomes the pseudonym on their own rows, before and after
|
|
150
|
+
-- are replaced wholesale on every matched row, erased_at is set. actor_id
|
|
151
|
+
-- and subject_id stay, so "somebody with this id did this" survives and
|
|
152
|
+
-- "who that was" does not. Row counts never change. Ledger-only: a host's
|
|
153
|
+
-- own tables (a profile, an invitation) are the host's to clean, in the same
|
|
154
|
+
-- transaction, and its final `person.erased` row is written after this.
|
|
155
|
+
create or replace function audit_erase_person(subject text, pseudonym text, subject_email text)
|
|
156
|
+
returns integer
|
|
157
|
+
language plpgsql
|
|
158
|
+
security definer
|
|
159
|
+
set search_path = pg_catalog, public
|
|
160
|
+
as $$
|
|
161
|
+
declare
|
|
162
|
+
touched integer;
|
|
163
|
+
begin
|
|
164
|
+
if subject is null or length(subject) = 0 then
|
|
165
|
+
raise exception 'audit_erase_person: a subject id is required';
|
|
166
|
+
end if;
|
|
167
|
+
if pseudonym is null or length(pseudonym) = 0 or length(pseudonym) > 256 then
|
|
168
|
+
raise exception 'audit_erase_person: a pseudonym of 1 to 256 characters is required';
|
|
169
|
+
end if;
|
|
170
|
+
update audit_events
|
|
171
|
+
set actor_display = case when actor_id = subject then pseudonym else actor_display end,
|
|
172
|
+
before = case when before is null then null else '{"erased":true}'::jsonb end,
|
|
173
|
+
after = case when after is null then null else '{"erased":true}'::jsonb end,
|
|
174
|
+
erased_at = now()
|
|
175
|
+
where erased_at is null
|
|
176
|
+
and (
|
|
177
|
+
actor_id = subject
|
|
178
|
+
or subject_id = subject
|
|
179
|
+
or (
|
|
180
|
+
subject_email is not null
|
|
181
|
+
and length(subject_email) > 0
|
|
182
|
+
and (
|
|
183
|
+
lower(coalesce(before::text, '')) like '%' || lower(subject_email) || '%'
|
|
184
|
+
or lower(coalesce(after::text, '')) like '%' || lower(subject_email) || '%'
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
get diagnostics touched = row_count;
|
|
189
|
+
return touched;
|
|
190
|
+
end
|
|
191
|
+
$$;
|
|
192
|
+
revoke all on function audit_erase_person(text, text, text) from public;
|
|
193
|
+
|
|
194
|
+
do $$
|
|
195
|
+
declare
|
|
196
|
+
rt text := current_database() || '_rt';
|
|
197
|
+
begin
|
|
198
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
199
|
+
execute format('revoke update, delete, truncate on audit_events from %I', rt);
|
|
200
|
+
execute format('grant execute on function audit_erase_person(text, text, text) to %I', rt);
|
|
201
|
+
end if;
|
|
202
|
+
end
|
|
203
|
+
$$;
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type LedgerVocabulary } from './vocabulary.js';
|
|
3
|
+
/** The bounds every row honours, in characters; `jsonBytes` is `before` and `after`, each, serialised. Match `migrations/0001_audit.sql`. */
|
|
4
|
+
export declare const AUDIT_LIMITS: {
|
|
5
|
+
readonly id: 256;
|
|
6
|
+
readonly display: 256;
|
|
7
|
+
readonly targetType: 64;
|
|
8
|
+
readonly reason: 512;
|
|
9
|
+
readonly reference: 512;
|
|
10
|
+
readonly requestId: 128;
|
|
11
|
+
readonly ip: 64;
|
|
12
|
+
readonly userAgent: 1024;
|
|
13
|
+
readonly jsonBytes: 65536;
|
|
14
|
+
};
|
|
15
|
+
/** A value that survives a JSON round trip unchanged: null, finite numbers, strings, booleans, arrays and plain objects of the same, with no cycle. */
|
|
16
|
+
export declare function isJsonValue(value: unknown, seen?: WeakSet<object>): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* One row as the ledger writes it, for a given vocabulary. Unknown keys are
|
|
19
|
+
* refused, so a misspelt column fails loudly instead of vanishing. A row in
|
|
20
|
+
* the break-glass context must carry the session, a reason from the closed
|
|
21
|
+
* code set and a reference; every other row's reason is free text.
|
|
22
|
+
*/
|
|
23
|
+
export declare function rowSchema(vocabulary: LedgerVocabulary): z.ZodObject<{
|
|
24
|
+
occurred_at: z.ZodISODateTime;
|
|
25
|
+
tenant_id: z.ZodNullable<z.ZodString>;
|
|
26
|
+
actor_class: z.ZodEnum<{
|
|
27
|
+
[x: string]: string;
|
|
28
|
+
}>;
|
|
29
|
+
actor_id: z.ZodString;
|
|
30
|
+
actor_display: z.ZodString;
|
|
31
|
+
action: z.ZodEnum<{
|
|
32
|
+
[x: string]: string;
|
|
33
|
+
}>;
|
|
34
|
+
target_type: z.ZodString;
|
|
35
|
+
target_id: z.ZodString;
|
|
36
|
+
outcome: z.ZodEnum<{
|
|
37
|
+
[x: string]: string;
|
|
38
|
+
}>;
|
|
39
|
+
context: z.ZodEnum<{
|
|
40
|
+
[x: string]: string;
|
|
41
|
+
}>;
|
|
42
|
+
session_id: z.ZodNullable<z.ZodString>;
|
|
43
|
+
reason: z.ZodNullable<z.ZodString>;
|
|
44
|
+
reference: z.ZodNullable<z.ZodString>;
|
|
45
|
+
request_id: z.ZodNullable<z.ZodString>;
|
|
46
|
+
ip: z.ZodNullable<z.ZodString>;
|
|
47
|
+
user_agent: z.ZodNullable<z.ZodString>;
|
|
48
|
+
tenant_visible: z.ZodBoolean;
|
|
49
|
+
before: z.ZodNullable<z.ZodUnknown>;
|
|
50
|
+
after: z.ZodNullable<z.ZodUnknown>;
|
|
51
|
+
erased_at: z.ZodNullable<z.ZodISODateTime>;
|
|
52
|
+
schema_version: z.ZodLiteral<1>;
|
|
53
|
+
subject_class: z.ZodNullable<z.ZodEnum<{
|
|
54
|
+
[x: string]: string;
|
|
55
|
+
}>>;
|
|
56
|
+
subject_id: z.ZodNullable<z.ZodString>;
|
|
57
|
+
}, z.core.$strict>;
|
|
58
|
+
export type AuditRow = z.infer<ReturnType<typeof rowSchema>>;
|