Files
apf_portal/docs
julien a97be121e6
CI / check (push) Successful in 2m16s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m29s
CI / a11y (push) Successful in 1m20s
CI / perf (push) Successful in 3m30s
fix(portal-bff): audit writes use raw INSERT (audit_writer has no SELECT for RETURNING) (#121)
## Summary

#120 shipped the audit pipeline but the end-to-end path was never smoke-tested against a running Postgres. First click on `/auth/logout` returned 500 with the Pino log:

```
PostgresError code 42501 — permission denied for table events
```

Despite:

- ACL on `audit.events` showing `audit_writer=a/audit_owner` (INSERT granted).
- `has_table_privilege('audit_writer', 'audit.events', 'INSERT')` returning `t`.
- `has_schema_privilege` / `has_type_privilege` all `t`.
- A direct psql `INSERT INTO audit.events ...` after `SET LOCAL ROLE audit_writer` **succeeding**.
- A psql `INSERT ... RETURNING id` after the same `SET LOCAL ROLE` **failing** with the exact same error.

Root cause: Prisma's ORM `tx.auditEvent.create(...)` issues `INSERT ... RETURNING *` to hydrate the returned entity. Postgres requires **SELECT** on every column listed in `RETURNING`. `audit_writer` has INSERT only by ADR-0013 design — RETURNING fails with `code 42501` and 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.recordEvent` now issues a parameterised raw INSERT via `tx.$executeRawUnsafe` instead of the ORM `create()`:

```ts
await tx.$executeRawUnsafe(
  `INSERT INTO "audit"."events"
     (id, event_type, audience, outcome, subject, actor_id_hash, trace_id, payload)
   VALUES (gen_random_uuid(), $1, $2::"audit"."AuditAudience", $3::"audit"."AuditOutcome",
           $4, $5, $6, $7::jsonb)`,
  input.eventType, input.audience, input.outcome,
  input.subject ?? null, actorIdHash, traceId, payloadJson,
);
```

The role contract per ADR-0013 stays strict: `audit_writer` keeps INSERT only, no SELECT/UPDATE/DELETE/TRUNCATE. The other natural fix (`GRANT SELECT` to `audit_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 or `audit_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.** `$executeRawUnsafe` accepts 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 like `eventType`. The spec pins this with a malicious-input test.

**Also fixes an env-sensitivity bug in `auth.controller.spec.ts`.** The test that asserts `session.absoluteExpiresAt == createdAt + 43200000` was reading the default via `readSessionTimeouts()` but didn't override `SESSION_ABSOLUTE_TIMEOUT_SECONDS`. If `apps/portal-bff/.env` has 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](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) §"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

- [x] `pnpm nx test portal-bff` (clean env) → **144/144 pass**.
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean.
- [x] Prettier-clean.
- [ ] Manual smoke against running BFF + Postgres:
  - [ ] Sign in → row in `audit.events` with `event_type = 'auth.sign_in'`.
  - [ ] Sign out → row with `event_type = 'auth.sign_out'`. **The 500 from before is gone.**
  - [ ] Verify the role contract is still strict :
    ```sql
    SET ROLE audit_writer;
    SELECT * FROM audit.events LIMIT 1;  -- should fail "permission denied"
    UPDATE audit.events SET event_type = 'x';  -- should fail
    DELETE FROM audit.events;  -- should fail
    ```

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #121
2026-05-13 19:48:32 +02:00
..

Documentation index

This is the entry point to all project documentation. It is maintained automatically: any addition, rename, or removal of a .md file under docs/ must be reflected here in the same change.

Conventions

  • Documentation is written in English.
  • One topic per file. Group related files into a folder when there are three or more.
  • Cross-reference with relative links so they keep working in GitHub, IDEs, and exported sites.
  • For architectural decisions, do not add them here — they belong in decisions/ as MADR 4.0.0 ADRs.

Sections

Daily development

  • development.md — repo layout, prerequisites, initial setup, daily commands, observability dev-loop (Jaeger UI, log ↔ trace correlation), dependency updates (Renovate), conventional commit cycle. Day-to-day reference for working on the project.

Architecture

  • architecture.md — cross-cutting Mermaid diagrams: C4 system context, C4 containers, Nx module boundaries, CI/CD pipeline. Single-decision diagrams (auth sequence, ERD, etc.) live inline in their ADR.

Onboarding & environment

Setup guides for new contributors:

Operations & runbooks

Empty — to be populated when we deploy.

Security, performance, accessibility

Empty — placeholders to be filled with rationale docs alongside their corresponding ADRs.