benjivers/notes Public Public: anyone can read this; nobody can change it.
Files 7260e8fc5b5c3ca9332ff3506ef97626f456e658
| 1 | /** |
| 2 | * The audit trail: every attempt to change something, as one row — who, |
| 3 | * what (a resource and a verb from the contract), on which row, when, and |
| 4 | * whether they were let. Refusals are rows too: a stranger knocking on a |
| 5 | * door that stays shut is exactly what an owner wants to read later. |
| 6 | * |
| 7 | * Recording never fails a request. A row that cannot be written becomes a |
| 8 | * line in the log, with everything the row would have said, and the |
| 9 | * request goes on; the log is where the forge keeps a run's output. |
| 10 | * |
| 11 | * Reads are not recorded (a page view is not an event), and neither is a |
| 12 | * request refused for coming too fast: a flood must not become the history, |
| 13 | * or push the real history out. |
| 14 | */ |
| 15 | |
| 16 | import type { Database } from "bun:sqlite"; |
| 17 | import type { Who } from "./who.ts"; |
| 18 | |
| 19 | export interface AuditRow { |
| 20 | readonly id: number; |
| 21 | readonly at: string; |
| 22 | readonly who_id: string | null; |
| 23 | readonly who_name: string | null; |
| 24 | readonly role: string; |
| 25 | readonly resource: string; |
| 26 | readonly verb: string; |
| 27 | readonly target: string | null; |
| 28 | readonly allowed: 0 | 1; |
| 29 | /** `ok`, or the failure: `signin_required`, `forbidden`, `invalid`, `not_found`, `cross_site`. */ |
| 30 | readonly outcome: string; |
| 31 | } |
| 32 | |
| 33 | export interface Attempt { |
| 34 | readonly who: Who; |
| 35 | readonly resource: string; |
| 36 | readonly verb: string; |
| 37 | readonly target?: string | number | undefined; |
| 38 | readonly outcome: string; |
| 39 | } |
| 40 | |
| 41 | /** The newest rows kept; older ones go as new ones come. */ |
| 42 | export const KEEP = 50_000; |
| 43 | |
| 44 | /** Was it let? Only the door says no; a bad body or a missing row got past it. */ |
| 45 | export const allowed = (outcome: string): boolean => |
| 46 | outcome !== "signin_required" && |
| 47 | outcome !== "forbidden" && |
| 48 | outcome !== "cross_site"; |
| 49 | |
| 50 | export function record(db: Database, attempt: Attempt): void { |
| 51 | const { who, resource, verb, target, outcome } = attempt; |
| 52 | const row = { |
| 53 | who_id: who.role === "anon" ? null : who.id, |
| 54 | who_name: who.role === "anon" ? null : who.name, |
| 55 | role: who.role, |
| 56 | resource, |
| 57 | verb, |
| 58 | target: target === undefined ? null : String(target), |
| 59 | allowed: allowed(outcome) ? 1 : 0, |
| 60 | outcome, |
| 61 | }; |
| 62 | try { |
| 63 | const { lastInsertRowid } = db |
| 64 | .query( |
| 65 | "INSERT INTO audit (who_id, who_name, role, resource, verb, target, allowed, outcome) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", |
| 66 | ) |
| 67 | .run( |
| 68 | row.who_id, |
| 69 | row.who_name, |
| 70 | row.role, |
| 71 | row.resource, |
| 72 | row.verb, |
| 73 | row.target, |
| 74 | row.allowed, |
| 75 | row.outcome, |
| 76 | ); |
| 77 | db.query("DELETE FROM audit WHERE id <= ?1").run( |
| 78 | Number(lastInsertRowid) - KEEP, |
| 79 | ); |
| 80 | } catch (error) { |
| 81 | console.error( |
| 82 | JSON.stringify({ |
| 83 | audit: row, |
| 84 | at: new Date().toISOString(), |
| 85 | unrecorded: String(error), |
| 86 | }), |
| 87 | ); |
| 88 | } |
| 89 | } |