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
This commit was merged in pull request #121.
This commit is contained in:
@@ -2,26 +2,29 @@ import { Test } from '@nestjs/testing';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuditWriter } from './audit.service';
|
||||
import type { AuditEventInput } from './audit.types';
|
||||
import { HashUserIdService } from './hash-user-id.service';
|
||||
|
||||
interface AuditEventCreateCall {
|
||||
data: {
|
||||
eventType: string;
|
||||
audience: 'workforce' | 'customer';
|
||||
outcome: 'success' | 'failure' | 'denied';
|
||||
subject: string | null;
|
||||
actorIdHash: string | null;
|
||||
traceId: string | null;
|
||||
payload: Prisma.InputJsonValue | typeof Prisma.JsonNull;
|
||||
};
|
||||
/**
|
||||
* Fields of an audit row as captured from the parameterised raw
|
||||
* INSERT — same positional order as the SQL placeholders. Anything
|
||||
* read from the mock goes through `extractInsertedRow` so the
|
||||
* tests assert against named fields rather than raw `$executeRawUnsafe`
|
||||
* argument indices.
|
||||
*/
|
||||
interface InsertedRow {
|
||||
eventType: unknown;
|
||||
audience: unknown;
|
||||
outcome: unknown;
|
||||
subject: unknown;
|
||||
actorIdHash: unknown;
|
||||
traceId: unknown;
|
||||
payloadJson: unknown;
|
||||
}
|
||||
|
||||
interface MockTx {
|
||||
$executeRawUnsafe: jest.Mock;
|
||||
auditEvent: { create: jest.Mock };
|
||||
}
|
||||
|
||||
interface MockPrisma {
|
||||
@@ -32,7 +35,6 @@ interface MockPrisma {
|
||||
function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } {
|
||||
const tx: MockTx = {
|
||||
$executeRawUnsafe: jest.fn().mockResolvedValue(0),
|
||||
auditEvent: { create: jest.fn().mockResolvedValue(undefined) },
|
||||
};
|
||||
const prisma: MockPrisma = {
|
||||
tx,
|
||||
@@ -42,6 +44,25 @@ function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } {
|
||||
return { prisma, cls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the inserted-row fields out of the `$executeRawUnsafe` mock.
|
||||
* `recordEvent` calls `$executeRawUnsafe` twice — first to `SET LOCAL
|
||||
* ROLE audit_writer`, then to issue the parameterised INSERT. The
|
||||
* INSERT call has args (sql, eventType, audience, outcome, subject,
|
||||
* actorIdHash, traceId, payloadJson).
|
||||
*/
|
||||
function extractInsertedRow(prisma: MockPrisma): InsertedRow {
|
||||
const calls = prisma.tx.$executeRawUnsafe.mock.calls;
|
||||
const insertCall = calls[1];
|
||||
if (!insertCall) {
|
||||
throw new Error(
|
||||
`expected $executeRawUnsafe to be called at least twice (SET ROLE + INSERT), got ${calls.length}`,
|
||||
);
|
||||
}
|
||||
const [, eventType, audience, outcome, subject, actorIdHash, traceId, payloadJson] = insertCall;
|
||||
return { eventType, audience, outcome, subject, actorIdHash, traceId, payloadJson };
|
||||
}
|
||||
|
||||
async function createSubject(): Promise<{
|
||||
writer: AuditWriter;
|
||||
prisma: MockPrisma;
|
||||
@@ -75,35 +96,69 @@ describe('AuditWriter', () => {
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.tx.$executeRawUnsafe).toHaveBeenCalledWith('SET LOCAL ROLE audit_writer');
|
||||
// SET ROLE must precede the INSERT, otherwise the runtime
|
||||
// privilege check happens with the wrong role.
|
||||
const setRoleOrder = prisma.tx.$executeRawUnsafe.mock.invocationCallOrder[0];
|
||||
const createOrder = prisma.tx.auditEvent.create.mock.invocationCallOrder[0];
|
||||
expect(setRoleOrder).toBeLessThan(createOrder);
|
||||
// recordEvent runs two raw statements per call: SET ROLE then
|
||||
// INSERT. SET ROLE must come first so the INSERT's privilege
|
||||
// check is against audit_writer, not the BFF's connection role.
|
||||
const calls = prisma.tx.$executeRawUnsafe.mock.calls;
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(calls[0]?.[0]).toBe('SET LOCAL ROLE audit_writer');
|
||||
expect(calls[1]?.[0]).toMatch(/INSERT INTO "audit"\."events"/);
|
||||
});
|
||||
|
||||
it('passes the input fields through to the create call', async () => {
|
||||
it('does NOT use Prisma ORM create() — the raw INSERT keeps audit_writer free of SELECT', async () => {
|
||||
// Bypassing the ORM is the explicit choice that lets ADR-0013's
|
||||
// append-only role contract hold. Prisma's create() emits
|
||||
// `INSERT … RETURNING *`, and RETURNING requires SELECT. Pin
|
||||
// the constraint so a well-meaning refactor can't silently
|
||||
// reintroduce the bug.
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
// Sanity check: the only writes go through $executeRawUnsafe.
|
||||
// No other tx method should be called.
|
||||
const allowedTxKeys = new Set(['$executeRawUnsafe']);
|
||||
const observedTxKeys = Object.keys(prisma.tx);
|
||||
expect(observedTxKeys.every((k) => allowedTxKeys.has(k))).toBe(true);
|
||||
});
|
||||
|
||||
it('parameterises the INSERT — never inlines values into SQL', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent({
|
||||
...baseInput,
|
||||
eventType: "auth'; DROP TABLE events; --",
|
||||
});
|
||||
|
||||
const insertCall = prisma.tx.$executeRawUnsafe.mock.calls[1];
|
||||
const sql = insertCall?.[0] as string;
|
||||
// SQL itself must be the parameterised template; the malicious
|
||||
// eventType has to land in the params array, not concatenated
|
||||
// into the SQL.
|
||||
expect(sql).toContain('$1');
|
||||
expect(sql).not.toContain('DROP TABLE');
|
||||
expect(insertCall?.[1]).toBe("auth'; DROP TABLE events; --");
|
||||
});
|
||||
|
||||
it('passes the input fields through positionally to the parameterised INSERT', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent({
|
||||
...baseInput,
|
||||
payload: { route: '/auth/login', clientId: 'spa' },
|
||||
});
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.login');
|
||||
expect(call.data.audience).toBe('workforce');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.subject).toBe('user:42');
|
||||
expect(call.data.payload).toEqual({ route: '/auth/login', clientId: 'spa' });
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.login');
|
||||
expect(row.audience).toBe('workforce');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.subject).toBe('user:42');
|
||||
expect(row.payloadJson).toBe(JSON.stringify({ route: '/auth/login', clientId: 'spa' }));
|
||||
});
|
||||
|
||||
it('records Prisma.JsonNull when no payload is provided', async () => {
|
||||
it('sends payload as null (JSONB column nullable) when no payload is provided', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.payload).toBe(Prisma.JsonNull);
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.payloadJson).toBeNull();
|
||||
});
|
||||
|
||||
it('reads actorIdHash from CLS when not passed explicitly', async () => {
|
||||
@@ -113,8 +168,7 @@ describe('AuditWriter', () => {
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
expect(cls.get).toHaveBeenCalledWith('actorIdHash');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBe('hash-from-cls');
|
||||
expect(extractInsertedRow(prisma).actorIdHash).toBe('hash-from-cls');
|
||||
});
|
||||
|
||||
it('prefers an explicit actorIdHash over the CLS-resolved one', async () => {
|
||||
@@ -123,8 +177,7 @@ describe('AuditWriter', () => {
|
||||
|
||||
await writer.recordEvent({ ...baseInput, actorIdHash: 'hash-from-input' });
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBe('hash-from-input');
|
||||
expect(extractInsertedRow(prisma).actorIdHash).toBe('hash-from-input');
|
||||
});
|
||||
|
||||
it('stores actorIdHash = null when neither input nor CLS has one', async () => {
|
||||
@@ -133,8 +186,7 @@ describe('AuditWriter', () => {
|
||||
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBeNull();
|
||||
expect(extractInsertedRow(prisma).actorIdHash).toBeNull();
|
||||
});
|
||||
|
||||
it('captures the active OTel trace id', async () => {
|
||||
@@ -145,8 +197,7 @@ describe('AuditWriter', () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.traceId).toBe('abc123');
|
||||
expect(extractInsertedRow(prisma).traceId).toBe('abc123');
|
||||
} finally {
|
||||
getActiveSpanSpy.mockRestore();
|
||||
}
|
||||
@@ -159,8 +210,7 @@ describe('AuditWriter', () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.recordEvent(baseInput);
|
||||
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.traceId).toBeNull();
|
||||
expect(extractInsertedRow(prisma).traceId).toBeNull();
|
||||
} finally {
|
||||
getActiveSpanSpy.mockRestore();
|
||||
}
|
||||
@@ -169,7 +219,8 @@ describe('AuditWriter', () => {
|
||||
it('propagates the underlying error — no catch-and-swallow', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
const dbError = new Error('permission denied for table events');
|
||||
prisma.tx.auditEvent.create.mockRejectedValueOnce(dbError);
|
||||
// First call (SET ROLE) succeeds, second (INSERT) rejects.
|
||||
prisma.tx.$executeRawUnsafe.mockResolvedValueOnce(0).mockRejectedValueOnce(dbError);
|
||||
|
||||
await expect(writer.recordEvent(baseInput)).rejects.toThrow(
|
||||
'permission denied for table events',
|
||||
@@ -186,13 +237,13 @@ describe('AuditWriter — typed event methods', () => {
|
||||
sessionId: 'sid-1',
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.sign_in');
|
||||
expect(call.data.audience).toBe('workforce');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(call.data.subject).toBe('session:sid-1');
|
||||
expect(call.data.payload).toEqual({ amr: ['pwd', 'mfa'] });
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.sign_in');
|
||||
expect(row.audience).toBe('workforce');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(row.subject).toBe('session:sid-1');
|
||||
expect(row.payloadJson).toBe(JSON.stringify({ amr: ['pwd', 'mfa'] }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,11 +252,11 @@ describe('AuditWriter — typed event methods', () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signInFailed({ failureKind: 'state-mismatch' });
|
||||
expect(hashUserId.hash).not.toHaveBeenCalled();
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.sign_in.failed');
|
||||
expect(call.data.outcome).toBe('failure');
|
||||
expect(call.data.actorIdHash).toBeNull();
|
||||
expect(call.data.payload).toEqual({ failureKind: 'state-mismatch' });
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.sign_in.failed');
|
||||
expect(row.outcome).toBe('failure');
|
||||
expect(row.actorIdHash).toBeNull();
|
||||
expect(row.payloadJson).toBe(JSON.stringify({ failureKind: 'state-mismatch' }));
|
||||
});
|
||||
|
||||
it('merges payload with failureKind under the same key', async () => {
|
||||
@@ -214,20 +265,21 @@ describe('AuditWriter — typed event methods', () => {
|
||||
failureKind: 'entra-error',
|
||||
payload: { entraError: 'access_denied', entraErrorDescription: 'user cancelled' },
|
||||
});
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.payload).toEqual({
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
failureKind: 'entra-error',
|
||||
entraError: 'access_denied',
|
||||
entraErrorDescription: 'user cancelled',
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('hashes the actor oid when one is provided (rare — identity-after-rejection path)', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signInFailed({ failureKind: 'amr-missing', actor: { oid: 'user-oid' } });
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(extractInsertedRow(prisma).actorIdHash).toBe('hash(user-oid)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,11 +288,11 @@ describe('AuditWriter — typed event methods', () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signOut({ actor: { oid: 'user-oid' }, sessionId: 'sid-2' });
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.sign_out');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(call.data.subject).toBe('session:sid-2');
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.sign_out');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(row.subject).toBe('session:sid-2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,11 +306,13 @@ describe('AuditWriter — typed event methods', () => {
|
||||
ageMs: 13 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.session.expired');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.subject).toBe('session:sid-3');
|
||||
expect(call.data.payload).toEqual({ reason: 'absolute', ageMs: 13 * 60 * 60 * 1000 });
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('auth.session.expired');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.subject).toBe('session:sid-3');
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({ reason: 'absolute', ageMs: 13 * 60 * 60 * 1000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
AuditEventInput,
|
||||
SignInActor,
|
||||
@@ -118,6 +117,7 @@ export class AuditWriter {
|
||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||
const actorIdHash =
|
||||
input.actorIdHash ?? this.cls.get<string | undefined>('actorIdHash') ?? null;
|
||||
const payloadJson = input.payload === undefined ? null : JSON.stringify(input.payload);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// Lock the connection to audit_writer for the duration of this
|
||||
@@ -125,27 +125,40 @@ export class AuditWriter {
|
||||
// pool's next consumer sees the original role.
|
||||
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`);
|
||||
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
eventType: input.eventType,
|
||||
audience: input.audience,
|
||||
outcome: input.outcome,
|
||||
subject: input.subject ?? null,
|
||||
// Deliberately NOT `tx.auditEvent.create(...)`. The Prisma ORM
|
||||
// create() emits `INSERT … RETURNING *` to hydrate the entity
|
||||
// it returns, and Postgres requires SELECT on every column
|
||||
// listed in RETURNING. `audit_writer` is granted INSERT only
|
||||
// (ADR-0013 §"Append-only by role grants"); RETURNING fails
|
||||
// with the deeply misleading "permission denied for table
|
||||
// events" error code 42501. Raw parameterised INSERT keeps
|
||||
// the role contract strict — audit_writer never needs SELECT.
|
||||
//
|
||||
// `gen_random_uuid()` is built into Postgres 13+ (the dev /
|
||||
// prod target is 17). The enum + jsonb casts are needed
|
||||
// because the parameter values are sent as TEXT over the
|
||||
// wire.
|
||||
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,
|
||||
payload: this.toJsonInput(input.payload),
|
||||
},
|
||||
});
|
||||
payloadJson,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Prisma's `Json` field accepts `Prisma.JsonNull` (null literal in
|
||||
// SQL JSONB) or a serialisable value; explicit `undefined` skips
|
||||
// the column. Map `payload` accordingly.
|
||||
private toJsonInput(
|
||||
payload: AuditEventInput['payload'],
|
||||
): Prisma.InputJsonValue | typeof Prisma.JsonNull {
|
||||
if (payload === undefined) return Prisma.JsonNull;
|
||||
return payload as Prisma.InputJsonValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,13 @@ describe('AuthController.callback', () => {
|
||||
});
|
||||
|
||||
it('records createdAt + absoluteExpiresAt on the session (ADR-0010 §"TTL policy")', async () => {
|
||||
// Force the ADR-0010 default for this test — apps/portal-bff/.env
|
||||
// may have a custom SESSION_ABSOLUTE_TIMEOUT_SECONDS for local
|
||||
// experiments and Nx auto-loads it. Asserting on the default in
|
||||
// a test that doesn't override it then fails non-deterministically.
|
||||
const originalAbsolute = process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'];
|
||||
delete process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'];
|
||||
try {
|
||||
const before = Date.now();
|
||||
const { controller } = makeController();
|
||||
const res = makeResStub();
|
||||
@@ -245,6 +252,13 @@ describe('AuthController.callback', () => {
|
||||
expect(session.createdAt).toBeLessThanOrEqual(after);
|
||||
// Default absoluteSeconds = 43200 (12 h) per ADR-0010.
|
||||
expect(session.absoluteExpiresAt).toBe((session.createdAt as number) + 43_200 * 1000);
|
||||
} finally {
|
||||
if (originalAbsolute === undefined) {
|
||||
delete process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'];
|
||||
} else {
|
||||
process.env['SESSION_ABSOLUTE_TIMEOUT_SECONDS'] = originalAbsolute;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('registers the new session in the user_sessions index after save', async () => {
|
||||
|
||||
@@ -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