From d88c1b4237eb822401ef884323b04ca2db458946 Mon Sep 17 00:00:00 2001 From: Travis Gerke Date: Thu, 30 Jul 2026 07:52:47 -0700 Subject: [PATCH 1/5] Enforce audit trigger protection with a separate runtime DB role ADR-0002 claimed the application role could not alter the append-only triggers, but nothing enforced it: the app connected as the table owner. Migration 0024 creates edc_app (no ownership, no TRIGGER/TRUNCATE privilege), compose splits DATABASE_URL from MIGRATE_DATABASE_URL, and integration tests prove the runtime role cannot disable, drop, or bypass the triggers. --- .../api/drizzle/0024_privilege_separation.sql | 40 +++++++++++ apps/api/drizzle/meta/_journal.json | 7 ++ apps/api/src/db/audit.test.ts | 66 +++++++++++++++++++ apps/api/src/db/client.ts | 7 ++ apps/api/src/db/migrate.ts | 4 +- docs/adr/0002-append-only-audit.md | 8 ++- docs/regulatory-traceability.md | 2 +- infra/compose.yaml | 8 ++- infra/initdb/01-app-role.sql | 5 ++ 9 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 apps/api/drizzle/0024_privilege_separation.sql create mode 100644 infra/initdb/01-app-role.sql diff --git a/apps/api/drizzle/0024_privilege_separation.sql b/apps/api/drizzle/0024_privilege_separation.sql new file mode 100644 index 0000000..d091169 --- /dev/null +++ b/apps/api/drizzle/0024_privilege_separation.sql @@ -0,0 +1,40 @@ +-- Privilege separation (ADR-0002; traceability P11-01). +-- +-- The append-only triggers in 0001 reject UPDATE/DELETE through any SQL +-- path, but a role that owns the tables can ALTER TABLE ... DISABLE TRIGGER +-- or TRUNCATE around them. The runtime role (edc_app) therefore never owns +-- clinical tables: migrations run as the owning role, the API connects as +-- edc_app, and edc_app holds no TRIGGER, TRUNCATE, or REFERENCES privilege +-- anywhere, so it cannot disable, drop, or bypass a trigger. +-- +-- edc_app is created NOLOGIN if missing so this migration succeeds on any +-- database. Deployments make it connectable themselves (ALTER ROLE edc_app +-- LOGIN PASSWORD '...'); infra/initdb does this for the local compose stack. +-- CREATE on the database is needed because the API creates one DuckLake +-- catalog schema per study. +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'edc_app') THEN + CREATE ROLE edc_app NOLOGIN; + END IF; + EXECUTE format('GRANT CREATE ON DATABASE %I TO edc_app', current_database()); +END $$; +--> statement-breakpoint +GRANT USAGE ON SCHEMA public TO edc_app; +--> statement-breakpoint +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO edc_app; +--> statement-breakpoint +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO edc_app; +--> statement-breakpoint +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO edc_app; +--> statement-breakpoint +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO edc_app; +--> statement-breakpoint +-- Append-only tables: the trigger is the enforcement; withholding UPDATE and +-- DELETE outright is defense in depth. A migration adding a new append-only +-- table must revoke these the same way (default privileges grant them). +REVOKE UPDATE, DELETE, TRUNCATE ON audit_events, item_value_versions, study_metadata_versions, codings, rtsm_events, subject_unblindings, protocol_versions FROM edc_app; +--> statement-breakpoint +-- Signatures keep UPDATE for the one-way invalidation transition; the +-- signatures_guard trigger constrains it to exactly that. +REVOKE DELETE, TRUNCATE ON signatures FROM edc_app; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 62f7ff8..4e48764 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1784691027650, "tag": "0023_query_source", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1785436800000, + "tag": "0024_privilege_separation", + "breakpoints": true } ] } diff --git a/apps/api/src/db/audit.test.ts b/apps/api/src/db/audit.test.ts index caf99dc..5494ad6 100644 --- a/apps/api/src/db/audit.test.ts +++ b/apps/api/src/db/audit.test.ts @@ -207,3 +207,69 @@ describe.skipIf(!dbAvailable)("audit core (integration)", () => { ); }); }); + +describe.skipIf(!dbAvailable)("privilege separation (integration)", () => { + // The runtime role (migration 0024) must not be able to reach around the + // append-only triggers. Each attempt runs in its own transaction under + // SET LOCAL ROLE, which the test connection can assume because the dev/CI + // migration role is superuser; rollback restores it. + const own = createDb(); + + beforeAll(async () => { + await runMigrations(); + }); + + afterAll(async () => { + await own.client.end(); + }); + + function asAppRole(statement: string) { + return own.client.begin(async (tx) => { + await tx`SET LOCAL ROLE edc_app`; + await tx.unsafe(statement); + }); + } + + it("cannot disable the append-only trigger", async () => { + await expectRejection( + asAppRole("ALTER TABLE audit_events DISABLE TRIGGER audit_events_append_only"), + /must be owner/, + ); + }); + + it("cannot drop the append-only trigger", async () => { + await expectRejection( + asAppRole("DROP TRIGGER audit_events_append_only ON audit_events"), + /must be owner/, + ); + }); + + it("cannot TRUNCATE the audit trail or version history", async () => { + await expectRejection(asAppRole("TRUNCATE audit_events"), /permission denied/); + await expectRejection(asAppRole("TRUNCATE item_value_versions"), /permission denied/); + }); + + it("lacks UPDATE and DELETE privileges on append-only tables entirely", async () => { + await expectRejection(asAppRole("UPDATE audit_events SET reason = 'x'"), /permission denied/); + await expectRejection(asAppRole("DELETE FROM audit_events"), /permission denied/); + await expectRejection(asAppRole("DELETE FROM signatures"), /permission denied/); + }); + + it("can still read and append", async () => { + const suffix = randomUUID().slice(0, 8); + await own.client.begin(async (tx) => { + await tx`SET LOCAL ROLE edc_app`; + const [user] = await tx` + INSERT INTO users (username, email, full_name, password_hash) + VALUES (${`priv-${suffix}`}, ${`priv-${suffix}@example.com`}, 'Priv Test', 'not-a-real-hash') + RETURNING id`; + if (!user) throw new Error("insert as edc_app failed"); + await tx` + INSERT INTO audit_events (actor_id, action, entity_type, entity_id) + VALUES (${user.id}, 'auth.login', 'user', ${user.id})`; + const [row] = await tx` + SELECT count(*)::int AS n FROM audit_events WHERE actor_id = ${user.id}`; + if (row?.n !== 1) throw new Error("select as edc_app failed"); + }); + }); +}); diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts index d07d2a6..af0da4e 100644 --- a/apps/api/src/db/client.ts +++ b/apps/api/src/db/client.ts @@ -8,6 +8,13 @@ export function databaseUrl(): string { return process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL; } +// Migrations run as the role that owns the tables; the runtime role +// (edc_app, migration 0024) cannot alter the append-only triggers. Falls +// back to DATABASE_URL for single-role setups (tests, local scripts). +export function migrateDatabaseUrl(): string { + return process.env.MIGRATE_DATABASE_URL ?? databaseUrl(); +} + export function createDb(url = databaseUrl()) { const client = postgres(url, { onnotice: () => {} }); return { db: drizzle(client, { schema }), client }; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 3613cdd..579e37b 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { sql } from "drizzle-orm"; import { migrate } from "drizzle-orm/postgres-js/migrator"; -import { createDb, databaseUrl } from "./client.js"; +import { createDb, migrateDatabaseUrl } from "./client.js"; -export async function runMigrations(url = databaseUrl()): Promise { +export async function runMigrations(url = migrateDatabaseUrl()): Promise { const { db, client } = createDb(url); const migrationsFolder = path.join(fileURLToPath(import.meta.url), "../../../drizzle"); try { diff --git a/docs/adr/0002-append-only-audit.md b/docs/adr/0002-append-only-audit.md index ddb4216..ae5ec53 100644 --- a/docs/adr/0002-append-only-audit.md +++ b/docs/adr/0002-append-only-audit.md @@ -15,8 +15,12 @@ forgets the middleware silently corrupts the guarantee. carrying who, when, oldβ†’new value, and reason-for-change, committed in the same transaction as the logical write. - Audit and version tables carry Postgres triggers that raise on UPDATE or DELETE. - The application role has no privilege to alter or drop these triggers. -- Automated tests assert that direct UPDATE/DELETE attempts fail. +- The application connects as a runtime role (`edc_app`, migration 0024) that does not + own the tables and holds no TRIGGER or TRUNCATE privilege, so it cannot disable, drop, + or bypass the triggers; migrations run as the owning role under a separate credential + (`MIGRATE_DATABASE_URL`). +- Automated tests assert that direct UPDATE/DELETE attempts fail and that the runtime + role cannot disable the triggers or TRUNCATE the tables. ## Consequences diff --git a/docs/regulatory-traceability.md b/docs/regulatory-traceability.md index ee34a78..212974a 100644 --- a/docs/regulatory-traceability.md +++ b/docs/regulatory-traceability.md @@ -14,7 +14,7 @@ Status legend: 🟒 implemented Β· 🟑 in progress Β· βšͺ planned | ID | Requirement (citation) | System mechanism | Status | |---|---|---|---| -| P11-01 | Secure, computer-generated, time-stamped audit trails for create/modify/delete; prior values not obscured (Β§11.10(e)) | Append-only version rows + DB triggers rejecting UPDATE/DELETE (ADR-0002) | 🟒 `audit.test.ts` | +| P11-01 | Secure, computer-generated, time-stamped audit trails for create/modify/delete; prior values not obscured (Β§11.10(e)) | Append-only version rows + DB triggers rejecting UPDATE/DELETE; runtime role cannot own the tables or disable the triggers (ADR-0002, migration 0024) | 🟒 `audit.test.ts` | | P11-02 | Audit trail retained as long as the record, available for review and copying (Β§11.10(e)) | Append-only trail with review UI (filter by action/entity/actor, paginated) and CSV export; full trail included in the study archive | 🟒 `audit.test.ts`, `snapshots.test.ts` | | P11-03 | System access limited to authorized individuals (Β§11.10(d)) | Unique accounts, RBAC scoped per-study/per-site, session timeout, lockout; admin account-lifecycle UI (create, deactivate/reactivate, unlock) with immediate session revocation | 🟒 `auth.test.ts`, `admin-users.test.ts` | | P11-04 | Authority checks: only authorized users can use the system, sign, or alter records (Β§11.10(g)) | Permission guards on every mutating route; signing permission is role-gated | 🟒 `auth.test.ts`, `capture.test.ts`, `signatures.test.ts` | diff --git a/infra/compose.yaml b/infra/compose.yaml index 6364fcd..21280a3 100644 --- a/infra/compose.yaml +++ b/infra/compose.yaml @@ -11,6 +11,9 @@ services: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data + # First boot of a fresh volume only: creates the edc_app login role. + # Existing volumes: ALTER ROLE edc_app LOGIN PASSWORD 'edc-dev-only'; + - ./initdb:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U edc -d edc"] interval: 5s @@ -22,7 +25,10 @@ services: context: .. dockerfile: apps/api/Dockerfile environment: - DATABASE_URL: postgres://edc:edc-dev-only@postgres:5432/edc + # Runtime role: cannot own clinical tables or touch their triggers + # (migration 0024). Migrations run as the owning role (edc). + DATABASE_URL: postgres://edc_app:edc-dev-only@postgres:5432/edc + MIGRATE_DATABASE_URL: postgres://edc:edc-dev-only@postgres:5432/edc PORT: "3000" # DuckLake analytics layer: Parquet files here, catalog in Postgres # (one schema + subdirectory per study) β€” no separate analytics server. diff --git a/infra/initdb/01-app-role.sql b/infra/initdb/01-app-role.sql new file mode 100644 index 0000000..d89abc8 --- /dev/null +++ b/infra/initdb/01-app-role.sql @@ -0,0 +1,5 @@ +-- Local development only; runs once, on first boot of a fresh pgdata volume. +-- Migration 0024 grants this role its privileges. Production deployments +-- create the runtime role themselves with a real password before the first +-- migration (see the deployment guide). +CREATE ROLE edc_app LOGIN PASSWORD 'edc-dev-only'; From 80778f7cae7b44b0f44c0d15c0c2df322fe53494 Mon Sep 17 00:00:00 2001 From: Travis Gerke Date: Thu, 30 Jul 2026 08:02:05 -0700 Subject: [PATCH 2/5] Make the audit trail fully reviewable: system scope, streamed export, UTC display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/audit (API + page) covers events written with no study β€” logins, account lifecycle, cross-study role changes β€” which were recorded but unreviewable. System-administration gated, mirroring /admin/access-log. - CSV exports stream the complete trail with keyset pagination instead of silently truncating at 10,000 rows. The cursor is the id alone: a JS Date cursor loses Postgres's microsecond precision and drops rows. - The review UI renders UTC (E6(R3) 4.2.2(d) unambiguous timestamps) and exposes the from/to time filters the API already had; the shared trail view now backs both the study and system pages. - System events stay out of the per-study archive deliberately: they span studies, and bundling them into one study's inspection copy would leak activity from the others. The /admin/audit CSV is their inspection copy. --- apps/api/src/routes/audit.test.ts | 64 +++++++- apps/api/src/routes/audit.ts | 182 ++++++++++++++++----- apps/web/src/api/hooks.ts | 14 ++ apps/web/src/components/AuditTrail.tsx | 208 ++++++++++++++++++++++++ apps/web/src/pages/AdminAuditPage.tsx | 14 ++ apps/web/src/pages/AuditPage.tsx | 161 +----------------- apps/web/src/pages/StudiesPage.tsx | 3 + apps/web/src/router.tsx | 8 + docs/regulatory-traceability.md | 4 +- site/src/content/docs/compliance.md | 6 +- site/src/content/docs/data-lifecycle.md | 3 +- 11 files changed, 466 insertions(+), 201 deletions(-) create mode 100644 apps/web/src/components/AuditTrail.tsx create mode 100644 apps/web/src/pages/AdminAuditPage.tsx diff --git a/apps/api/src/routes/audit.test.ts b/apps/api/src/routes/audit.test.ts index 57de481..22bd81d 100644 --- a/apps/api/src/routes/audit.test.ts +++ b/apps/api/src/routes/audit.test.ts @@ -6,7 +6,7 @@ import { hashPassword } from "../auth/password.js"; import { grantRole } from "../auth/rbac.js"; import { createDb, databaseUrl } from "../db/client.js"; import { runMigrations } from "../db/migrate.js"; -import { roles, sites, studies, users } from "../db/schema/index.js"; +import { auditEvents, roles, sites, studies, users } from "../db/schema/index.js"; import { buildServer } from "../server.js"; const { db, client } = createDb(); @@ -140,4 +140,66 @@ describe.skipIf(!dbAvailable)("audit trail review", () => { ); expect(lines.some((line) => line.includes("subject.enrolled"))).toBe(true); }); + + describe("system scope (/admin/audit)", () => { + let adminToken = ""; + let adminId = ""; + + beforeAll(async () => { + const [admin] = await db + .insert(users) + .values({ + username: `sysadmin-${suffix}`, + email: `sysadmin-${suffix}@example.com`, + fullName: "System Admin", + passwordHash: await hashPassword(PASSWORD), + isSystemAdmin: true, + }) + .returning(); + if (!admin) throw new Error("fixture failed"); + adminId = admin.id; + adminToken = ( + await server.inject({ + method: "POST", + url: "/auth/login", + payload: { username: `sysadmin-${suffix}`, password: PASSWORD }, + }) + ).json().token; + }); + + it("requires system administration, not just audit.review", async () => { + const denied = await get("/admin/audit", fx.dmToken); + expect(denied.statusCode).toBe(403); + }); + + it("lists only events without a study (logins, account lifecycle)", async () => { + const res = await get("/admin/audit", adminToken); + expect(res.statusCode).toBe(200); + const body = res.json(); + const actions = body.events.map((e: { action: string }) => e.action); + expect(actions).toContain("auth.login"); + expect(actions).not.toContain("subject.enrolled"); + expect(body.facets.actions).toContain("auth.login"); + }); + + it("streams a complete CSV across keyset batches (no row cap)", async () => { + // 1,500 rows crosses the 1,000-row batch boundary; a unique entity + // type isolates the assertion from other fixtures' events. + const entityType = `bulk-${suffix}`; + const bulk = Array.from({ length: 1500 }, (_, i) => ({ + actorId: adminId, + action: "auth.login", + entityType, + entityId: `row-${i}`, + })); + await db.insert(auditEvents).values(bulk); + + const res = await get(`/admin/audit?format=csv&entityType=${entityType}`, adminToken); + expect(res.statusCode).toBe(200); + const lines = res.body.split("\n").filter((line: string) => line.length > 0); + expect(lines).toHaveLength(1501); // header + every row + const ids = new Set(lines.slice(1).map((line: string) => line.split(",")[5])); + expect(ids.size).toBe(1500); // keyset pagination neither drops nor repeats + }); + }); }); diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts index 68ac3b6..fbfe840 100644 --- a/apps/api/src/routes/audit.ts +++ b/apps/api/src/routes/audit.ts @@ -1,7 +1,10 @@ -import { and, desc, eq, gte, lte, sql } from "drizzle-orm"; +import { Readable } from "node:stream"; +import { type SQL, and, desc, eq, gte, isNull, lt, lte, sql } from "drizzle-orm"; import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; +import { requireSystemAdmin } from "../auth/plugin.js"; import { hasPermission } from "../auth/rbac.js"; +import type { Db } from "../db/client.js"; import { auditEvents, users } from "../db/schema/index.js"; import { canUnblind, maskBlindedAuditRows } from "../services/blinding.js"; @@ -17,17 +20,95 @@ const filterSchema = z.object({ format: z.enum(["json", "csv"]).default("json"), }); +const CSV_HEADER = + "occurred_at,actor,actor_name,action,entity_type,entity_id,old_value,new_value,reason"; +const CSV_BATCH = 1_000; + function csvField(value: unknown): string { if (value === null || value === undefined) return ""; const text = typeof value === "string" ? value : JSON.stringify(value); return `"${text.replaceAll('"', '""')}"`; } +function selectRows(db: Db, where: SQL | undefined, limit: number, byId = false) { + return db + .select({ + id: auditEvents.id, + occurredAt: auditEvents.occurredAt, + actor: users.username, + actorName: users.fullName, + action: auditEvents.action, + entityType: auditEvents.entityType, + entityId: auditEvents.entityId, + oldValue: auditEvents.oldValue, + newValue: auditEvents.newValue, + reason: auditEvents.reason, + }) + .from(auditEvents) + .innerJoin(users, eq(auditEvents.actorId, users.id)) + .where(where) + .orderBy(...(byId ? [desc(auditEvents.id)] : [desc(auditEvents.occurredAt), desc(auditEvents.id)])) + .limit(limit); +} + +type AuditRow = Awaited>[number]; + +function csvLine(e: AuditRow): string { + return [ + e.occurredAt.toISOString(), + e.actor, + e.actorName, + e.action, + e.entityType, + e.entityId, + e.oldValue, + e.newValue, + e.reason, + ] + .map(csvField) + .join(","); +} + +/** + * A CSV export is an inspection copy (P11-02): it must be complete, so no + * row cap. Keyset-paged batches keep memory flat however large the trail is. + * The cursor is the id alone β€” a JS Date cursor truncates Postgres's + * microsecond timestamps and silently drops rows β€” so the export is ordered + * by id (insertion order, newest first) rather than the UI's timestamp sort. + */ +function csvStream( + db: Db, + conditions: SQL[], + mask?: (rows: AuditRow[]) => Promise, +): Readable { + async function* chunks() { + yield `${CSV_HEADER}\n`; + let cursor: bigint | undefined; + for (;;) { + const page = + cursor === undefined ? conditions : [...conditions, lt(auditEvents.id, cursor)]; + const batch = await selectRows(db, and(...page), CSV_BATCH, true); + const last = batch.at(-1); + if (!last) return; + cursor = last.id; + const visible = mask ? await mask(batch) : batch; + yield `${visible.map(csvLine).join("\n")}\n`; + } + } + // objectMode defaults to true for Readable.from; the reply needs bytes. + return Readable.from(chunks(), { objectMode: false }); +} + /** * The E6(R3) audit review surface (E6-03): the trail is not just stored but * reviewable β€” filterable by action, entity, actor, and time, and exportable * as CSV for inspection copies (P11-05). Read-only by construction; the * table itself rejects UPDATE/DELETE by trigger. + * + * Two scopes: per-study (`audit.review`-gated, blinding-masked) and system + * (`/admin/audit`, system-administration scope) for the events written with + * no study β€” logins, account lifecycle, cross-study role changes β€” which + * would otherwise be recorded but unreviewable. */ export const auditRoutes: FastifyPluginAsync = async (app) => { app.get("/studies/:studyId/audit", async (request, reply) => { @@ -47,71 +128,90 @@ export const auditRoutes: FastifyPluginAsync = async (app) => { if (f.actor) conditions.push(eq(users.username, f.actor)); if (f.from) conditions.push(gte(auditEvents.occurredAt, new Date(f.from))); if (f.to) conditions.push(lte(auditEvents.occurredAt, new Date(f.to))); + + // Blinded audit review: reviewers without data.unblind see who/when/why + // for blinded items, but not the values themselves. + const unblinded = await canUnblind(app.db, request.user.id, { studyId }); + + if (f.format === "csv") { + const mask = unblinded + ? undefined + : (rows: AuditRow[]) => maskBlindedAuditRows(app.db, studyId, rows); + return reply + .header("content-type", "text/csv; charset=utf-8") + .header("content-disposition", `attachment; filename="audit-${studyId}.csv"`) + .send(csvStream(app.db, conditions, mask)); + } + const where = and(...conditions); + const rows = await selectRows(app.db, where, f.limit).offset(f.offset); + const visible = unblinded ? rows : await maskBlindedAuditRows(app.db, studyId, rows); + const events = visible.map((row) => ({ ...row, id: String(row.id) })); - const rows = await app.db + const [{ total } = { total: 0 }] = await app.db + .select({ total: sql`count(*)::int` }) + .from(auditEvents) + .innerJoin(users, eq(auditEvents.actorId, users.id)) + .where(where); + // Facets scoped to the study drive the filter dropdowns. + const facets = await app.db .select({ - id: auditEvents.id, - occurredAt: auditEvents.occurredAt, - actor: users.username, - actorName: users.fullName, action: auditEvents.action, entityType: auditEvents.entityType, - entityId: auditEvents.entityId, - oldValue: auditEvents.oldValue, - newValue: auditEvents.newValue, - reason: auditEvents.reason, }) .from(auditEvents) - .innerJoin(users, eq(auditEvents.actorId, users.id)) - .where(where) - .orderBy(desc(auditEvents.occurredAt), desc(auditEvents.id)) - .limit(f.format === "csv" ? 10_000 : f.limit) - .offset(f.format === "csv" ? 0 : f.offset); + .where(eq(auditEvents.studyId, studyId)) + .groupBy(auditEvents.action, auditEvents.entityType); - // Blinded audit review: reviewers without data.unblind see who/when/why - // for blinded items, but not the values themselves. - const unblinded = await canUnblind(app.db, request.user.id, { studyId }); - const visible = unblinded ? rows : await maskBlindedAuditRows(app.db, studyId, rows); - const events = visible.map((row) => ({ ...row, id: String(row.id) })); + return { + total, + events, + facets: { + actions: [...new Set(facets.map((f) => f.action))].sort(), + entityTypes: [...new Set(facets.map((f) => f.entityType))].sort(), + }, + }; + }); + + // System-level events carry no study id, so no study-scoped permission can + // reach them; system-administration scope mirrors /admin/access-log. No + // blinding masking: nothing item-level is ever written without a study. + app.get("/admin/audit", { preHandler: requireSystemAdmin() }, async (request, reply) => { + const parsed = filterSchema.safeParse(request.query ?? {}); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const f = parsed.data; + + const conditions = [isNull(auditEvents.studyId)]; + if (f.action) conditions.push(eq(auditEvents.action, f.action)); + if (f.entityType) conditions.push(eq(auditEvents.entityType, f.entityType)); + if (f.entityId) conditions.push(eq(auditEvents.entityId, f.entityId)); + if (f.actor) conditions.push(eq(users.username, f.actor)); + if (f.from) conditions.push(gte(auditEvents.occurredAt, new Date(f.from))); + if (f.to) conditions.push(lte(auditEvents.occurredAt, new Date(f.to))); if (f.format === "csv") { - const header = - "occurred_at,actor,actor_name,action,entity_type,entity_id,old_value,new_value,reason"; - const lines = events.map((e) => - [ - e.occurredAt.toISOString(), - e.actor, - e.actorName, - e.action, - e.entityType, - e.entityId, - e.oldValue, - e.newValue, - e.reason, - ] - .map(csvField) - .join(","), - ); return reply .header("content-type", "text/csv; charset=utf-8") - .header("content-disposition", `attachment; filename="audit-${studyId}.csv"`) - .send([header, ...lines].join("\n")); + .header("content-disposition", 'attachment; filename="system-audit.csv"') + .send(csvStream(app.db, conditions)); } + const where = and(...conditions); + const rows = await selectRows(app.db, where, f.limit).offset(f.offset); + const events = rows.map((row) => ({ ...row, id: String(row.id) })); + const [{ total } = { total: 0 }] = await app.db .select({ total: sql`count(*)::int` }) .from(auditEvents) .innerJoin(users, eq(auditEvents.actorId, users.id)) .where(where); - // Facets scoped to the study drive the filter dropdowns. const facets = await app.db .select({ action: auditEvents.action, entityType: auditEvents.entityType, }) .from(auditEvents) - .where(eq(auditEvents.studyId, studyId)) + .where(isNull(auditEvents.studyId)) .groupBy(auditEvents.action, auditEvents.entityType); return { diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts index f6c8042..ce9a184 100644 --- a/apps/web/src/api/hooks.ts +++ b/apps/web/src/api/hooks.ts @@ -321,6 +321,9 @@ export interface AuditFilters { action?: string; entityType?: string; actor?: string; + /** ISO datetimes; the API compares against server-side UTC timestamps. */ + from?: string; + to?: string; limit: number; offset: number; } @@ -336,6 +339,8 @@ export function auditQueryString(filters: AuditFilters): string { if (filters.action) params.set("action", filters.action); if (filters.entityType) params.set("entityType", filters.entityType); if (filters.actor) params.set("actor", filters.actor); + if (filters.from) params.set("from", filters.from); + if (filters.to) params.set("to", filters.to); params.set("limit", String(filters.limit)); params.set("offset", String(filters.offset)); return params.toString(); @@ -349,6 +354,15 @@ export function useAudit(studyId: string, filters: AuditFilters) { }); } +/** System-level events (no study): logins, account lifecycle, role changes. */ +export function useSystemAudit(filters: AuditFilters) { + return useQuery({ + queryKey: ["audit", "system", filters], + placeholderData: (previous) => previous, + queryFn: () => api(`/admin/audit?${auditQueryString(filters)}`), + }); +} + export function useSignForm(formInstanceId: string) { const queryClient = useQueryClient(); return useMutation({ diff --git a/apps/web/src/components/AuditTrail.tsx b/apps/web/src/components/AuditTrail.tsx new file mode 100644 index 0000000..d759b92 --- /dev/null +++ b/apps/web/src/components/AuditTrail.tsx @@ -0,0 +1,208 @@ +import { Fragment, useState } from "react"; +import type { UseQueryResult } from "@tanstack/react-query"; +import { type AuditFilters, type AuditPage, auditQueryString } from "../api/hooks.js"; +import { Button, Card, ErrorNote, Input, Spinner } from "./ui.js"; + +const PAGE_SIZE = 50; + +// Reviewers in different time zones must read the same instant identically +// (E6(R3) 4.2.2(d)); render server UTC, never the browser's locale time. +function formatUtc(iso: string): string { + return `${iso.slice(0, 10)} ${iso.slice(11, 19)}`; +} + +function ValueCell({ label, value }: { label: string; value: unknown }) { + if (value === null || value === undefined) return null; + return ( +
+ {label} +
+        {JSON.stringify(value, null, 2)}
+      
+
+ ); +} + +/** + * The audit review surface shared by the per-study and system-level pages. + * The page supplies the data hook (study- or admin-scoped) and the CSV + * export URL; filters, table, and pagination behave identically. + */ +export function AuditTrail({ + useData, + csvHref, +}: { + useData: (filters: AuditFilters) => UseQueryResult; + csvHref: (queryString: string) => string; +}) { + const [action, setAction] = useState(""); + const [entityType, setEntityType] = useState(""); + const [actor, setActor] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [offset, setOffset] = useState(0); + const [expanded, setExpanded] = useState(null); + + // datetime-local values are in the browser's zone; the API takes ISO + // instants, so convert (invalid/partial input is simply not applied). + const toIso = (local: string) => { + const date = new Date(local); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); + }; + const fromIso = from ? toIso(from) : undefined; + const untilIso = to ? toIso(to) : undefined; + const filters: AuditFilters = { + ...(action ? { action } : {}), + ...(entityType ? { entityType } : {}), + ...(actor ? { actor } : {}), + ...(fromIso ? { from: fromIso } : {}), + ...(untilIso ? { to: untilIso } : {}), + limit: PAGE_SIZE, + offset, + }; + const { data, isPending, isError } = useData(filters); + + if (isPending) return ; + if (isError || !data) return Failed to load the audit trail.; + + const selectClass = + "rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-400 focus:outline-none"; + const setFilter = (set: (v: string) => void) => (value: string) => { + set(value); + setOffset(0); + setExpanded(null); + }; + + return ( +
+
+ + +
+ setFilter(setActor)(e.target.value)} + /> +
+ + + + Export CSV + +
+ + + + + + + + + + + + + + {data.events.map((event) => ( + + setExpanded(expanded === event.id ? null : event.id)} + > + + + + + + + {expanded === event.id ? ( + + + + ) : null} + + ))} + +
When (UTC)ActorActionEntityReason
+ {formatUtc(event.occurredAt)} + {event.actor}{event.action}{event.entityType} + {event.reason ?? ""} +
+
+ + +
+
+ {event.entityType} Β· {event.entityId} Β· event #{event.id} +
+
+
+ +
+ + {data.total === 0 + ? "No events match." + : `${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)} of ${data.total}`} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/AdminAuditPage.tsx b/apps/web/src/pages/AdminAuditPage.tsx new file mode 100644 index 0000000..bd61d0a --- /dev/null +++ b/apps/web/src/pages/AdminAuditPage.tsx @@ -0,0 +1,14 @@ +import { useSystemAudit } from "../api/hooks.js"; +import { AuditTrail } from "../components/AuditTrail.js"; +import { PageTitle } from "../components/ui.js"; + +export function AdminAuditPage() { + return ( +
+ + System audit trail + + `/api/admin/audit?${qs}&format=csv`} /> +
+ ); +} diff --git a/apps/web/src/pages/AuditPage.tsx b/apps/web/src/pages/AuditPage.tsx index a001d4e..dc677a7 100644 --- a/apps/web/src/pages/AuditPage.tsx +++ b/apps/web/src/pages/AuditPage.tsx @@ -1,50 +1,10 @@ import { Link, useParams } from "@tanstack/react-router"; -import { Fragment, useState } from "react"; -import { type AuditFilters, auditQueryString, useAudit } from "../api/hooks.js"; -import { Button, Card, ErrorNote, Input, PageTitle, Spinner } from "../components/ui.js"; - -const PAGE_SIZE = 50; - -function ValueCell({ label, value }: { label: string; value: unknown }) { - if (value === null || value === undefined) return null; - return ( -
- {label} -
-        {JSON.stringify(value, null, 2)}
-      
-
- ); -} +import { type AuditFilters, useAudit } from "../api/hooks.js"; +import { AuditTrail } from "../components/AuditTrail.js"; +import { PageTitle } from "../components/ui.js"; export function AuditPage() { const { studyId } = useParams({ from: "/app/studies/$studyId/audit" }); - const [action, setAction] = useState(""); - const [entityType, setEntityType] = useState(""); - const [actor, setActor] = useState(""); - const [offset, setOffset] = useState(0); - const [expanded, setExpanded] = useState(null); - - const filters: AuditFilters = { - ...(action ? { action } : {}), - ...(entityType ? { entityType } : {}), - ...(actor ? { actor } : {}), - limit: PAGE_SIZE, - offset, - }; - const { data, isPending, isError } = useAudit(studyId, filters); - - if (isPending) return ; - if (isError || !data) return Failed to load the audit trail.; - - const selectClass = - "rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-400 focus:outline-none"; - const setFilter = (set: (v: string) => void) => (value: string) => { - set(value); - setOffset(0); - setExpanded(null); - }; - return (
@@ -59,117 +19,10 @@ export function AuditPage() { Audit trail - -
- - -
- setFilter(setActor)(e.target.value)} - /> -
- - Export CSV - -
- - - - - - - - - - - - - - {data.events.map((event) => ( - - setExpanded(expanded === event.id ? null : event.id)} - > - - - - - - - {expanded === event.id ? ( - - - - ) : null} - - ))} - -
WhenActorActionEntityReason
- {new Date(event.occurredAt).toLocaleString()} - {event.actor}{event.action}{event.entityType} - {event.reason ?? ""} -
-
- - -
-
- {event.entityType} Β· {event.entityId} Β· event #{event.id} -
-
-
- -
- - {data.total === 0 - ? "No events match." - : `${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)} of ${data.total}`} - -
- - -
-
+ useAudit(studyId, filters)} + csvHref={(qs) => `/api/studies/${studyId}/audit?${qs}&format=csv`} + />
); } diff --git a/apps/web/src/pages/StudiesPage.tsx b/apps/web/src/pages/StudiesPage.tsx index 129edb5..620853b 100644 --- a/apps/web/src/pages/StudiesPage.tsx +++ b/apps/web/src/pages/StudiesPage.tsx @@ -71,6 +71,9 @@ export function StudiesPage() { + + + diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index fadf531..1432b3a 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -12,6 +12,7 @@ import { useLogout, useMe } from "./api/hooks.js"; import { NotificationsBell } from "./components/NotificationsBell.js"; import { Button, Spinner } from "./components/ui.js"; import { AdminAccessLogPage } from "./pages/AdminAccessLogPage.js"; +import { AdminAuditPage } from "./pages/AdminAuditPage.js"; import { AdminAnomaliesPage } from "./pages/AdminAnomaliesPage.js"; import { AdminDictionariesPage } from "./pages/AdminDictionariesPage.js"; import { AdminUsersPage } from "./pages/AdminUsersPage.js"; @@ -206,6 +207,12 @@ const adminAccessLogRoute = createRoute({ component: AdminAccessLogPage, }); +const adminAuditRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/admin/audit", + component: AdminAuditPage, +}); + const adminAnomaliesRoute = createRoute({ getParentRoute: () => appRoute, path: "/admin/anomalies", @@ -255,6 +262,7 @@ const routeTree = rootRoute.addChildren([ adminDictionariesRoute, adminUsersRoute, adminAccessLogRoute, + adminAuditRoute, adminAnomaliesRoute, changePasswordRoute, teamRoute, diff --git a/docs/regulatory-traceability.md b/docs/regulatory-traceability.md index 212974a..2376129 100644 --- a/docs/regulatory-traceability.md +++ b/docs/regulatory-traceability.md @@ -15,7 +15,7 @@ Status legend: 🟒 implemented Β· 🟑 in progress Β· βšͺ planned | ID | Requirement (citation) | System mechanism | Status | |---|---|---|---| | P11-01 | Secure, computer-generated, time-stamped audit trails for create/modify/delete; prior values not obscured (Β§11.10(e)) | Append-only version rows + DB triggers rejecting UPDATE/DELETE; runtime role cannot own the tables or disable the triggers (ADR-0002, migration 0024) | 🟒 `audit.test.ts` | -| P11-02 | Audit trail retained as long as the record, available for review and copying (Β§11.10(e)) | Append-only trail with review UI (filter by action/entity/actor, paginated) and CSV export; full trail included in the study archive | 🟒 `audit.test.ts`, `snapshots.test.ts` | +| P11-02 | Audit trail retained as long as the record, available for review and copying (Β§11.10(e)) | Append-only trail with review UI (filter by action/entity/actor/time, paginated, UTC display) and complete streamed CSV export (no row cap); full study trail in the archive; system-level events reviewable and exportable at `/admin/audit` | 🟒 `audit.test.ts` (routes), `snapshots.test.ts` | | P11-03 | System access limited to authorized individuals (Β§11.10(d)) | Unique accounts, RBAC scoped per-study/per-site, session timeout, lockout; admin account-lifecycle UI (create, deactivate/reactivate, unlock) with immediate session revocation | 🟒 `auth.test.ts`, `admin-users.test.ts` | | P11-04 | Authority checks: only authorized users can use the system, sign, or alter records (Β§11.10(g)) | Permission guards on every mutating route; signing permission is role-gated | 🟒 `auth.test.ts`, `capture.test.ts`, `signatures.test.ts` | | P11-05 | Validation of systems to ensure accuracy, reliability, consistent intended performance (Β§11.10(a)) | Versioned releases; `pnpm validation-pack` joins this matrix to the commit's test results; the release workflow generates it per tag and attaches it to the GitHub release | 🟒 | @@ -35,7 +35,7 @@ Status legend: 🟒 implemented Β· 🟑 in progress Β· βšͺ planned |---|---|---|---| | E6-01 | Data governance across the data lifecycle: capture β†’ validation β†’ transfer β†’ storage β†’ destruction (Annex 1 Β§4.2) | Metadata-driven capture: the versioned study definition *is* the documented capture/validation logic; audited subject lifecycle (screening/enrolled/screen-failed/completed/withdrawn with reasons); `site/data-lifecycle.qmd` maps every Β§4.2 lifecycle element to its system mechanism and the sponsor-side procedure it expects | 🟒 `study-builds.test.ts`, `capture.test.ts`, `subject-lifecycle.test.ts` | | E6-02 | Computerized systems validated proportionate to risk | Deterministic versioned builds; validation pack ships per release with full automated test evidence | 🟒 | -| E6-03 | Audit trails enabled by default; metadata defined; routine review expected | Audit always-on (not configurable off); dedicated review UI (`/studies/:id/audit`) with action/entity/actor filters, facets, pagination, CSV export; `audit.review` permission-gated | 🟒 `audit.test.ts` | +| E6-03 | Audit trails enabled by default; metadata defined; routine review expected | Audit always-on (not configurable off); dedicated review UI (`/studies/:id/audit`) with action/entity/actor/time filters, facets, pagination, CSV export; `audit.review` permission-gated; system-level events (no study) reviewable at `/admin/audit` | 🟒 `audit.test.ts` | | E6-04 | Traceability of data corrections and transformations | Reason-for-change on corrections; every workbench run (SQL, R, Python) persists an execution record β€” exact content, pinned snapshot, script version, outcome β€” plus an audit event with the code text; R/Python runs also persist logs and outputs | 🟒 `capture.test.ts`, `snapshots.test.ts` | | E6-05 | Access management: unique credentials, role-appropriate access, timely revocation | RBAC with per-study/per-site scoping; grants/revocations audited and managed in the per-study Team UI; deactivation revokes live sessions immediately (not at next timeout) | 🟒 `auth.test.ts`, `admin-users.test.ts`, `team.test.ts` | | E6-06 | Security incident detection and response (Β§4.3.3(b) "system monitoring", Β§3.16.1(w) incident reporting) | Periodic anomaly sweep over the access log and audit trail: failed-login bursts per source address, lockouts, session-binding violations; findings notify system administrators and are reviewed at `/admin/security-anomalies`, where acknowledgement (the recorded response) is written to the audit trail | 🟒 `security-anomalies.test.ts`, `access-log.test.ts` | diff --git a/site/src/content/docs/compliance.md b/site/src/content/docs/compliance.md index 1776bb9..a5380c0 100644 --- a/site/src/content/docs/compliance.md +++ b/site/src/content/docs/compliance.md @@ -52,8 +52,10 @@ hashing, configurable password policy, session timeout and lockout; role-based permissions scoped per-study and per-site with six default clinical roles. **Audit trail review (E6-03).** A dedicated review UI with filters (action, -entity, actor, time) and CSV export, because E6(R3) treats review as an -expectation, not an option. +entity, actor, time range), UTC timestamps, and CSV export that streams the +complete trail with no row cap. Two scopes: per-study, and a system-level +page for events recorded outside any study (logins, account lifecycle, role +changes). E6(R3) treats review as an expectation, not an option. **Device checks and access review (P11-14).** Sessions are bound to the client they were issued to: a token presented by a different browser is diff --git a/site/src/content/docs/data-lifecycle.md b/site/src/content/docs/data-lifecycle.md index 128a18e..00fc121 100644 --- a/site/src/content/docs/data-lifecycle.md +++ b/site/src/content/docs/data-lifecycle.md @@ -86,7 +86,8 @@ built: | Review | Where | |---|---| | Data review | [Query dashboard](/edc-core/guide/review/): open/answered/closed lifecycle, manual and system-raised, monitor reopen | -| Audit trail review | `/studies/:id/audit`: filter by action, entity, actor, time; export CSV (`audit.review`-gated) | +| Audit trail review | `/studies/:id/audit`: filter by action, entity, actor, time range; export the complete trail as CSV (`audit.review`-gated) | +| System-level audit review | `/admin/audit`: logins, lockouts, account lifecycle, and cross-study role changes β€” the events recorded without a study (system administrators) | | Access review | [Access log](/edc-core/guide/user-admin/) with CSV export | | Security events | [Anomaly review](/edc-core/deployment/#security-anomaly-detection) with audited acknowledgement | From 92ccf1ca0b854a8e60ee7091042550be93b20641 Mon Sep 17 00:00:00 2001 From: Travis Gerke Date: Thu, 30 Jul 2026 08:04:45 -0700 Subject: [PATCH 3/5] Document the two-role database model, clock discipline, and upgrade path - deployment.md: new Database roles and Clock synchronization sections; checklist items for the split credential and NTP verification. Host time was an unaddressed dependency of the 11.10(e) time-stamp claim. - Migration 0024 hands ownership of pre-split DuckLake catalog schemas to edc_app so in-place catalog upgrades keep working after the role split; existing deployments need only the ALTER ROLE ... LOGIN step. - Traceability matrix: point E6-01 and DP-02 evidence at the Astro doc paths that replaced the retired .qmd files. --- .../api/drizzle/0024_privilege_separation.sql | 26 ++++++++++ docs/regulatory-traceability.md | 4 +- site/src/content/docs/deployment.md | 51 +++++++++++++++++-- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/api/drizzle/0024_privilege_separation.sql b/apps/api/drizzle/0024_privilege_separation.sql index d091169..b3f5407 100644 --- a/apps/api/drizzle/0024_privilege_separation.sql +++ b/apps/api/drizzle/0024_privilege_separation.sql @@ -38,3 +38,29 @@ REVOKE UPDATE, DELETE, TRUNCATE ON audit_events, item_value_versions, study_meta -- Signatures keep UPDATE for the one-way invalidation transition; the -- signatures_guard trigger constrains it to exactly that. REVOKE DELETE, TRUNCATE ON signatures FROM edc_app; +--> statement-breakpoint +-- Existing deployments: DuckLake catalog schemas created before the role +-- split are owned by the old single role, and the API must own them to run +-- in-place catalog upgrades at startup. Transfer the ones this role owns; +-- schemas created after the split are made by edc_app directly. ALTER TABLE +-- ... OWNER TO also covers sequences and views. +DO $$ +DECLARE + s record; + c record; +BEGIN + FOR s IN + SELECT n.oid, n.nspname + FROM pg_namespace n + JOIN pg_roles r ON r.oid = n.nspowner + WHERE n.nspname LIKE 'ducklake\_%' AND r.rolname = current_user + LOOP + EXECUTE format('ALTER SCHEMA %I OWNER TO edc_app', s.nspname); + FOR c IN + SELECT relname FROM pg_class + WHERE relnamespace = s.oid AND relkind IN ('r', 'p', 'S', 'v', 'm') + LOOP + EXECUTE format('ALTER TABLE %I.%I OWNER TO edc_app', s.nspname, c.relname); + END LOOP; + END LOOP; +END $$; diff --git a/docs/regulatory-traceability.md b/docs/regulatory-traceability.md index 2376129..f7ac180 100644 --- a/docs/regulatory-traceability.md +++ b/docs/regulatory-traceability.md @@ -33,7 +33,7 @@ Status legend: 🟒 implemented Β· 🟑 in progress Β· βšͺ planned | ID | Requirement (section) | System mechanism | Status | |---|---|---|---| -| E6-01 | Data governance across the data lifecycle: capture β†’ validation β†’ transfer β†’ storage β†’ destruction (Annex 1 Β§4.2) | Metadata-driven capture: the versioned study definition *is* the documented capture/validation logic; audited subject lifecycle (screening/enrolled/screen-failed/completed/withdrawn with reasons); `site/data-lifecycle.qmd` maps every Β§4.2 lifecycle element to its system mechanism and the sponsor-side procedure it expects | 🟒 `study-builds.test.ts`, `capture.test.ts`, `subject-lifecycle.test.ts` | +| E6-01 | Data governance across the data lifecycle: capture β†’ validation β†’ transfer β†’ storage β†’ destruction (Annex 1 Β§4.2) | Metadata-driven capture: the versioned study definition *is* the documented capture/validation logic; audited subject lifecycle (screening/enrolled/screen-failed/completed/withdrawn with reasons); `site/src/content/docs/data-lifecycle.md` maps every Β§4.2 lifecycle element to its system mechanism and the sponsor-side procedure it expects | 🟒 `study-builds.test.ts`, `capture.test.ts`, `subject-lifecycle.test.ts` | | E6-02 | Computerized systems validated proportionate to risk | Deterministic versioned builds; validation pack ships per release with full automated test evidence | 🟒 | | E6-03 | Audit trails enabled by default; metadata defined; routine review expected | Audit always-on (not configurable off); dedicated review UI (`/studies/:id/audit`) with action/entity/actor/time filters, facets, pagination, CSV export; `audit.review` permission-gated; system-level events (no study) reviewable at `/admin/audit` | 🟒 `audit.test.ts` | | E6-04 | Traceability of data corrections and transformations | Reason-for-change on corrections; every workbench run (SQL, R, Python) persists an execution record β€” exact content, pinned snapshot, script version, outcome β€” plus an audit event with the code text; R/Python runs also persist logs and outputs | 🟒 `capture.test.ts`, `snapshots.test.ts` | @@ -54,7 +54,7 @@ Status legend: 🟒 implemented Β· 🟑 in progress Β· βšͺ planned | ID | Requirement | System mechanism | Status | |---|---|---|---| | DP-01 | GDPR pseudonymization by design | No direct identifiers in clinical tables by construction; subject keys only; site holds the link | 🟒 | -| DP-02 | GDPR/HIPAA hosting guidance | Deployment guide (`site/deployment.qmd`): TLS termination and secure cookies, volume-level encryption at rest, paired database+lake backups sized to the records-retention period, access-log retention, processor/transfer posture (GDPR Art. 28/32/44), production checklist | 🟒 | +| DP-02 | GDPR/HIPAA hosting guidance | Deployment guide (`site/src/content/docs/deployment.md`): TLS termination and secure cookies, volume-level encryption at rest, paired database+lake backups sized to the records-retention period, access-log retention, processor/transfer posture (GDPR Art. 28/32/44), production checklist | 🟒 | ## Standards conformance diff --git a/site/src/content/docs/deployment.md b/site/src/content/docs/deployment.md index 6bfb55a..22d57fa 100644 --- a/site/src/content/docs/deployment.md +++ b/site/src/content/docs/deployment.md @@ -62,6 +62,46 @@ Four places hold study data; everything else is stateless: The R and Python engines mount the lake read-only and keep no state of their own. The web container serves the SPA and holds nothing. +## Database roles + +The stack uses two Postgres roles (migration 0024): + +- The **owner role** (`edc` in the dev compose) owns every clinical table + and runs migrations. The API reads its credential from + `MIGRATE_DATABASE_URL`, uses it only for the migration step at startup, + and falls back to `DATABASE_URL` when it is unset. +- The **runtime role** (`edc_app`) is what the API connects with + (`DATABASE_URL`). It reads and writes through the application paths but + does not own the clinical tables and holds no TRIGGER or TRUNCATE + privilege, so it cannot disable, drop, or bypass the append-only triggers + that protect the audit trail (ADR-0002). A leaked or misused application + credential cannot rewrite history. + +A fresh dev compose stack creates the `edc_app` login on first boot +(`infra/initdb/01-app-role.sql`). Everywhere else, create it yourself with a +real password before starting the API: + +```sql +CREATE ROLE edc_app LOGIN PASSWORD ''; +``` + +If migrations ran before the login existed, the migration has already +created a non-login `edc_app`; enable it with +`ALTER ROLE edc_app LOGIN PASSWORD '...'` instead. Existing deployments need +nothing else: the migration re-grants table privileges and hands ownership +of pre-split DuckLake catalog schemas to `edc_app`. + +## Clock synchronization + +Every audit and version timestamp is taken server-side from PostgreSQL's +clock, which is the host's clock. "Time-stamped audit trails" (Β§11.10(e)) +are only as trustworthy as that clock, so keep the database host disciplined +with NTP (`chrony` or `systemd-timesyncd` on Linux; cloud hosts usually ship +this enabled β€” verify rather than assume). If the database runs in a VM, +check that the hypervisor does not step the guest clock on resume; a host +that sleeps can wake up seconds or minutes wrong and stamp records until +sync catches up. + ## Encryption in transit Terminate TLS at a reverse proxy (Caddy, nginx, Traefik, or your cloud load @@ -194,9 +234,14 @@ data on your behalf. Before first real use. Items marked *(wired)* are already handled by `compose.prod.yaml`; verify them instead if you deploy any other way: -- [ ] Replace `POSTGRES_PASSWORD` / `DATABASE_URL` credentials (the dev - compose default is labeled `edc-dev-only` for a reason) and inject them as - secrets, not baked into files. +- [ ] Replace `POSTGRES_PASSWORD` / `DATABASE_URL` / `MIGRATE_DATABASE_URL` + credentials (the dev compose default is labeled `edc-dev-only` for a + reason) and inject them as secrets, not baked into files. +- [ ] `DATABASE_URL` connects as `edc_app`, not the owner role; the owner + credential lives only in `MIGRATE_DATABASE_URL` (see + [Database roles](#database-roles)). +- [ ] NTP verified on the database host (see + [Clock synchronization](#clock-synchronization)). - [ ] `NODE_ENV=production` on the API container (secure cookies). *(wired)* - [ ] TLS-terminating reverse proxy in front; no other published ports, in particular not 5432 (Postgres) or 8000/8001 (engines). *(wired)* From 05034ae7c1a50624bdf62475bc133e56365e4a47 Mon Sep 17 00:00:00 2001 From: Travis Gerke Date: Thu, 30 Jul 2026 08:08:06 -0700 Subject: [PATCH 4/5] Fold the audit scope switch into useAudit Biome correctly flagged the hook-as-prop pattern (useHookAtTopLevel); passing studyId (null = system scope) into AuditTrail keeps the single hook call at the top level and thins both pages. --- apps/api/src/routes/audit.ts | 9 +++++---- apps/web/src/api/hooks.ts | 24 ++++++++++++------------ apps/web/src/components/AuditTrail.tsx | 21 +++++++-------------- apps/web/src/pages/AdminAuditPage.tsx | 3 +-- apps/web/src/pages/AuditPage.tsx | 6 +----- apps/web/src/router.tsx | 2 +- 6 files changed, 27 insertions(+), 38 deletions(-) diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts index fbfe840..fb16efb 100644 --- a/apps/api/src/routes/audit.ts +++ b/apps/api/src/routes/audit.ts @@ -1,5 +1,5 @@ import { Readable } from "node:stream"; -import { type SQL, and, desc, eq, gte, isNull, lt, lte, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNull, lt, lte, type SQL, sql } from "drizzle-orm"; import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { requireSystemAdmin } from "../auth/plugin.js"; @@ -47,7 +47,9 @@ function selectRows(db: Db, where: SQL | undefined, limit: number, byId = false) .from(auditEvents) .innerJoin(users, eq(auditEvents.actorId, users.id)) .where(where) - .orderBy(...(byId ? [desc(auditEvents.id)] : [desc(auditEvents.occurredAt), desc(auditEvents.id)])) + .orderBy( + ...(byId ? [desc(auditEvents.id)] : [desc(auditEvents.occurredAt), desc(auditEvents.id)]), + ) .limit(limit); } @@ -85,8 +87,7 @@ function csvStream( yield `${CSV_HEADER}\n`; let cursor: bigint | undefined; for (;;) { - const page = - cursor === undefined ? conditions : [...conditions, lt(auditEvents.id, cursor)]; + const page = cursor === undefined ? conditions : [...conditions, lt(auditEvents.id, cursor)]; const batch = await selectRows(db, and(...page), CSV_BATCH, true); const last = batch.at(-1); if (!last) return; diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts index ce9a184..bc17d88 100644 --- a/apps/web/src/api/hooks.ts +++ b/apps/web/src/api/hooks.ts @@ -346,20 +346,20 @@ export function auditQueryString(filters: AuditFilters): string { return params.toString(); } -export function useAudit(studyId: string, filters: AuditFilters) { - return useQuery({ - queryKey: ["audit", studyId, filters], - placeholderData: (previous) => previous, - queryFn: () => api(`/studies/${studyId}/audit?${auditQueryString(filters)}`), - }); -} - -/** System-level events (no study): logins, account lifecycle, role changes. */ -export function useSystemAudit(filters: AuditFilters) { +/** + * studyId null selects the system scope (/admin/audit): events recorded + * with no study β€” logins, account lifecycle, role changes. + */ +export function useAudit(studyId: string | null, filters: AuditFilters) { return useQuery({ - queryKey: ["audit", "system", filters], + queryKey: ["audit", studyId ?? "system", filters], placeholderData: (previous) => previous, - queryFn: () => api(`/admin/audit?${auditQueryString(filters)}`), + queryFn: () => + api( + studyId + ? `/studies/${studyId}/audit?${auditQueryString(filters)}` + : `/admin/audit?${auditQueryString(filters)}`, + ), }); } diff --git a/apps/web/src/components/AuditTrail.tsx b/apps/web/src/components/AuditTrail.tsx index d759b92..147de60 100644 --- a/apps/web/src/components/AuditTrail.tsx +++ b/apps/web/src/components/AuditTrail.tsx @@ -1,6 +1,5 @@ import { Fragment, useState } from "react"; -import type { UseQueryResult } from "@tanstack/react-query"; -import { type AuditFilters, type AuditPage, auditQueryString } from "../api/hooks.js"; +import { type AuditFilters, auditQueryString, useAudit } from "../api/hooks.js"; import { Button, Card, ErrorNote, Input, Spinner } from "./ui.js"; const PAGE_SIZE = 50; @@ -24,17 +23,11 @@ function ValueCell({ label, value }: { label: string; value: unknown }) { } /** - * The audit review surface shared by the per-study and system-level pages. - * The page supplies the data hook (study- or admin-scoped) and the CSV - * export URL; filters, table, and pagination behave identically. + * The audit review surface shared by the per-study and system-level pages; + * studyId null selects the system scope. Filters, table, and pagination + * behave identically in both. */ -export function AuditTrail({ - useData, - csvHref, -}: { - useData: (filters: AuditFilters) => UseQueryResult; - csvHref: (queryString: string) => string; -}) { +export function AuditTrail({ studyId }: { studyId: string | null }) { const [action, setAction] = useState(""); const [entityType, setEntityType] = useState(""); const [actor, setActor] = useState(""); @@ -60,7 +53,7 @@ export function AuditTrail({ limit: PAGE_SIZE, offset, }; - const { data, isPending, isError } = useData(filters); + const { data, isPending, isError } = useAudit(studyId, filters); if (isPending) return ; if (isError || !data) return Failed to load the audit trail.; @@ -127,7 +120,7 @@ export function AuditTrail({ Export CSV diff --git a/apps/web/src/pages/AdminAuditPage.tsx b/apps/web/src/pages/AdminAuditPage.tsx index bd61d0a..ff9710c 100644 --- a/apps/web/src/pages/AdminAuditPage.tsx +++ b/apps/web/src/pages/AdminAuditPage.tsx @@ -1,4 +1,3 @@ -import { useSystemAudit } from "../api/hooks.js"; import { AuditTrail } from "../components/AuditTrail.js"; import { PageTitle } from "../components/ui.js"; @@ -8,7 +7,7 @@ export function AdminAuditPage() { System audit trail - `/api/admin/audit?${qs}&format=csv`} /> +
); } diff --git a/apps/web/src/pages/AuditPage.tsx b/apps/web/src/pages/AuditPage.tsx index dc677a7..33b4cd0 100644 --- a/apps/web/src/pages/AuditPage.tsx +++ b/apps/web/src/pages/AuditPage.tsx @@ -1,5 +1,4 @@ import { Link, useParams } from "@tanstack/react-router"; -import { type AuditFilters, useAudit } from "../api/hooks.js"; import { AuditTrail } from "../components/AuditTrail.js"; import { PageTitle } from "../components/ui.js"; @@ -19,10 +18,7 @@ export function AuditPage() { Audit trail - useAudit(studyId, filters)} - csvHref={(qs) => `/api/studies/${studyId}/audit?${qs}&format=csv`} - /> + ); } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 1432b3a..6a82b7d 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -12,8 +12,8 @@ import { useLogout, useMe } from "./api/hooks.js"; import { NotificationsBell } from "./components/NotificationsBell.js"; import { Button, Spinner } from "./components/ui.js"; import { AdminAccessLogPage } from "./pages/AdminAccessLogPage.js"; -import { AdminAuditPage } from "./pages/AdminAuditPage.js"; import { AdminAnomaliesPage } from "./pages/AdminAnomaliesPage.js"; +import { AdminAuditPage } from "./pages/AdminAuditPage.js"; import { AdminDictionariesPage } from "./pages/AdminDictionariesPage.js"; import { AdminUsersPage } from "./pages/AdminUsersPage.js"; import { ApprovalQueuePage } from "./pages/ApprovalQueuePage.js"; From 2d93aae479bae28f5d0a5bba61853039105cd1ec Mon Sep 17 00:00:00 2001 From: Travis Gerke Date: Thu, 30 Jul 2026 08:14:46 -0700 Subject: [PATCH 5/5] Skip table-owned sequences in the DuckLake ownership transfer A serial/identity sequence cannot change owner on its own (it follows its table), so the upgrade loop in migration 0024 failed on any pre-split catalog containing one. Verified against a synthetic pre-split schema: tables carry their owned sequences and indexes; standalone relations transfer explicitly. --- apps/api/drizzle/0024_privilege_separation.sql | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/api/drizzle/0024_privilege_separation.sql b/apps/api/drizzle/0024_privilege_separation.sql index b3f5407..d33ac87 100644 --- a/apps/api/drizzle/0024_privilege_separation.sql +++ b/apps/api/drizzle/0024_privilege_separation.sql @@ -57,8 +57,16 @@ BEGIN LOOP EXECUTE format('ALTER SCHEMA %I OWNER TO edc_app', s.nspname); FOR c IN - SELECT relname FROM pg_class - WHERE relnamespace = s.oid AND relkind IN ('r', 'p', 'S', 'v', 'm') + SELECT cl.relname FROM pg_class cl + WHERE cl.relnamespace = s.oid AND cl.relkind IN ('r', 'p', 'S', 'v', 'm') + -- Sequences owned by a table column (serial/identity) cannot change + -- owner on their own; they follow their table's ALTER automatically. + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.objid = cl.oid + AND d.classid = 'pg_class'::regclass + AND d.deptype IN ('a', 'i') + ) LOOP EXECUTE format('ALTER TABLE %I.%I OWNER TO edc_app', s.nspname, c.relname); END LOOP;