fix(portal-bff): audit writes use raw INSERT (audit_writer has no SELECT for RETURNING) #121
@@ -1,6 +1,7 @@
|
||||
---
|
||||
status: accepted
|
||||
date: 2026-04-29
|
||||
last-updated: 2026-05-13
|
||||
decision-makers: R&D Lead
|
||||
tags: [security, observability, data]
|
||||
---
|
||||
@@ -95,6 +96,8 @@ enum AuditOutcome {
|
||||
|
||||
**Writer.** A NestJS `AuditService` exposes a single typed method per event family. Inside a request, the service uses the same Prisma transaction as the business action where applicable; outside a request (background expirations, scheduled jobs), it uses a fresh transaction. Writer connection runs under the `audit_writer` role and has only `INSERT` on `audit.events` — any attempt to `UPDATE` or `DELETE` is rejected by Postgres regardless of code intent.
|
||||
|
||||
> **Implementation trap — Prisma ORM cannot be used for the write.** Prisma's `tx.auditEvent.create(...)` issues `INSERT … RETURNING *` to hydrate the entity it returns. Postgres requires the `SELECT` privilege on every column listed in `RETURNING`, and `audit_writer` has `INSERT` only by design — there is no `SELECT` grant on the writer role. The ORM path therefore fails at runtime with `PostgresError 42501 / "permission denied for table events"`, an error whose message mentions neither `SELECT` nor `RETURNING`. The write path uses **parameterised `$executeRawUnsafe`** with no `RETURNING` clause; the schema-level `id UUID @default(uuid())` from Prisma is replaced server-side with `gen_random_uuid()` in the SQL. This is a deliberate consequence of the role-separation contract and is pinned by a spec test. The alternative — granting `SELECT` on `audit.events` to `audit_writer` — would collapse the writer / reader role separation that the rest of this ADR rests on, so we go the other way.
|
||||
|
||||
**Events emitted in v1.**
|
||||
|
||||
| `event_type` | When | `outcome` |
|
||||
@@ -161,7 +164,7 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
||||
- `apps/portal-bff/prisma/schema.prisma` enables the `multiSchema` preview, declares the `audit` schema alongside `public`, and carries the `AuditEvent` model with `AuditAudience` (`workforce | customer`) and `AuditOutcome` (`success | failure | denied`) enums.
|
||||
- The migration `prisma/migrations/*_init_audit_schema/migration.sql` creates `audit.events`, `ALTER`s table + enum types to be owned by `audit_owner`, and re-applies the role grants explicitly: `INSERT` to `audit_writer`, `SELECT` to `audit_reader`, `SELECT, DELETE` to `audit_archiver` (SELECT is needed for archiver to evaluate the `created_at` predicate of "delete older than retention"). No grant of `UPDATE` or `TRUNCATE` to anyone — including the migrator's own login at runtime; only fresh schema migrations amend the table.
|
||||
- The roles themselves and the schema with default privileges are provisioned earlier by `infra/local/init/postgres/01-init.sql` (dev) — production replicates the same SQL via the future on-prem infrastructure ADR.
|
||||
- `apps/portal-bff/src/audit/audit.service.ts` exposes a single `AuditWriter.recordEvent(input)` method. Every write runs in a transaction whose first statement is `SET LOCAL ROLE audit_writer`, so the runtime contract holds even if the BFF connection is otherwise privileged. `trace_id` is auto-resolved from the active OTel span; `actor_id_hash` is read from CLS or accepted as an explicit override (placeholder until ADR-0009 / ADR-0010 land their guards). Failures propagate — no catch-and-swallow, per "blocking writes: no audit ⇒ no action".
|
||||
- `apps/portal-bff/src/audit/audit.service.ts` exposes a single `AuditWriter.recordEvent(input)` method. Every write runs in a transaction whose first statement is `SET LOCAL ROLE audit_writer`, so the runtime contract holds even if the BFF connection is otherwise privileged. The INSERT itself is a **parameterised `$executeRawUnsafe`**, not `tx.auditEvent.create(...)` — see the "Implementation trap" callout in the Writer section above for the RETURNING-requires-SELECT explanation. `trace_id` is auto-resolved from the active OTel span; `actor_id_hash` is read from CLS or accepted as an explicit override (placeholder until ADR-0009 / ADR-0010 land their guards). Failures propagate — no catch-and-swallow, per "blocking writes: no audit ⇒ no action".
|
||||
- BFF connects via the shared `DATABASE_URL` (the role switch is per-transaction). A separate `AUDIT_DATABASE_URL` connection pool is the production hardening, deferred — see "wired as features land" below.
|
||||
- Smoke-tested end to end against the local-dev Postgres: `audit_writer` INSERTs successfully, fails on `UPDATE` and `DELETE`; `audit_archiver` SELECTs + DELETEs successfully.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user