diff --git a/apps/api/migrations/0024_audit_log.sql b/apps/api/migrations/0024_audit_log.sql new file mode 100644 index 0000000..f9ebf30 --- /dev/null +++ b/apps/api/migrations/0024_audit_log.sql @@ -0,0 +1,40 @@ +-- The audit log — one row per privileged action a staff account took. Owned by the `api` +-- worker; WRITTEN by `notify`, which records every call to its admin-gated `/internal/*` +-- endpoints here. Generated from packages/domain/src/audit-db.ts (AUDIT_LOG_SCHEMA_DDL) — +-- keep in sync. +-- +-- This is not the worker log. `logger.info` lines are for reading during an incident: they +-- are sampled, they expire, and nothing can be asked of them later. The actions this table +-- exists for leave no other trace on the database at all — a maintenance broadcast is a +-- WebSocket frame and then nothing, and a coach message to every online player is +-- deliberately not stored (see 0019_notification.sql) — so without a row here there is no +-- answer at all to "who sent that, and when", a question that tends to arrive weeks later. +-- +-- `player_id` is the ACTOR: the staff account that made the call, never whoever it was +-- aimed at. A target, where there is one, lives inside `data` with the rest of what was +-- asked for. The broadcasts have no target at all, so a column for one would be null on +-- half the table and could not be the thing every row is filed under. +-- +-- `data` is JSON as TEXT, and deliberately unstructured: each action records what it was +-- asked to do, and those shapes have nothing in common beyond being worth keeping. It is +-- nullable because an action whose whole content is its name has nothing to put there, and +-- null says that where `{}` would only look like a payload that went missing. Capped at +-- 4096 characters by the writer, since a request body's size is the caller's to choose; +-- over that the row keeps the head of the JSON and says `truncated`. +-- +-- `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the server +-- (unlike `notification`'s, which is handed to the client) and nothing deletes from this +-- table, so there are no freed rowids to be reused. Reads order by it rather than by +-- `date`, which ties when two actions land in the same millisecond. +CREATE TABLE IF NOT EXISTS audit_log ( + audit_log_id INTEGER PRIMARY KEY, + player_id INTEGER NOT NULL, + action TEXT NOT NULL, + data TEXT, + date TEXT NOT NULL + ); + +-- The two ways anyone asks this table anything: everything one staffer did, and every time +-- a given action was taken. Both newest-first, which is the order an audit trail is read in. +CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC); diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 09126f5..5ff53d4 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -21,6 +21,7 @@ import { subRoomDataBlob, updateAccount, verifyPassword, + writeAuditLog, } from '@repo/domain' import { intVar, @@ -1121,6 +1122,58 @@ const app = new Hono() // grant_type=refresh_token). A refresh grant thus rotates its token. const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId)) + // A STAFF sign-in is an audit event in its own right. What these accounts then do is + // recorded against them on the `audit_log` table (`notify` files every `/internal/*` + // call there); this is the row saying the session those actions came from began — and + // from what device, build and IP, which is the first thing anyone asks when an action + // further down the log looks wrong. The flags themselves are the trigger, so an account + // granted the role between logins starts being recorded the next time it signs in. + // + // A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS, + // and recording those would bury the logins under hourly repeats that say nothing new + // about who came in or from where. + // + // Written after the token is minted, so the row means a login that actually happened, + // and caught rather than awaited into the response: a failed audit insert must not turn + // a staffer's successful sign-in into a 500. It leaves a loud line in the worker log + // instead — the one thing a gap in an audit trail should never do quietly. + const staffRoles = + roleAccount && grantType !== 'refresh_token' + ? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role)) + : [] + if (staffRoles.length > 0) { + try { + await writeAuditLog(c.env.DB, { + playerId: Number(accountId), + action: 'staff_login', + // The IP leads: it is the first thing anyone asks of a staff sign-in, and the one + // field here that says WHERE the session came from rather than what it could do. + // Empty outside the Cloudflare edge — recorded as the empty string it is rather + // than omitted, so a row can't be read as "we didn't record one". + // + // Then: which powers the session carries, HOW they signed in, and the same + // unverified-but-recorded device/build context the account row keeps — kept here + // too so the row stands on its own months later, after the account's + // `lastLoginIp` and device have been overwritten by every login since. + data: { + ip: clientIp, + roles: staffRoles, + grantType, + username: roleAccount?.username ?? null, + deviceId, + deviceClass, + version: version ?? null, + }, + }) + } catch (err) { + logger.error('could not write a staff_login audit log row', { + accountId, + grantType, + error: err instanceof Error ? err.message : String(err), + }) + } + } + return c.json({ access_token: accessToken, expires_in: TOKEN_TTL_SECONDS, diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index dd6dad2..ee87ef2 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../auth.app' import { + AUDIT_LOG_SCHEMA_DDL, GAME_VERSION, getAccountsByDeviceId, hashPassword, @@ -70,6 +71,9 @@ beforeAll(async () => { for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Presence table (owned by the rooms worker) — signup seeds the Orientation row. for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose + // migrations don't run here, so build it from the mirrored DDL. + for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set. const hash = await hashPassword(LOGIN_PASSWORD) @@ -1780,3 +1784,120 @@ describe('ban evasion at the token endpoint', () => { } }) }) + +// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files +// every `/internal/*` call on. What those accounts then do is attributed to them; this is +// the row that says the session it came from began, and from what device, build and IP. +describe('staff login audit', () => { + interface AuditRow { + player_id: number + action: string + data: string | null + date: string + } + + const staffLogins = async (accountId: number): Promise => { + const { results } = await env.DB.prepare( + `SELECT player_id, action, data, date FROM audit_log + WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id` + ) + .bind(accountId) + .all() + return results + } + + /** Seed an account with the given flags and a known password. */ + const seedAccount = async (accountId: number, data: Record) => + env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + username: `Staff${accountId}`, + passwordHash: await hashPassword(LOGIN_PASSWORD), + ...data, + }) + ) + .run() + + test('a login by an isModerator account is recorded, with the context of the sign-in', async () => { + await seedAccount(9601, { username: 'ModPlayer', isModerator: true }) + const res = await postToken( + `grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` + + '&device_id=dev-mod&device_class=2&ver=20230414', + '203.0.113.9' + ) + expect(res.status).toBe(200) + + const rows = await staffLogins(9601) + expect(rows).toHaveLength(1) + expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false) + const data = JSON.parse(rows[0].data!) as Record + expect(data).toMatchObject({ + ip: '203.0.113.9', + roles: ['moderator'], + grantType: 'password', + username: 'ModPlayer', + deviceId: 'dev-mod', + deviceClass: 2, + version: '20230414', + }) + // The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and + // the reader of a raw row should not have to hunt for it. + expect(Object.keys(data)[0]).toBe('ip') + }) + + // Both flags on one account record both — the row says which powers the session carries, + // not merely that it carries some. + test('an account holding both flags records both roles', async () => { + await seedAccount(9602, { isDeveloper: true, isModerator: true }) + expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200) + + const rows = await staffLogins(9602) + expect(rows).toHaveLength(1) + expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([ + 'developer', + 'moderator', + ]) + }) + + // The flags are the whole trigger. An ordinary player signing in is not an audit event, + // and recording every login would bury the ones that are. + test('an ordinary login records nothing', async () => { + await seedAccount(9603, {}) + expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200) + expect(await staffLogins(9603)).toHaveLength(0) + }) + + // A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury + // the actual sign-ins under hourly repeats saying nothing new about who came in or from + // where — so the login is recorded once, and the refreshes after it are not. + test('a refresh_token grant is not a login and is not recorded', async () => { + await seedAccount(9604, { isDeveloper: true }) + const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`) + expect(first.status).toBe(200) + expect(await staffLogins(9604)).toHaveLength(1) + + const refreshed = await postToken( + `grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}` + ) + expect(refreshed.status).toBe(200) + expect(await staffLogins(9604)).toHaveLength(1) + }) + + // The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so + // granting the role starts the trail at their next sign-in rather than retroactively. + test('an account granted the role starts being recorded on its next login', async () => { + await seedAccount(9605, {}) + expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200) + expect(await staffLogins(9605)).toHaveLength(0) + + await env.DB.prepare( + `UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1` + ) + .bind(9605) + .run() + + expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200) + expect(await staffLogins(9605)).toHaveLength(1) + }) +}) diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc index f8dc089..cdd980b 100644 --- a/apps/auth/wrangler.jsonc +++ b/apps/auth/wrangler.jsonc @@ -9,6 +9,11 @@ // history separate from the rooms worker's migrations on the same database. The // "local" placeholder is replaced with the real id from RECFLARE_D1 (see .env) at // deploy time. + // + // It also WRITES one table it doesn't own: a sign-in by an account holding the + // isDeveloper/isModerator flag is recorded on `audit_log` as a `staff_login` + // (apps/api/migrations/0024_audit_log.sql, owned by the `api` worker). That is the row + // saying the session every later audited action came from began, and from what IP. "d1_databases": [ { "binding": "DB", diff --git a/apps/notify/README.md b/apps/notify/README.md index be6819e..04e9906 100644 --- a/apps/notify/README.md +++ b/apps/notify/README.md @@ -23,6 +23,15 @@ instance holds the shared hub state (one process across all connections). `MessageReceived` to every connected client (online-only; not persisted). Same admin-role gate. +Every `/internal/*` call — including one the role gate refuses, and one that 404s — is +recorded on the shared database's `audit_log` table, against the account that made it: +`player_id` is the caller, `action` is the path as a snake_case verb (`coach_message`), +and `data` carries the request body or query string plus the response status. The write +happens in the admin gate, so a new `/internal/*` endpoint is audited the day it is added. +A token carrying an admin role but no usable `sub` is refused (401) rather than let through +unattributable. A failed audit write never fails the call; it leaves an error in the worker +log. The table is owned by the `api` worker (`apps/api/migrations/0024_audit_log.sql`). + ## Hub protocol 1. Client `POST /hub/v1/negotiate`, then opens a WebSocket to `/hub/v1?id=`. diff --git a/apps/notify/src/notify.app.ts b/apps/notify/src/notify.app.ts index a866f8a..7ab960b 100644 --- a/apps/notify/src/notify.app.ts +++ b/apps/notify/src/notify.app.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { createNotification } from '@repo/domain' +import { createNotification, writeAuditLog } from '@repo/domain' import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' @@ -119,16 +119,100 @@ async function connectionOwner(c: Context): Promise { ) } +/** + * The `action` an `/internal/` call is recorded under — the path after the prefix as a + * stable snake_case verb, so `/internal/coach-message` files under `coach_message`. + * + * Derived rather than mapped, so the audit trail can't fall behind the routes: a new + * `/internal/*` endpoint is logged the day it is added, with no second list to keep in sync. + * The cost is that the value comes from the request, so it is squeezed down to `[a-z0-9_]` + * and capped — this middleware also runs for `/internal/` paths that match no route at all + * (they 404 just after it), and an admin token reaching for one is still worth a row. + */ +function auditAction(path: string): string { + const slug = path + .replace(/^\/internal\/?/, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 64) + return slug === '' ? 'unknown' : slug +} + +/** + * Record one `/internal/*` call against the account that made it. + * + * What is kept is what was ASKED FOR plus how it was answered: the request body (or the + * query string, for the reads) is the substance of the action — a coach message's text and + * recipient exist nowhere else once the frame has gone out, and a broadcast is a WebSocket + * frame and then nothing — and the status says whether it happened. Reading the body here + * costs nothing: Hono caches a parsed body on the request, so this is the same object the + * handler parsed rather than a second read of a consumed stream. + * + * Never throws. By the time this runs the action has usually already taken place, so a + * failed insert must not turn a delivered coach message into an error for the caller — it + * leaves a loud line in the worker log instead, which is the one thing a gap in an audit + * trail should never do quietly. + */ +async function recordInternalCall(c: Context, playerId: number, status: number) { + const action = auditAction(c.req.path) + const data: Record = { method: c.req.method, path: c.req.path, status } + + // A GET carries no body by definition; everything else may, and a request whose body + // isn't JSON (or is empty, as the DELETE's is) simply records none. + const body = c.req.method === 'GET' ? null : await c.req.json().catch(() => null) + if (body !== null) data.body = body + const query = c.req.query() + if (Object.keys(query).length > 0) data.query = query + + try { + await writeAuditLog(c.env.DB, { playerId, action, data }) + } catch (err) { + logger.error('could not write an audit log row', { + action, + playerId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * Gates the `/internal/*` endpoints on a valid Bearer token that carries one of the * {@link ADMIN_ROLES} in its `role` claim. 401 for a missing/invalid token, 403 for a * valid token that lacks an admin role. + * + * Also where every call through here is AUDITED (see {@link recordInternalCall}). It sits in + * the gate rather than in each handler for the same reason the gate does: these endpoints + * are the operator's remote control over every connected client, and a record of who used + * it should not be something a new endpoint can forget to add. */ const requireAdmin: MiddlewareHandler = async (c, next) => { - const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get()) + const secret = await c.env.JWT_SECRET.get() + const roles = await validateAndGetRoles(c.req.raw, secret) if (roles === null) return c.json({ error: 'Unauthorized' }, 401) - if (!roles.some((role) => ADMIN_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403) - await next() + + // Every call is filed under the account that made it, so a token with an admin role but + // no usable `sub` is refused rather than let through unattributable. One minted by `auth` + // always has one, so this costs a real caller nothing. + const accountId = await validateAndGetAccountId(c.req.raw, secret) + if (accountId === null) return c.json({ error: 'Unauthorized' }, 401) + + if (!roles.some((role) => ADMIN_ROLES.has(role))) { + // Recorded like any other call: a signed-in account reaching for an admin endpoint is + // exactly what an audit trail is kept for, and unlike the 401 above we know who it was. + await recordInternalCall(c, accountId, 403) + return c.json({ error: 'Forbidden' }, 403) + } + + // Recorded AFTER the handler so the row can carry the outcome, and from a `finally` so a + // handler that throws is recorded too — as the 500 `onError` is about to answer with. + let status = 500 + try { + await next() + status = c.res.status + } finally { + await recordInternalCall(c, accountId, status) + } } const app = new Hono() diff --git a/apps/notify/src/test/integration/api.test.ts b/apps/notify/src/test/integration/api.test.ts index 9916cda..ef5e8c4 100644 --- a/apps/notify/src/test/integration/api.test.ts +++ b/apps/notify/src/test/integration/api.test.ts @@ -2,7 +2,7 @@ import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' -import { NOTIFICATION_SCHEMA_DDL } from '@repo/domain' +import { AUDIT_LOG_SCHEMA_DDL, NOTIFICATION_SCHEMA_DDL } from '@repo/domain' import '../../notify.app' @@ -55,6 +55,9 @@ beforeAll(async () => { // The notification store a targeted coach message writes to. Owned by the `api` worker's // migrations, which don't run here, so build it from the mirrored DDL. for (const ddl of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(ddl).run() + // The audit trail every /internal/* call is filed on. Owned by `api` too, so likewise + // built from the mirrored DDL. + for (const ddl of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(ddl).run() }) /** Who `connect` is by default — kept clear of the player ids the tests notify. */ @@ -801,3 +804,119 @@ describe('CORS', () => { expect(res.headers.get('access-control-allow-origin')).toBe('*') }) }) + +// Every /internal/* call is recorded on the `audit_log` table, against the account that +// made it. These endpoints are the operator's remote control over every connected client +// and most of what they do leaves no other trace on the database — a maintenance broadcast +// is a WebSocket frame and then nothing — so the row is the only lasting answer to "who +// sent that". +describe('audit log', () => { + interface AuditRow { + player_id: number + action: string + data: string | null + date: string + } + + /** Everything recorded against one actor, oldest first. */ + const auditRows = async (playerId: number): Promise => { + const { results } = await env.DB.prepare( + 'SELECT player_id, action, data, date FROM audit_log WHERE player_id = ?1 ORDER BY audit_log_id' + ) + .bind(playerId) + .all() + return results + } + + const auditData = (row: AuditRow): Record => + JSON.parse(row.data ?? '{}') as Record + + // A distinct actor per test: the rest of the file drives these endpoints as account 1, + // so filtering on the caller is what keeps each assertion about its own call. + test('records a successful call against the caller, with what was asked for', async () => { + const actor = 4242 + const res = await post( + '/internal/coach-message', + { playerId: 9500, messageContent: 'audited' }, + await bearer(String(actor), ['moderator']) + ) + expect(res.status).toBe(200) + + const rows = await auditRows(actor) + expect(rows).toHaveLength(1) + // The action is the path after `/internal/`, as a snake_case verb. + expect(rows[0].action).toBe('coach_message') + expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false) + expect(auditData(rows[0])).toMatchObject({ + method: 'POST', + path: '/internal/coach-message', + status: 200, + // The message and its recipient exist nowhere else once the frame has gone out. + body: { playerId: 9500, messageContent: 'audited' }, + }) + }) + + // A refused call is still a call someone made, and here — unlike the 401 below — we know + // who made it. A signed-in account reaching for an admin endpoint is close to the whole + // point of keeping the trail. + test('records a call the role gate refused, with the status that refused it', async () => { + const actor = 4243 + const res = await post( + '/internal/broadcast', + { notificationType: 1 }, + await bearer(String(actor), ['gameClient']) + ) + expect(res.status).toBe(403) + + const rows = await auditRows(actor) + expect(rows).toHaveLength(1) + expect(rows[0].action).toBe('broadcast') + expect(auditData(rows[0]).status).toBe(403) + }) + + test('records a rejected body as the attempt it was', async () => { + const actor = 4244 + const res = await post( + '/internal/coach-message', + { playerId: 9501 }, + await bearer(String(actor), ['moderator']) + ) + expect(res.status).toBe(400) + + const rows = await auditRows(actor) + expect(rows).toHaveLength(1) + expect(auditData(rows[0])).toMatchObject({ status: 400, body: { playerId: 9501 } }) + }) + + // The reads are recorded too, and a call that carries its arguments in the query string + // rather than a body records those instead. + test('records a read, with its query string', async () => { + const actor = 4245 + const res = await exports.default.fetch(`${ORIGIN}/internal/hub-state/pending?all=true`, { + method: 'DELETE', + headers: await bearer(String(actor), ['developer']), + }) + expect(res.status).toBe(200) + + const rows = await auditRows(actor) + expect(rows).toHaveLength(1) + expect(rows[0].action).toBe('hub_state_pending') + expect(auditData(rows[0])).toMatchObject({ method: 'DELETE', query: { all: 'true' } }) + }) + + // Nothing to file it under: `player_id` is the actor, and a request with no valid token + // names nobody. The 401 is the record of that one, in the worker log. + test('records nothing for a call with no valid token', async () => { + const count = async () => + (await env.DB.prepare('SELECT COUNT(*) AS n FROM audit_log').first<{ n: number }>())!.n + + const before = await count() + const res = await exports.default.fetch(`${ORIGIN}/internal/broadcast`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notificationType: 1 }), + }) + expect(res.status).toBe(401) + expect(await count()).toBe(before) + }) +}) diff --git a/apps/notify/wrangler.jsonc b/apps/notify/wrangler.jsonc index b3c8471..9e6864c 100644 --- a/apps/notify/wrangler.jsonc +++ b/apps/notify/wrangler.jsonc @@ -8,14 +8,21 @@ "bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }], - // The shared `recflare` D1 database, for the message store behind - // `GET /api/messages/v2/get`: a coach message to ONE player is a durable message and is - // written here before it is pushed. No `migrations_dir` — the `api` worker owns this - // schema (apps/api/migrations/0019_message.sql); notify only writes rows. + // The shared `recflare` D1 database. Two tables, both owned (and migrated) by the `api` + // worker — no `migrations_dir` here; notify only writes rows: // - // The coach BROADCAST is deliberately not stored: it reaches everyone online at once, so - // persisting it would write a row per player — thousands per maintenance notice, for - // something nobody needs to re-read. + // - `notification` (apps/api/migrations/0019_notification.sql), the message store behind + // `GET /api/messages/v2/get`: a coach message to ONE player is a durable message and is + // written there before it is pushed. + // - `audit_log` (apps/api/migrations/0024_audit_log.sql), where EVERY `/internal/*` call + // is recorded against the staff account that made it. These endpoints are the + // operator's remote control over every connected client and most of what they do leaves + // no other trace, so the row is the only lasting answer to "who sent that". + // + // The coach BROADCAST is deliberately not stored as a notification: it reaches everyone + // online at once, so persisting it would write a row per player — thousands per + // maintenance notice, for something nobody needs to re-read. Its audit row is one row + // either way, which is part of why that trail is worth keeping. "d1_databases": [ { "binding": "DB", diff --git a/packages/domain/src/audit-db.ts b/packages/domain/src/audit-db.ts new file mode 100644 index 0000000..aad8a35 --- /dev/null +++ b/packages/domain/src/audit-db.ts @@ -0,0 +1,115 @@ +/** + * The audit log on the shared `recflare` D1 database — one row per privileged action a + * staff account took, written by whoever performs the action. + * + * This is not the worker log. `logger.info` lines are for reading during an incident: they + * are sampled, they expire, and nothing can be asked of them later. An audit row is the + * durable answer to "who sent that, and when" — the kind of question that arrives weeks + * after the fact, about an action (a coach message, a maintenance broadcast) that left no + * other trace on the database at all. + * + * `player_id` is the ACTOR — the staff account that made the call — not whoever the action + * was aimed at. A target, where there is one, lives inside `data` along with the rest of + * what was asked for; the broadcasts have no target at all, so a column for one would be + * null on half the table and could not be the thing every row is filed under. + * + * `data` is JSON as TEXT, and deliberately unstructured: each action records what it was + * asked to do, and those shapes have nothing in common beyond being worth keeping. Read it + * with `JSON.parse`; nothing queries INTO it (D1 has `json_extract` if that ever changes). + * + * The `api` worker owns this schema/migration (`apps/api/migrations/0024_audit_log.sql`, + * applied under its own `migrations_table` so it doesn't clash with the other workers' + * migrations that share the database). {@link AUDIT_LOG_SCHEMA_DDL} mirrors that migration + * so a worker that writes the table but doesn't migrate it — `notify` — can build it + * directly in its tests. + */ + +/** + * How much serialized `data` one row keeps. + * + * A body arrives from a caller, so its size is theirs to choose, and an audit row that + * carries a multi-megabyte payload costs the same storage whether or not anyone ever reads + * it. Over the cap the row keeps the head of the JSON and says so (see {@link writeAuditLog}) + * rather than dropping `data` altogether: a truncated record still answers what was done, + * and a row that silently lost its payload does not. + */ +export const MAX_AUDIT_DATA_CHARS = 4096 + +/** Schema DDL (mirror of apps/api/migrations/0024_audit_log.sql). */ +export const AUDIT_LOG_SCHEMA_DDL: string[] = [ + // `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the + // server (unlike `notification`'s, which is handed to the client), and nothing deletes + // from this table, so there are no freed rowids to be reused. + // + // `date` is an ISO-8601 UTC string, which is fixed-width and so orders correctly under + // SQLite's plain string comparison — though reads order by `audit_log_id`, which is + // monotonic and doesn't tie when two actions land in the same millisecond. + // + // `data` is nullable: an action whose whole content is its name (and its actor, and its + // time) has nothing to put there, and null says that where `{}` would only look like a + // payload that went missing. + `CREATE TABLE IF NOT EXISTS audit_log ( + audit_log_id INTEGER PRIMARY KEY, + player_id INTEGER NOT NULL, + action TEXT NOT NULL, + data TEXT, + date TEXT NOT NULL + )`, + // The two ways anyone asks this table anything: everything one staffer did, and every + // time a given action was taken. Both newest-first, which is the order an audit trail is + // read in. + `CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC)`, +] + +/** What a caller records. The id and the timestamp are minted here. */ +export interface NewAuditLogEntry { + /** The staff account that took the action — the actor, never the target. */ + playerId: number + /** + * What they did, as a stable `snake_case` verb: `coach_message`, `broadcast`. It is what + * the log is filtered on, so it names the ACTION and not the outcome — a call that was + * refused is the same action as one that went through, distinguished by what `data` says + * about it. + */ + action: string + /** Whatever describes this particular action. Serialized to JSON; omit where there is none. */ + data?: unknown +} + +/** + * Record one action. + * + * Callers should not let a failure here fail the action itself: by the time this runs the + * thing has usually already happened, so throwing would report a coach message as refused + * after it was delivered. Catch it and log loudly instead — a gap in the audit trail is + * worth an error line, but it is not worth lying to the caller about what took place. + */ +export async function writeAuditLog(db: D1Database, entry: NewAuditLogEntry): Promise { + await db + .prepare(`INSERT INTO audit_log (player_id, action, data, date) VALUES (?1, ?2, ?3, ?4)`) + .bind(entry.playerId, entry.action, serializeAuditData(entry.data), new Date().toISOString()) + .run() +} + +/** + * `data` as it goes into the column: JSON text, null when there is nothing to say, and a + * self-describing stand-in when what there is exceeds {@link MAX_AUDIT_DATA_CHARS}. + * + * The stand-in is itself valid JSON, so every non-null `data` in the table parses — a reader + * never has to guess whether a row was truncated or simply malformed. + */ +function serializeAuditData(data: unknown): string | null { + if (data === undefined || data === null) return null + const json = JSON.stringify(data) + // `JSON.stringify` answers undefined for a value that isn't representable (a bare + // function, a symbol). Nothing here passes one, but the column would take the string + // "undefined" if something ever did. + if (json === undefined) return null + if (json.length <= MAX_AUDIT_DATA_CHARS) return json + return JSON.stringify({ + truncated: true, + chars: json.length, + preview: json.slice(0, MAX_AUDIT_DATA_CHARS), + }) +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ce0d284..3d3cefb 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -7,6 +7,7 @@ export { MatchmakingErrorCode, } from './enums' export * from './accounts-db' +export * from './audit-db' export * from './clubs-db' export * from './d1-binds' export * from './images-db'