fix(portal-bff): audit writes use raw INSERT (audit_writer has no SELECT for RETURNING) #121
Reference in New Issue
Block a user
Delete Branch "fix/portal-bff/audit-insert-without-returning"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
#120 shipped the audit pipeline but the end-to-end path was never smoke-tested against a running Postgres. First click on
/auth/logoutreturned 500 with the Pino log:Despite:
audit.eventsshowingaudit_writer=a/audit_owner(INSERT granted).has_table_privilege('audit_writer', 'audit.events', 'INSERT')returningt.has_schema_privilege/has_type_privilegeallt.INSERT INTO audit.events ...afterSET LOCAL ROLE audit_writersucceeding.INSERT ... RETURNING idafter the sameSET LOCAL ROLEfailing with the exact same error.Root cause: Prisma's ORM
tx.auditEvent.create(...)issuesINSERT ... RETURNING *to hydrate the returned entity. Postgres requires SELECT on every column listed inRETURNING.audit_writerhas INSERT only by ADR-0013 design — RETURNING fails withcode 42501and the error message reads "permission denied for table events" (no mention of SELECT or RETURNING, which is what made it deeply non-obvious to diagnose).Fix
AuditWriter.recordEventnow issues a parameterised raw INSERT viatx.$executeRawUnsafeinstead of the ORMcreate():The role contract per ADR-0013 stays strict:
audit_writerkeeps INSERT only, no SELECT/UPDATE/DELETE/TRUNCATE. The other natural fix (GRANT SELECTtoaudit_writer) would have weakened the writer/reader role separation, so we deliberately went the other way.Notable choices
gen_random_uuid()server-side instead of Prisma's@default(uuid())client-side. The model still declares@default(uuid())for any future ORM read oraudit_reader-side query, but the write path uses the built-in Postgres function. No extension required (Postgres 13+).Explicit enum and jsonb casts. Parameters travel as TEXT over the wire; the SQL casts (
$2::"audit"."AuditAudience",$7::jsonb) ensure Postgres parses them as the right type. Without the casts, the type system rejects the INSERT before privilege check even fires.Parameterised, not interpolated.
$executeRawUnsafeaccepts a SQL template with$1, $2, …placeholders and a vararg of values — same wire-level parameter binding as a prepared statement, so SQL injection isn't possible even on caller-controlled inputs likeeventType. The spec pins this with a malicious-input test.Also fixes an env-sensitivity bug in
auth.controller.spec.ts. The test that assertssession.absoluteExpiresAt == createdAt + 43200000was reading the default viareadSessionTimeouts()but didn't overrideSESSION_ABSOLUTE_TIMEOUT_SECONDS. Ifapps/portal-bff/.envhas a custom value (as it did during the manual audit debugging), the test failed non-deterministically. Now the test deletes the env var before running and restores it after — same pattern as the other env-touching tests in this file.ADR amendment
ADR-0013 §"Writer" now carries an Implementation trap callout explaining why Prisma's ORM
create()cannot be used for audit writes (RETURNING requires SELECT, audit_writer has INSERT only). The corresponding Confirmation entry cross-references the callout. Two-commit shape on this PR (code + docs) — the squash-merge will fold them.Test plan
pnpm nx test portal-bff(clean env) → 144/144 pass.pnpm nx lint portal-bff→ clean.pnpm nx build portal-bff→ clean.audit.eventswithevent_type = 'auth.sign_in'.event_type = 'auth.sign_out'. The 500 from before is gone.the fix in the previous commit (raw INSERT instead of tx.auditEvent.create) is a non-obvious consequence of the append-only-by-role contract — adr-0013's writer/reader role separation forbids audit_writer from holding SELECT, which prisma's default `INSERT … RETURNING *` requires. without this note in the adr, a future contributor will likely reach for prisma's orm create() again and hit the same misleading "permission denied for table events" 500 we spent a session debugging. added: - an "implementation trap" callout in §"Writer" of the decision outcome explaining why orm create() can't be used and what we do instead. - a cross-reference in the corresponding confirmation entry. - last-updated: 2026-05-13 in the frontmatter. no decision changes — the role contract, schema, and writer behavior are unchanged; this is purely a footgun-warning to preserve hard-won context.