diff --git a/.env.example b/.env.example index ab1973a..739065f 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,31 @@ # SPDX-FileCopyrightText: 2026 Quality Runtime contributors # SPDX-License-Identifier: Apache-2.0 -DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime +# The server's own connection. This role owns nothing: it holds the privileges +# to read and write rows and no more, so the guarantees the policies make hold +# against it too. Neither a superuser nor BYPASSRLS — the server refuses to +# start as either. `docs/development.md` creates both roles. +DATABASE_URL=postgres://qualityruntime:qualityruntime@localhost:5432/qualityruntime -# The role migrations are applied as, which owns the schema. Required by -# `db:migrate`, which never falls back to DATABASE_URL. -MIGRATION_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime +# Applying migrations needs a role that owns the schema, which the server's must +# not. Required by `db:migrate`, which never falls back to DATABASE_URL. +MIGRATION_DATABASE_URL=postgres://qualityruntime_migrator:qualityruntime@localhost:5432/qualityruntime + +# A scratch database for the concurrency tests, which need two connections at +# once and so cannot run on PGlite like the rest of the suite. Unset, those +# tests are skipped and `bun run test` still needs nothing running. The database +# is wiped on every run, so its name must end in `_test`. +# TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime_test # Public origin the server is reached at. BETTER_AUTH_URL=http://localhost:3000 +# The rendered API reference at /api/v1/reference loads its JavaScript from a +# public CDN. A deployment that cannot reach one points this at its own copy of +# @scalar/api-reference. Unset, the CDN is used; /api/v1/openapi.json is +# unaffected either way. +# API_REFERENCE_BUNDLE_URL= + # Used for encryption, signing, and hashing. At least 32 high-entropy # characters; generate one with `openssl rand -base64 32`. Left empty on # purpose, so a copied example cannot become a known production secret. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa25aee..f609666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,22 @@ permissions: jobs: check: runs-on: ubuntu-latest + services: + # The version `docs/development.md` tells a developer to run, and the one + # the concurrency suite has actually been exercised against. + postgres: + image: postgres:18 + env: + POSTGRES_PASSWORD: postgres + # The suite wipes this database, and refuses a name not ending `_test`. + POSTGRES_DB: qualityruntime_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -21,9 +37,13 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile - run: bun run check - # PGlite runs PostgreSQL in-process, so the migration and schema tests - # need no service container. + # PGlite runs PostgreSQL in-process, so almost nothing here needs a + # service. The exception is the concurrency suite: PGlite is a single + # connection, so a lock cannot be exercised on it, and without this a + # change that breaks one would pass CI (ADR 0020). - run: bun run test + env: + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/qualityruntime_test dco: # Trust is PR-level: the GitHub App is the only identity that can open a PR diff --git a/README.md b/README.md index cd4ac8c..9e9e9d5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Quality Runtime aims to make standards, controls, procedures, evidence, audits, The project is built around a few principles: - **Open source and self-hostable** — the core product should be genuinely useful without a hosted service. -- **AI-native** — AI should be able to understand and act across the system through clear models, APIs, and tools. +- **AI-native** — AI should be able to understand and act across the system through clear models, APIs, and tools. The API describes itself: `GET /api/v1/openapi.json`. - **Deterministic where it matters** — critical enforcement should remain explicit, testable, and auditable. - **Composable** — customization should happen through stable models, workflows, APIs, SDKs, and extensions rather than permanent forks. - **Understandable** — prefer simple architecture and boring primitives over unnecessary infrastructure and abstraction. @@ -59,8 +59,10 @@ Development setup is documented in [docs/development.md](./docs/development.md). If you are exploring the codebase, also read: +- [docs/product.md](./docs/product.md) — why the product exists, and what it deliberately is not - [AGENTS.md](./AGENTS.md) — repository rules for humans and coding agents - [ARCHITECTURE.md](./ARCHITECTURE.md) — architecture and system invariants +- [docs/adr/](./docs/adr/) — the decisions behind both, one file each ## Hosted service diff --git a/apps/server/app.ts b/apps/server/app.ts index 83537e3..76c3f1e 100644 --- a/apps/server/app.ts +++ b/apps/server/app.ts @@ -1,18 +1,101 @@ // SPDX-FileCopyrightText: 2026 Quality Runtime contributors // SPDX-License-Identifier: Apache-2.0 -import { Hono } from "hono"; -import type { Auth } from "./auth.ts"; +import type { RootDatabase } from "@qualityruntime/db"; +import type { PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { Scalar } from "@scalar/hono-api-reference"; +import { type Context, Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { HTTPException } from "hono/http-exception"; +import { type Auth, sessionCookieName } from "./auth.ts"; +import { controls } from "./controls.ts"; +import { failure } from "./responses.ts"; +import { openApiDocument, openApiPath, referencePath } from "./openapi.ts"; +import { organizationContext } from "./organization.ts"; +import { history } from "./history.ts"; /** * The HTTP surface. * * Better Auth owns every route under `/api/auth` and validates methods itself, - * so every method is forwarded. Domain routes mount alongside it as they - * arrive; `/api/auth` is Better Auth's own API, not this product's public one. + * so every method is forwarded. That is Better Auth's own API, not this + * product's public one, which lives under `/api/v1`. + * + * Every tenant-owned resource sits under `/api/v1/organizations/:organizationId` + * behind `organizationContext`, which resolves the caller's membership before + * any handler runs (ADR 0004). Mounted anywhere else, a handler would find no + * `withOrganization` on its context and fail rather than serve unscoped rows. + * + * Unmatched paths and uncaught errors answer in the same failure shape as the + * routes, so a client has one thing to parse. Better Auth is untouched by any + * of it: it answers `/api/auth/*` itself, so no request there reaches the + * not-found handler, and its own responses and limits are its contract. */ -export function createApp(auth: Auth) { - return new Hono().all("/api/auth/*", (c) => auth.handler(c.req.raw)); -} +export function createApp({ + auth, + db, + apiReferenceBundleUrl, +}: { + auth: Auth; + db: RootDatabase; + /** + * Where the rendered reference loads its bundle from, when not the CDN. + * + * Passed in rather than read from `process.env` here: this is core, and core + * must not know how a deployment keeps its configuration (ARCH-01). A Workers + * entry has no `process.env` at all — its environment arrives per request. + */ + apiReferenceBundleUrl?: string; +}) { + const tenant = "/api/v1/organizations/:organizationId"; -export type App = ReturnType; + /** + * How much of a body a route may read, decided before any of it is read. + * + * A body is read and parsed in full before a validator sees it, so the field + * bounds in a route schema do not bound the work a request costs. Chosen + * here rather than on the route: a limiter with no `Content-Length` to go on + * buffers the stream before passing it down, so a route that needs a + * different allowance has to be told apart here, ahead of this one. + */ + const tooLarge = (c: Context) => + c.json(failure("payload_too_large", "The request body is too large."), 413); + + return ( + new Hono() + .notFound((c) => c.json(failure("not_found", "No such endpoint."), 404)) + .onError((error, c) => { + if (error instanceof HTTPException) { + // Hono raises these before a handler runs — a malformed JSON body, for + // one. Its own response is plain text, so the envelope is rebuilt here + // unless the thrower supplied a response of its own. + if (error.res) return error.res; + const code = error.status >= 500 ? "internal" : "invalid_request"; + return c.json(failure(code, error.message), error.status); + } + // Anything else is a bug here: logged in full, reported without details. + console.error(error); + return c.json(failure("internal", "The request could not be completed."), 500); + }) + .all("/api/auth/*", (c) => auth.handler(c.req.raw)) + // Ahead of the organization prefix and outside it: the document describes + // the API, not anyone's data, and belongs to no tenant. + .get(openApiPath, (c) => c.json(openApiDocument(sessionCookieName(auth)))) + // The same document, rendered. Reads the JSON above by URL, so nothing + // about how it is built depends on this (ADR 0007). + .get( + referencePath, + Scalar({ + url: openApiPath, + pageTitle: "Quality Runtime API", + // Scalar loads its own bundle from a CDN. A deployment that cannot + // reach one points this at its own copy (ADR 0015). + ...(apiReferenceBundleUrl ? { cdn: apiReferenceBundleUrl } : {}), + }), + ) + .use("/api/v1/*", bodyLimit({ maxSize: 64 * 1024, onError: tooLarge })) + .use(`${tenant}/*`, organizationContext({ auth, db })) + .route(tenant, controls) + .route(tenant, history) + ); +} diff --git a/apps/server/audit.test.ts b/apps/server/audit.test.ts new file mode 100644 index 0000000..2e1099e --- /dev/null +++ b/apps/server/audit.test.ts @@ -0,0 +1,674 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Audit history: that control changes are recorded, attributed, and cannot be + * altered afterwards (AUDIT-01). + * + * Changes are made over HTTP, the way they really are; the history is read back + * through a tenant context, because the policies are what decide it is + * readable. Requests run as a non-superuser role that owns the tables, with + * row-level security forced so the policies bind their owner. That exercises + * the policies; the deployment's own posture — a runtime role that owns + * nothing — is `privileges.test.ts` and `documented-setup.test.ts`. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema, withOrganization } from "@qualityruntime/db"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +let db: ReturnType; +let app: ReturnType; + +type Tenant = { cookie: string; organizationId: string; userId: string }; +let acme: Tenant; +let globex: Tenant; + +type Control = { id: string; name: string; description: string | null; status: string }; + +const json = async (response: Response): Promise => (await response.json()) as T; + +const request = ( + tenant: Tenant, + path: string, + init: Omit & { headers?: Record } = {}, +) => + app.request(`/api/v1/organizations/${tenant.organizationId}/controls${path}`, { + ...init, + // Merged, not replaced: a caller's own headers are the point of + // passing them, and dropping them silently makes a test pass for + // the wrong reason. + headers: { + cookie: tenant.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + +/** A request to the organization itself, rather than to its controls. */ +const organization = ( + tenant: Tenant, + path: string, + init: Omit & { headers?: Record } = {}, +) => + app.request(`/api/v1/organizations/${tenant.organizationId}${path}`, { + ...init, + headers: { cookie: tenant.cookie, ...init.headers }, + }); + +async function given(tenant: Tenant, body: unknown = { name: "Access review" }): Promise { + const response = await request(tenant, "", { method: "POST", body: JSON.stringify(body) }); + expect(response.status).toBe(201); + return (await json<{ data: Control }>(response)).data; +} + +/** Signs a user up and returns their id and session cookie. */ +async function signUp(name: string, email: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name, email, password: "correct horse" }), + }); + expect(response.status).toBe(200); + return { + userId: (await json<{ user: { id: string } }>(response)).user.id, + cookie: response.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "), + }; +} + +const patch = (tenant: Tenant, id: string, body: unknown) => + request(tenant, `/${id}`, { method: "PATCH", body: JSON.stringify(body) }); + +/** The history of one resource, newest first, read through the tenant context. */ +const historyOf = (tenant: Tenant, resourceId: string) => + withOrganization(db, tenant.organizationId, (tx) => + tx + .select() + .from(schema.auditEvent) + .where( + and( + eq(schema.auditEvent.resourceType, "control"), + eq(schema.auditEvent.resourceId, resourceId), + ), + ) + .orderBy(desc(schema.auditEvent.createdAt), desc(schema.auditEvent.id)), + ); + +beforeAll(async () => { + const client = new PGlite(); + db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const tenant = async (slug: string): Promise => { + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Ada Lovelace", + email: `${slug}@example.test`, + password: "correct horse", + }), + }); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const userId = (await json<{ user: { id: string } }>(signedUp)).user.id; + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: slug, slug }), + }); + expect(created.status).toBe(200); + return { cookie, userId, organizationId: (await json<{ id: string }>(created)).id }; + }; + + acme = await tenant("acme"); + globex = await tenant("globex"); + + await client.exec(` + create role qualityruntime_app nosuperuser nobypassrls; + grant all on all tables in schema public to qualityruntime_app; + alter table "control" owner to qualityruntime_app; + alter table "audit_event" owner to qualityruntime_app; + set role qualityruntime_app; + `); +}, 60_000); + +describe("recording a change", () => { + it("records the creation of a control", async () => { + const created = await given(acme, { name: "Quarterly access review" }); + + const [event, ...rest] = await historyOf(acme, created.id); + + expect(rest).toEqual([]); + expect(event).toMatchObject({ + action: "created", + resourceType: "control", + resourceId: created.id, + organizationId: acme.organizationId, + before: null, + after: { name: "Quarterly access review", description: null, status: "draft" }, + }); + }); + + it("attributes the change to the caller, by id and by name", async () => { + const created = await given(acme, { name: "Attributed" }); + + const [event] = await historyOf(acme, created.id); + + expect(event).toMatchObject({ + actorType: "user", + actorId: acme.userId, + actorLabel: "Ada Lovelace", + }); + }); + + it("attributes an impersonated change to the administrator", async () => { + // Better Auth's admin plugin can put an administrator in a member's + // session, recording who on `session.impersonatedBy`. Set directly here: + // what is under test is who the change is attributed to, not the plugin's + // route. The administrator is accountable, so they are the actor; the + // member is whose account it happened through. + const administrator = await signUp("Grace Hopper", "administrator@example.test"); + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "session" set "impersonated_by" = ${administrator.userId} + where "user_id" = ${acme.userId}`, + ), + ); + + const created = await given(acme, { name: "Done on their behalf" }); + + const [event] = await historyOf(acme, created.id); + expect(event).toMatchObject({ + actorType: "user", + actorId: administrator.userId, + // Named, so the event still says who once their account is gone. + actorLabel: "Grace Hopper", + onBehalfOfId: acme.userId, + onBehalfOfLabel: "Ada Lovelace", + }); + + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "session" set "impersonated_by" = null where "user_id" = ${acme.userId}`, + ), + ); + }); + + it("times an event when it happens, not when its transaction began", async () => { + // `now()` is fixed for a whole transaction, so two events written in one + // would share a timestamp — and two requests racing on the same record + // would be ordered by when they started rather than by what happened + // first. `clock_timestamp()` is what makes these differ. Counted in SQL + // because a JavaScript Date rounds the difference away. + const resourceId = "ctl_0000000000000001"; + const distinct = await withOrganization(db, acme.organizationId, async (tx) => { + for (const action of ["created", "updated"]) { + // The clock has to move between them for this to mean anything, and + // two inserts alone can land inside the same microsecond. + await tx.execute(sql`select pg_sleep(0.005)`); + await tx.insert(schema.auditEvent).values({ + organizationId: acme.organizationId, + actorType: "system", + actorId: null, + actorLabel: "a scheduled job", + action, + resourceType: "control", + resourceId, + after: {}, + }); + } + const result = await tx.execute( + sql`select count(distinct "created_at")::int as n from "audit_event" + where "resource_id" = ${resourceId}`, + ); + return (result as unknown as { rows: { n: number }[] }).rows[0]!.n; + }); + + expect(distinct).toBe(2); + }); + + it("records only the fields an update changed", async () => { + const created = await given(acme, { name: "Before", description: "Unchanged" }); + + expect( + (await patch(acme, created.id, { name: "After", description: "Unchanged" })).status, + ).toBe(200); + + const [event] = await historyOf(acme, created.id); + expect(event?.action).toBe("updated"); + expect(event?.before).toEqual({ name: "Before" }); + expect(event?.after).toEqual({ name: "After" }); + }); + + it("records a status change", async () => { + const created = await given(acme, { name: "Promoted" }); + + await patch(acme, created.id, { status: "active" }); + + const [event] = await historyOf(acme, created.id); + expect(event?.before).toEqual({ status: "draft" }); + expect(event?.after).toEqual({ status: "active" }); + }); + + it("records nothing when an update changes nothing", async () => { + const created = await given(acme, { name: "Same" }); + + expect((await patch(acme, created.id, { name: "Same" })).status).toBe(200); + + // Only the creation. A no-op request would otherwise bury real changes. + const history = await historyOf(acme, created.id); + expect(history.map((event) => event.action)).toEqual(["created"]); + }); + + it("accumulates one event per change", async () => { + const created = await given(acme, { name: "Busy" }); + + await patch(acme, created.id, { name: "Busier" }); + await patch(acme, created.id, { status: "active" }); + + const history = await historyOf(acme, created.id); + expect(history.map((event) => event.action)).toEqual(["updated", "updated", "created"]); + }); +}); + +describe("history and the change it describes", () => { + it("records nothing when the change is refused", async () => { + // The transition is illegal, so the transaction returns without writing — + // neither the control nor its history moves. + const created = await given(acme, { name: "Refused" }); + + expect((await patch(acme, created.id, { status: "retired" })).status).toBe(409); + + const history = await historyOf(acme, created.id); + expect(history.map((event) => event.action)).toEqual(["created"]); + }); + + it("leaves no history behind when the transaction fails", async () => { + // The audit row is written inside the same transaction as the change, so a + // rollback has to take both. Forced here by failing after both writes. + const created = await given(acme, { name: "Doomed" }); + const before = await historyOf(acme, created.id); + + const attempt = withOrganization(db, acme.organizationId, async (tx) => { + await tx + .update(schema.control) + .set({ name: "Never" }) + .where(eq(schema.control.id, created.id)); + await tx.insert(schema.auditEvent).values({ + organizationId: acme.organizationId, + actorType: "user", + actorId: acme.userId, + actorLabel: "Ada Lovelace", + action: "updated", + resourceType: "control", + resourceId: created.id, + after: { name: "Never" }, + }); + throw new Error("the change failed"); + }); + await expect(attempt).rejects.toThrow("the change failed"); + + expect(await historyOf(acme, created.id)).toEqual(before); + const { data } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + expect(data.name).toBe("Doomed"); + }); +}); + +describe("append-only", () => { + it("cannot be updated from inside the tenant", async () => { + const created = await given(acme, { name: "Immutable" }); + const [event] = await historyOf(acme, created.id); + + const updated = await withOrganization(db, acme.organizationId, (tx) => + tx + .update(schema.auditEvent) + .set({ action: "rewritten" }) + .where(eq(schema.auditEvent.id, event!.id)) + .returning(), + ); + + // No UPDATE policy exists, so no row is visible to an update at all. + expect(updated).toEqual([]); + expect((await historyOf(acme, created.id))[0]?.action).toBe("created"); + }); + + it("cannot be deleted from inside the tenant", async () => { + const created = await given(acme, { name: "Indelible" }); + const [event] = await historyOf(acme, created.id); + + const deleted = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.auditEvent).where(eq(schema.auditEvent.id, event!.id)).returning(), + ); + + expect(deleted).toEqual([]); + expect(await historyOf(acme, created.id)).toHaveLength(1); + }); +}); + +describe("append-only under a non-owning runtime role", () => { + /** + * The posture `docs/deployment.md` requires for audit integrity: the server + * connects as a role that owns nothing and holds only SELECT and INSERT here. + * Row security cannot govern TRUNCATE, and cannot restrain an owner at all, + * so this is the configuration in which the history is protected from the + * runtime role itself rather than only from the application's code paths. + */ + const asRuntimeRole = async (work: () => Promise) => { + const client = db.$client; + await client.exec(` + reset role; + create role qualityruntime_runtime nosuperuser nobypassrls; + grant select, insert, update, delete on "control" to qualityruntime_runtime; + grant select, insert on "audit_event" to qualityruntime_runtime; + set role qualityruntime_runtime; + `); + try { + await work(); + } finally { + await client.exec(` + reset role; + drop owned by qualityruntime_runtime; + drop role qualityruntime_runtime; + set role qualityruntime_app; + `); + } + }; + + it("refuses TRUNCATE, which no policy can cover", async () => { + await given(acme, { name: "Protected" }); + + await asRuntimeRole(async () => { + await expect(db.execute(sql`truncate table "audit_event"`)).rejects.toThrow(); + }); + + // Still there, read back as the ordinary test role. + const events = await withOrganization(db, acme.organizationId, (tx) => + tx.select().from(schema.auditEvent), + ); + expect(events.length).toBeGreaterThan(0); + }); + + it("refuses an update or delete outright rather than matching no rows", async () => { + await asRuntimeRole(async () => { + // Without the grant this is a privilege error, not the silent no-op a + // policy produces — the difference between cannot and did not. + await expect(db.execute(sql`update "audit_event" set "action" = 'x'`)).rejects.toThrow(); + await expect(db.execute(sql`delete from "audit_event"`)).rejects.toThrow(); + }); + }); + + it("can still read and append its tenant's history", async () => { + await asRuntimeRole(async () => { + const created = await withOrganization(db, acme.organizationId, (tx) => + tx + .insert(schema.auditEvent) + .values({ + organizationId: acme.organizationId, + actorType: "system", + actorId: null, + actorLabel: "a scheduled job", + action: "created", + resourceType: "control", + resourceId: "ctl_0000000000000000", + after: {}, + }) + .returning(), + ); + + expect(created).toHaveLength(1); + }); + }); +}); + +describe("tenant isolation", () => { + it("hides one organization's history from another", async () => { + const theirs = await given(globex, { name: "Globex only" }); + + expect(await historyOf(globex, theirs.id)).toHaveLength(1); + expect(await historyOf(acme, theirs.id)).toEqual([]); + }); + + it("refuses an event labelled with another organization", async () => { + const attempt = withOrganization(db, acme.organizationId, (tx) => + tx.insert(schema.auditEvent).values({ + organizationId: globex.organizationId, + actorType: "user", + actorId: acme.userId, + actorLabel: "Ada Lovelace", + action: "created", + resourceType: "control", + resourceId: "ctl_0000000000000000", + after: {}, + }), + ); + + await expect(attempt).rejects.toThrow(); + }); +}); + +describe("reading an organization's history", () => { + type Event = { id: string; resourceType: string; resourceId: string; action: string }; + /** Every event id an unfiltered walk of this organization's history sees. */ + async function walkHistory(query: string): Promise { + const seen: string[] = []; + let cursor: string | null = null; + for (let guard = 0; guard < 40; guard++) { + const suffix: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""; + const body = await json<{ data: { id: string }[]; nextCursor: string | null }>( + await organization(acme, `/history?${query}${suffix}`), + ); + seen.push(...body.data.map((event) => event.id)); + if (!body.nextCursor) return seen; + cursor = body.nextCursor; + } + throw new Error("paging did not terminate"); + } + + const readHistory = async (tenant: Tenant, query = "") => + json<{ data: Event[]; nextCursor: string | null }>( + await organization(tenant, `/history${query}`), + ); + + it("survives the record it describes", async () => { + // The reason this collection exists. A control's history used to be + // reachable only through the control, so discarding one put its history + // beyond every route in the product (ADR 0018). + const control = await given(acme, { name: "Discarded, but not forgotten" }); + expect((await request(acme, `/${control.id}`, { method: "DELETE" })).status).toBe(204); + + const { data } = await readHistory(acme, `?resource=${control.id}`); + + expect(data.map((event) => event.action).sort()).toEqual(["created", "deleted"]); + expect(data.every((event) => event.resourceId === control.id)).toBe(true); + }); + + it("narrows to one record by the identifier alone", async () => { + const mine = await given(acme, { name: "Mine" }); + const other = await given(acme, { name: "Another" }); + + const { data } = await readHistory(acme, `?resource=${mine.id}`); + + expect(data.every((event) => event.resourceId === mine.id)).toBe(true); + expect(data.some((event) => event.resourceId === other.id)).toBe(false); + }); + + it("shows an organization nothing of another's", async () => { + // The policies decide this, not the route: there is no record to look up + // and refuse, because history outlives records. + const theirs = await given(globex, { name: "Globex only" }); + + const { data } = await readHistory(acme, `?resource=${theirs.id}`); + const everything = await readHistory(acme); + + expect(data).toEqual([]); + expect(everything.data.some((event) => event.resourceId === theirs.id)).toBe(false); + }); + + it("renders what happened, not just that something did", async () => { + // The row is reshaped on the way out, so every part of that shaping is a + // place a field can be silently dropped. Nulling `before` or the + // impersonation attribution left the whole suite green until this. + const administrator = await signUp("Grace Hopper", "behalf@example.test"); + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "session" set "impersonated_by" = ${administrator.userId} + where "user_id" = ${acme.userId}`, + ), + ); + const control = await given(acme, { name: "Before" }); + expect( + ( + await request(acme, `/${control.id}`, { + method: "PATCH", + body: JSON.stringify({ name: "After" }), + }) + ).status, + ).toBe(200); + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "session" set "impersonated_by" = null where "user_id" = ${acme.userId}`, + ), + ); + + const { data } = await json<{ + data: { + action: string; + actor: { + id: string; + label: string | null; + onBehalfOf: { id: string; label: string } | null; + }; + before: Record | null; + after: Record | null; + }[]; + }>(await organization(acme, `/history?resource=${control.id}`)); + + const change = data.find((event) => event.action === "updated"); + expect(change?.before).toEqual({ name: "Before" }); + expect(change?.after).toEqual({ name: "After" }); + // The administrator is accountable; the member is whose account it went + // through. Both survive the reshaping. + expect(change?.actor.id).toBe(administrator.userId); + expect(change?.actor.label).toBe("Grace Hopper"); + expect(change?.actor.onBehalfOf).toEqual({ id: acme.userId, label: "Ada Lovelace" }); + + const creation = data.find((event) => event.action === "created"); + expect(creation?.before).toBeNull(); + }); + + it("answers 404 to a caller who is not a member, as every other collection does", async () => { + // The organization is still resolved before the handler runs. Removing the + // record lookup removed record-level absences, not this one. + const theirs = await app.request(`/api/v1/organizations/${globex.organizationId}/history`, { + headers: { cookie: acme.cookie }, + }); + + expect(theirs.status).toBe(404); + }); + + it("pages the unfiltered history without repeating or skipping, even on a tie", async () => { + // Two mutations lived here: applying the cursor only when filtered, which + // makes an unfiltered walk repeat its first page forever; and dropping the + // id from the ordering, which loses rows whose timestamps are identical. + // Neither is visible without an unfiltered walk over tied timestamps. + const control = await given(acme, { name: "Tied" }); + const tied = ["aud_tie0000000000001", "aud_tie0000000000002", "aud_tie0000000000003"]; + await withOrganization(db, acme.organizationId, async (tx) => { + for (const id of tied) { + await tx.execute( + sql`insert into "audit_event" + ("id", "organization_id", "actor_type", "actor_id", "action", + "resource_type", "resource_id", "after", "created_at") + values (${id}, ${acme.organizationId}, 'system', null, 'updated', + 'control', ${control.id}, '{}'::jsonb, + '2031-01-01T00:00:00.000000Z'::timestamptz)`, + ); + } + }); + + // Without this the index returns the rows in `(created_at, id)` order + // whatever the query asked for, so an ordering that forgot the id would + // still look right. Turning the index scan off is what makes the query's + // own `ORDER BY` the thing under test rather than the planner's choice. + await db.execute(sql`set enable_indexscan = off`); + await db.execute(sql`set enable_bitmapscan = off`); + let seen: string[] = []; + try { + seen = await walkHistory("limit=2"); + } finally { + await db.execute(sql`reset enable_indexscan`); + await db.execute(sql`reset enable_bitmapscan`); + } + + expect(new Set(seen).size).toBe(seen.length); + // The three tied rows are ordered by id and all of them come back. + expect(seen.filter((id) => tied.includes(id))).toEqual([...tied].reverse()); + }); + + it("renders a deletion and a change nobody made", async () => { + // `after` is absent for a deletion and must stay null rather than become + // an empty object, and an actor the product attributes to itself must not + // be rendered as a user. + const control = await given(acme, { name: "Short-lived" }); + expect((await request(acme, `/${control.id}`, { method: "DELETE" })).status).toBe(204); + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`insert into "audit_event" + ("id", "organization_id", "actor_type", "actor_id", "action", + "resource_type", "resource_id", "after") + values ('aud_system0000000000', ${acme.organizationId}, 'system', null, 'updated', + 'control', ${control.id}, '{}'::jsonb)`, + ), + ); + + const { data } = await json<{ + data: { action: string; actor: { type: string; id: string | null }; after: unknown }[]; + }>(await organization(acme, `/history?resource=${control.id}`)); + + expect(data.find((event) => event.action === "deleted")?.after).toBeNull(); + const byNobody = data.find((event) => event.actor.type === "system"); + expect(byNobody).toBeDefined(); + expect(byNobody?.actor.id).toBeNull(); + }); + + it("refuses a cursor from a differently-filtered history", async () => { + // A cursor is a position in an ordering, and narrowing the history makes a + // different one: the same position names different rows (ADR 0006). + const control = await given(acme, { name: "Cursor crossing" }); + const narrowed = await readHistory(acme, `?resource=${control.id}&limit=1`); + const whole = await readHistory(acme, "?limit=1"); + expect(whole.nextCursor).not.toBeNull(); + + const crossed = await organization( + acme, + `/history?resource=${control.id}&cursor=${encodeURIComponent(whole.nextCursor!)}`, + ); + + expect(narrowed.nextCursor).not.toBe(whole.nextCursor); + expect(crossed.status).toBe(400); + }); +}); diff --git a/apps/server/audit.ts b/apps/server/audit.ts new file mode 100644 index 0000000..a3cc2b9 --- /dev/null +++ b/apps/server/audit.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Recording what changed (AUDIT-01). + * + * `organizationContext` binds `recordChange` to the caller and organization as + * `c.var.audit`, so a handler chooses what happened but never who did it. + * Reasoning: `docs/adr/0005-audit-history.md`. + */ + +import { schema, type TenantTransaction } from "@qualityruntime/db"; + +type AuditFields = schema.AuditFields; + +/** + * The record types audit history can refer to. Extended as entities arrive. + * + * A list rather than a union, because `history.ts` needs to iterate it to work + * out which record an identifier names. One source, so a new entity cannot be + * recordable and unreadable. + */ +export const resourceTypes = ["control"] as const; + +export type ResourceType = (typeof resourceTypes)[number]; + +type Records = { resourceType: ResourceType; resourceId: string }; + +/** + * What happened to a record, and what it looked like either side of it. + * + * A union rather than two optional fields, so the shape states the rule instead + * of merely permitting it: a creation has nothing before it and a deletion + * nothing after. Two optional fields would let a creation carry a `before` and + * a deletion an `after`, and nothing would object. + * + * `updated` keeps `before` optional because not every change is a replacement: + * one that only adds something has no previous value to name. + */ +export type Change = Records & + ( + | { action: "created"; before?: never; after: AuditFields } + | { action: "deleted"; before: AuditFields; after?: never } + | { action: "updated"; before?: AuditFields; after: AuditFields } + ); + +/** Who the change is attributed to, resolved once per request. */ +export type Actor = { + type: (typeof schema.actorTypes)[number]; + id: string; + /** How the actor was named at the time; see `schema/audit.ts`. */ + label: string | null; + /** + * Whose account the actor was working through, when that is someone else — + * an administrator impersonating a member. The administrator is the actor, + * because they are the one accountable for what happened. + */ + onBehalfOf?: { id: string; label: string | null }; +}; + +/** Records a change on the transaction that makes it. */ +export type RecordChange = (tx: TenantTransaction, change: Change) => Promise; + +/** + * Writes the audit row for `change`. + * + * Takes the transaction rather than a handle of its own, because history that + * can commit without the change it describes — or the other way round — is + * worse than none. Both live or neither does. + */ +export function recordChange( + tx: TenantTransaction, + actor: Actor, + organizationId: string, + change: Change, +): Promise { + return tx + .insert(schema.auditEvent) + .values({ + organizationId, + actorType: actor.type, + actorId: actor.id, + actorLabel: actor.label, + onBehalfOfId: actor.onBehalfOf?.id ?? null, + onBehalfOfLabel: actor.onBehalfOf?.label ?? null, + action: change.action, + resourceType: change.resourceType, + resourceId: change.resourceId, + before: change.before ?? null, + after: change.after, + }) + .then(() => undefined); +} + +/** + * The fields of `row` that `only` names, as they stand. + * + * Callers select domain fields, including timestamps describing domain events. + * Record identity is stored separately; bookkeeping timestamps (`created_at`, + * `updated_at`) describe the write and are omitted by the caller. + */ +export function fieldsOf(row: T, only: readonly K[]) { + return Object.fromEntries(only.map((field) => [field, row[field]])) as Pick; +} + +/** + * Whether two field values are the same. + * + * Two `Date`s for one instant are different objects, so `!==` would report a + * change on every update that touched a timestamp; and coercing both to strings + * instead would lose milliseconds, and make `null` and the string `"null"` the + * same value. + */ +const same = (a: unknown, b: unknown) => + a instanceof Date && b instanceof Date ? a.getTime() === b.getTime() : a === b; + +/** + * The subset of `after` that differs from `before`, on both sides. + * + * Returns `undefined` when nothing changed: a request that asked for the values + * a record already had is not an event, and recording it would bury the ones + * that are. + */ +export function diffFields( + before: T, + after: T, +): { before: Partial; after: Partial } | undefined { + const changed = (Object.keys(after) as (keyof T)[]).filter( + (key) => !same(before[key], after[key]), + ); + if (changed.length === 0) return undefined; + + return { + before: Object.fromEntries(changed.map((key) => [key, before[key]])) as Partial, + after: Object.fromEntries(changed.map((key) => [key, after[key]])) as Partial, + }; +} diff --git a/apps/server/auth.test.ts b/apps/server/auth.test.ts index ac63e1d..a17ac37 100644 --- a/apps/server/auth.test.ts +++ b/apps/server/auth.test.ts @@ -28,18 +28,20 @@ import { type Auth, authOptions, createAuth } from "./auth.ts"; const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); -let db: ReturnType; +let db: ReturnType; + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); let auth: Auth; let app: ReturnType; beforeAll(async () => { - db = drizzle(new PGlite(), { schema }); + db = createTestDatabase(new PGlite()); await migrate(db, { migrationsFolder }); auth = createAuth(db, { baseURL: "http://localhost", secret: "test-secret-of-at-least-32-characters", }); - app = createApp(auth); + app = createApp({ auth, db }); }, 60_000); /** Drops the response attributes so the value is a valid `Cookie` request header. */ diff --git a/apps/server/auth.ts b/apps/server/auth.ts index 2e61dd8..0f05fa9 100644 --- a/apps/server/auth.ts +++ b/apps/server/auth.ts @@ -5,6 +5,7 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { generateId, schema } from "@qualityruntime/db"; // `minimal` leaves out the bundled Kysely path, which the Drizzle adapter // replaces; it keeps the Workers bundle smaller. +import { getCookies } from "better-auth/cookies"; import { betterAuth } from "better-auth/minimal"; import type { BetterAuthOptions } from "better-auth/types"; import { admin, organization, twoFactor } from "better-auth/plugins"; @@ -26,7 +27,16 @@ export const authOptions = { // Authenticator apps show this as the TOTP issuer. appName: "Quality Runtime", emailAndPassword: { enabled: true }, - plugins: [organization(), admin(), twoFactor()], + plugins: [ + // Deleting an organization cascades through every tenant-owned table, + // and a foreign key's cascade answers to neither row-level security nor + // table privileges — it would take the audit log and every attestation + // with it. Removing a tenant is an operator's job, not a self-serve + // route an owner can reach (ADR 0005, ADR 0014). + organization({ disableOrganizationDeletion: true }), + admin(), + twoFactor(), + ], // Identifiers are prefixed and CHECK-enforced, so Better Auth must generate // them through `@qualityruntime/db` or every insert is rejected (ADR 0002). advanced: { database: { generateId } }, @@ -61,3 +71,12 @@ export function createAuth(db: AuthDatabase, { baseURL, secret }: AuthEnvironmen } export type Auth = ReturnType; + +/** + * The cookie Better Auth authenticates a session with, for this instance. + * + * Asked of Better Auth rather than written down: it prefixes the name with + * `__Secure-` when the base URL is HTTPS, so a fixed string would be right in + * development and wrong in every deployment. `openapi.ts` publishes it. + */ +export const sessionCookieName = (auth: Auth): string => getCookies(auth.options).sessionToken.name; diff --git a/apps/server/bun.ts b/apps/server/bun.ts index 2ce255c..2411a12 100644 --- a/apps/server/bun.ts +++ b/apps/server/bun.ts @@ -10,7 +10,7 @@ * (ARCH-01). */ -import { createDatabase } from "@qualityruntime/db"; +import { assertTenantIsolation, createDatabase } from "@qualityruntime/db"; import { Pool } from "pg"; import { createApp } from "./app.ts"; import { createAuth } from "./auth.ts"; @@ -26,9 +26,16 @@ function requireEnv(name: string): string { const pool = new Pool({ connectionString: requireEnv("DATABASE_URL") }); const db = createDatabase(pool); -export default createApp( - createAuth(db, { +// A role that bypasses row-level security disables tenant isolation silently, +// so refuse to start rather than serve without it (ADR 0003). +await assertTenantIsolation(db); + +export default createApp({ + db, + // Optional, unlike the rest: the reference falls back to the public CDN. + apiReferenceBundleUrl: process.env.API_REFERENCE_BUNDLE_URL, + auth: createAuth(db, { baseURL: requireEnv("BETTER_AUTH_URL"), secret: requireEnv("BETTER_AUTH_SECRET"), }), -); +}); diff --git a/apps/server/concurrency.test.ts b/apps/server/concurrency.test.ts new file mode 100644 index 0000000..7b2543f --- /dev/null +++ b/apps/server/concurrency.test.ts @@ -0,0 +1,427 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Races, against a real PostgreSQL. + * + * PGlite uses a single connection, so it cannot exercise contention between + * transactions. This suite uses separate PostgreSQL connections to verify + * decisions made after waiting for locks and tenant isolation across pool reuse. + * + * Lock-race tests force the interleaving: a second session holds a row lock, + * the request waits, and the second session may change the row before + * releasing it. The waiting request must decide from the state it finds after + * the wait. + * + * Skipped unless `TEST_DATABASE_URL` is set, so `bun run test` still needs + * nothing running. It names a database this file *wipes*, so it refuses one + * whose name does not end in `_test`. + */ + +import { fileURLToPath } from "node:url"; +import { createDatabase, schema } from "@qualityruntime/db"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool, type PoolClient } from "pg"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +// The test runner does not read the repository's `.env`, and this is the one +// suite that needs something out of it. Optional, as it is for drizzle-kit: +// no file and no variable simply means these tests do not run. +try { + process.loadEnvFile(fileURLToPath(new URL("../../.env", import.meta.url))); +} catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; +} + +/** The role the application connects as: owns nothing, bypasses nothing. */ +const runtime = "qualityruntime_races"; + +// Races wait on each other by design, and `blocked` gives a request up to +// fifteen seconds to join a lock queue. The default five-second test timeout +// cut that short whenever the whole suite was running beside this file. +// A real hang still fails, and a deadlock is an error PostgreSQL raises. +vi.setConfig({ testTimeout: 30_000 }); + +/** How many connections the application pool may open, and so may leave dirty. */ +const connections = 8; + +const connectionString = process.env.TEST_DATABASE_URL; + +/** + * Unset means skip; set and unusable means stop. + * + * Only the first is silent, and deliberately so — `bun run test` needs nothing + * running. Treating the second as a skip too would be the worst of both: a + * developer who pointed this at the wrong database would see a green suite + * that tested none of it, which is exactly the failure this file exists to end. + */ +if (connectionString) { + const named = new URL(connectionString).pathname.slice(1); + if (!named.endsWith("_test")) { + throw new Error( + `TEST_DATABASE_URL names "${named}", and this suite wipes the database it is given. ` + + `Point it at one whose name ends in "_test".`, + ); + } +} +const usable = Boolean(connectionString); + +let admin: Pool; +/** The one connection holding the suite's lock, from before setup to after teardown. */ +let lockHolder: PoolClient; +let pool: Pool; +let app: ReturnType; +let db: ReturnType; + +type Tenant = { cookie: string; organizationId: string }; +let acme: Tenant; +let globex: Tenant; + +const json = async (response: Response): Promise => (await response.json()) as T; +type Request = Omit & { headers?: Record }; + +const request = (path: string, init: Request = {}) => + app.request(`/api/v1/organizations/${acme.organizationId}${path}`, { + ...init, + headers: { + cookie: acme.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + +/** A held lock, and a way to wait for the requests piling up behind it. */ +type Held = { + session: PoolClient; + /** Resolves once `waiters` backends are blocked *by this session*. */ + blocked: (waiters?: number) => Promise; +}; + +/** + * Runs `act` against a row this holds locked, releasing only when it says so. + * + * `act` starts its requests, waits for them to block, makes its change and + * commits — so each request is released into a world that moved under it. + * + * Waiting on the block is the part that makes any of this a test. Starting a + * request only schedules it: without this, the other session can finish before + * the handler has touched the database, the two never overlap, and every test + * passes whether or not the lock it was written for exists. And it has to be a + * wait for *this* lock — anything else waiting anywhere in the database would + * otherwise do, which is the same false negative wearing a disguise. + */ +async function holding( + lock: string, + parameters: unknown[], + act: (held: Held) => Promise, +): Promise { + const session = await admin.connect(); + try { + await session.query("begin"); + await session.query(lock, parameters); + const { rows } = await session.query<{ pid: number }>("select pg_backend_pid() as pid"); + const holder = rows[0]!.pid; + + const blocked = async (waiters = 1) => { + // Generous on purpose: these block in milliseconds when the machine is idle, + // and a suite that fails because it was busy is worse than a slow one. + for (let attempt = 0; attempt < 1500; attempt++) { + // Following the chain, not just the first link: waiters queue behind + // each other, so the second one's `pg_blocking_pids` names the first + // waiter rather than the session actually holding the row. + const { rows: waiting } = await admin.query<{ waiting: number }>( + `with recursive queue as ( + select pid from pg_stat_activity + where datname = current_database() and $1 = any(pg_blocking_pids(pid)) + union + select behind.pid from pg_stat_activity behind, queue + where behind.datname = current_database() + and queue.pid = any(pg_blocking_pids(behind.pid)) + ) + select count(*)::int as waiting from queue where pid <> $1`, + [holder], + ); + if ((waiting[0]?.waiting ?? 0) >= waiters) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`only some of ${waiters} request(s) ever blocked on this lock`); + }; + + return await act({ session, blocked }); + } finally { + await session.query("rollback").catch(() => undefined); + session.release(); + } +} + +const control = async (name: string) => { + const response = await request("/controls", { + method: "POST", + body: JSON.stringify({ name }), + }); + expect(response.status).toBe(201); + return (await json<{ data: { id: string } }>(response)).data.id; +}; + +beforeAll(async () => { + if (!usable) return; + + admin = new Pool({ connectionString }); + + // One runner at a time. This file wipes the schema it works in, so a second + // run — a reviewer's, or a watch mode — would pull the tables out from under + // the first and fail it in ways that look like real races. The lock belongs + // to a session, so it is taken on a connection checked out for the whole + // run: a pooled query's connection goes back to the pool, which may close it + // when idle and release the lock mid-run. It still goes if a run is killed. + lockHolder = await admin.connect(); + await lockHolder.query("select pg_advisory_lock(hashtext('qualityruntime concurrency suite'))"); + + // A clean schema every run: these tests create rows and the database is + // shared with whatever the last run left. + await admin.query("drop schema if exists public cascade"); + await admin.query("create schema public"); + await admin.query("drop schema if exists drizzle cascade"); + + await migrate(drizzle({ client: admin, schema, casing: "snake_case" }), { migrationsFolder }); + + await admin.query(`drop role if exists ${runtime}`); + await admin.query(`create role ${runtime} nosuperuser nobypassrls`); + await admin.query(`grant usage on schema public to ${runtime}`); + await admin.query( + `grant select, insert, update, delete on all tables in schema public to ${runtime}`, + ); + await admin.query(`revoke update, delete on "audit_event" from ${runtime}`); + await admin.query(`revoke update, delete on "file" from ${runtime}`); + await admin.query(`revoke update on "control_requirement" from ${runtime}`); + await admin.query(`revoke delete on "organization" from ${runtime}`); + + // Every connection this pool hands out is the constrained role, so the + // policies are in force exactly as they are in a deployment. More than one + // connection, because that is the entire point of this file. + pool = new Pool({ connectionString, max: connections, options: `-c role=${runtime}` }); + db = createDatabase(pool); + app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const tenant = async (slug: string): Promise => { + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Ada", + email: `${slug}@example.test`, + password: "correct horse", + }), + }); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: slug, slug }), + }); + expect(created.status).toBe(200); + return { cookie, organizationId: (await json<{ id: string }>(created)).id }; + }; + + acme = await tenant("acme"); + globex = await tenant("globex"); +}, 120_000); + +afterAll(async () => { + await pool?.end(); + if (admin) { + // Cleanup first, then the lock: the next runner must not start while this + // one's role is still being dropped. + await admin.query(`drop owned by ${runtime}`).catch(() => undefined); + await admin.query(`drop role if exists ${runtime}`).catch(() => undefined); + await lockHolder + ?.query("select pg_advisory_unlock(hashtext('qualityruntime concurrency suite'))") + .catch(() => undefined); + lockHolder?.release(); + await admin.end(); + } +}); + +describe.skipIf(!usable)("what a lock actually prevents", () => { + it("decides a discard from the control as it is when the lock is granted", async () => { + // The handler reads the control under `for update`, and the question is + // whether it reads it *before* or *after* a change that is in flight. With + // the lock, it waits and then sees the activated control; without it, it + // would decide from a draft that no longer exists and delete a control + // that had been in effect. + const id = await control("Activated underneath"); + + const response = await holding( + `select * from "control" where "id" = $1 for update`, + [id], + async ({ session, blocked }) => { + const discarding = request(`/controls/${id}`, { method: "DELETE" }); + await blocked(); + await session.query(`update "control" set "status" = 'active' where "id" = $1`, [id]); + await session.query("commit"); + return discarding; + }, + ); + + expect(response.status).toBe(409); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("was_in_effect"); + }); + + it("decides a transition from the status the lock reveals, not the one it read", async () => { + // `draft → retired` is refused and `active → retired` allowed. The request + // is made while the control is a draft and lands after it is active, so a + // handler that decided from its first read would answer 409. + const id = await control("Racing the lifecycle"); + + const response = await holding( + `select * from "control" where "id" = $1 for update`, + [id], + async ({ session, blocked }) => { + const retiring = request(`/controls/${id}`, { + method: "PATCH", + body: JSON.stringify({ status: "retired" }), + }); + await blocked(); + await session.query(`update "control" set "status" = 'active' where "id" = $1`, [id]); + await session.query("commit"); + return retiring; + }, + ); + + expect(response.status).toBe(200); + expect((await json<{ data: { status: string } }>(response)).data.status).toBe("retired"); + }); + + it("lets two amendments through in turn, and records what each replaced", async () => { + // Serialised rather than refused: neither names a version, so neither is + // asking to be protected. What must not happen is an audit event claiming + // to have replaced something it did not — the second amendment's `before` + // has to be what the first wrote, not what it read before the first ran. + // + // `Promise.all` alone would not force that: it starts both requests, it + // does not make their reads overlap. Both have to be waiting on the same + // lock before either is let go. + const id = await control("Original"); + + const [first, second] = await holding( + `select * from "control" where "id" = $1 for update`, + [id], + async ({ session, blocked }) => { + const both = Promise.all([ + request(`/controls/${id}`, { + method: "PATCH", + body: JSON.stringify({ name: "One" }), + }), + request(`/controls/${id}`, { + method: "PATCH", + body: JSON.stringify({ name: "Two" }), + }), + ]); + await blocked(2); + await session.query("commit"); + return both; + }, + ); + + expect([first.status, second.status]).toEqual([200, 200]); + const { data } = await json<{ data: { action: string; before: { name?: string } | null }[] }>( + await request(`/history?resource=${id}`), + ); + const replaced = data + .filter((event) => event.action === "updated") + .map((event) => event.before?.name); + + expect(replaced).toHaveLength(2); + // One replaced the original; the other replaced whatever the first wrote. + expect(replaced).toContain("Original"); + expect(new Set(replaced).size).toBe(2); + }); +}); + +describe.skipIf(!usable)("tenants sharing a connection pool", () => { + /** Lists a tenant's controls as that tenant. */ + const listing = async (who: Tenant) => { + const response = await app.request(`/api/v1/organizations/${who.organizationId}/controls`, { + headers: { cookie: who.cookie }, + }); + expect(response.status).toBe(200); + return json<{ data: { id: string; organizationId: string }[] }>(response); + }; + + it("never shows one organization a connection another just used", async () => { + // The tenant is a transaction-local setting on a pooled connection, and + // until now there was no pool: PGlite is one connection, so a request could + // not inherit one another had just finished with. This is the first thing + // that can tell whether `set_config(…, true)` really is scoped the way + // every policy depends on (ADR 0003). + const mine = await control("Acme's own"); + const theirs = await app.request(`/api/v1/organizations/${globex.organizationId}/controls`, { + method: "POST", + headers: { cookie: globex.cookie, "content-type": "application/json" }, + body: JSON.stringify({ name: "Globex's own" }), + }); + expect(theirs.status).toBe(201); + const theirControl = (await json<{ data: { id: string } }>(theirs)).data.id; + + // Enough interleaved requests to hand every connection to both tenants in + // turn, several times over. + const pages = await Promise.all( + Array.from({ length: 40 }, (_, turn) => listing(turn % 2 === 0 ? acme : globex)), + ); + + for (const [turn, page] of pages.entries()) { + const who = turn % 2 === 0 ? acme : globex; + expect(page.data.every((row) => row.organizationId === who.organizationId)).toBe(true); + const ids = page.data.map((row) => row.id); + expect(ids).toContain(who === acme ? mine : theirControl); + expect(ids).not.toContain(who === acme ? theirControl : mine); + } + }); + + it("leaves no tenant behind on a connection it has finished with", async () => { + // A query that forgot to open a tenant context must see nothing, even on a + // connection that has just served twenty requests for one. `set_config(…, + // true)` is transaction-local, so the setting is spent — but PostgreSQL + // leaves it as the empty string rather than unsetting it, and what matters + // is that no row's organization can ever equal that (ADR 0003). + await Promise.all(Array.from({ length: 20 }, () => listing(acme))); + + // Every connection, not one: `pool.query` hands back whichever is free, so + // a tenant left behind on any other would go unseen. They are checked out + // together so that each is a different one. + const held = await Promise.all(Array.from({ length: connections }, () => pool.connect())); + try { + for (const connection of held) { + const { rows: leftover } = await connection.query<{ left: string | null }>( + "select current_setting('qualityruntime.organization_id', true) as left", + ); + // Transaction-local, so the setting is spent. PostgreSQL leaves it as + // the empty string rather than unsetting it, and what matters is that + // no row's organization can equal that (ADR 0003). + expect(leftover[0]?.left ?? "").toBe(""); + + const { rows: visible } = await connection.query<{ seen: number }>( + 'select count(*)::int as seen from "control"', + ); + expect(visible[0]?.seen).toBe(0); + } + } finally { + for (const connection of held) connection.release(); + } + }); +}); diff --git a/apps/server/controls.test.ts b/apps/server/controls.test.ts new file mode 100644 index 0000000..19843ad --- /dev/null +++ b/apps/server/controls.test.ts @@ -0,0 +1,843 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The control resource, over HTTP, against a migrated database. + * + * Authentication and membership are `organization.test.ts`'s subject; here a + * member is assumed and what is tested is the resource: its shape, its + * validation, its lifecycle rule, and that another organization's control is + * invisible rather than forbidden. + * + * Requests run as a non-superuser role that owns the tables, with row-level + * security forced so the policies bind their owner. That exercises the + * policies; the deployment's own posture — a runtime role that owns nothing — + * is `privileges.test.ts` and `documented-setup.test.ts`. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema, withOrganization } from "@qualityruntime/db"; +import { eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +let db: ReturnType; +let app: ReturnType; +let acme: { cookie: string; organizationId: string }; +let globex: { cookie: string; organizationId: string }; + +const json = async (response: Response): Promise => (await response.json()) as T; + +type Control = { + id: string; + organizationId: string; + name: string; + description: string | null; + status: string; + activatedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +type Failure = { + error: { code: string; message: string; details?: { path: string; message: string }[] }; +}; + +beforeAll(async () => { + const client = new PGlite(); + db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const tenant = async (slug: string) => { + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Ada", + email: `${slug}@example.test`, + password: "correct horse", + }), + }); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: slug, slug }), + }); + expect(created.status).toBe(200); + return { cookie, organizationId: (await json<{ id: string }>(created)).id }; + }; + + acme = await tenant("acme"); + globex = await tenant("globex"); + + await client.exec(` + create role qualityruntime_app nosuperuser nobypassrls; + grant all on all tables in schema public to qualityruntime_app; + alter table "control" owner to qualityruntime_app; + set role qualityruntime_app; + `); +}, 60_000); + +/** A request as a member of `tenant`, to that tenant's controls. */ +const request = ( + tenant: { cookie: string; organizationId: string }, + path: string, + init: Omit & { headers?: Record } = {}, +) => + app.request(`/api/v1/organizations/${tenant.organizationId}/controls${path}`, { + ...init, + // Merged, not replaced: a caller's own headers are the point of passing + // them, and dropping them silently makes a test pass for the wrong reason. + headers: { + cookie: tenant.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + +const create = (tenant: { cookie: string; organizationId: string }, body: unknown) => + request(tenant, "", { method: "POST", body: JSON.stringify(body) }); + +const patch = (tenant: { cookie: string; organizationId: string }, id: string, body: unknown) => + request(tenant, `/${id}`, { method: "PATCH", body: JSON.stringify(body) }); + +const discard = (tenant: { cookie: string; organizationId: string }, id: string) => + request(tenant, `/${id}`, { method: "DELETE" }); + +/** Creates a control and returns it, failing the test if that did not work. */ +async function given( + tenant: { cookie: string; organizationId: string }, + body: unknown = { name: "Access review" }, +): Promise { + const response = await create(tenant, body); + expect(response.status).toBe(201); + return (await json<{ data: Control }>(response)).data; +} + +describe("creating a control", () => { + it("returns 201 and the stored control", async () => { + const response = await create(acme, { name: "Quarterly access review" }); + + expect(response.status).toBe(201); + const { data } = await json<{ data: Control }>(response); + expect(data.id).toMatch(/^ctl_[0-9a-z]{16}$/); + expect(data.name).toBe("Quarterly access review"); + expect(data.description).toBeNull(); + expect(data.organizationId).toBe(acme.organizationId); + }); + + it("starts every control as a draft, whatever the body asks for", async () => { + // Status is not part of the create contract: a control is authored first + // and put into effect deliberately. + const data = await given(acme, { name: "Backup restore test", status: "active" }); + + expect(data.status).toBe("draft"); + }); + + it("files the control under the organization in the path, not one in the body", async () => { + const data = await given(acme, { + name: "Planted", + organizationId: globex.organizationId, + }); + + expect(data.organizationId).toBe(acme.organizationId); + }); + + it("trims a name and keeps the trimmed form", async () => { + const data = await given(acme, { name: " Padded " }); + + expect(data.name).toBe("Padded"); + }); + + it("returns exactly the documented fields", async () => { + // A guard on the contract: a column added to the table starts appearing + // here silently otherwise. + const data = await given(acme); + + expect(Object.keys(data).sort()).toEqual([ + "activatedAt", + "createdAt", + "description", + "id", + "name", + "organizationId", + "status", + "updatedAt", + ]); + }); + + it.each([ + ["a missing name", {}], + ["a blank name", { name: " " }], + ["a name that is not a string", { name: 42 }], + ["a name beyond the length bound", { name: "x".repeat(201) }], + ])("refuses %s", async (_case, body) => { + const response = await create(acme, body); + + expect(response.status).toBe(400); + const { error } = await json(response); + expect(error.code).toBe("invalid_request"); + expect(error.details?.map((detail) => detail.path)).toContain("name"); + }); + + it("refuses a NUL character rather than failing the statement", async () => { + // PostgreSQL rejects NUL in `text` outright, so without this the driver + // raises and the caller sees a 500 for what is a bad request. + const response = await create(acme, { name: `a${String.fromCharCode(0)}b` }); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); + + it("refuses a body larger than the limit without parsing it", async () => { + const response = await request(acme, "", { + method: "POST", + body: JSON.stringify({ name: "Fine", padding: "x".repeat(100_000) }), + }); + + expect(response.status).toBe(413); + expect((await json(response)).error.code).toBe("payload_too_large"); + }); + + it("refuses a malformed body in the same shape as everything else", async () => { + const response = await request(acme, "", { method: "POST", body: "{not json" }); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); +}); + +describe("reading a control", () => { + it("returns one by id", async () => { + const created = await given(acme, { name: "Supplier audit" }); + + const response = await request(acme, `/${created.id}`); + + expect(response.status).toBe(200); + expect((await json<{ data: Control }>(response)).data).toEqual(created); + }); + + it("lists newest first", async () => { + const own = await tenantWithControls(["First", "Second", "Third"]); + + const { data } = await json<{ data: Control[] }>(await request(own, "")); + + expect(data.map((row) => row.name)).toEqual(["Third", "Second", "First"]); + }); + + it("answers 404 for an id that does not exist", async () => { + const response = await request(acme, "/ctl_0000000000000000"); + + expect(response.status).toBe(404); + expect((await json(response)).error.code).toBe("not_found"); + }); + + it.each([ + ["an id of the wrong shape", "not-an-id"], + ["an id carrying another table's prefix", "org_v1stgxr8z5jdhi6b"], + ["a percent-encoded NUL", "%00"], + ])("answers 404 for %s", async (_case, id) => { + // The last one would otherwise reach PostgreSQL and fail the statement. + const response = await request(acme, `/${id}`); + + expect(response.status).toBe(404); + expect((await json(response)).error.code).toBe("not_found"); + }); + + it("answers 404 for another organization's control, not 403", async () => { + // The policy makes it invisible, so this is not a special case in the + // handler — and a member of Acme cannot learn that the id is real. + const theirs = await given(globex, { name: "Globex only" }); + + const response = await request(acme, `/${theirs.id}`); + const absent = await request(acme, "/ctl_0000000000000000"); + + expect(response.status).toBe(404); + expect(await json(response)).toEqual(await json(absent)); + }); +}); + +describe("updating a control", () => { + it("changes the fields it is given and leaves the rest", async () => { + const created = await given(acme, { name: "Before", description: "Original" }); + + const response = await patch(acme, created.id, { name: "After" }); + + expect(response.status).toBe(200); + const { data } = await json<{ data: Control }>(response); + expect(data.name).toBe("After"); + expect(data.description).toBe("Original"); + expect(data.id).toBe(created.id); + }); + + it("clears a description when explicitly given null", async () => { + const created = await given(acme, { name: "Describable", description: "Original" }); + + const { data } = await json<{ data: Control }>( + await patch(acme, created.id, { description: null }), + ); + + expect(data.description).toBeNull(); + }); + + it("advances the updated timestamp", async () => { + const created = await given(acme, { name: "Touched" }); + // Backdated in SQL so the assertion can be strict without waiting on a + // clock: written directly, `$onUpdate` never sees it. + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "control" set updated_at = now() - interval '1 day' where id = ${created.id}`, + ), + ); + const { data: stale } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + + const { data } = await json<{ data: Control }>( + await patch(acme, created.id, { name: "Moved" }), + ); + + expect(Date.parse(data.updatedAt)).toBeGreaterThan(Date.parse(stale.updatedAt)); + expect(data.createdAt).toBe(created.createdAt); + }); + + it("refuses a body that asks for no change", async () => { + const created = await given(acme); + + const response = await patch(acme, created.id, {}); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); + + it("refuses to change the organization", async () => { + // Not a field of the update contract, so it is dropped rather than applied. + const created = await given(acme, { name: "Stays put" }); + + const { data } = await json<{ data: Control }>( + await patch(acme, created.id, { name: "Stays put", organizationId: globex.organizationId }), + ); + + expect(data.organizationId).toBe(acme.organizationId); + }); + + it("answers 404 for another organization's control", async () => { + const theirs = await given(globex, { name: "Globex only" }); + + const response = await patch(acme, theirs.id, { name: "Hijacked" }); + + expect(response.status).toBe(404); + }); +}); + +describe("the control lifecycle", () => { + const advance = async (to: string, through: string[] = []) => { + const created = await given(acme, { name: `To ${to}` }); + for (const step of through) + expect((await patch(acme, created.id, { status: step })).status).toBe(200); + return created; + }; + + it.each([ + ["draft to active", [], "active"], + ["active to retired", ["active"], "retired"], + ])("allows %s", async (_case, through, to) => { + const created = await advance(to, through); + + const response = await patch(acme, created.id, { status: to }); + + expect(response.status).toBe(200); + expect((await json<{ data: Control }>(response)).data.status).toBe(to); + }); + + it.each([ + ["draft straight to retired", [], "retired"], + ["active back to draft", ["active"], "draft"], + ["retired back to active", ["active", "retired"], "active"], + ["retired back to draft", ["active", "retired"], "draft"], + ])("refuses %s", async (_case, through, to) => { + const created = await advance(to, through); + + const response = await patch(acme, created.id, { status: to }); + + expect(response.status).toBe(409); + const { error } = await json(response); + expect(error.code).toBe("invalid_transition"); + }); + + it("allows setting the status a control already has, and writes nothing", async () => { + // A no-op must not move the version: another client's tag would go stale + // while history said nothing happened. + const created = await given(acme, { name: "Unchanged" }); + const read = await request(acme, `/${created.id}`); + + const response = await patch(acme, created.id, { status: "draft", name: "Unchanged" }); + + expect(response.status).toBe(200); + expect(response.headers.get("etag")).toBe(read.headers.get("etag")); + const { data } = await json<{ data: Control }>(response); + expect(data.updatedAt).toBe((await json<{ data: Control }>(read)).data.updatedAt); + }); + + it("refuses a status outside the lifecycle", async () => { + const created = await given(acme); + + const response = await patch(acme, created.id, { status: "approved" }); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); + + it("leaves the control untouched when a transition is refused", async () => { + const created = await given(acme, { name: "Original name" }); + + await patch(acme, created.id, { name: "Renamed", status: "retired" }); + + const { data } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + expect(data.name).toBe("Original name"); + expect(data.status).toBe("draft"); + }); +}); + +/** A fresh organization holding exactly `names`, so ordering tests stand alone. */ +async function tenantWithControls(names: string[]) { + const slug = `list-${names.length}-${Math.random().toString(36).slice(2, 8)}`; + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Ada", email: `${slug}@example.test`, password: "correct horse" }), + }); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: slug, slug }), + }); + const tenant = { cookie, organizationId: (await json<{ id: string }>(created)).id }; + + for (const name of names) await given(tenant, { name }); + return tenant; +} + +/** + * Evidence against a control, written as the tenant. No route records evidence + * yet, but the table and the foreign key restricting a control's deletion do. + */ +const recordEvidence = (controlId: string) => + withOrganization(db, acme.organizationId, (tx) => + tx.insert(schema.evidence).values({ + organizationId: acme.organizationId, + controlId, + title: "Minutes", + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + }), + ); + +describe("discarding a draft", () => { + it("says what to do instead of reviving a retired control", async () => { + // Retired is final, so a retired control never becomes a draft that could + // be discarded: what replaces it is a new control. + const created = await given(acme, { name: "Was in effect" }); + for (const step of ["active", "retired"]) { + expect((await patch(acme, created.id, { status: step })).status).toBe(200); + } + + const response = await patch(acme, created.id, { status: "draft" }); + + expect(response.status).toBe(409); + const { error } = await json(response); + expect(error.code).toBe("invalid_transition"); + expect(error.details?.[0]?.message).toContain("author a new one instead"); + }); + + it("will not let the column that decides this be cleared", async () => { + // A draft is tied to a null `activated_at`, so anything able to clear it + // could turn a control that was in effect into a deletable draft. A policy + // cannot prevent that — `WITH CHECK` sees only the new row — so a trigger + // does. + const created = await given(acme, { name: "Tamper with the evidence of it" }); + expect((await patch(acme, created.id, { status: "active" })).status).toBe(200); + + const error = await withOrganization(db, acme.organizationId, (tx) => + tx + .update(schema.control) + .set({ status: "draft", activatedAt: null }) + .where(eq(schema.control.id, created.id)), + ).then( + () => null, + (thrown: Error) => thrown, + ); + + const reason = error?.cause instanceof Error ? error.cause.message : error?.message; + expect(reason).toMatch(/set by the database/); + // And so the control is still undeletable. + const removed = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.control).where(eq(schema.control.id, created.id)).returning(), + ); + expect(removed).toEqual([]); + }); + + it("keeps when a control took effect after it is retired", async () => { + // Set once: retiring a control ends it, and does not rewrite when it began. + const created = await given(acme, { name: "Activated, then retired" }); + expect((await patch(acme, created.id, { status: "active" })).status).toBe(200); + const first = (await json<{ data: Control }>(await request(acme, `/${created.id}`))).data + .activatedAt; + expect(first).not.toBeNull(); + + expect((await patch(acme, created.id, { status: "retired" })).status).toBe(200); + + const { data } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + expect(data.activatedAt).toBe(first); + }); + + it("records it even when the API is not what activated the control", async () => { + // The trigger assigns it, so no code path can activate a control without + // leaving the record that makes it undeletable. + const created = await given(acme, { name: "Activated in SQL" }); + + const [row] = await withOrganization(db, acme.organizationId, (tx) => + tx + .update(schema.control) + .set({ status: "active" }) + .where(eq(schema.control.id, created.id)) + .returning(), + ); + + expect(row?.activatedAt).not.toBeNull(); + }); + + it("will not let the database turn one that was in effect back into a draft", async () => { + // The route refuses the move; this is raw SQL, which the policy would then + // let delete a draft. The stamp survives, and a draft may not carry one. + const created = await given(acme, { name: "Laundered in SQL" }); + expect((await patch(acme, created.id, { status: "active" })).status).toBe(200); + + const redrafting = withOrganization(db, acme.organizationId, (tx) => + tx.update(schema.control).set({ status: "draft" }).where(eq(schema.control.id, created.id)), + ); + + await expect(redrafting).rejects.toMatchObject({ + cause: { constraint: "control_took_effect_unless_draft" }, + }); + }); + + /** A control at `status`, moved there through the lifecycle. */ + const at = async (status: "draft" | "active" | "retired") => { + const created = await given(acme, { name: `A ${status} one` }); + for (const step of { draft: [], active: ["active"], retired: ["active", "retired"] }[status]) { + expect((await patch(acme, created.id, { status: step })).status).toBe(200); + } + return created; + }; + + it("removes a draft nobody wants, which is the only way to be rid of one", async () => { + // The lifecycle has no `draft → retired`, so before this an abandoned draft + // could only be disposed of by first putting it into effect (ADR 0017). + const created = await at("draft"); + + const response = await discard(acme, created.id); + + expect(response.status).toBe(204); + expect((await request(acme, `/${created.id}`)).status).toBe(404); + }); + + it.each([ + ["active", "Retire it instead"], + ["retired", "part of the record"], + ])("refuses to remove a %s control, and says what to do", async (status, advice) => { + const created = await at(status as "active" | "retired"); + + const response = await discard(acme, created.id); + + expect(response.status).toBe(409); + const { error } = await json(response); + expect(error.code).toBe("was_in_effect"); + expect(error.details?.[0]?.message).toContain(advice); + // Still there, and still what it was. + const { data } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + expect(data.status).toBe(status); + }); + + it("will not let the database remove one either", async () => { + // The route is the courteous answer; the policy is the guarantee. A + // statement sent inside the tenant's own context matches nothing. + const created = await at("active"); + + const removed = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.control).where(eq(schema.control.id, created.id)).returning(), + ); + + expect(removed).toEqual([]); + }); + + it("cannot be marked retired behind the API's back", async () => { + // Retired means no longer in effect, so a control that never was cannot + // be: only a draft may lack the stamp. + const created = await given(acme, { name: "Retired without effect" }); + + const retiring = withOrganization(db, acme.organizationId, (tx) => + tx.update(schema.control).set({ status: "retired" }).where(eq(schema.control.id, created.id)), + ); + + await expect(retiring).rejects.toMatchObject({ + cause: { constraint: "control_took_effect_unless_draft" }, + }); + expect((await discard(acme, created.id)).status).toBe(204); + }); + + it("will not let the database remove one made active behind the API's back", async () => { + // Raw SQL rather than the route, and nothing sets the column: the trigger + // stamps the control on becoming active whoever does it, so the DELETE + // policy refuses it all the same. + const created = await given(acme, { name: "Active without a record of it" }); + + const removed = await withOrganization(db, acme.organizationId, async (tx) => { + await tx + .update(schema.control) + .set({ status: "active" }) + .where(eq(schema.control.id, created.id)); + return tx.delete(schema.control).where(eq(schema.control.id, created.id)).returning(); + }); + + expect(removed).toEqual([]); + }); + + it("will not let the database remove a retired one either", async () => { + // `retired` is a separate row state from `active`, and a predicate that + // named only one of them would pass the test above. + const created = await at("retired"); + + const removed = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.control).where(eq(schema.control.id, created.id)).returning(), + ); + + expect(removed).toEqual([]); + }); + + it("answers 404 for another organization's draft, and leaves it alone", async () => { + const theirs = await given(globex, { name: "Theirs" }); + + const response = await discard(acme, theirs.id); + + expect(response.status).toBe(404); + // The route's own answer, not Hono's for an unrouted method: those are + // both 404 and only the body tells them apart. + expect((await json(response)).error.message).toBe("No such control."); + expect((await request(globex, `/${theirs.id}`)).status).toBe(200); + }); + + it("keeps the history of a control it removed", async () => { + // `resource_id` is a plain column, not a reference, so history outlives + // what it describes — which is the whole point of an append-only log. + const created = await given(acme, { name: "Short-lived" }); + await discard(acme, created.id); + + const events = await withOrganization(db, acme.organizationId, (tx) => + tx.select().from(schema.auditEvent).where(eq(schema.auditEvent.resourceId, created.id)), + ); + + expect(events.map((event) => event.action).sort()).toEqual(["created", "deleted"]); + const deletion = events.find((event) => event.action === "deleted"); + // Everything audited, not merely the fields this assertion happens to name. + expect(deletion?.before).toEqual({ + name: "Short-lived", + description: null, + status: "draft", + }); + // Nothing is left to describe, so nothing is claimed. + expect(deletion?.after).toBeNull(); + }); + + it("refuses a draft that carries evidence, rather than failing on a foreign key", async () => { + const created = await given(acme, { name: "Has evidence" }); + await recordEvidence(created.id); + + const response = await discard(acme, created.id); + + expect(response.status).toBe(409); + const { error } = await json(response); + expect(error.code).toBe("has_evidence"); + expect(error.details?.[0]?.message).toContain("cannot be discarded"); + expect((await request(acme, `/${created.id}`)).status).toBe(200); + }); + + it("answers 404 for an id of the wrong shape, a NUL included", async () => { + // An id is checked against the shape PostgreSQL enforces before it is used: + // a NUL in `text` fails the statement, which would otherwise be a 500 for + // what is plainly an absence. + const malformed = await discard(acme, "not-an-id"); + const nul = await discard(acme, encodeURIComponent("ctl_v1stgxr8z5jdhi6\u0000")); + + expect(malformed.status).toBe(404); + expect((await json(malformed)).error.message).toBe("No such control."); + expect(nul.status).toBe(404); + }); +}); + +describe("changing only what you read", () => { + /** The control's current entity tag, as a client would obtain it. */ + const tagOf = async (id: string) => { + const response = await request(acme, `/${id}`); + expect(response.status).toBe(200); + return response.headers.get("etag")!; + }; + + const patchWith = (id: string, tag: string | undefined, body: unknown) => + request(acme, `/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + ...(tag ? { headers: { "if-match": tag } } : {}), + }); + + it("serves a tag that changes when the control does", async () => { + const created = await given(acme, { name: "Versioned" }); + const first = await tagOf(created.id); + + expect((await patchWith(created.id, undefined, { name: "Renamed" })).status).toBe(200); + + expect(first).toMatch(/^"\d+"$/); + expect(await tagOf(created.id)).not.toBe(first); + }); + + it("refuses a change against a version that has moved", async () => { + // What last-writer-wins looks like from the loser's side: two people read + // the same control, and the second write silently discarded the first + // until this (ADR 0019). + const created = await given(acme, { name: "Contested" }); + const read = await tagOf(created.id); + expect((await patchWith(created.id, read, { name: "First wins" })).status).toBe(200); + + const late = await patchWith(created.id, read, { name: "Second, unaware" }); + + expect(late.status).toBe(412); + expect((await json(late)).error.code).toBe("precondition_failed"); + const { data } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + expect(data.name).toBe("First wins"); + }); + + it("allows a change against the version just read", async () => { + const created = await given(acme, { name: "Agreed" }); + + const response = await patchWith(created.id, await tagOf(created.id), { name: "Changed" }); + + expect(response.status).toBe(200); + // The answer carries the new tag, so a client can chain edits without + // reading again. + expect(response.headers.get("etag")).toBe(await tagOf(created.id)); + }); + + it("changes nothing when it refuses", async () => { + const created = await given(acme, { name: "Untouched" }); + + expect((await patchWith(created.id, '"0"', { status: "active" })).status).toBe(412); + + const { data } = await json<{ data: Control }>(await request(acme, `/${created.id}`)); + expect(data.name).toBe("Untouched"); + expect(data.status).toBe("draft"); + expect(data.activatedAt).toBeNull(); + }); + + it("takes a star to mean only if it is still there", async () => { + const created = await given(acme, { name: "Any version" }); + + const response = await patchWith(created.id, "*", { name: "Whatever it was" }); + + expect(response.status).toBe(200); + }); + + it("refuses a discard against a version that has moved", async () => { + // Discarding is not undoable, so this is the one worth being sure of. + const created = await given(acme, { name: "About to go" }); + const read = await tagOf(created.id); + expect((await patchWith(created.id, read, { name: "Changed underneath" })).status).toBe(200); + + const stale = await request(acme, `/${created.id}`, { + method: "DELETE", + headers: { "if-match": read }, + }); + + expect(stale.status).toBe(412); + expect((await request(acme, `/${created.id}`)).status).toBe(200); + }); + + it("discards against the version just read", async () => { + const created = await given(acme, { name: "Agreed to go" }); + + const gone = await request(acme, `/${created.id}`, { + method: "DELETE", + headers: { "if-match": await tagOf(created.id) }, + }); + + expect(gone.status).toBe(204); + }); + + it.each([ + ["a control that has been in effect", "was_in_effect"], + ["a control carrying evidence", "has_evidence"], + ])("says why %s cannot be discarded, whatever the tag says", async (_case, code) => { + // A precondition answers a request that would otherwise have succeeded + // (RFC 9110 §13.2.1). Asking first would make a refused request disclose + // whether the caller's tag matched, which is both wrong and a leak. + const created = await given(acme, { name: `Refused: ${code}` }); + if (code === "was_in_effect") { + expect((await patchWith(created.id, undefined, { status: "active" })).status).toBe(200); + } else { + await recordEvidence(created.id); + } + + const stale = await request(acme, `/${created.id}`, { + method: "DELETE", + headers: { "if-match": '"0"' }, + }); + const current = await request(acme, `/${created.id}`, { + method: "DELETE", + headers: { "if-match": await tagOf(created.id) }, + }); + + // The same answer either way: the tag tells the caller nothing. + expect(stale.status).toBe(409); + expect(current.status).toBe(409); + expect((await json(stale)).error.code).toBe(code); + }); + + it("says a transition is illegal rather than that the tag is stale", async () => { + const created = await given(acme, { name: "Illegal and stale" }); + const read = await tagOf(created.id); + expect((await patchWith(created.id, undefined, { name: "Moved on" })).status).toBe(200); + + const both = await patchWith(created.id, read, { status: "retired" }); + + expect(both.status).toBe(409); + expect((await json(both)).error.code).toBe("invalid_transition"); + }); + + it("goes on working for a client that asks for no guarantee", async () => { + // Optional, so a simple client is not broken by this existing. + const created = await given(acme, { name: "Unconditional" }); + + expect((await patchWith(created.id, undefined, { name: "Fine" })).status).toBe(200); + expect((await request(acme, `/${created.id}`, { method: "DELETE" })).status).toBe(204); + }); +}); diff --git a/apps/server/controls.ts b/apps/server/controls.ts new file mode 100644 index 0000000..2fdf516 --- /dev/null +++ b/apps/server/controls.ts @@ -0,0 +1,364 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Control routes. + * + * Mounted under `/api/v1/organizations/:organizationId`, behind + * `organizationContext` — so by the time a handler runs, the caller is a member + * and `c.var.withOrganization` is bound to their organization. Nothing here + * filters by organization itself: row-level security does that (ADR 0003), and + * a control belonging to another one is simply not there, which is why an + * ordinary 404 is the right answer for it. + */ + +import { idPattern, schema } from "@qualityruntime/db"; +import { eq, getTableColumns } from "drizzle-orm"; +import { type Context, Hono } from "hono"; +import { createMiddleware } from "hono/factory"; +import { z } from "zod"; +import { diffFields, fieldsOf } from "./audit.ts"; +import { entityTag, ifMatch, rowVersion, withoutVersion } from "./preconditions.ts"; +import { failure } from "./responses.ts"; +import type { OrganizationEnv } from "./organization.ts"; +import { + collectionQuery, + cursorAt, + newestFirst, + orderedBy, + page, + rowsAfter, +} from "./pagination.ts"; +import { jsonBody, prose, queryParams, words } from "./validation.ts"; + +type ControlStatus = (typeof schema.controlStatuses)[number]; + +/** Bounds the database does not impose: `text` accepts a megabyte as happily as a sentence. */ +const name = words(200); +const description = prose(10_000); + +/** A control is always created as a draft; `PATCH` is what moves it on. */ +export const createBody = z.object({ name, description: description.nullish() }); + +export const updateBody = z + .object({ + name: name.optional(), + // Explicitly `null` clears it; absent leaves it alone. + description: description.nullable().optional(), + status: z.enum(schema.controlStatuses).optional(), + }) + .refine((body) => Object.keys(body).length > 0, { + message: "Provide at least one field to change.", + }) + // `refine` converts to nothing, so the same rule is stated again in a form + // JSON Schema has. `minProperties` would not do: an unknown property counts + // towards it, and the server drops those before finding nothing left to + // change (ADR 0007). + .meta({ + anyOf: [{ required: ["name"] }, { required: ["description"] }, { required: ["status"] }], + description: "At least one of name, description or status.", + }); + +/** + * The status changes the lifecycle in `docs/data-model.md` allows: one way. + * + * A control in effect is withdrawn deliberately rather than quietly unpublished, + * and a withdrawn one stays withdrawn — what replaces it is a new control, so + * the one evidence was recorded against keeps meaning what it meant. Setting + * the status it already has is a no-op and always allowed. The database holds + * the part that matters: nothing that took effect can be a draft again. + */ +const transitions: Record = { + draft: ["active"], + active: ["retired"], + retired: [], +}; + +/** What audit history records about a control: its own fields, nothing else. */ +const audited = ["name", "description", "status"] as const; + +const version = rowVersion(schema.control); + +/** The one answer for an `If-Match` that no longer names this control. */ +const staleControl = (c: Context) => + c.json( + failure("precondition_failed", "The control changed since it was read.", [ + { path: "", message: "Read it again, and decide against what it now says." }, + ]), + 412, + ); + +const isControlId = new RegExp(idPattern("control")); + +/** Controls and their history are both records of what has happened. */ +export const controlOrder = newestFirst("controls", schema.control.createdAt, schema.control.id); +/** A control, as a client sees it. Strict, so a new column cannot slip out. */ +export const controlResponse = z.strictObject({ + id: z.string(), + organizationId: z.string(), + name: z.string(), + description: z.string().nullable(), + status: z.enum(schema.controlStatuses), + /** + * When it first took effect, or null if it never has. Set by the database + * and never changed, so it is part of the record: the status says where a + * control is now, this says when it began to count (ADR 0017). + */ + activatedAt: z.iso.datetime().nullable(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}); + +/** + * Stops an id that could not name a control before it reaches PostgreSQL. + * + * Mostly tidiness — one less pointless query — but it also keeps a path + * segment carrying a NUL from failing the statement itself, which the driver + * would report as a server error rather than the absence it actually is. + */ +const knownControlId = createMiddleware(async (c, next) => { + if (!isControlId.test(c.req.param("controlId") ?? "")) { + return c.json(failure("not_found", "No such control."), 404); + } + await next(); +}); + +export const controls = new Hono() + .get("/controls", queryParams(collectionQuery(controlOrder)), async (c) => { + const { limit, cursor } = c.req.valid("query"); + + const found = await c.var.withOrganization((tx) => + tx + .select({ ...getTableColumns(schema.control), cursorAt: cursorAt(controlOrder) }) + .from(schema.control) + .where(cursor ? rowsAfter(controlOrder, cursor) : undefined) + // `created_at` defaults to `now()`, which is the transaction's start + // time, so rows written together share it; the id breaks the tie and + // makes the order — and so the cursor — total. + .orderBy(...orderedBy(controlOrder)) + .limit(limit + 1), + ); + + const { rows, nextCursor } = page(found, limit, controlOrder); + return c.json({ data: rows, nextCursor }); + }) + + .post("/controls", jsonBody(createBody), async (c) => { + const body = c.req.valid("json"); + + const row = await c.var.withOrganization(async (tx) => { + const [created] = await tx + .insert(schema.control) + .values({ + // The organization the caller was authorized for, never one from the + // body. A mismatch would fail the policy's WITH CHECK anyway. + organizationId: c.var.member.organizationId, + name: body.name, + description: body.description ?? null, + }) + .returning(); + + // Same transaction as the insert: history and the change it describes + // commit together or not at all (ADR 0005). + await c.var.audit(tx, { + action: "created", + resourceType: "control", + resourceId: created!.id, + after: fieldsOf(created!, audited), + }); + return created; + }); + + return c.json({ data: row }, 201); + }) + + .get("/controls/:controlId", knownControlId, async (c) => { + const [row] = await c.var.withOrganization((tx) => + tx + .select({ ...getTableColumns(schema.control), version }) + .from(schema.control) + .where(eq(schema.control.id, c.req.param("controlId"))), + ); + if (!row) return c.json(failure("not_found", "No such control."), 404); + + // What a conditional write quotes back, so that what is changed is what + // was read (ADR 0019). + c.header("etag", entityTag(row)); + return c.json({ data: withoutVersion(row) }); + }) + + .patch("/controls/:controlId", knownControlId, jsonBody(updateBody), async (c) => { + const body = c.req.valid("json"); + const controlId = c.req.param("controlId"); + + // Read and write in one transaction, with the row locked: the status rule + // is decided from what is currently stored, and two concurrent patches must + // not both get to see the old value (ADR 0003 — one callback, one + // transaction). + const result = await c.var.withOrganization(async (tx) => { + const [current] = await tx + .select({ ...getTableColumns(schema.control), version }) + .from(schema.control) + .where(eq(schema.control.id, controlId)) + .for("update"); + if (!current) return { outcome: "missing" } as const; + + if ( + body.status && + body.status !== current.status && + !transitions[current.status].includes(body.status) + ) { + return { outcome: "illegal", from: current.status, to: body.status } as const; + } + + // Last of the refusals, and after the lock. A precondition answers a + // request that would otherwise have succeeded (RFC 9110 §13.2.1) — asking + // first would let a refused request disclose whether its tag matched. + if (ifMatch(c.req.header("if-match"), entityTag(current)) === "failed") { + return { outcome: "stale" } as const; + } + + // Nothing different means nothing to write. An UPDATE would still move + // the row's version and `updated_at`, so a request setting the values a + // control already has would stale every other client's tag while + // history said nothing happened. + const changed = diffFields( + fieldsOf(current, audited), + fieldsOf({ ...current, ...body }, audited), + ); + if (!changed) return { outcome: "updated", row: current } as const; + + const [row] = await tx + .update(schema.control) + // `activated_at` is not set here. A trigger sets it the first time a + // control becomes active and refuses any other write, and a CHECK ties + // a draft to its absence — which is what lets the DELETE policy test + // the status alone (ADR 0017). + .set(body) + .where(eq(schema.control.id, controlId)) + .returning({ ...getTableColumns(schema.control), version }); + + await c.var.audit(tx, { + action: "updated", + resourceType: "control", + resourceId: controlId, + before: changed.before, + after: changed.after, + }); + + // The row was locked and found, so the update matched it. + return { outcome: "updated", row: row! } as const; + }); + + if (result.outcome === "missing") { + return c.json(failure("not_found", "No such control."), 404); + } + if (result.outcome === "illegal") { + return c.json( + failure("invalid_transition", `A control cannot go from ${result.from} to ${result.to}.`, [ + { + path: "status", + message: transitions[result.from].length + ? `Allowed from ${result.from}: ${transitions[result.from].join(", ")}.` + : `A ${result.from} control stays ${result.from}; author a new one instead.`, + }, + ]), + 409, + ); + } + + if (result.outcome === "stale") return staleControl(c); + + c.header("etag", entityTag(result.row)); + return c.json({ data: withoutVersion(result.row) }); + }) + + .delete("/controls/:controlId", knownControlId, async (c) => { + const controlId = c.req.param("controlId"); + + // Only a draft. A control that was in effect is part of the record and is + // retired rather than removed; a draft claims nothing and was never relied + // on, so there is nothing about it to keep (ADR 0017). The policy says the + // same thing, so this is the courteous answer rather than the enforcement. + const result = await c.var.withOrganization(async (tx) => { + // Locked for the same reason `PATCH` locks: the decision is made from + // what is stored, and a concurrent patch must not activate it in between. + const [current] = await tx + .select({ ...getTableColumns(schema.control), version }) + .from(schema.control) + .where(eq(schema.control.id, controlId)) + .for("update"); + if (!current) return { outcome: "missing" } as const; + + // A draft is exactly a control that never took effect — the database + // holds that — so the status is the whole question, as it is for the + // policy. + if (current.status !== "draft") { + return { outcome: "in_effect", status: current.status } as const; + } + + // Evidence outlives the control it was recorded against — the foreign key + // restricts rather than cascades, so that attested evidence cannot be + // disposed of by removing what it is evidence of (ADR 0014). Asked here + // so the answer is a 409 naming the reason rather than a 500. + const [evidence] = await tx + .select({ id: schema.evidence.id }) + .from(schema.evidence) + .where(eq(schema.evidence.controlId, controlId)) + .limit(1); + if (evidence) return { outcome: "has_evidence" } as const; + + // Last, for the same reason as the amendment above: a control that could + // not be discarded anyway must answer why, not whether the tag matched. + if (ifMatch(c.req.header("if-match"), entityTag(current)) === "failed") { + return { outcome: "stale" } as const; + } + + const [removed] = await tx + .delete(schema.control) + .where(eq(schema.control.id, controlId)) + .returning({ id: schema.control.id }); + // The lock above is what makes this impossible: a locked draft is one the + // policy admits. A delete matching nothing must still not be answered + // 204, and there is no honest 4xx for it. Evidence checks the same thing. + if (!removed) throw new Error(`Control ${controlId} was locked as a draft but not deleted.`); + + // Written after the row is gone, and it survives it: `resource_id` is a + // plain column, not a reference, so history outlives what it describes. + await c.var.audit(tx, { + action: "deleted", + resourceType: "control", + resourceId: controlId, + before: fieldsOf(current, audited), + }); + + return { outcome: "discarded" } as const; + }); + + if (result.outcome === "missing") { + return c.json(failure("not_found", "No such control."), 404); + } + if (result.outcome === "in_effect") { + const advice = { + active: "Retire it instead; a control that was relied on is part of the record.", + retired: "A retired control is part of the record and is kept.", + }[result.status]; + return c.json( + failure("was_in_effect", "The control has been in effect.", [ + { path: "status", message: advice }, + ]), + 409, + ); + } + if (result.outcome === "stale") return staleControl(c); + if (result.outcome === "has_evidence") { + return c.json( + failure("has_evidence", "The control carries evidence.", [ + { path: "", message: "A control that has evidence cannot be discarded." }, + ]), + 409, + ); + } + + return c.body(null, 204); + }); diff --git a/apps/server/documented-setup.test.ts b/apps/server/documented-setup.test.ts new file mode 100644 index 0000000..066a3eb --- /dev/null +++ b/apps/server/documented-setup.test.ts @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The setup the documents print, run as they print it. + * + * `privileges.test.ts` constructs the expected privileges directly. This test + * checks that the documented setup produces them: it extracts SQL from both + * deployment and development docs, creates roles as a superuser, applies + * migrations as the migrator, runs the revokes, and exercises the runtime role. + * This also checks grants needed during setup, such as creating the migration + * journal's schema, which final privilege assertions alone would miss. + * + * Two kinds of drift are caught. A document that no longer *works* fails the + * walk through the product. A document that quietly grants *more* fails the + * bound at the end, which asks PostgreSQL what the runtime role actually ended + * up holding rather than trusting the statements to be the ones intended. + * + * What is not covered, and is not pretended to be: + * + * The roles are reached with `SET ROLE` on one PGlite session, so no connection + * string is ever opened — `LOGIN` is asserted as an attribute rather than used, + * and the passwords not at all. That session's *user* stays the superuser, so + * `SET ROLE` succeeds from anywhere regardless of what the documents grant: + * every escape that works by becoming another role is invisible here by + * construction, which is why membership is asked about directly below rather + * than demonstrated. + * + * Nothing outside the SQL runs, so a `docker` invocation naming the wrong + * container is still only prose. And the database is built fresh, so the + * one-off grant `docs/deployment.md` gives for a database that already has + * tables is not executed — on this one it would do nothing. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { assertTenantIsolation, schema } from "@qualityruntime/db"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const repository = new URL("../../", import.meta.url); +const migrationsFolder = fileURLToPath(new URL("packages/db/migrations", repository)); + +/** A fence opening or closing, indented or not, and the language it declares. */ +const fence = /^\s*(?:```|~~~)(\w*)/; + +/** + * Statements that hand out privileges. + * + * Anything under these headings that sets privileges up and is *not* one of the + * blocks below is a statement an operator runs and this does not — so it is an + * error. That is the whole failure mode: a document saying one thing while the + * suite proves another. + */ +const setsUpPrivileges = /\b(?:GRANT|REVOKE|CREATE\s+ROLE|ALTER\s+DEFAULT\s+PRIVILEGES)\b/i; + +/** + * The SQL blocks a document's section tells an operator to run, in order. + * + * A fenced ```sql block is taken whole; a fenced shell block is taken for the + * body of each `<<'SQL'` heredoc, which is how `docs/development.md` writes the + * same statements. + * + * Everything it does not understand is an error rather than a skip. A shell + * block that mentions `psql` but carries no heredoc this can read — `psql -c`, + * `< line.replace(/\r$/, "")); + const headings = lines.flatMap((line, index) => (line === heading ? [index] : [])); + if (headings.length === 0) throw new Error(`${document} has no section "${heading}".`); + if (headings.length > 1) { + throw new Error(`${document} has ${headings.length} sections called "${heading}".`); + } + + const depth = heading.split(" ", 1)[0]!.length; + const ends = new RegExp(`^#{1,${depth}} `); + const heredoc = /<<'SQL'\n([\s\S]*?)\nSQL(\n|$)/g; + + const blocks: string[] = []; + let language: string | null = null; + let body: string[] = []; + + /** One fenced block: what of it is SQL to run, and what must not be missed. */ + const take = (source: string) => { + if (language === "sql") { + blocks.push(source); + return; + } + for (const [, sql] of source.matchAll(heredoc)) blocks.push(sql!); + // `psql -c '…'`, a `< + sql.replace(/ON DATABASE qualityruntime\b/g, `ON DATABASE "${database}"`); + +const runtime = "qualityruntime"; +const migrator = "qualityruntime_migrator"; + +const documents = [ + { path: "docs/deployment.md", heading: "### Two roles" }, + { path: "docs/development.md", heading: "## Database" }, +] as const; + +describe.each(documents)("the setup in $path", ({ path, heading }) => { + it("creates the roles, applies the migrations, and runs the product", async () => { + const markdown = await readFile(fileURLToPath(new URL(path, repository)), "utf8"); + const [before, after] = documentedSql(markdown, heading, path); + + const client = new PGlite(); + const db = drizzle({ client, schema, casing: "snake_case" }); + const [here] = (await client.query<{ name: string }>("select current_database() as name")).rows; + + // As a superuser, which is what both documents say this block needs. + await client.exec(forThisDatabase(before, here!.name)); + + // As the migrator: the tables it creates are the tables it owns, and the + // default privileges set above are what carry them to the runtime role. + await client.exec(`SET ROLE ${migrator};`); + await migrate(db, { migrationsFolder }); + await client.exec("RESET ROLE;"); + + await client.exec(forThisDatabase(after, here!.name)); + + // Both roles are reached by a connection string, so both have to be able to + // open one. `SET ROLE` works just as well without `LOGIN`, which is why + // this is asserted rather than demonstrated. + const attributes = { + rolcanlogin: true, + rolsuper: false, + rolbypassrls: false, + // A replication connection streams the write-ahead log, which is every + // tenant's rows with no policy anywhere in the path. + rolreplication: false, + rolcreaterole: false, + rolcreatedb: false, + }; + const { rows: roles } = await client.query>( + `select rolname, ${Object.keys(attributes).join(", ")} from pg_roles + where rolname in ($1, $2) order by rolname`, + [runtime, migrator], + ); + expect(roles).toEqual([ + { rolname: runtime, ...attributes }, + { rolname: migrator, ...attributes }, + ]); + + await client.exec(`SET ROLE ${runtime};`); + // The server's own start-up check, on the role the document produced. + await expect(assertTenantIsolation(db)).resolves.toBeUndefined(); + + const app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const asJson = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = async (response: Response): Promise => (await response.json()) as T; + + const signedUp = await app.request( + "/api/auth/sign-up/email", + asJson({ name: "Ada", email: "ada@example.test", password: "correct horse" }), + ); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + + const created = await app.request("/api/auth/organization/create", { + ...asJson({ name: "Acme", slug: "acme" }), + headers: { "content-type": "application/json", cookie }, + }); + expect(created.status).toBe(200); + const organizationId = (await json<{ id: string }>(created)).id; + + const base = `/api/v1/organizations/${organizationId}`; + type Request = Omit & { headers?: Record }; + const request = (suffix: string, init: Request = {}) => + app.request(`${base}${suffix}`, { ...init, headers: { cookie, ...init.headers } }); + + // Every route there is, on privileges the document alone produced. + const control = await request("/controls", asJson({ name: "Access review" })); + expect(control.status).toBe(201); + const controlId = (await json<{ data: { id: string } }>(control)).data.id; + + const activated = await request(`/controls/${controlId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status: "active" }), + }); + expect(activated.status).toBe(200); + + const history = await request(`/history?resource=${controlId}`); + expect(history.status).toBe(200); + + // The refusals the revoke block exists for. + const refused = async (statement: string) => { + const failure = await client.exec(statement).then( + () => null, + (error: Error) => error, + ); + return failure?.message ?? ""; + }; + + expect(await refused(`UPDATE "audit_event" SET "action" = 'rewritten'`)).toMatch(/denied/i); + expect(await refused(`DELETE FROM "audit_event"`)).toMatch(/denied/i); + expect(await refused(`UPDATE "file" SET "filename" = 'renamed'`)).toMatch(/denied/i); + expect(await refused(`DELETE FROM "organization"`)).toMatch(/denied/i); + + await client.exec("RESET ROLE;"); + + // `ALTER DEFAULT PRIVILEGES` decides what will be true of tables that do + // not exist yet, and the bound below can only ask about tables that do. So + // make the one the next migration would make, and bound that too. + await client.exec(`SET ROLE ${migrator};`); + await client.exec(`CREATE TABLE "the_next_migration" ("id" text primary key);`); + await client.exec("RESET ROLE;"); + + // And the bound, which is the half that catches a document granting *more*. + // Asked of PostgreSQL rather than read off the statements, so it holds + // however the privilege was arrived at. + // + // A role the runtime role is a *member* of is a role it can `SET ROLE` to, + // and every `has_…_privilege` below follows inheritance only. A membership + // granted `WITH INHERIT FALSE` carries every privilege of the table owner + // and shows up in none of them, so it is asked about separately. + const { rows: memberships } = await client.query<{ rolname: string }>( + `select r.rolname from pg_roles r + where r.rolname <> $1 and pg_has_role($1, r.oid, 'MEMBER') + order by r.rolname`, + [runtime], + ); + expect(memberships).toEqual([]); + + // Every schema, not only `public`: `drizzle` is a schema the migrator + // creates, and a table owned anywhere is a table outside the policies. + const ordinary = `n.nspname not in ('pg_catalog', 'information_schema', 'pg_toast') + and n.nspname not like 'pg\\_temp%' and n.nspname not like 'pg\\_toast%'`; + + const { rows: excess } = await client.query<{ relname: string; privilege: string }>( + `select c.relname, p.privilege_type as privilege + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + cross join unnest(array[ + 'TRUNCATE', 'REFERENCES', 'TRIGGER', + 'SELECT WITH GRANT OPTION', 'INSERT WITH GRANT OPTION', + 'UPDATE WITH GRANT OPTION', 'DELETE WITH GRANT OPTION' + ]) as p(privilege_type) + where ${ordinary} and c.relkind = 'r' + and has_table_privilege($1, c.oid, p.privilege_type) + order by c.relname, p.privilege_type`, + [runtime], + ); + expect(excess).toEqual([]); + + // Creating a table is how a role comes to own one, and owning one is how it + // escapes the policies. Neither document may hand that over, in any schema. + const { rows: creatable } = await client.query<{ nspname: string }>( + `select n.nspname from pg_namespace n + where ${ordinary} and has_schema_privilege($1, n.oid, 'CREATE') + order by n.nspname`, + [runtime], + ); + expect(creatable).toEqual([]); + + const { rows: database } = await client.query<{ create: boolean }>( + `select has_database_privilege($1, current_database(), 'CREATE') as create`, + [runtime], + ); + expect(database[0]).toEqual({ create: false }); + + const { rows: owned } = await client.query<{ relname: string }>( + `select c.relname from pg_class c + join pg_namespace n on n.oid = c.relnamespace + join pg_roles r on r.oid = c.relowner + where ${ordinary} and r.rolname = $1 + order by c.relname`, + [runtime], + ); + expect(owned).toEqual([]); + + await client.close(); + }, 60_000); +}); + +/** + * The other document a deployment copies rather than reads. + * + * `.env.example` is where an operator starts, so a setting the code requires and + * the example omits is a server that will not boot, discovered at boot. Derived + * from the source rather than listed here, so neither side can drift alone. + */ +describe(".env.example", () => { + /** + * Every environment variable this repository reads. + * + * Found by looking rather than by keeping a list: a list is a thing to forget + * to add to, and the setting that goes missing from the example is the one + * nobody thought about. Source files and the one test that needs a database + * of its own; `node_modules` and build output are not ours to scan. + */ + const named = async () => { + const found = new Set(); + const roots = [new URL("apps/", repository), new URL("packages/", repository)]; + const walk = async (directory: URL): Promise => { + for (const entry of await readdir(fileURLToPath(directory), { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, directory); + if (entry.isDirectory()) { + await walk(child); + } else if (entry.name.endsWith(".ts")) { + const text = await readFile(fileURLToPath(child), "utf8"); + const reads = [ + /requireEnv\("([A-Z_]+)"\)/g, + /(?:process|Bun)\.env\.([A-Z_]+)/g, + /(?:process|Bun)\.env\["([A-Z_]+)"\]/g, + ]; + for (const pattern of reads) { + for (const [, name] of text.matchAll(pattern)) if (name) found.add(name); + } + } + } + }; + for (const root of roots) await walk(root); + return found; + }; + + it("names every setting the server reads, and nothing it does not", async () => { + const example = await readFile(fileURLToPath(new URL(".env.example", repository)), "utf8"); + // A commented-out key still documents the setting; it marks it optional. + const documented = new Set( + [...example.matchAll(/^#?\s*([A-Z_]+)=/gm)].map(([, name]) => name!), + ); + + const required = await named(); + expect([...required].filter((name) => !documented.has(name)).sort()).toEqual([]); + expect([...documented].filter((name) => !required.has(name)).sort()).toEqual([]); + }); +}); diff --git a/apps/server/history.ts b/apps/server/history.ts new file mode 100644 index 0000000..1809209 --- /dev/null +++ b/apps/server/history.ts @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Reading audit history. + * + * `audit.ts` is how an event is written; this is how one is read. The two are + * deliberately separate: what a change records is a rule every mutating handler + * follows, and what history a caller may ask for is a route. + * + * One collection, filterable, rather than a history route per entity. Audit + * rows outlive the records they describe — that is the point of them — so a + * history reachable only through a live record is one that disappears exactly + * when it is most wanted (ADR 0018). + */ + +import { type IdType, idPattern, schema } from "@qualityruntime/db"; +import { resourceTypes } from "./audit.ts"; +import { and, eq, getTableColumns } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import type { OrganizationEnv } from "./organization.ts"; +import { + collectionQuery, + cursorAt, + newestFirst, + orderedBy, + page, + rowsAfter, +} from "./pagination.ts"; +import { rejection } from "./validation.ts"; + +/** Exactly what `audit.ts` records against, so the two cannot drift apart. */ +const resources = resourceTypes satisfies readonly IdType[]; + +/** + * Which record an identifier belongs to, from the identifier itself. + * + * A caller names one thing — the record — and the type comes free, because an + * identifier here says what it is (ADR 0002). It is needed because the index + * that serves this leads with `resource_type`, so a query naming only the id + * would sort the organization's whole history to answer. + */ +function resourceOf(id: string): { resourceType: string; resourceId: string } | undefined { + const type = resources.find((each) => new RegExp(idPattern(each)).test(id)); + return type ? { resourceType: type, resourceId: id } : undefined; +} + +/** + * Exactly the identifiers `resourceOf` can place, as one pattern. + * + * Built from the same source, so a new resource type cannot be accepted by one + * and unrecognised by the other. A pattern rather than a refinement, so it + * survives into the published schema (ADR 0007). + */ +const isResourceId = new RegExp(resources.map((type) => `(?:${idPattern(type)})`).join("|")); + +/** + * The history being read, scoped to it. + * + * A cursor is a position in an ordering, and a filtered history is a different + * ordering from the whole of one — the same position names different rows. The + * collection name carries the filter so a cursor cannot cross between them + * (ADR 0006). + */ +export const historyOrder = (resource?: string) => + newestFirst( + `history${resource ? `/${resource}` : ""}`, + schema.auditEvent.createdAt, + schema.auditEvent.id, + ); + +/** + * Which record the history is being narrowed to, if any. + * + * Read on its own before anything else, because it decides which ordering the + * cursor belongs to: validating the cursor first would check it against the + * unfiltered history and refuse every page after the first. + */ +const narrowing = z.object({ + resource: z + .string() + .regex(isResourceId, "Must be the identifier of a record that has history.") + .optional() + .meta({ + description: + "Limit the history to one record. The record need not still exist — history outlives " + + "what it describes.", + }), +}); + +/** What a caller may ask of the history, for the ordering `resource` names. */ +export const historyQuery = (resource?: string) => + collectionQuery(historyOrder(resource)).extend(narrowing.shape); + +export const auditEventResponse = z.strictObject({ + id: z.string(), + /** Named here, unlike on a record's own routes, because the URL does not. */ + resourceType: z.enum(resources), + resourceId: z.string(), + action: z.string(), + actor: z.strictObject({ + type: z.enum(schema.actorTypes), + id: z.string().nullable(), + label: z.string().nullable(), + onBehalfOf: z.strictObject({ id: z.string(), label: z.string().nullable() }).nullable(), + }), + before: z.record(z.string(), z.unknown()).nullable(), + after: z.record(z.string(), z.unknown()).nullable(), + createdAt: z.iso.datetime(), +}); + +/** + * What an audit event looks like over HTTP. + * + * Written out rather than returned as the row: the row carries the + * organization, which the URL already named, and splitting the actor out keeps + * the columns describing one thing together. It also stops a column added to + * `audit_event` becoming an API change by accident. + */ +const auditEventShape = (event: typeof schema.auditEvent.$inferSelect) => ({ + id: event.id, + resourceType: event.resourceType, + resourceId: event.resourceId, + action: event.action, + actor: { + type: event.actorType, + id: event.actorId, + label: event.actorLabel, + onBehalfOf: event.onBehalfOfId + ? { id: event.onBehalfOfId, label: event.onBehalfOfLabel } + : null, + }, + before: event.before, + after: event.after, + createdAt: event.createdAt, +}); + +export const history = new Hono().get("/history", async (c) => { + // Which record, if any, before anything else: the cursor is only meaningful + // against the ordering that answer names. + const asked = narrowing.safeParse(c.req.query()); + if (!asked.success) return c.json(rejection("query", asked.error), 400); + + const ordering = historyOrder(asked.data.resource); + const query = historyQuery(asked.data.resource).safeParse(c.req.query()); + if (!query.success) return c.json(rejection("query", query.error), 400); + const { limit, cursor, resource } = query.data; + + // The pattern above already refused anything else, so this cannot be + // undefined — but deriving it is what keeps the query on its index. + const only = resource ? resourceOf(resource) : undefined; + + const rows = await c.var.withOrganization((tx) => + tx + .select({ ...getTableColumns(schema.auditEvent), cursorAt: cursorAt(ordering) }) + .from(schema.auditEvent) + .where( + and( + only ? eq(schema.auditEvent.resourceType, only.resourceType) : undefined, + only ? eq(schema.auditEvent.resourceId, only.resourceId) : undefined, + cursor ? rowsAfter(ordering, cursor) : undefined, + ), + ) + .orderBy(...orderedBy(ordering)) + .limit(limit + 1), + ); + + const { rows: found, nextCursor } = page(rows, limit, ordering); + return c.json({ data: found.map(auditEventShape), nextCursor }); +}); diff --git a/apps/server/openapi.test.ts b/apps/server/openapi.test.ts new file mode 100644 index 0000000..3b60fa3 --- /dev/null +++ b/apps/server/openapi.test.ts @@ -0,0 +1,407 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * That the OpenAPI document describes the API that is actually served. + * + * A document assembled by hand is only worth having if it cannot drift, so two + * things are checked against reality rather than against themselves: every + * route the app registers is described, and every response the tests provoke + * parses against the schema the document publishes. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema } from "@qualityruntime/db"; +import Ajv2020 from "ajv/dist/2020.js"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; +import { sessionCookieName } from "./auth.ts"; +import { openApiDocument, openApiPath, referencePath } from "./openapi.ts"; + +/** + * A JSON Schema validator, because the point is what a client sees. + * + * Checking a response against the Zod schema it was built from would only prove + * the two agree with each other; this validates it against the schema the + * document actually publishes, conversion included. + */ +const ajv = new Ajv2020({ strict: false, allErrors: true }); + +type Document = ReturnType; + +/** The schema the document promises for one operation and status. */ +function publishedSchema(document: Document, method: string, path: string, status: number) { + const operation = (document.paths[path] as Record | undefined)?.[method] as + | { responses: Record }> } + | undefined; + const schema = operation?.responses[String(status)]?.content?.["application/json"]?.schema; + if (!schema) throw new Error(`The document promises nothing for ${method} ${path} ${status}.`); + return schema; +} + +/** Asserts a response is what the document said it would be. */ +async function conformsToDocument( + document: Document, + method: string, + path: string, + response: Response, +) { + const body = await response.json(); + const validate = ajv.compile(publishedSchema(document, method, path, response.status)); + if (!validate(body)) { + expect(validate.errors).toEqual([]); + } + expect(validate(body)).toBe(true); +} + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +let app: ReturnType; +let auth: ReturnType; +let acme: { cookie: string; organizationId: string }; + +const json = async (response: Response): Promise => (await response.json()) as T; + +async function create(name: string): Promise<{ id: string }> { + const response = await request("", { method: "POST", body: JSON.stringify({ name }) }); + expect(response.status).toBe(201); + return (await json<{ data: { id: string } }>(response)).data; +} + +const request = ( + path: string, + init: Omit & { headers?: Record } = {}, +) => + app.request(`/api/v1/organizations/${acme.organizationId}/controls${path}`, { + ...init, + // Merged, not replaced: a caller's own headers are the point of + // passing them, and dropping them silently makes a test pass for + // the wrong reason. + headers: { + cookie: acme.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + +beforeAll(async () => { + const client = new PGlite(); + const db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + auth = createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }); + app = createApp({ + db, + auth, + }); + + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Ada", email: "acme@example.test", password: "correct horse" }), + }); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: "Acme", slug: "acme" }), + }); + expect(created.status).toBe(200); + acme = { cookie, organizationId: (await json<{ id: string }>(created)).id }; + + await client.exec(` + create role qualityruntime_app nosuperuser nobypassrls; + grant all on all tables in schema public to qualityruntime_app; + alter table "control" owner to qualityruntime_app; + alter table "audit_event" owner to qualityruntime_app; + set role qualityruntime_app; + `); +}, 60_000); + +describe("the document", () => { + it("is served without a session, because it describes no one's data", async () => { + const response = await app.request(openApiPath); + + expect(response.status).toBe(200); + const document = await json<{ openapi: string; paths: Record }>(response); + expect(document.openapi).toBe("3.1.0"); + expect(Object.keys(document.paths).length).toBeGreaterThan(0); + }); + + it("renders itself for a person to read", async () => { + const response = await app.request(referencePath); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toMatch(/text\/html/); + // Whatever it renders, it renders *this* document. + expect(await response.text()).toContain(openApiPath); + }); + + it("describes every route the app serves under /api/v1", async () => { + // Derived from the router, so a route added without an operation fails + // here. Hono lists one entry per handler, including validators, so the + // same method and path appear more than once. + const served = new Set( + app.routes + .filter((route) => route.method !== "ALL" && route.path.startsWith("/api/v1")) + .map( + (route) => `${route.method.toLowerCase()} ${route.path.replaceAll(/:(\w+)/g, "{$1}")}`, + ), + ); + // Neither is an operation: one is the document, the other renders it. + served.delete(`get ${openApiPath}`); + served.delete(`get ${referencePath}`); + + const document = openApiDocument(sessionCookieName(auth)); + const described = new Set( + Object.entries(document.paths).flatMap(([path, methods]) => + Object.keys(methods).map((method) => `${method} ${path}`), + ), + ); + + expect([...served].sort()).toEqual([...described].sort()); + }); + + it("gives every path parameter the pattern its identifiers actually have", () => { + const document = openApiDocument(sessionCookieName(auth)); + const parameters = Object.values(document.paths) + .flatMap((methods) => Object.values(methods)) + .flatMap((operation) => (operation as { parameters?: unknown[] }).parameters ?? []) + .filter((parameter) => (parameter as { in: string }).in === "path"); + + expect(parameters.length).toBeGreaterThan(0); + for (const parameter of parameters as { name: string; schema: { pattern: string } }[]) { + expect(parameter.schema.pattern).toMatch(/^\^[a-z]+_\[0-9a-z\]\{\d+\}\$$/); + } + }); + + it("documents a cursor as the string a client sends, not what it decodes to", () => { + // The schema carries a transform; describing its output would tell clients + // to send an object they cannot send. + const document = openApiDocument(sessionCookieName(auth)); + const list = document.paths["/api/v1/organizations/{organizationId}/controls"]!.get as { + parameters: { name: string; schema: { type: string } }[]; + }; + const cursor = list.parameters.find((parameter) => parameter.name === "cursor"); + + expect(cursor?.schema.type).toBe("string"); + }); +}); + +describe("the requests it promises to accept", () => { + const controls = "/api/v1/organizations/{organizationId}/controls"; + const one = `${controls}/{controlId}`; + const NUL = String.fromCharCode(0); + + /** The request schema the document publishes for an operation. */ + const publishedBody = (method: string, path: string) => { + const operation = ( + openApiDocument(sessionCookieName(auth)).paths[path] as Record + )[method] as { requestBody: { content: Record } }; + return operation.requestBody.content["application/json"]!.schema; + }; + + it.each([ + ["a body with no name", "post", controls, {}], + ["a name of only spaces", "post", controls, { name: " " }], + ["a name carrying a NUL", "post", controls, { name: `a${NUL}b` }], + ["a name beyond the bound", "post", controls, { name: "x".repeat(201) }], + ["a change that changes nothing", "patch", one, {}], + ["a change naming only an unknown field", "patch", one, { unknown: 1 }], + ["a status outside the lifecycle", "patch", one, { status: "approved" }], + ])("publishes a schema that refuses %s", async (_case, method, path, body) => { + // The server refuses each of these. A document that says otherwise sends + // clients to build requests that cannot work. + const validate = ajv.compile(publishedBody(method, path)); + + expect(validate(body)).toBe(false); + }); + + it.each([ + ["an ordinary body", { name: "Access review", description: "Quarterly." }], + // Bounds are checked before trimming, so this is inside them for both the + // server and the schema; getting that the wrong way round would have the + // document refuse a request the server accepts. + ["a name with surrounding whitespace", { name: " Padded " }], + ["a name at the length bound", { name: "x".repeat(200) }], + ])("publishes a schema that accepts %s, as the server does", async (_case, body) => { + const validate = ajv.compile(publishedBody("post", controls)); + + expect(validate(body)).toBe(true); + expect((await request("", { method: "POST", body: JSON.stringify(body) })).status).toBe(201); + }); + + it("agrees with the server about a name only short enough once trimmed", async () => { + // 201 characters sent, 200 after trimming. Whether this is accepted is + // exactly the question of whether bounds are checked before or after + // trimming, and the document and the server have to give the same answer. + const body = { name: ` ${"x".repeat(200)}` }; + const validate = ajv.compile(publishedBody("post", controls)); + + expect(validate(body)).toBe(false); + expect((await request("", { method: "POST", body: JSON.stringify(body) })).status).toBe(400); + }); + + it.each([ + ["get", "/api/v1/organizations/{organizationId}/controls/{controlId}"], + ["patch", "/api/v1/organizations/{organizationId}/controls/{controlId}"], + ])("says that %s %s answers with an ETag", (method, path) => { + // A document that asks for `If-Match` and never says where the tag comes + // from describes half a contract (ADR 0019). + const operation = ( + openApiDocument(sessionCookieName(auth)).paths[path] as Record + )[method] as { responses: Record }> }; + + expect(operation.responses["200"]?.headers).toHaveProperty("ETag"); + }); + + it("publishes where a cursor comes from", () => { + const list = openApiDocument(sessionCookieName(auth)).paths[controls]!.get as { + parameters: { name: string; schema: { description?: string } }[]; + }; + const cursor = list.parameters.find((parameter) => parameter.name === "cursor"); + + // Nothing in the schema can say which strings are cursors, so the document + // has to say where a client gets one. + expect(cursor?.schema.description).toMatch(/nextCursor/); + }); +}); + +describe("the responses it promises", () => { + const controls = "/api/v1/organizations/{organizationId}/controls"; + const one = `${controls}/{controlId}`; + + let document: Document; + beforeAll(() => { + document = openApiDocument(sessionCookieName(auth)); + }); + + it("describes a created control", async () => { + const response = await request("", { + method: "POST", + body: JSON.stringify({ name: "Access review" }), + }); + + expect(response.status).toBe(201); + await conformsToDocument(document, "post", controls, response); + }); + + it("describes a page of controls", async () => { + const response = await request("?limit=1"); + + expect(response.status).toBe(200); + await conformsToDocument(document, "get", controls, response); + }); + + it("describes one control", async () => { + const created = await create("Retrieved"); + + const response = await request(`/${created.id}`); + + expect(response.status).toBe(200); + await conformsToDocument(document, "get", one, response); + }); + + it("describes a control that was changed", async () => { + const created = await create("Before"); + + const response = await request(`/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ name: "After" }), + }); + + expect(response.status).toBe(200); + await conformsToDocument(document, "patch", one, response); + }); + + it("describes a control that took effect", async () => { + // Every other case here leaves `activatedAt` null, which matches the + // document's null branch whatever the non-null one says. Only a control + // that has actually been activated checks the format it is served in. + const created = await create("Activated"); + + const response = await request(`/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "active" }), + }); + + expect(response.status).toBe(200); + await conformsToDocument(document, "patch", one, response); + + // Read back rather than cloned: the check above consumes the body. + const { data } = await json<{ data: { activatedAt: string } }>(await request(`/${created.id}`)); + expect(data.activatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/); + }); + + it("describes a page of audit events", async () => { + const created = await create("With history"); + + const response = await app.request( + `/api/v1/organizations/${acme.organizationId}/history?resource=${created.id}`, + { headers: { cookie: acme.cookie } }, + ); + + expect(response.status).toBe(200); + await conformsToDocument( + document, + "get", + "/api/v1/organizations/{organizationId}/history", + response, + ); + }); + + it("describes a refused status change", async () => { + const created = await create("Refused"); + + const response = await request(`/${created.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "retired" }), + }); + + expect(response.status).toBe(409); + await conformsToDocument(document, "patch", one, response); + }); + + it("describes a body that is too large", async () => { + const response = await request("", { + method: "POST", + body: JSON.stringify({ name: "Fine", padding: "x".repeat(100_000) }), + }); + + expect(response.status).toBe(413); + await conformsToDocument(document, "post", controls, response); + }); + + it("describes an unauthenticated request", async () => { + const response = await app.request(`/api/v1/organizations/${acme.organizationId}/controls`); + + expect(response.status).toBe(401); + await conformsToDocument(document, "get", controls, response); + }); + + it("describes a control that is not there", async () => { + const response = await request("/ctl_0000000000000000"); + + expect(response.status).toBe(404); + await conformsToDocument(document, "get", one, response); + }); + + it("describes a body that is not valid", async () => { + const response = await request("", { method: "POST", body: "{}" }); + + expect(response.status).toBe(400); + await conformsToDocument(document, "post", controls, response); + }); +}); diff --git a/apps/server/openapi.ts b/apps/server/openapi.ts new file mode 100644 index 0000000..d890de1 --- /dev/null +++ b/apps/server/openapi.ts @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The OpenAPI description of `/api/v1`. + * + * Reuses request schemas and documented response schemas. Custom validation + * and domain rules may impose constraints beyond JSON Schema; response + * conformance is checked by tests, not by runtime serialization. + * Reasoning — including why this is assembled here rather than by a library — + * is in `docs/adr/0007-openapi-from-the-schemas.md`. + */ + +import { auditEventResponse, historyQuery } from "./history.ts"; +import { idPattern, type IdType } from "@qualityruntime/db"; +import { z } from "zod"; +import { controlOrder, controlResponse, createBody, updateBody } from "./controls.ts"; +import { collectionQuery } from "./pagination.ts"; +import { collection, failureResponse, single } from "./responses.ts"; + +/** OpenAPI 3.1 is JSON Schema draft 2020-12, which is what Zod emits. */ +const jsonSchema = (schema: z.ZodType, io: "input" | "output") => { + const { $schema, ...rest } = z.toJSONSchema(schema, { io, target: "draft-2020-12" }) as Record< + string, + unknown + >; + void $schema; + return rest; +}; + +const body = (schema: z.ZodType) => ({ + required: true, + content: { "application/json": { schema: jsonSchema(schema, "input") } }, +}); + +const responds = (description: string, schema: z.ZodType) => ({ + description, + content: { "application/json": { schema: jsonSchema(schema, "output") } }, +}); + +const fails = (description: string) => responds(description, failureResponse); + +/** + * A response carrying the record's version, which a conditional write quotes. + * + * Declared where one is actually served: a document that asks for `If-Match` + * and never says where the tag comes from describes half a contract (ADR 0019). + */ +const versioned = (description: string, schema: z.ZodType) => ({ + ...responds(description, schema), + headers: { + ETag: { + description: "The record's version. Quote it back in `If-Match` to change only this.", + schema: { type: "string" }, + }, + }, +}); + +/** + * Query parameters, one per property of the schema. + * + * OpenAPI describes a query as a list of parameters rather than an object, so + * the object is taken apart here; `io: "input"` is what makes a cursor appear + * as the string a client actually sends rather than the position it decodes to. + */ +function queryParameters(schema: z.ZodObject) { + const described = jsonSchema(schema, "input") as { + properties?: Record; + required?: string[]; + }; + return Object.entries(described.properties ?? {}).map(([name, property]) => ({ + name, + in: "query", + required: described.required?.includes(name) ?? false, + schema: property, + })); +} + +/** Which kind of identifier each path parameter holds, for its pattern. */ +const pathParameterTypes: Record = { + organizationId: "organization", + controlId: "control", +}; + +/** Derived from the path itself, so a parameter cannot be left undescribed. */ +function pathParameters(path: string) { + return [...path.matchAll(/\{(\w+)\}/g)].map(([, name]) => { + const type = pathParameterTypes[name!]; + if (!type) throw new Error(`No identifier type is declared for the "${name}" path parameter.`); + return { + name, + in: "path", + required: true, + schema: { type: "string", pattern: idPattern(type) }, + }; + }); +} + +/** `If-Match` where it is optional: supplied, the write is conditional on it (ADR 0019). */ +const conditional = { + name: "If-Match", + in: "header", + required: false, + description: + "The ETag of the record as it was read. Supplied, the write is refused with 412 if the " + + "record has changed since; `*` means only if it still exists.", + schema: { type: "string" }, +}; + +type Operation = { + method: "get" | "post" | "patch" | "put" | "delete"; + path: string; + summary: string; + query?: z.ZodObject; + request?: z.ZodType; + /** Headers an operation takes, which no schema here describes. */ + parameters?: unknown[]; + responses: Record; +}; + +const tenant = "/api/v1/organizations/{organizationId}"; +const controls = `${tenant}/controls`; + +/** + * Every operation `/api/v1` serves. + * + * `openapi.test.ts` checks this against the routes the app actually registers, + * so an operation cannot be added to one and forgotten in the other. + */ +const operations: Operation[] = [ + { + method: "get", + path: controls, + summary: "List the organization's controls, newest first.", + query: collectionQuery(controlOrder), + responses: { + "200": responds("A page of controls.", collection(controlResponse)), + "400": fails("The query is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such organization, or the caller is not a member of it."), + }, + }, + { + method: "post", + path: controls, + summary: "Create a control. It always starts as a draft.", + request: createBody, + responses: { + "201": responds("The control that was created.", single(controlResponse)), + "400": fails("The body is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such organization, or the caller is not a member of it."), + "413": fails("The body is too large."), + }, + }, + { + method: "get", + path: `${controls}/{controlId}`, + summary: "Retrieve one control.", + responses: { + "200": versioned("The control.", single(controlResponse)), + "401": fails("The request is not authenticated."), + "404": fails("No such control."), + }, + }, + { + method: "patch", + path: `${controls}/{controlId}`, + summary: "Change a control. Status moves follow the lifecycle.", + parameters: [conditional], + request: updateBody, + responses: { + "200": versioned("The control as it now stands.", single(controlResponse)), + "400": fails("The body is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such control."), + "409": fails("That status change is not one the lifecycle allows."), + "413": fails("The body is too large."), + "412": fails("The record changed since it was read."), + }, + }, + { + method: "delete", + path: `${controls}/{controlId}`, + summary: "Discard a control that was never in effect and carries no evidence.", + parameters: [conditional], + responses: { + "204": { description: "The draft is gone." }, + "401": fails("The request is not authenticated."), + "404": fails("No such control."), + "409": fails("The control has been in effect, or it carries evidence."), + "412": fails("The record changed since it was read."), + }, + }, + { + method: "get", + path: `${tenant}/history`, + summary: "Audit history, newest first. Narrow it to one record with `resource`.", + query: historyQuery(), + responses: { + "200": responds("A page of audit events.", collection(auditEventResponse)), + "400": fails("The query is not valid."), + "401": fails("The request is not authenticated."), + // The organization, not the record. Asking about a record that is not + // there is an empty page, because history outlives records — but the + // organization is still resolved before the handler runs (ADR 0018). + "404": fails("No such organization, or the caller is not a member of it."), + }, + }, +]; + +/** + * The document. + * + * Schemas are inlined rather than collected into `components`: they come + * straight from the Zod definitions, and hand-written `$ref`s would be a second + * description of the same thing to keep in step. No `servers` either — a client + * has the URL it fetched this from, and a guess here would be wrong behind any + * proxy. + */ +export function openApiDocument(sessionCookie: string) { + const paths: Record> = {}; + + for (const operation of operations) { + const parameters = [ + ...pathParameters(operation.path), + ...queryParameters(operation.query ?? z.object({})), + ...(operation.parameters ?? []), + ]; + paths[operation.path] ??= {}; + paths[operation.path]![operation.method] = { + summary: operation.summary, + ...(parameters.length > 0 ? { parameters } : {}), + ...(operation.request ? { requestBody: body(operation.request) } : {}), + responses: operation.responses, + }; + } + + return { + openapi: "3.1.0", + info: { + title: "Quality Runtime", + version: "0", + description: + "The open-source runtime for quality and compliance. Every tenant-owned resource " + + "lives under an organization, and a caller must be a member of it.", + }, + components: { + securitySchemes: { + // Better Auth issues this on sign-in and renews it, and names it + // differently under HTTPS — so the name is taken from the instance. + // `/api/auth` is its own API and is not described here. + session: { type: "apiKey", in: "cookie", name: sessionCookie }, + }, + }, + security: [{ session: [] }], + paths, + }; +} + +/** What the app mounts. Public: it describes the API, not anyone's data. */ +export const openApiPath = "/api/v1/openapi.json"; + +/** + * Where the document is rendered for a person to read. + * + * Beside the document rather than instead of it: a client reads the JSON, and + * this is for whoever has to understand it first. + */ +export const referencePath = "/api/v1/reference"; diff --git a/apps/server/organization.test.ts b/apps/server/organization.test.ts new file mode 100644 index 0000000..fe12a2a --- /dev/null +++ b/apps/server/organization.test.ts @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The organization request context, and the one route that uses it, driven + * through real HTTP requests against a migrated database. + * + * Two organizations with real memberships, created through Better Auth rather + * than seeded, so what is exercised is the path an actual client takes. + * + * Requests run as a non-superuser role that owns the tables, with row-level + * security forced so the policies bind their owner — PGlite's default role is a + * superuser and PostgreSQL exempts those from every policy, so the tenant + * scoping below would be vacuous otherwise. The deployment's own posture, a + * runtime role that owns nothing, is `privileges.test.ts`'s subject. The routes carry no `where organization_id`: that the rows come + * back scoped anyway is the whole point. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema } from "@qualityruntime/db"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { Hono } from "hono"; +import { createApp } from "./app.ts"; +import { type Auth, createAuth } from "./auth.ts"; +import { organizationContext, type OrganizationEnv } from "./organization.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +let db: ReturnType; +let auth: Auth; +let app: ReturnType; + +/** A signed-in user who owns one organization holding one control. */ +type Tenant = { cookie: string; organizationId: string; controlName: string }; + +let acme: Tenant; +let globex: Tenant; +/** Signed in, but a member of nothing. */ +let outsider: string; + +const json = async (response: Response): Promise => (await response.json()) as T; + +const cookieOf = (response: Response) => + response.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + +async function signUp(email: string): Promise { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Ada", email, password: "correct horse battery" }), + }); + expect(response.status).toBe(200); + return cookieOf(response); +} + +async function createTenant(slug: string, controlName: string): Promise { + const cookie = await signUp(`${slug}@example.test`); + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: slug, slug }), + }); + expect(created.status).toBe(200); + const { id } = await json<{ id: string }>(created); + + await db.insert(schema.control).values({ organizationId: id, name: controlName }); + return { cookie, organizationId: id, controlName }; +} + +beforeAll(async () => { + const client = new PGlite(); + db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + auth = createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }); + app = createApp({ + db, + auth, + }); + + acme = await createTenant("acme", "Access review"); + globex = await createTenant("globex", "Supplier audit"); + outsider = await signUp("outsider@example.test"); + + // Fixtures are seeded above as the superuser; everything from here runs as + // the application role, so the policies apply (ADR 0003). + await client.exec(` + create role qualityruntime_app nosuperuser nobypassrls; + grant all on all tables in schema public to qualityruntime_app; + alter table "control" owner to qualityruntime_app; + set role qualityruntime_app; + `); +}, 60_000); + +const listControls = (organizationId: string, cookie?: string) => + app.request(`/api/v1/organizations/${organizationId}/controls`, { + headers: cookie ? { cookie } : {}, + }); + +describe("authentication", () => { + it("refuses an anonymous request", async () => { + const response = await listControls(acme.organizationId); + + expect(response.status).toBe(401); + expect(await json(response)).toEqual({ + error: { code: "unauthenticated", message: expect.any(String) }, + }); + }); + + it("refuses a request carrying a nonsense session cookie", async () => { + const response = await listControls(acme.organizationId, "better-auth.session_token=forged"); + + expect(response.status).toBe(401); + }); +}); + +describe("response headers", () => { + it("forbids caching every response, refused or served", async () => { + // Cookie-authenticated and membership-dependent: a shared cache reusing one + // of these would hand a member's controls to an outsider, or the reverse. + const served = await listControls(acme.organizationId, acme.cookie); + const refused = await listControls(acme.organizationId, outsider); + const anonymous = await listControls(acme.organizationId); + + for (const response of [served, refused, anonymous]) { + expect(response.headers.get("cache-control")).toBe("no-store"); + } + }); + + it("forbids caching a response a handler built itself", async () => { + // A raw `Response` never passes through Hono's prepared headers, so the + // middleware has to put this one back on afterwards. + const raw = new Hono() + .use("/organizations/:organizationId/*", organizationContext({ auth, db })) + .get("/organizations/:organizationId/raw", () => new Response("ok")); + + const response = await raw.request(`/organizations/${acme.organizationId}/raw`, { + headers: { cookie: acme.cookie }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("passes on the cookie Better Auth issues for an ageing session", async () => { + // Better Auth renews a session that is close to expiring and returns the + // replacement cookie in its own headers. Dropping it would keep extending + // the session in the database while the browser signed out on schedule. + const email = "renewed@example.test"; + const cookie = await signUp(email); + const [user] = await db.select().from(schema.user).where(eq(schema.user.email, email)); + // Close enough to expiry that Better Auth renews it on the next look-up. + await db + .update(schema.session) + .set({ expiresAt: new Date(Date.now() + 60 * 60 * 1000) }) + .where(eq(schema.session.userId, user!.id)); + + const response = await listControls(acme.organizationId, cookie); + + expect(response.headers.getSetCookie().join()).toMatch(/session_token/); + }); +}); + +describe("failure shape", () => { + it("answers an unknown endpoint in the documented envelope", async () => { + const response = await app.request("/api/v1/nope"); + + expect(response.status).toBe(404); + expect(await json(response)).toEqual({ + error: { code: "not_found", message: expect.any(String) }, + }); + }); + + it("answers an unsupported method on a real resource the same way", async () => { + // DELETE is not part of the control resource; the collection itself is. + const response = await app.request(`/api/v1/organizations/${acme.organizationId}/controls`, { + method: "DELETE", + headers: { cookie: acme.cookie }, + }); + + expect(response.status).toBe(404); + expect(await json<{ error: { code: string } }>(response)).toMatchObject({ + error: { code: "not_found" }, + }); + }); +}); + +describe("membership", () => { + it("serves a member of the organization", async () => { + const response = await listControls(acme.organizationId, acme.cookie); + + expect(response.status).toBe(200); + const { data } = await json<{ data: { name: string }[] }>(response); + expect(data.map((row) => row.name)).toEqual([acme.controlName]); + }); + + it("refuses a signed-in user who is a member of another organization", async () => { + const response = await listControls(acme.organizationId, globex.cookie); + + expect(response.status).toBe(404); + }); + + it("refuses a signed-in user who is a member of nothing", async () => { + const response = await listControls(acme.organizationId, outsider); + + expect(response.status).toBe(404); + }); + + it.each([ + ["an id of the wrong shape", "not-an-id"], + ["an id carrying another table's prefix", "usr_v1stgxr8z5jdhi6b"], + ["a percent-encoded NUL", "%00"], + ])("refuses %s as an organization", async (_case, id) => { + // The last would otherwise be handed to PostgreSQL, which rejects NUL in + // `text` and would turn a plain absence into a 500. + const response = await listControls(id, acme.cookie); + + expect(response.status).toBe(404); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("not_found"); + }); + + it("answers the same way for an organization that does not exist", async () => { + // A non-member must not be able to tell the two apart: were this a 403, + // any leaked identifier would become a membership oracle. + const missing = await listControls("org_0000000000000000", acme.cookie); + const forbidden = await listControls(globex.organizationId, acme.cookie); + + expect(missing.status).toBe(forbidden.status); + expect(await json(missing)).toEqual(await json(forbidden)); + }); +}); + +describe("tenant scoping", () => { + it("shows each organization only its own controls", async () => { + const mine = await json<{ data: { name: string }[] }>( + await listControls(globex.organizationId, globex.cookie), + ); + + expect(mine.data.map((row) => row.name)).toEqual([globex.controlName]); + }); + + it("does not let the session's active organization override the path", async () => { + // Better Auth sets `session.activeOrganizationId` when an organization is + // created, so Acme's session is *active* in Acme. Asking for Globex must + // still be refused on membership, not quietly answered with Acme's rows. + const response = await listControls(globex.organizationId, acme.cookie); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/organization.ts b/apps/server/organization.ts new file mode 100644 index 0000000..1cf9415 --- /dev/null +++ b/apps/server/organization.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The organization a request acts in, resolved once before any handler runs. + * + * Which organization comes from the URL, not from the session — reasoning in + * `docs/adr/0004-organization-in-the-request-path.md`. This module turns that + * path segment into a membership, and refuses the request if there isn't one. + */ + +import { + idPattern, + type RootDatabase, + schema, + type TenantTransaction, + withOrganization, +} from "@qualityruntime/db"; +import { and, eq } from "drizzle-orm"; +import type { PgQueryResultHKT } from "drizzle-orm/pg-core"; +import { createMiddleware } from "hono/factory"; +import { type Actor, type RecordChange, recordChange } from "./audit.ts"; +import type { Auth } from "./auth.ts"; +import { failure } from "./responses.ts"; + +const isOrganizationId = new RegExp(idPattern("organization")); + +/** What every handler behind this middleware can rely on. */ +export type OrganizationEnv = { + Variables: { + /** Their membership of this organization — the authorization decision, kept. */ + member: typeof schema.member.$inferSelect; + /** + * Runs tenant-owned work scoped to this request's organization. Bound, so a + * handler cannot scope to an organization the caller was not authorized for. + */ + withOrganization: (work: (tx: TenantTransaction) => Promise) => Promise; + /** + * Records a change on the transaction that makes it. Bound to the caller + * for the same reason: a handler says what happened, never who did it. + */ + audit: RecordChange; + }; +}; + +/** + * Requires an authenticated caller who is a member of `:organizationId`. + * + * A caller who is not a member gets 404 rather than 403. 403 would confirm that + * an organization exists, turning any leaked or guessed identifier into a + * membership oracle; a non-member should not be able to tell an organization + * they cannot see from one that is not there. + */ +export function organizationContext({ + auth, + db, +}: { + auth: Auth; + db: RootDatabase; +}) { + return createMiddleware(async (c, next) => { + // Every response here depends on who asked, so no shared cache may reuse + // one: a hit would serve another tenant's rows, or deny a member on a + // cached refusal. Applied before anything can return. + c.header("cache-control", "no-store"); + + // `returnHeaders` because `getSession` renews an ageing session and issues + // the replacement cookie in those headers. Taking only the body would keep + // extending the session in the database while never telling the browser, + // which then signs out at the original expiry despite being active. + const { headers, response: session } = await auth.api.getSession({ + headers: c.req.raw.headers, + returnHeaders: true, + }); + for (const cookie of headers.getSetCookie()) { + c.header("set-cookie", cookie, { append: true }); + } + + if (!session) { + return c.json(failure("unauthenticated", "Sign in to make this request."), 401); + } + + // Authorization, and the only thing that decides it: `session` may carry an + // `activeOrganizationId`, but that is context, never proof of access + // (TENANT-01). + const organizationId = c.req.param("organizationId"); + if (!organizationId) { + // A routing mistake, not a bad request: this middleware is only correct + // on a path that carries the segment. + throw new Error("organizationContext requires an :organizationId path segment."); + } + + // A segment that could not name an organization is not one, so it gets the + // same answer as one the caller is not in. It never reaches PostgreSQL — + // a NUL in `text` fails the statement, which would surface as a 500 for + // what is plainly an absence. + if (!isOrganizationId.test(organizationId)) { + return c.json(failure("not_found", "No such organization."), 404); + } + + const [membership] = await db + .select() + .from(schema.member) + .where( + and( + eq(schema.member.organizationId, organizationId), + eq(schema.member.userId, session.user.id), + ), + ); + if (!membership) { + return c.json(failure("not_found", "No such organization."), 404); + } + + c.set("member", membership); + // Better Auth's admin plugin can impersonate: `session.user` is then the + // member being acted as, and `impersonatedBy` the administrator doing it. + // The administrator is the actor — attributing their change to the member + // would be a false record, which is worse than none (AUDIT-01). + const impersonator = session.session.impersonatedBy; + // Only the id is on the session, so the name costs a look-up — on the rare + // impersonated request only, and worth it there: the label is what still + // names the accountable person once their account is gone (ADR 0005). + const [administrator] = impersonator + ? await db + .select({ name: schema.user.name }) + .from(schema.user) + .where(eq(schema.user.id, impersonator)) + : []; + const actor: Actor = impersonator + ? { + type: "user", + id: impersonator, + label: administrator?.name || null, + onBehalfOf: { id: session.user.id, label: session.user.name || null }, + } + : { type: "user", id: session.user.id, label: session.user.name || null }; + + c.set("audit", (tx, change) => recordChange(tx, actor, organizationId, change)); + // The driver is erased here so handlers need not be generic over it; every + // transaction method a handler uses is identical across drivers. + c.set("withOrganization", ((work) => + withOrganization( + db, + organizationId, + work, + )) as OrganizationEnv["Variables"]["withOrganization"]); + + await next(); + + // A handler that returned a `Response` of its own bypasses the prepared + // headers above, and this one is not optional. Rebuilt rather than mutated + // because a proxied response can carry immutable headers. + if (c.res.headers.get("cache-control") !== "no-store") { + c.res = new Response(c.res.body, c.res); + c.res.headers.set("cache-control", "no-store"); + } + }); +} diff --git a/apps/server/package.json b/apps/server/package.json index eb04444..855a532 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,13 +7,16 @@ "dependencies": { "@better-auth/drizzle-adapter": "1.7.5", "@qualityruntime/db": "workspace:*", + "@scalar/hono-api-reference": "^0.12.2", "better-auth": "1.7.5", "drizzle-orm": "^0.45.2", "hono": "^4.10.7", - "pg": "^8.23.0" + "pg": "^8.23.0", + "zod": "^4.6.5" }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", - "@types/pg": "^8.23.1" + "@types/pg": "^8.23.1", + "ajv": "^8.20.0" } } diff --git a/apps/server/pagination.test.ts b/apps/server/pagination.test.ts new file mode 100644 index 0000000..ba96862 --- /dev/null +++ b/apps/server/pagination.test.ts @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The collection contract: how `/api/v1` collections page, over HTTP. + * + * Exercised on both collections that have one — controls, and the history of a + * control — because the point of settling it once is that they behave the same. + * Requests run as a non-superuser role that owns the tables, with row-level + * security forced so the policies bind their owner. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema, withOrganization } from "@qualityruntime/db"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +let db: ReturnType; +let app: ReturnType; +let acme: { cookie: string; organizationId: string }; + +const json = async (response: Response): Promise => (await response.json()) as T; + +type Page = { data: T[]; nextCursor: string | null }; +type Named = { id: string; name: string }; +type Failure = { error: { code: string; details?: { path: string }[] } }; + +const NUL = String.fromCharCode(0); +const encoded = (value: string) => + encodeURIComponent(Buffer.from(value, "utf8").toString("base64url")); + +/** A request to this organization, at `path` under it. */ +const organization = async ( + path: string, + init: Omit & { headers?: Record } = {}, +): Promise => + app.request(`/api/v1/organizations/${acme.organizationId}${path}`, { + ...init, + // Merged, not replaced: a caller's own headers are the point of + // passing them, and dropping them silently makes a test pass for + // the wrong reason. + headers: { + cookie: acme.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + +const request = async ( + path: string, + init: Omit & { headers?: Record } = {}, +): Promise => + app.request(`/api/v1/organizations/${acme.organizationId}/controls${path}`, { + ...init, + // Merged, not replaced: a caller's own headers are the point of + // passing them, and dropping them silently makes a test pass for + // the wrong reason. + headers: { + cookie: acme.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + +async function create(name: string): Promise { + const response = await request("", { method: "POST", body: JSON.stringify({ name }) }); + expect(response.status).toBe(201); + return (await json<{ data: Named }>(response)).data; +} + +/** Walks every page from `from`, returning what a client would have collected. */ +async function walkUsing( + fetch: (query: string) => Promise, + query: string, + from: string | null = null, +): Promise { + const collected: T[] = []; + let cursor: string | null = from; + for (let guard = 0; guard < 20; guard++) { + const suffix: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""; + const response = await fetch(`${query}${suffix}`); + expect(response.status).toBe(200); + const body: Page = await json>(response); + collected.push(...body.data); + if (!body.nextCursor) return collected; + cursor = body.nextCursor; + } + throw new Error("paging did not terminate"); +} + +const walk = (path: string, query: string, from: string | null = null) => + walkUsing((full) => request(`${path}?${full}`), query, from); + +/** `walk`, for a collection under the organization rather than under controls. */ +const walkOrganization = (path: string, query: string, from: string | null = null) => + walkUsing( + (full) => organization(`${path}${path.includes("?") ? "&" : "?"}${full}`), + query, + from, + ); + +beforeAll(async () => { + const client = new PGlite(); + db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Ada", email: "acme@example.test", password: "correct horse" }), + }); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: "Acme", slug: "acme" }), + }); + expect(created.status).toBe(200); + acme = { cookie, organizationId: (await json<{ id: string }>(created)).id }; + + await client.exec(` + create role qualityruntime_app nosuperuser nobypassrls; + grant all on all tables in schema public to qualityruntime_app; + alter table "control" owner to qualityruntime_app; + alter table "audit_event" owner to qualityruntime_app; + set role qualityruntime_app; + `); +}, 60_000); + +describe("paging a collection", () => { + it("returns every row exactly once, newest first", async () => { + const names = ["one", "two", "three", "four", "five"]; + for (const name of names) await create(name); + + const walked = await walk("", "limit=2"); + + // Every control this organization has, in one order, with no repeats. + expect(walked.map((row) => row.name)).toEqual([...names].reverse()); + }); + + it("ends without a cursor rather than on an empty page", async () => { + const { nextCursor, data } = await json>(await request("?limit=100")); + + expect(data.length).toBeGreaterThan(0); + expect(nextCursor).toBeNull(); + }); + + it("offers a cursor when there is more, and honours it", async () => { + const first = await json>(await request("?limit=2")); + expect(first.nextCursor).not.toBeNull(); + + const second = await json>( + await request(`?limit=2&cursor=${encodeURIComponent(first.nextCursor!)}`), + ); + + const ids = new Set(first.data.map((row) => row.id)); + expect(second.data.some((row) => ids.has(row.id))).toBe(false); + }); + + it("refuses a non-canonical encoding of a genuine cursor", async () => { + const { nextCursor } = await json>(await request("?limit=2")); + + const response = await request(`?limit=2&cursor=${encodeURIComponent(`${nextCursor!}!!`)}`); + + expect(response.status).toBe(400); + }); + + it("does not skip or repeat a row when the collection grows mid-walk", async () => { + // The reason this is a cursor and not an offset. Newer rows sort ahead of + // the page already read, so an offset would shift everything down and repeat + // one; a cursor names a position and is unaffected. + const before = await json>(await request("?limit=100")); + const first = await json>(await request("?limit=2")); + + await create("inserted mid-walk"); + + const rest = await walk("", "limit=2", first.nextCursor); + const seen = [...first.data, ...rest].map((row) => row.id); + + expect(new Set(seen).size).toBe(seen.length); + // Everything that existed when the walk began is still accounted for. + expect(seen).toEqual(expect.arrayContaining(before.data.map((row) => row.id))); + }); +}); + +describe("the collection query", () => { + it("does not lose rows whose timestamps differ only in microseconds", async () => { + // PostgreSQL keeps a timestamptz to the microsecond; a JavaScript Date only + // to the millisecond. A cursor built from a Date would resume up to 999µs + // early — or, here, name .123000 for a row at .123900 and skip the two + // between. Written with explicit timestamps because a clock cannot be + // relied on to produce the collision on demand. + const control = await create("microseconds"); + const at = ["123100", "123500", "123900"]; + await withOrganization(db, acme.organizationId, async (tx) => { + for (const [index, micros] of at.entries()) { + await tx.execute( + sql`insert into "audit_event" + ("id", "organization_id", "actor_type", "actor_id", "action", + "resource_type", "resource_id", "after", "created_at") + values (${`aud_000000000000000${index}`}, ${acme.organizationId}, 'system', null, + 'updated', 'control', ${control.id}, '{}'::jsonb, + ${`2030-01-01T00:00:00.${micros}Z`}::timestamptz)`, + ); + } + }); + + const walked = await walkOrganization<{ id: string }>( + `/history?resource=${control.id}`, + "limit=1", + ); + + // All three, plus the creation the control already had. + expect(walked).toHaveLength(at.length + 1); + expect(new Set(walked.map((event) => event.id)).size).toBe(walked.length); + }); + + it("issues cursors in the three-part form these fixtures use", async () => { + // Guards the fixtures below: were the wire format to change, they would go + // on passing on a malformed envelope without reaching what they test. + const { nextCursor } = await json>(await request("?limit=1")); + const decoded = Buffer.from(nextCursor!, "base64url").toString("utf8"); + + expect(decoded.split("|")).toHaveLength(3); + expect(decoded.startsWith("controls:recent|")).toBe(true); + }); + + it.each([ + ["a limit of zero", "limit=0"], + ["a limit beyond the maximum", "limit=101"], + ["a limit that is not a number", "limit=lots"], + ["a cursor this API did not issue", "cursor=not-a-cursor"], + // Each of these decodes, and each would reach PostgreSQL as a 500 if the + // decoded halves were not checked against what this API actually issues. + [ + "a cursor whose id carries a NUL", + `cursor=${encoded(`controls:recent|2026-01-01T00:00:00.000000Z|ctl_a${NUL}b`)}`, + ], + [ + "a cursor with a year PostgreSQL cannot hold", + `cursor=${encoded("controls:recent|-010000-01-01T00:00:00.000000Z|ctl_v1stgxr8z5jdhi6b")}`, + ], + [ + "a cursor with a millisecond timestamp", + `cursor=${encoded("controls:recent|2026-01-01T00:00:00.000Z|ctl_v1stgxr8z5jdhi6b")}`, + ], + [ + "a cursor with extra parts", + `cursor=${encoded("controls:recent|2026-01-01T00:00:00.000000Z|ctl_v1stgxr8z5jdhi6b|more")}`, + ], + // Well-formed to look at, and rejected by the cast: the shape of a + // timestamp says nothing about whether the instant exists. + [ + "a cursor dated the thirtieth of February", + `cursor=${encoded("controls:recent|2026-02-30T00:00:00.000000Z|ctl_v1stgxr8z5jdhi6b")}`, + ], + [ + "a cursor with a twenty-fifth hour", + `cursor=${encoded("controls:recent|2026-01-01T25:00:00.000000Z|ctl_v1stgxr8z5jdhi6b")}`, + ], + [ + "a cursor in year zero, which PostgreSQL has not got", + `cursor=${encoded("controls:recent|0000-01-01T00:00:00.000000Z|ctl_v1stgxr8z5jdhi6b")}`, + ], + ])("refuses %s", async (_case, query) => { + const response = await request(`?${query}`); + + expect(response.status).toBe(400); + const { error } = await json(response); + expect(error.code).toBe("invalid_request"); + expect(error.details?.map((detail) => detail.path)).toContain(query.split("=")[0]); + expect(response.status).not.toBe(500); + }); + + it("uses a default limit when none is given", async () => { + const { data } = await json>(await request("")); + + expect(data.length).toBeLessThanOrEqual(25); + }); +}); + +describe("the history of a record", () => { + it("pages the same way, newest first", async () => { + const control = await create("history"); + for (const name of ["first rename", "second rename", "third rename"]) { + expect( + (await request(`/${control.id}`, { method: "PATCH", body: JSON.stringify({ name }) })) + .status, + ).toBe(200); + } + + const walked = await walkOrganization<{ action: string; after: Record }>( + `/history?resource=${control.id}`, + "limit=2", + ); + + expect(walked.map((event) => event.action)).toEqual([ + "updated", + "updated", + "updated", + "created", + ]); + expect(walked.at(-1)?.after).toEqual({ name: "history", description: null, status: "draft" }); + }); + + it("describes an event without repeating what the URL already said", async () => { + const control = await create("shaped"); + + const { data } = await json>>( + await organization(`/history?resource=${control.id}`), + ); + + // The contract, written out rather than the row: no organization — the URL + // named it — and the actor's columns grouped. The resource *is* named, + // because this collection spans records and the URL no longer says which. + expect(Object.keys(data[0]!).sort()).toEqual([ + "action", + "actor", + "after", + "before", + "createdAt", + "id", + "resourceId", + "resourceType", + ]); + expect(data[0]!.actor).toEqual({ + type: "user", + id: expect.stringMatching(/^usr_/) as string, + label: "Ada", + onBehalfOf: null, + }); + }); + + it("answers with an empty page for a record that is not there", async () => { + // History outlives what it describes, so there is nothing to look the + // record up in: a control that never existed and one in another + // organization are both simply an organization's history that says + // nothing about them (ADR 0018). + const response = await organization("/history?resource=ctl_0000000000000000"); + + expect(response.status).toBe(200); + expect((await json>(response)).data).toEqual([]); + }); + + it("refuses an identifier that names no kind of record", async () => { + const response = await organization("/history?resource=nonsense"); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); +}); diff --git a/apps/server/pagination.ts b/apps/server/pagination.ts new file mode 100644 index 0000000..8e8e74b --- /dev/null +++ b/apps/server/pagination.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * How `/api/v1` collections are paged. + * + * A collection is ordered by a key and the identifier that breaks its ties, and + * paged by a cursor naming the last row of the page before. Collections that + * record what happened are newest first. Reasoning: + * `docs/adr/0006-cursor-paged-collections.md`. + */ + +import { desc, sql, type SQL } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; +import { z } from "zod"; + +/** + * How a collection is ordered, and therefore what a cursor into it means. + * + * The `name` says which collection in which order, and is part of the cursor. A + * position means nothing anywhere else — not in a different ordering, and not + * in a different collection that happens to be ordered the same way — so a + * cursor from one is refused rather than quietly answered from the wrong place. + */ +export type Ordering = { + readonly name: string; + /** Newest first: every collection so far records what has happened. */ + readonly key: PgColumn; + readonly id: PgColumn; + /** The key rendered losslessly as text, for the cursor. */ + readonly keyAsText: SQL; + /** Whether text coming back is something the cast below will accept. */ + readonly keyIsValid: (value: string) => boolean; +}; + +/** The shape a timestamp key takes. Says nothing about whether it exists. */ +const timestampFormat = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; + +/** + * Whether `value` is a real instant PostgreSQL will accept. + * + * The shape alone is not enough: `2026-02-30` and hour `25` match it and are + * rejected by the cast, which would be a 500 for a bad request. Round-tripping + * through `Date` settles the calendar; year zero is checked separately because + * JavaScript has one and PostgreSQL does not. + */ +function isRealInstant(value: string): boolean { + if (!timestampFormat.test(value) || value.startsWith("0000-")) return false; + // Microseconds are beyond what `Date` holds, so the check runs on the + // millisecond prefix; the remaining digits are digits either way. + const millisecond = `${value.slice(0, 23)}Z`; + const parsed = new Date(millisecond); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === millisecond; +} + +/** + * Newest first — the ordering of a collection that is a record of what has + * happened rather than a document with an order of its own. + * + * `to_char` against UTC rather than a plain `::text` cast, whose output depends + * on the session's `TimeZone`; and as text rather than through a `Date`, which + * keeps only milliseconds where PostgreSQL keeps microseconds — a cursor built + * from one names a position up to 999µs before the row it came from, and every + * row in that gap is skipped. + */ +export const newestFirst = (collection: string, createdAt: PgColumn, id: PgColumn): Ordering => ({ + name: `${collection}:recent`, + key: createdAt, + id, + keyAsText: sql`to_char(${createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, + keyIsValid: isRealInstant, +}); + +/** The position of a row in a collection's order. */ +export type Cursor = { ordering: string; key: string; id: string }; + +/** Accepts the generated identifier shape, without checking registered prefixes. */ +const idFormat = /^[a-z]{2,8}_[0-9a-z]{16,24}$/; + +/** + * A cursor is opaque to clients — `ordering|key|id`, base64url. + * + * Opaque because it is a position in an ordering, not a value: it is only + * meaningful against the same collection in the same order, and a client that + * takes it apart will break when the ordering changes. + */ +const encodeCursor = ({ ordering, key, id }: Cursor): string => + Buffer.from(`${ordering}|${key}|${id}`, "utf8").toString("base64url"); + +/** + * The query a collection ordered by `ordering` accepts. + * + * A cursor must name this ordering and carry a valid key and identifier shape. + * Values PostgreSQL refuses — a NUL, a year outside its range — would otherwise + * turn a bad request into a 500. + */ +export const collectionQuery = (ordering: Ordering) => + z.object({ + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor: z + .string() + // Long enough for any cursor this issues; a megabyte of base64 is not one. + .max(256) + .meta({ + description: + "The nextCursor of a previous page of this same collection. Opaque: it is a position " + + "in an ordering, not a value; only a well-formed one for this collection is accepted.", + }) + .transform((value, ctx): Cursor => { + const parts = Buffer.from(value, "base64url").toString("utf8").split("|"); + const [name = "", key = "", id = ""] = parts; + if ( + // base64url decoding skips what it cannot read, so a cursor with + // junk appended would otherwise decode to a genuine one. + encodeCursor({ ordering: name, key, id }) !== value || + parts.length !== 3 || + name !== ordering.name || + !ordering.keyIsValid(key) || + !idFormat.test(id) + ) { + ctx.addIssue({ code: "custom", message: "Not a cursor from a previous page." }); + return z.NEVER; + } + return { ordering: name, key, id }; + }) + .optional(), + }); + +/** Selects the key a cursor is built from, alongside the row's own columns. */ +export const cursorAt = (ordering: Ordering) => ordering.keyAsText; + +/** The collection's order, for the query that reads it. */ +export const orderedBy = (ordering: Ordering) => [desc(ordering.key), desc(ordering.id)] as const; + +/** + * Restricts a query to the rows after `cursor` in the collection's order. + * + * A row comparison rather than `key < … or (key = … and id < …)`: PostgreSQL + * evaluates it against the same column order an index is built in, and it + * keeps the tie-breaker in the comparison. Offset boundaries shift under + * concurrent inserts and deletes, which can repeat or skip rows. + */ +export function rowsAfter(ordering: Ordering, cursor: Cursor): SQL { + return sql`(${ordering.key}, ${ordering.id}) < (${cursor.key}::timestamptz, ${cursor.id}::text)`; +} + +/** + * Splits rows fetched with `limit + 1` into a page and the cursor after it. + * + * Asking for one more row than the page holds is how the last page is known + * exactly, without a second query and without a count that would be wrong by + * the time it was read. `cursorAt` is dropped on the way out: it is how a page + * is found, not something a caller asked for. + */ +export function page( + rows: T[], + limit: number, + ordering: Ordering, +) { + const kept = rows.slice(0, limit); + const last = kept.at(-1); + + return { + rows: kept.map((row) => { + const { cursorAt: position, ...rest } = row; + void position; + return rest as Omit; + }), + nextCursor: + rows.length > limit && last + ? encodeCursor({ ordering: ordering.name, key: last.cursorAt, id: last.id }) + : null, + }; +} diff --git a/apps/server/preconditions.test.ts b/apps/server/preconditions.test.ts new file mode 100644 index 0000000..15c122b --- /dev/null +++ b/apps/server/preconditions.test.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * What `If-Match` is read to mean. + * + * The routes are tested over HTTP; this is the header grammar on its own, + * because the cases that matter — a list, a star, a weak tag — are awkward to + * reach through a route and easy to get subtly wrong. + */ + +import { describe, expect, it } from "vite-plus/test"; +import { ifMatch } from "./preconditions.ts"; + +describe("reading If-Match", () => { + const tag = '"424242"'; + + it("treats an absent header as no request for a guarantee", () => { + // Not a failure: a client that does not ask keeps the old behaviour, which + // is what stops this from breaking every simple client (ADR 0019). + expect(ifMatch(undefined, tag)).toBe("absent"); + }); + + it("matches the tag it was given", () => { + expect(ifMatch(tag, tag)).toBe("met"); + }); + + it("fails a tag that names another version", () => { + expect(ifMatch('"1"', tag)).toBe("failed"); + }); + + it("takes a star to mean any version, provided there is one", () => { + expect(ifMatch("*", tag)).toBe("met"); + }); + + it("accepts a list, and matches if any of it does", () => { + expect(ifMatch(`"1", ${tag}, "3"`, tag)).toBe("met"); + expect(ifMatch('"1", "2"', tag)).toBe("failed"); + }); + + it("never matches a weak tag", () => { + // `If-Match` is a strong comparison (RFC 9110). Nothing here issues a weak + // tag, and one arriving means a client or a proxy invented it. + expect(ifMatch(`W/${tag}`, tag)).toBe("failed"); + }); + + it("does not find a wildcard inside a tag", () => { + // A tag is opaque and quoted: a comma or an asterisk between the quotes is + // part of it. Splitting the field on every comma exposed the `*` in + // `"old,*,other"` and let any write through. + expect(ifMatch('"old,*,other"', tag)).toBe("failed"); + expect(ifMatch('"*"', tag)).toBe("failed"); + expect(ifMatch('"a,b", "c"', tag)).toBe("failed"); + expect(ifMatch(`"a,b", ${tag}`, tag)).toBe("met"); + }); + + it("takes a wildcard only as the whole field", () => { + // RFC 9110 allows `*` alone or a list of tags, never a mixture. A list + // carrying one is malformed, and writing anyway is the wrong way to be + // wrong. + expect(ifMatch(" * ", tag)).toBe("met"); + expect(ifMatch("\u00a0*\u00a0", tag)).toBe("failed"); + expect(ifMatch(`*, ${tag}`, tag)).toBe("failed"); + expect(ifMatch(`${tag}, *`, tag)).toBe("failed"); + }); + + it("ignores empty elements wherever they are", () => { + // RFC 9110 §5.6.1 asks a recipient to tolerate them rather than refuse a + // list that is otherwise perfectly good. + expect(ifMatch(`, ${tag}`, tag)).toBe("met"); + expect(ifMatch(`${tag},`, tag)).toBe("met"); + expect(ifMatch(`"1",, ${tag}`, tag)).toBe("met"); + expect(ifMatch(`,,\t ${tag} ,,`, tag)).toBe("met"); + // A field of nothing but separators names no tag, so it matches none. + expect(ifMatch(",,,", tag)).toBe("failed"); + }); + + it("fails a field that is not tags at all", () => { + expect(ifMatch("42", tag)).toBe("failed"); + expect(ifMatch(`${tag} ${tag}`, tag)).toBe("failed"); + expect(ifMatch(`"unterminated`, tag)).toBe("failed"); + // One good tag does not excuse a malformed one beside it. + expect(ifMatch(`${tag}, "not valid"`, tag)).toBe("failed"); + expect(ifMatch(`${tag}, "tab\there"`, tag)).toBe("failed"); + }); + + it("fails an empty header rather than treating it as absent", () => { + // A header that is present and says nothing is a client that meant to send + // a tag. Refusing is the safe reading; `absent` would write regardless. + expect(ifMatch("", tag)).toBe("failed"); + expect(ifMatch(" ", tag)).toBe("failed"); + }); +}); diff --git a/apps/server/preconditions.ts b/apps/server/preconditions.ts new file mode 100644 index 0000000..25309ce --- /dev/null +++ b/apps/server/preconditions.ts @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Conditional writes: `If-Match`, and the row version it quotes. + * + * Two people editing one record is otherwise last-writer-wins, and the loser + * never learns. A caller that read a record can name the version it read and be + * refused if it has moved since (ADR 0019). Optional: a caller that does not + * ask gets the write regardless. + */ + +import { type SQL, sql } from "drizzle-orm"; +import type { PgTable } from "drizzle-orm/pg-core"; + +/** + * A row's version, as text. + * + * `xmin` identifies the transaction that wrote the row version. Updates in + * separate transactions receive different values until transaction IDs wrap; + * repeated updates within one transaction share a value. `updated_at` has only + * JavaScript's millisecond precision, so separate writes can share a timestamp. + * + * A dump and restore can change `xmin`, so tags are not durable across a + * restore. VACUUM FREEZE preserves the reported value (ADR 0019). + */ +export const rowVersion = (table: PgTable): SQL => sql`${table}."xmin"::text`; + +/** The entity tag for a row read with `rowVersion`. */ +export const entityTag = (row: { version: string }) => `"${row.version}"`; + +/** What `If-Match` said about the row as it now stands. */ +export type Precondition = "absent" | "met" | "failed"; + +/** + * Every entity tag in an `If-Match`, or null if the field is not one. + * + * A tag is opaque and quoted, and a comma or an asterisk inside the quotes is + * part of it — so the field cannot be split on commas. `"a,*,b"` is one tag + * that matches nothing, and splitting it would expose an `*` that was never a + * wildcard and let any write through (RFC 9110 §8.8.3). + */ +function entityTags(field: string): string[] | null { + // `etagc` (RFC 9110 §8.8.3): visible ASCII but the quote, or obs-text. A + // space or a control character inside the quotes makes it not a tag. + const tag = /(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"/y; + const tags: string[] = []; + let at = 0; + const skip = (pattern: RegExp) => { + pattern.lastIndex = at; + if (pattern.exec(field)) at = pattern.lastIndex; + }; + + // Empty elements are ignored wherever they appear, leading ones included: + // RFC 9110 §5.6.1 asks recipients to tolerate them rather than refuse. + skip(/[ \t,]*/y); + while (at < field.length) { + tag.lastIndex = at; + const found = tag.exec(field); + if (!found) return null; + tags.push(found[0]); + at = tag.lastIndex; + skip(/[ \t]*/y); + if (at >= field.length) break; + if (field[at] !== ",") return null; + at += 1; + skip(/[ \t,]*/y); + } + return tags; +} + +/** + * Whether a write may proceed, given the caller's `If-Match`. + * + * `absent` is not a failure: a client that does not ask for the guarantee gets + * the old behaviour rather than an error, which is what keeps a simple client + * simple. A route that needs the guarantee refuses `absent` itself. + * + * `*` matches any existing row, which is how a caller says "only if it is still + * there" — and only when it is the whole field, never one item of a list. + * Comparison is strong, per RFC 9110: a weak tag (`W/"…"`) never matches, and + * nothing here issues one. Anything that is not a well-formed field fails, + * because a client that sent the header meant something by it. + */ +export function ifMatch(header: string | undefined, tag: string): Precondition { + if (header === undefined) return "absent"; + // Only SP and HTAB surround it (RFC 9110 OWS); `trim` would also accept + // Unicode spaces a client never meant as a wildcard. + if (/^[ \t]*\*[ \t]*$/.test(header)) return "met"; + const offered = entityTags(header); + return offered?.includes(tag) ? "met" : "failed"; +} + +/** + * The row as a client sees it: everything but the version. + * + * The version is read alongside the columns so that one query serves both the + * body and the tag, and a strict response schema would refuse it in the body. + */ +export const withoutVersion = (row: T): Omit => { + const { version, ...rest } = row; + void version; + return rest; +}; diff --git a/apps/server/privileges.test.ts b/apps/server/privileges.test.ts new file mode 100644 index 0000000..7ebdba7 --- /dev/null +++ b/apps/server/privileges.test.ts @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The deployment posture, run as a deployment runs it. + * + * Applies the migrations as PGlite's default role, standing in for a migrator + * that owns the tables, and creates a runtime role that owns none, then + * exercises ordinary work and forbidden operations through that role. + * Owner-based policy tests cannot establish these privilege restrictions: an + * owner can disable row security or truncate its tables. The separate + * `documented-setup.test.ts` verifies the setup SQL printed in the docs. + * + * Two things are being proved. That the product actually runs on the privileges + * it documents, which is the part that is easy to get wrong and never notice. + * And that with those privileges "append-only" and "final" hold against the + * runtime role itself rather than only against the code (ADR 0014). + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema, withOrganization } from "@qualityruntime/db"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +/** The runtime role, named as `docs/deployment.md` names it. */ +const runtime = "qualityruntime"; + +let client: PGlite; +let db: ReturnType; +let app: ReturnType; + +const createTestDatabase = (pg: PGlite) => drizzle({ client: pg, schema, casing: "snake_case" }); + +type Tenant = { cookie: string; organizationId: string }; +let acme: Tenant; +let control: string; + +const json = async (response: Response): Promise => (await response.json()) as T; + +type Request = Omit & { headers?: Record }; + +const request = (tenant: Tenant, path: string, init: Request = {}) => + app.request(`/api/v1/organizations/${tenant.organizationId}${path}`, { + ...init, + headers: { cookie: tenant.cookie, ...init.headers }, + }); + +const asJson = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +/** + * Evidence against a control, written as the runtime role inside the tenant. + * No route records evidence yet; the table, its policies and the privileges + * are what is being tested. + */ +const recordEvidence = (controlId: string, { attested = false } = {}) => + withOrganization(db, acme.organizationId, async (tx) => { + const [row] = await tx + .insert(schema.evidence) + .values({ + organizationId: acme.organizationId, + controlId, + title: "Minutes", + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + }) + .returning(); + if (attested) { + const [signed] = await tx + .update(schema.evidence) + .set({ + attestedAt: new Date(), + attestedById: "usr_0000000000000000", + attestedByLabel: "Ada", + }) + .where(eq(schema.evidence.id, row!.id)) + .returning(); + // A fixture that silently failed to attest would let every test built + // on it pass for the wrong reason. + expect(signed?.attestedAt).toBeInstanceOf(Date); + } + return row!.id; + }); + +/** Whether a statement was refused, and what PostgreSQL said. */ +async function refused(statement: string): Promise { + try { + await client.exec(statement); + } catch (error) { + return (error as Error).message; + } + throw new Error(`expected PostgreSQL to refuse: ${statement}`); +} + +beforeAll(async () => { + client = new PGlite(); + db = createTestDatabase(client); + + // The migrator owns what it creates. PGlite's default role stands in for it. + await migrate(db, { migrationsFolder }); + + // Exactly what `docs/deployment.md` says to grant, and nothing else. If the + // product needs more than this, this test is where that surfaces. + // + // The grants are applied directly rather than through the migrator role that + // document describes, so what is proved here is the posture, not the setup: + // `ALTER DEFAULT PRIVILEGES` and the migrator's own grants are not exercised. + await client.exec(` + CREATE ROLE ${runtime} NOSUPERUSER NOBYPASSRLS; + GRANT USAGE ON SCHEMA public TO ${runtime}; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${runtime}; + REVOKE UPDATE, DELETE ON "audit_event" FROM ${runtime}; + REVOKE UPDATE, DELETE ON "file" FROM ${runtime}; + REVOKE UPDATE ON "control_requirement" FROM ${runtime}; + REVOKE DELETE ON "organization" FROM ${runtime}; + SET ROLE ${runtime}; + `); + + app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const signedUp = await app.request( + "/api/auth/sign-up/email", + asJson({ name: "Ada", email: "acme@example.test", password: "correct horse" }), + ); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const created = await app.request("/api/auth/organization/create", { + ...asJson({ name: "Acme", slug: "acme" }), + headers: { "content-type": "application/json", cookie }, + }); + expect(created.status).toBe(200); + acme = { cookie, organizationId: (await json<{ id: string }>(created)).id }; + + const madeControl = await request(acme, "/controls", asJson({ name: "Access review" })); + expect(madeControl.status).toBe(201); + control = (await json<{ data: { id: string } }>(madeControl)).data.id; +}, 60_000); + +describe("the product runs on the privileges it documents", () => { + it("signs a user up and creates an organization", () => { + // Better Auth's own writes, through the runtime role. + expect(acme.organizationId).toMatch(/^org_[0-9a-z]{16}$/); + }); + + it("changes a control and reads its history", async () => { + const activated = await request(acme, `/controls/${control}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status: "active" }), + }); + expect(activated.status).toBe(200); + + const history = await request(acme, `/history?resource=${control}`); + const { data } = await json<{ data: { action: string }[] }>(history); + + expect(history.status).toBe(200); + expect(data.map((event) => event.action).sort()).toEqual(["created", "updated"]); + }); + + it("needs no privilege on a sequence, because nothing here has one", async () => { + // Identifiers are generated by the application (ADR 0002), so a deployment + // granting only table privileges is not missing something. + const { rows } = await client.query<{ count: number }>( + `select count(*)::int as count from information_schema.sequences where sequence_schema = 'public'`, + ); + + expect(rows[0]?.count).toBe(0); + }); +}); + +describe("what the runtime role cannot do", () => { + it("cannot turn row-level security off", async () => { + // The whole guarantee rests on this. An owner could; this role is not one. + expect(await refused(`ALTER TABLE "control" DISABLE ROW LEVEL SECURITY`)).toMatch( + /owner|permission/i, + ); + }); + + it("cannot truncate a table", async () => { + // TRUNCATE is outside row security entirely, so only a privilege stops it. + expect(await refused(`TRUNCATE TABLE "audit_event"`)).toMatch(/permission|denied/i); + }); + + it("cannot drop or alter the schema", async () => { + expect(await refused(`CREATE TABLE "smuggled" ("a" integer)`)).toMatch(/permission|denied/i); + expect(await refused(`ALTER TABLE "evidence" DROP COLUMN "attested_at"`)).toMatch( + /owner|permission/i, + ); + }); + + it("cannot rewrite audit history, and is told so rather than ignored", async () => { + // The policies already make an update match nothing. Revoking the privilege + // turns a silent no-op into a refusal, which is what a bug deserves. + expect(await refused(`UPDATE "audit_event" SET "action" = 'rewritten'`)).toMatch( + /permission|denied/i, + ); + expect(await refused(`DELETE FROM "audit_event"`)).toMatch(/permission|denied/i); + }); + + it("cannot change attested evidence, though it may change a draft", async () => { + // Here the privilege is granted and the policy is what refuses: evidence is + // ordinary until it is attested. + const retitle = (evidenceId: string) => + withOrganization(db, acme.organizationId, (tx) => + tx + .update(schema.evidence) + .set({ title: "Behind the API" }) + .where(eq(schema.evidence.id, evidenceId)) + .returning(), + ); + + expect(await retitle(await recordEvidence(control))).toHaveLength(1); + expect(await retitle(await recordEvidence(control, { attested: true }))).toEqual([]); + }); + + it("offers no route that would delete an organization", async () => { + // Better Auth ships one, and it would cascade through every tenant-owned + // table — audit log and attestations included — as an owner's self-serve + // action. Removing a tenant is an operator's job. + const response = await app.request("/api/auth/organization/delete", { + ...asJson({ organizationId: acme.organizationId }), + headers: { "content-type": "application/json", cookie: acme.cookie }, + }); + + expect(response.status).toBe(404); + const remaining = await db.select({ id: schema.organization.id }).from(schema.organization); + expect(remaining).toHaveLength(1); + }); + + it("cannot take the audit log or an attestation out through a cascade", async () => { + // A foreign key's cascade is a referential action: it answers to neither + // row-level security nor the cascaded table's privileges. So `organization` + // — which has no policies and which everything references — was a way to + // delete the whole audit log with one statement the revokes above do not + // cover. The privilege on the parent is what closes it. + expect(await refused(`DELETE FROM "organization"`)).toMatch(/permission|denied/i); + + // `control` keeps its DELETE privilege; the foreign key is what refuses, + // so evidence cannot be disposed of by removing what it is evidence of. + // Inside a tenant context, since outside one the row is not even visible + // and the delete would match nothing for the wrong reason. + const made = await request(acme, "/controls", asJson({ name: "Backup restore" })); + const doomed = (await json<{ data: { id: string } }>(made)).data.id; + await recordEvidence(doomed); + + // Drizzle wraps the driver's error, so PostgreSQL's reason is the cause. + const error = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.control).where(eq(schema.control.id, doomed)), + ).then( + () => null, + (thrown: Error) => thrown, + ); + + const reason = error?.cause instanceof Error ? error.cause.message : error?.message; + expect(reason).toMatch(/foreign key|still referenced/i); + }); + + it("holds no privilege that would cascade into a table nothing may delete from", async () => { + // Derived, so a later table added with `ON DELETE cascade` into either of + // these fails here rather than quietly reopening the hole above. + const { rows } = await client.query<{ parent: string; child: string }>( + `select parent.relname as parent, child.relname as child + from pg_constraint fk + join pg_class parent on parent.oid = fk.confrelid + join pg_class child on child.oid = fk.conrelid + where fk.contype = 'f' + and fk.confdeltype = 'c' + and child.relname in ('audit_event', 'evidence') + and has_table_privilege($1, parent.oid, 'DELETE') + order by parent.relname, child.relname`, + [runtime], + ); + + expect(rows).toEqual([]); + }); + + it("holds no privilege a policy would never let it use", async () => { + // Derived, so the grants `docs/deployment.md` lists cannot drift from the + // schema: a table with no policy for a command is one the runtime role + // should not hold that privilege on, and a future append-only table fails + // here until its revoke is written down. + // + // Bounded by what it asks. Only tables with row security are considered, so + // one where `ENABLE ROW LEVEL SECURITY` was forgotten is invisible here — + // `assertTenantIsolation` and the cascade check above cover other ground. + const { rows } = await client.query<{ relname: string; command: string }>( + `select c.relname, p.privilege_type as command + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + cross join unnest(array['SELECT', 'INSERT', 'UPDATE', 'DELETE']) as p(privilege_type) + where n.nspname = 'public' and c.relrowsecurity + and has_table_privilege($1, c.oid, p.privilege_type) + and not exists ( + select 1 from pg_policy pol + where pol.polrelid = c.oid + and pol.polcmd in ('*', case p.privilege_type + when 'SELECT' then 'r' when 'INSERT' then 'a' + when 'UPDATE' then 'w' else 'd' end) + ) + order by c.relname, p.privilege_type`, + [runtime], + ); + + expect(rows).toEqual([]); + }); + + it("sees nothing of another organization, as it never could", async () => { + const theirs = await withOrganization(db, "org_0000000000000000", (tx) => + tx.select().from(schema.control), + ); + + expect(theirs).toEqual([]); + }); +}); diff --git a/apps/server/responses.ts b/apps/server/responses.ts new file mode 100644 index 0000000..248f905 --- /dev/null +++ b/apps/server/responses.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * JSON envelopes for domain API responses. + * + * A record uses `data`, a collection adds `nextCursor` (ADR 0006), and a + * failure uses `error` (ADR 0004). `failure` builds error responses; `single` + * and `collection` build schemas for documentation and tests. Handlers build + * successful responses themselves; 204 responses have no envelope. + */ + +import { z } from "zod"; + +/** Which part of the request was wrong, and why. Omitted when nothing is. */ +export type Detail = { path: string; message: string }; + +export const failure = (code: string, message: string, details?: Detail[]) => ({ + error: details?.length ? { code, message, details } : { code, message }, +}); + +/** + * `code` is what a client branches on and is part of the contract; `message` is + * for a human reading a log and may be reworded. Neither carries anything the + * caller is not already entitled to know. + */ +export const failureResponse = z.strictObject({ + error: z.strictObject({ + code: z.string(), + message: z.string(), + details: z.array(z.strictObject({ path: z.string(), message: z.string() })).optional(), + }), +}); + +/** One record. */ +export const single = (item: T) => z.strictObject({ data: item }); + +/** A page of records, and where the next one starts — null on the last. */ +export const collection = (item: T) => + z.strictObject({ data: z.array(item), nextCursor: z.string().nullable() }); diff --git a/apps/server/validation.ts b/apps/server/validation.ts new file mode 100644 index 0000000..c57396f --- /dev/null +++ b/apps/server/validation.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Request body validation for `/api/v1`. + * + * Hono's own `validator` does the plumbing; this only decides what a rejection + * looks like, which is part of the API contract rather than of any one route. + */ + +import { validator } from "hono/validator"; +import { z } from "zod"; +import { failure } from "./responses.ts"; + +/** + * PostgreSQL `text` cannot hold a NUL, and rejects the whole statement if asked + * to. A body carrying one is a bad request, not a server error. + * + * A pattern rather than a refinement so that it survives into the published + * JSON Schema: a refinement converts to nothing, and a document that accepts + * what the server rejects is worse than no document (ADR 0007). + */ +// oxlint-disable-next-line no-control-regex -- a NUL is precisely the point +const withoutNul = /^[^\u0000]*$/; + +/** + * A short line someone typed: bounded, not blank, and trimmed. + * + * Every rule is checked against what the client sent and the value is trimmed + * afterwards. Trimming first would mean the published bounds described a string + * nobody sent — 200 characters with a space in front would be accepted by the + * server and refused by its own schema (ADR 0007). + */ +export const words = (max: number) => + z + .string() + .min(1) + .max(max) + .regex(withoutNul) + .regex(/\S/) + .transform((value) => value.trim()) + .meta({ description: "Surrounding whitespace is removed once the length is checked." }); + +/** Longer text, which may be empty once trimmed. */ +export const prose = (max: number) => + z + .string() + .max(max) + .regex(withoutNul) + .transform((value) => value.trim()); + +/** + * The rejection names every field that was wrong, because a 400 a client cannot + * act on is barely better than a 500. + * + * `path` is `""` for an issue about the input as a whole, such as a rule + * spanning fields, rather than a made-up field name. + */ +export const rejection = (what: string, error: z.ZodError) => + failure( + "invalid_request", + `The request ${what} is not valid.`, + error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })), + ); + +/** + * Parses a JSON body against `schema`, answering 400 when it does not fit. + * + * The supplied schema decides how to handle unknown properties. Current + * request objects use `z.object`, which strips them for client compatibility. + */ +export const jsonBody = (schema: T) => + validator("json", (value, c) => { + const result = schema.safeParse(value); + return result.success ? result.data : c.json(rejection("body", result.error), 400); + }); + +/** + * Parses the query string against `schema`, answering 400 when it does not fit. + * + * Hono supplies a string for a single value and an array for repeated keys. + * The schema decides what to accept and coerce, such as a numeric limit or + * an encoded cursor. + */ +export const queryParams = (schema: T) => + validator("query", (value, c) => { + const result = schema.safeParse(value); + return result.success ? result.data : c.json(rejection("query", result.error), 400); + }); diff --git a/bun.lock b/bun.lock index cd4a02f..f3b24d5 100644 --- a/bun.lock +++ b/bun.lock @@ -16,14 +16,17 @@ "dependencies": { "@better-auth/drizzle-adapter": "1.7.5", "@qualityruntime/db": "workspace:*", + "@scalar/hono-api-reference": "^0.12.2", "better-auth": "1.7.5", "drizzle-orm": "^0.45.2", "hono": "^4.10.7", "pg": "^8.23.0", + "zod": "^4.6.5", }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", "@types/pg": "^8.23.1", + "ajv": "^8.20.0", }, }, "packages/db": { @@ -268,6 +271,18 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@scalar/client-side-rendering": ["@scalar/client-side-rendering@0.4.1", "", { "dependencies": { "@scalar/schemas": "0.10.0", "@scalar/types": "0.20.0", "@scalar/validation": "0.6.3" } }, "sha512-4Ha2ihIPFwmhheSU6U9DlpjgRQ2YOqq2JSmc4ubT2ERApaA/HzuZ1IMyLXDvRICI+KbDducfGw5th9O+TQf+6A=="], + + "@scalar/helpers": ["@scalar/helpers@0.12.0", "", {}, "sha512-rcX0rFLiWWc0VBr/E+HkatubL0I0ZzxASQkd3032NWEMAEfNd2TiJ9kH46HwbSAOOl696ghxymWegjpjjvxwzA=="], + + "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.12.2", "", { "dependencies": { "@scalar/client-side-rendering": "0.4.1" }, "peerDependencies": { "hono": "^4.12.5" } }, "sha512-VrdMxw/HmejceR9rH0eu4uYKWBILnts5Qj2tgzplufoQbPc+FiFsRKGQ9ZNtZ4CZuvrhEj1y/HrmBX5Zz2sqpg=="], + + "@scalar/schemas": ["@scalar/schemas@0.10.0", "", { "dependencies": { "@scalar/helpers": "0.12.0", "@scalar/validation": "0.6.3" } }, "sha512-jFqfd+oqqlTfs87V+QkIrR9DiRv+/h1x3GzSGbGe1qrM6togeXwvVG4aurn+LZ+Ej9of54/6FmoI8DPuLOKx7A=="], + + "@scalar/types": ["@scalar/types@0.20.0", "", { "dependencies": { "@scalar/helpers": "0.12.0", "nanoid": "^5.1.6", "type-fest": "^5.8.0", "zod": "^4.4.3" } }, "sha512-+05slk/Q6MRMqEQt6eM77Jt9DnDPAvfcc/6r7mbrXhIqJSp3oAPrixpAnp1knBSlFaxgixcK/GMKw9QDfD+Zgg=="], + + "@scalar/validation": ["@scalar/validation@0.6.3", "", {}, "sha512-j3s9XPv8Wo1EG5/naBi4XGF0uXYQ2A3QZNI0NKWgCMxRHVrDJGlZEYymcHDHY7fquRm73P8U3c8xqJqB5yad6w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="], @@ -412,6 +427,8 @@ "@yuku-toolchain/types": ["@yuku-toolchain/types@0.9.5", "", {}, "sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -452,6 +469,10 @@ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -464,6 +485,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "kysely": ["kysely@0.29.6", "", {}, "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -546,6 +569,8 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="], @@ -570,6 +595,8 @@ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], @@ -584,6 +611,8 @@ "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -610,6 +639,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "postcss/nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], diff --git a/docs/adr/0003-tenant-isolation-with-row-level-security.md b/docs/adr/0003-tenant-isolation-with-row-level-security.md new file mode 100644 index 0000000..29c3212 --- /dev/null +++ b/docs/adr/0003-tenant-isolation-with-row-level-security.md @@ -0,0 +1,70 @@ +# 3. Tenant isolation with row-level security + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +Tenant isolation is a system invariant (TENANT-01): tenant-owned data must not cross an organization boundary regardless of client behaviour. `control` is the first tenant-owned table, and the isolation model it establishes is the one every later domain table inherits — so it is worth getting right while exactly one table has to be retrofitted if it is wrong. + +The ordinary approach is to write `where organization_id = …` in every query. It works until it doesn't: one forgotten predicate in one list endpoint leaks another tenant's records, code review is the only thing standing between the mistake and production, and a system that expects both people and AI agents to add domain code makes that a poor place to put the boundary. + +PostgreSQL can enforce it instead. This decides whether it should, and what the application must do to make that enforcement real. + +## Decision + +Tenant isolation is enforced by PostgreSQL row-level security. Every tenant-owned table carries a policy comparing its `organization_id` to a transaction-local setting, `qualityruntime.organization_id`: + +```sql +ALTER TABLE "control" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "control" FORCE ROW LEVEL SECURITY; + +CREATE POLICY "control_tenant_isolation" ON "control" + USING ("organization_id" = current_setting('qualityruntime.organization_id', true)) + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true)); +``` + +One function sets that context, and domain code reaches tenant-owned tables only through it: + +```ts +const controls = await withOrganization(db, organizationId, (tx) => tx.select().from(control)); +``` + +Four details carry the weight: + +**`FORCE`, not just `ENABLE`.** PostgreSQL exempts a table's owner from its policies. A self-hosted deployment normally connects as the role that owns the schema, so `ENABLE` alone would produce a policy that is present, tested by the wrong role, and inert in production. `FORCE` removes the owner's exemption. + +**Superusers are exempt regardless.** `FORCE` does not apply to a superuser or a `BYPASSRLS` role, and no statement in a migration can change that. The application must connect as neither; `docs/deployment.md` states this as a requirement, and it is the one part of this design the database cannot enforce on its own. + +**A missing context denies rather than permits.** `current_setting(…, true)` returns NULL on a fresh connection and can return an empty string after a transaction-local setting expires. Neither matches an organization identifier, so a transaction without tenant context sees no rows and cannot insert any. The alternative failure — an unset context meaning "no filter" — is the one that leaks everything. + +**`WITH CHECK` as well as `USING`.** `USING` governs what rows are visible to reads, updates, and deletes; `WITH CHECK` governs what a row may look like after an insert or update. For an `ALL` policy, PostgreSQL reuses `USING` when `WITH CHECK` is omitted. Both are written explicitly here to make the visibility and write constraints clear. + +`withOrganization` opens a transaction and calls `set_config(name, value, true)` — `SET LOCAL` as a function, so the organization is a bound parameter rather than string-interpolated SQL, and PostgreSQL discards it when the transaction ends, including on rollback and before a pooled connection is reused. + +This is **not** authorization, and it does not contain an authorization mistake. `withOrganization` scopes a request that has already been authorized; whether the caller may act in this organization is decided from their `member` row beforehand. Pass it an organization the caller does not belong to and the policy will faithfully scope to that organization — what the policy contains is a _missing_ tenant predicate or a _missing_ tenant context, not a wrong answer about who the caller is. + +Nesting is refused for the same reason the design works at all. `SET LOCAL` is scoped to a transaction, not to a savepoint, so calling `withOrganization` inside a tenant context — which Drizzle would implement as a savepoint — leaves the inner organization in force after the savepoint is released, and the rest of the outer transaction runs as the wrong tenant. `withOrganization` throws when handed a transaction rather than trying to restore the previous value: code already inside a tenant context has the transaction it needs, and re-scoping one is not a thing the domain should want. + +Better Auth's tables are deliberately excluded. It reads `member` and `invitation` to work out which organizations a user belongs to, which necessarily happens before any organization is known — a policy there would break sign-in, and those tables are reached through Better Auth's own authorization rather than domain queries. + +The policies are hand-written SQL in [`packages/db/migrations/0001_tenancy_and_finality.sql`](../../packages/db/migrations/0001_tenancy_and_finality.sql) rather than declared with drizzle-kit's `pgPolicy` and `enableRLS`. drizzle-kit cannot express `FORCE`, so declaring part of the boundary in the schema would leave the load-bearing statement appended to the generated file by hand and absent from the snapshot, where a later regeneration could silently drop it. One readable SQL file is the safer shape for a security boundary; `drizzle-kit generate --custom` creates the file and its journal entry without diffing the schema. + +## Consequences + +A domain query that forgets its tenant predicate returns that organization's rows rather than every organization's. The predicate becomes a performance concern — the index on `organization_id` still matters — instead of a security one. + +The application must connect to PostgreSQL as a non-superuser role without `BYPASSRLS`, which rules out the `postgres` superuser that container images create by default. Such roles bypass row security even when it is forced. `assertTenantIsolation` checks the runtime connection and refuses startup under either; migration credentials are configured separately. + +Tests of policy enforcement must use a non-superuser role without `BYPASSRLS`; PGlite's default superuser bypasses the policies. `packages/db/enforcement.test.ts` creates such a role and makes it the owner of `control` to exercise `FORCE ROW LEVEL SECURITY`. That ownership is a test condition, not the recommended deployment setup: the runtime role should own no tables ([ADR 0014](0014-the-runtime-role-owns-nothing.md)). Separate cases verify startup refusal for an exempt role, for `row_security = off`, and for a table whose row security is not forced. + +`packages/db/schema/migrations.test.ts` derives the list of tenant-owned tables from the schema and asserts that each has row-level security enabled, forced, and carrying a policy. A future domain table with an `organization_id` fails that test until it has one, so the protection is opt-out by accident rather than opt-in by memory. + +At most one policy applies to a table for any one command, and every policy is permissive. Some tables have a single policy covering all four commands; others name each command separately, because what a tenant may do to a row depends on the row — `audit_event` grants no `UPDATE` or `DELETE` at all ([ADR 0005](0005-audit-history.md)), `evidence` and `file` refuse once attested ([ADR 0012](0012-evidence-and-attestation.md)), and `control` allows a delete only of one that was never in effect ([ADR 0017](0017-discarding-a-draft-control.md)). + +One per command is the part that matters. Permissive policies are OR-ed together, so a _second_ policy for the same command would widen access rather than narrow it — which is the opposite of what anyone adding one usually intends. A future need for finer-grained access within a tenant should express it as a restrictive policy, or inside the policy already covering that command, rather than beside it. + +Each `withOrganization` callback is one transaction, and a request may make several — the membership look-up that authorizes it happens outside any of them. So the unit of atomicity is the callback, not the request: writes that must succeed or fail together belong in **one** callback, not in two that happen to follow each other. Code that wants a connection with no tenant context says so by not calling `withOrganization` at all. diff --git a/docs/adr/0004-organization-in-the-request-path.md b/docs/adr/0004-organization-in-the-request-path.md new file mode 100644 index 0000000..559f373 --- /dev/null +++ b/docs/adr/0004-organization-in-the-request-path.md @@ -0,0 +1,53 @@ +# 4. The organization is in the request path + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +Every tenant-owned resource belongs to exactly one organization, and [ADR 0003](0003-tenant-isolation-with-row-level-security.md) scopes database work to one organization per transaction. Something has to tell a request which organization it is acting in. This decides what, before any URL exists to be changed later. + +Three candidates: + +- **The session.** Better Auth's organization plugin already records `session.activeOrganizationId` and offers `setActive` to change it. +- **A header**, such as `X-Organization-Id`. +- **A path segment**, `/api/v1/organizations/{organizationId}/…`. + +## Decision + +The organization is a path segment. Every tenant-owned resource lives under `/api/v1/organizations/{organizationId}`: + +```text +GET /api/v1/organizations/org_v1stgxr8z5jdhi6b/controls +``` + +`organizationContext` resolves that segment to the caller's `member` row before any handler runs, and hands the handler a `withOrganization` already bound to it. A handler cannot choose a different organization. Mounting a tenant-owned resource outside that prefix leaves its handler without `withOrganization` at all, so it fails loudly rather than serving unscoped rows; and were it to reach a tenant-owned table by some other route, ADR 0003 means an unscoped query returns nothing. Neither path leaks. + +**The session was rejected on correctness.** `setActive` makes the organization ambient, mutable, and shared by every request that cookie makes. Two browser tabs in different organizations, or an agent working across several, race: a request's meaning depends on which `setActive` landed last, and the loser silently reads or writes in the wrong organization. Making the caller send `setActive` before each request is both a round trip and still racy. A request should mean the same thing whenever it is replayed. + +`session.activeOrganizationId` keeps its existing job — remembering which organization a UI should offer by default. It remains context, never authorization (TENANT-01), and the routes never read it. + +**A header was rejected on ergonomics, not correctness.** It is stateless and would work. But a URL that fully identifies the resource is easier to log, audit, bookmark, paste into a ticket, and — for the AI clients this product expects — construct and reason about without out-of-band knowledge. An omitted header also has no good failure mode: reject it and the header was mandatory anyway, fall back to the session and the ambiguity the session was rejected for is back. + +A caller who is not a member of the organization gets **404, not 403**. A 403 confirms the organization exists, which turns a leaked or guessed identifier into a membership oracle. A non-member cannot distinguish an organization they cannot see from one that is not there — and `organization.id` is unguessable ([ADR 0002](0002-prefixed-identifiers.md)), so 404 costs a legitimate caller nothing. + +Failures carry a machine-readable code: + +```json +{ "error": { "code": "not_found", "message": "No such organization." } } +``` + +## Consequences + +URLs are longer, and every tenant-owned route repeats the prefix. That is the price of a request that means one thing. Resources genuinely outside a tenant — instance administration, the signed-in user's own profile — do not take the prefix, and their absence from it is meaningful rather than an oversight. + +The membership lookup is one indexed query per request, on `member_organization_id_user_id_uidx`. It is not cached: membership is the authorization decision, and a stale cache is a caller acting in an organization they have been removed from. + +Authorization and isolation stay separate and are both exercised. `organizationContext` decides _whether_ the caller may act; row-level security decides _what_ they can touch once they may. `apps/server/organization.test.ts` runs the whole HTTP stack as a non-superuser role so both are real in the same test — the routes contain no `where organization_id`, and the rows come back scoped regardless. + +The domain API resolves `member.role` onto the context but does not yet use it to restrict actions. Better Auth's organization-management routes enforce their own role permissions. + +Should a tenant-owned resource ever need to be reachable without naming its organization — a short link, a webhook callback — it needs its own deliberate route that resolves the organization from the resource, not a relaxation of this one. diff --git a/docs/adr/0005-audit-history.md b/docs/adr/0005-audit-history.md new file mode 100644 index 0000000..1f51df3 --- /dev/null +++ b/docs/adr/0005-audit-history.md @@ -0,0 +1,65 @@ +# 5. Audit history + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +Material changes must leave durable history: what changed, who or what changed it, when, to which record, and from what state (AUDIT-01). Until now nothing did — `docs/data-model.md` said so in as many words, because a control was mutable and kept nothing. + +This is worth settling while `control` is the only mutable domain entity. Auditing is not a feature that can be added to six entities afterwards: the shape it takes decides how every mutation is written, and history that begins halfway through a system's life has a hole in it that cannot be filled. + +## Decision + +One table, `audit_event`, recording changes to records. Every mutating handler writes one **in the same transaction as the change it describes**. + +```ts +await c.var.withOrganization(async (tx) => { + const [created] = await tx.insert(control).values(…).returning(); + await c.var.audit(tx, { action: "created", resourceType: "control", resourceId: created.id, after: … }); +}); +``` + +What decides the shape: + +**Attribution outlives the actor.** `actor_id` is not a foreign key to `user`, which cascades on deletion — history that disappears with the person who made it is not history. Alongside it, `actor_label` keeps how the actor was named at the time, because a bare `usr_…` tells a later reader nothing once the row is gone. `resource_id` is not a foreign key either, for the same reason: a record's history must survive the record. + +**The actor is whoever is accountable.** Better Auth's admin plugin can put an administrator inside a member's session. Attributing what they then do to the member would be a false record, which is worse than no record — so the administrator is the actor, and `on_behalf_of_id` says whose account it happened through. Those columns exist now rather than later because impersonation is reachable today: every change made through it before the columns existed would be attributed to the wrong person, permanently. + +**The actor has a type from the start.** Only `user` is written today, but people are not the only things that will change a control — background work, integrations, and agents all will. An actor type cannot be introduced later without inventing one for every row already written, so `actor_type` exists now with `system` reserved for the first of those. A CHECK constrains the set, and a second CHECK requires a `user` event to name a user. + +**An event is timed when it happens.** `created_at` defaults to `clock_timestamp()`, not `now()`. `now()` is fixed for a whole transaction, and two requests changing the same record serialize on its row lock — so the one that _started_ first can commit second, and transaction-start order would put that record's history backwards. + +**Events describe records, not everything.** `resource_type` and `resource_id` are required, and `action` is a bare verb — `created`, `updated`, `attested`, `deleted`. Authentication events are Better Auth's business, and a log of everything that ever happened is a different thing from the history of a record. + +**Only what changed is stored.** `before` and `after` hold a record's own fields, and for an update only the ones that differ; `before` is null for a creation. Identity is already a column, bookkeeping timestamps describe the write rather than the change, and an update that changes nothing writes no event at all — a request that set the values a record already had would otherwise bury the ones that did something. + +**Append-only, enforced by PostgreSQL.** A tenant-owned table normally gets one policy covering every command ([ADR 0003](0003-tenant-isolation-with-row-level-security.md)). Audit history needs less: + +```sql +CREATE POLICY "audit_event_tenant_read" ON "audit_event" FOR SELECT USING (…); +CREATE POLICY "audit_event_tenant_append" ON "audit_event" FOR INSERT WITH CHECK (…); +``` + +There is no UPDATE or DELETE policy. With row security forced and no policy naming those commands, no row is visible to either, so an attempt matches nothing — the application cannot rewrite or erase history through its tenant context, whatever its code says. + +A trigger raising an exception was considered and rejected. It would fire on the foreign key's cascade as well, and make deleting an organization fail. Deleting an organization does still remove its history, because PostgreSQL runs a cascade as an internal referential action that row security does not apply to. + +**Policies are half of it; grants are the other half.** `TRUNCATE` is not subject to row security at all, and a role that owns the table can disable the policies outright — so policies alone do not protect history from a compromised request path using an owning role. Preventing the runtime role from rewriting or erasing history requires it not to own these tables and not to hold `TRUNCATE`, `UPDATE`, or `DELETE` on `audit_event`. These restrictions prevent mutation by that role; they do not provide tamper detection for changes made by a privileged operator. Privilege setup belongs to deployment rather than to a migration; `docs/deployment.md` states the requirements for audit integrity. + +## Consequences + +Every mutating handler now has a second thing it must do, and forgetting it is silent. Two things make that unlikely rather than impossible: `audit` arrives on the request already bound to the caller and their organization, so a handler chooses what happened but never who did it or where; and the write takes the transaction, so it cannot be deferred to after the response, where it would be a second source of truth that can disagree with the first. + +Auditing is not free. Every create and every meaningful update costs an extra insert in the same transaction, and the table grows without bound. That is the intended trade: this is the record a quality system exists to keep. Retention, archival, and how far back a deployment must keep events are real questions, and none of them is answered here. + +`before` and `after` are `jsonb` with no schema. That is deliberate — a typed column per field per entity does not generalize — but it means nothing stops a handler writing a shape no reader expects. `fieldsOf` and `diffFields` exist so that handlers do not each invent one, and the resource's own module names the fields it audits. + +Removing a user still removes their name from `user`; `actor_label` keeps a copy. A deployment with an erasure obligation therefore has an audit log to think about, and the alternative — history that says an unknown identifier did something — would not satisfy AUDIT-01. It is recorded here as a known tension rather than solved. + +The two configurations therefore give different guarantees, and the difference is not cosmetic. `apps/server/audit.test.ts` exercises both: the ordinary tests run as the table owner and show that no application code path can alter an event, and one runs as a non-owner runtime role with only `SELECT` and `INSERT` granted, where `TRUNCATE` and a direct `UPDATE` are refused outright. Privilege separation is now the documented default ([ADR 0014](0014-the-runtime-role-owns-nothing.md)), so "append-only" here means against the runtime role, not only against the code. + +`GET /controls/{controlId}/history` read it, paged by cursor ([ADR 0006](0006-cursor-paged-collections.md)) — which is the order this was done in on purpose, because an audit log is the collection least able to tolerate an offset. That route has since been replaced by one organization-wide history, for the reason this ADR gives above: these rows outlive the records they describe, so reaching them through a live record was never going to hold ([ADR 0018](0018-one-history-rather-than-one-per-record.md)). diff --git a/docs/adr/0006-cursor-paged-collections.md b/docs/adr/0006-cursor-paged-collections.md new file mode 100644 index 0000000..bf742ce --- /dev/null +++ b/docs/adr/0006-cursor-paged-collections.md @@ -0,0 +1,57 @@ +# 6. Collections are paged by cursor + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +`GET /controls` returned every control an organization had. That was deliberate — paging belongs with the rest of a collection's contract rather than bolted on — but it does not survive contact with a real deployment, and audit history made it urgent: an audit log only grows, and it is the collection most likely to be read from a client that cannot hold it all. + +Two collections now exist and a third is never far away, so this settles how all of them behave rather than how one does. + +## Decision + +Every `/api/v1` collection takes the same query and answers in the same shape: + +```http +GET /api/v1/organizations/{organizationId}/controls?limit=25&cursor=Y29udHJvbHM6cmVjZW50fDIwMjYtMDktMThUMTA6MTE6MDAuMDAwMDAwWnxjdGxfMDAwMDAwMDAwMDAwMDAwMQ +``` + +```json +{ "data": [ … ], "nextCursor": "Y29udHJvbHM6cmVjZW50fDIwMjYtMDktMThUMTA6MDk6MDAuMDAwMDAwWnxjdGxfMDAwMDAwMDAwMDAwMDAwNA" } +``` + +`nextCursor` is null on the last page, so a client pages until it is null and never has to ask whether an empty page is coming. + +**A cursor, not an offset.** `LIMIT`/`OFFSET` is correct only against a collection that is not changing. These are ordered newest first and written to constantly: a row inserted ahead of the offset between requests shifts the next page back onto an already-read row; deleting one ahead of it can skip an unread row. For an audit log — where the whole point is that nothing is missed — that is disqualifying. A cursor names a position in an ordering rather than a count from the start, so concurrent inserts cannot move it. + +**The order is fixed, and total.** `(created_at, id)` descending. The identifier is in the key not for display but because `created_at` alone is not unique — `control.created_at` defaults to `now()`, which is the transaction's start time, so rows written together share it — and a key with ties is a cursor that can repeat or skip a row. + +**The cursor carries the timestamp as PostgreSQL holds it.** A `timestamptz` keeps microseconds; a JavaScript `Date` keeps milliseconds. Taking the ordering key through a `Date` silently rounds it down, so the cursor names a position up to 999µs _before_ the row it came from — and the rows in that gap are skipped, which is precisely the failure a cursor exists to prevent. The value is selected as text with `to_char(… at time zone 'UTC', …)` and compared back with an explicit `::timestamptz`, so nothing in the round trip loses precision. `to_char` against UTC rather than a bare `::text` cast, whose output depends on the session's `TimeZone` — a cursor must not mean something different to the next connection that reads it. + +**Every part is validated against the shape this API issues.** A cursor reaches a query either way, so "decodes to three parts" is not enough: an identifier carrying a NUL, or a year outside what PostgreSQL can hold, would be a 500 for what is a bad request. The ordering must be this collection's, the timestamp must match the exact format `to_char` produces, the identifier must match the form `packages/db/id.ts` generates, and the encoding must be the canonical one — base64url decoding skips characters it cannot read, so junk appended to a real cursor would otherwise pass. This is shape, not provenance: a client can build a well-formed cursor itself, and that is fine for the reason below. + +**The cursor is opaque.** It encodes `ordering|created_at|id`, base64url, and is documented as meaningful only against the same collection in the same order. It is not signed: a tampered cursor selects a different position, which is not an escalation — row-level security still scopes the query, and every position it could name is one the caller may already read. Making it unforgeable would protect nothing. + +**A cursor that is not well formed is a 400.** It means the client sent something that cannot be a position in this collection, and serving page one instead would turn a client bug into silently wrong data. + +**One more row than the page.** The query asks for `limit + 1`; if it comes back, there is a next page and the extra row is dropped. That answers "is there more" exactly, without a second query and without a count that would be stale before it was read. + +An audit event is written out as an explicit shape rather than returned as its row, unlike a control. The row carries the organization, which the URL has already named, and the actor's four columns describe one thing and read better grouped. (The resource was in that list until [ADR 0018](0018-one-history-rather-than-one-per-record.md) made history a collection spanning records, at which point the URL stopped naming it.) It also keeps a column added to `audit_event` from becoming an API change by accident. + +## Consequences + +A client cannot jump to page five, and there is no total. Both are real costs and both are the point: a total is a lie the moment it is computed on a collection being written to, and an absolute page number means nothing in an ordering that shifts. + +The order is not a client's to choose. Sorting by name, or oldest first, would each need their own cursor encoding, because the cursor _is_ the ordering key. When a collection needs that, the cursor gains the key it sorts by and clients that treated it as opaque keep working — which is why it is opaque. A standard's requirements were the first to need it, and [ADR 0009](0009-importing-a-standard.md) records how an ordering became a value rather than a constant. + +Filtering was not part of the initial implementation. A filter composes with this cleanly: it narrows the rows, the ordering key is unchanged, and the cursor still names a position. `?status=active` can be added to controls without revisiting any of this. The first filter to arrive was `?resource=` on history ([ADR 0018](0018-one-history-rather-than-one-per-record.md)), and it bore that out with one wrinkle worth carrying forward: a filtered collection is a _different_ ordering, so its name has to carry the filter or a cursor will cross between them. + +> **Superseded by [ADR 0018](0018-one-history-rather-than-one-per-record.md).** `GET /controls/{controlId}/history` checked the control was visible before reading its history, and answered 404 when it was not. That is now one organization-wide collection with no record lookup: audit rows outlive the records they describe, which is exactly why requiring the record to still exist was wrong. The empty list this paragraph worried about conceals nothing — an out-of-tenant control produced one either way. + +The ordering key is in the index, not just in the query. `control` is indexed on `(organization_id, created_at, id)` and `audit_event` on `(organization_id, resource_type, resource_id, created_at, id)` (see [`0000_schema.sql`](../../packages/db/migrations/0000_schema.sql)), so a page is a backward index scan with the row comparison as an index condition — no sort, and no scan of everything that matched. Without that, paging a large collection costs a full scan per page, which is the quadratic version of the thing paging exists to avoid. A future filter or ordering needs the same consideration: a cursor that the index cannot follow is slower than the offset it replaced. + +The limit is capped at 100 and defaults to 25. The cap is what stops a page being a denial of service; the default is a guess, and the kind that is easy to change once something reads these collections in anger. diff --git a/docs/adr/0007-openapi-from-the-schemas.md b/docs/adr/0007-openapi-from-the-schemas.md new file mode 100644 index 0000000..3effb52 --- /dev/null +++ b/docs/adr/0007-openapi-from-the-schemas.md @@ -0,0 +1,65 @@ +# 7. OpenAPI is assembled from the schemas, not by a framework + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +`/api/v1` has five operations across two resources, and one of its stated aims is that AI clients can understand and operate it. A machine-readable description is how that stops being a claim. + +Requests were already described in code: every body and query has a Zod schema that the server enforces. Responses were not — they were whatever the handler happened to return, guarded by a hand-written list of expected keys in a test. + +Three ways to publish a document were considered: + +- **`@hono/zod-openapi`** — routes are declared through its `createRoute` and `OpenAPIHono`, and the document falls out. +- **`hono-openapi`** — routes stay as they are and each gains a `describeRoute(…)` annotation. +- **Assemble it here**, from the schemas the routes already use. + +## Decision + +Assemble it. `apps/server/openapi.ts` builds the document from the same Zod schemas the routes validate against, and serves it at `/api/v1/openapi.json`. + +Zod 4 converts a schema to JSON Schema itself — `z.toJSONSchema` — and OpenAPI 3.1 _is_ JSON Schema draft 2020-12, so the step a library would perform is one function call. `io: "input"` matters: a cursor is a string on the wire and a decoded position afterwards, and describing the output side would tell clients to send something they cannot send. + +**The drift a library prevents is prevented by a test instead.** That is the real trade, so it is worth being concrete about. `openapi.test.ts` derives the operations the app actually registers from `app.routes` and requires the document to describe exactly those — no more, no fewer. A route added without an operation fails the suite, which is the guarantee `describeRoute` gives by construction. + +Tests validate representative responses against the published schemas using a JSON Schema validator, including error responses such as 400, 401, 404, 409 and 413. Request examples check that the published constraints accept valid inputs and reject invalid ones. This checks the emitted contract as a client sees it; it does not prove equivalence for every possible request or response. + +**Express constraints in JSON Schema where possible.** `z.toJSONSchema` drops a `refine` silently, which is the subtle failure here: the server keeps rejecting, the document stops saying so, and a client builds requests that cannot work. So the rules are written in forms JSON Schema has: a `pattern` for "no NUL" rather than a refinement; an `anyOf` over `name`, `description` and `status` restated through `meta` for "at least one field to change", since `minProperties` would count an unknown property the server drops; and a `\S` pattern so a name of only spaces is refused by the published schema and not merely after trimming. + +Rules that the emitted schema does not express remain runtime checks and are explained in `description`. Cursor decoding is one example: the published schema describes a string, while the server also checks its encoding, ordering and key. The description directs clients to use the `nextCursor` of a previous page. + +The same concern decides where trimming happens. Length and pattern are checked against what the client sent and the value is trimmed afterwards; trimming first would publish bounds describing a string nobody sent, and a name of 200 characters with a space in front would be accepted by the server and refused by its own schema. + +**`@hono/zod-openapi` was rejected as too large a commitment** for what it delivers here. It decides how every route in the codebase is written, for a document; if it were later abandoned, every route would be rewritten. **`hono-openapi` was rejected on dependencies**: it is a reasonable library, but it brings a chain of young transitive packages, and a compliance product has a poor argument for adding supply chain to emit a JSON file it can already emit. + +Neither rejection is permanent. The routes are untouched by this decision — only `openapi.ts` knows about the document — so adopting a library later costs deleting one module. + +Some smaller choices: + +**Response shapes became Zod schemas.** This is the substantive part, and would have been worth doing without a document: `controlResponse` and `auditEventResponse` are strict, so they describe the contract exactly and a test catches a field appearing or disappearing. The key-set assertion they replace could only ever check names. + +**Path parameters are derived from the path.** The `{name}` segments are read out of the path string and given the pattern `packages/db/id.ts` generates for that kind of identifier, so a parameter cannot be left undescribed and the pattern cannot disagree with the CHECK constraint. + +**Schemas are inlined rather than collected under `components`.** Hand-written `$ref`s would be a second description of the same thing to keep in step. The document repeats itself; nothing that reads it minds. + +**No `servers`.** A client has the URL it fetched the document from, and any value here would be wrong behind a proxy. + +**The published cookie name comes from Better Auth, not from a string here.** It prefixes the session cookie with `__Secure-` when the base URL is HTTPS, so a fixed name would be right in development and wrong in every deployment. + +**The document needs no session.** It describes the API, not anyone's data, and a client that cannot read it before signing in is harder to use for no benefit. + +## Consequences + +Adding an operation means adding it in two places — the route and the operations list — and the suite fails until both exist. That is the cost of not having a library, paid at the moment the work is being done rather than discovered later by a client. + +`ajv` is a test dependency, for validating the document the way a client would read it. Nothing at runtime depends on it. + +Response schemas are documentation and test material, not runtime validation. Handlers do not parse what they return: it would cost something on every request to catch a class of bug the tests already catch, and a schema that throws in production turns a wrong field into an outage. + +`/api/auth` is not described. It is Better Auth's API with its own contract and its own documentation, and copying it here would create a second description to keep current. + +The document has no `info.version` that means anything yet — it says `0`, which is honest while the API is not stable. Versioning it is a decision for the first release, and `/api/v1` in the path is not that decision. diff --git a/docs/adr/0014-the-runtime-role-owns-nothing.md b/docs/adr/0014-the-runtime-role-owns-nothing.md new file mode 100644 index 0000000..92a1971 --- /dev/null +++ b/docs/adr/0014-the-runtime-role-owns-nothing.md @@ -0,0 +1,64 @@ +# 14. The runtime role owns nothing + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +Three decisions here rest on PostgreSQL refusing something: tenant isolation ([ADR 0003](0003-tenant-isolation-with-row-level-security.md)), an append-only audit log ([ADR 0005](0005-audit-history.md)), and evidence that cannot change once attested ([ADR 0012](0012-evidence-and-attestation.md)). + +All three were qualified in the same way, and the qualification has been carried forward for eleven iterations: _against the application_. `FORCE ROW LEVEL SECURITY` makes the policies apply to a table's owner, which is what makes the single-role setup safe against a forgotten predicate. It does nothing about the rest of what an owner may do. `TRUNCATE` is outside row security entirely. So is `ALTER TABLE … DISABLE ROW LEVEL SECURITY`. So is dropping a column. + +`docs/deployment.md` described the separation that fixes this under a heading called _Hardening_, as something a deployment _could_ do. Nothing did it, nothing tested it, and no one had checked that the product would actually run that way. + +## Decision + +**Two roles, and the separation is the supported setup rather than a recommendation.** + +A migrator owns the schema and applies migrations. The server connects as a role that owns nothing and holds `SELECT, INSERT, UPDATE, DELETE` and no more. `MIGRATION_DATABASE_URL` is the migrator's and `DATABASE_URL` is the server's. `drizzle-kit` reads only the first: falling back to the server's would run a migration without the migrator's guard below. + +The migrator is not given `BYPASSRLS`. It owns the tables, `FORCE ROW LEVEL SECURITY` holds it to the policies, and a data migration that forgot to choose a tenant would then succeed on zero rows. The role runs with `row_security = off` instead, which rejects queries subject to row security, even with a matching tenant context. This makes accidental tenant-data access fail loudly. Intentional tenant-scoped data migrations must enable row security locally and establish their tenant context, as described in [deployment](../deployment.md#two-roles). Nothing is exempted. + +Grants come from `ALTER DEFAULT PRIVILEGES FOR ROLE `, set once, so every table a future migration creates is usable without a grant per migration. + +**Some privileges are then taken back:** + +```sql +REVOKE UPDATE, DELETE ON "audit_event" FROM qualityruntime; +REVOKE UPDATE, DELETE ON "file" FROM qualityruntime; +REVOKE UPDATE ON "control_requirement" FROM qualityruntime; +REVOKE DELETE ON "organization" FROM qualityruntime; +``` + +None of the first three is decoration, and none is strictly necessary — the policies already make such a statement match nothing. What they change is the failure: a silent no-op becomes a refusal, which is what a bug in this area deserves. + +**The last is necessary, and it is the one the others do not cover.** A foreign key's `ON DELETE cascade` is a referential action, subject to neither row-level security nor the privileges on the table it cascades into. Every tenant-owned table references `organization`, and `organization` is Better Auth's table with no policies of its own, so `DELETE FROM "organization"` deletes the audit log and every attestation in one statement that the revokes above do not touch. Taking the privilege on the parent is what closes it. Better Auth's own `organization/delete` route is disabled for the same reason: removing a tenant is an operator's deliberate act, not a customer administrator's API call. + +The other cascade into a final record was `evidence`'s own parent. Deleting a control took its attested evidence with it, around [ADR 0012](0012-evidence-and-attestation.md)'s policy; that foreign key is now `ON DELETE restrict`, so evidence has to be disposed of deliberately and attested evidence cannot be. + +**The list is derived rather than maintained.** `apps/server/privileges.test.ts` asks PostgreSQL which tables have row security and no policy for a command, and asserts the runtime role holds no such privilege. A second derivation asks which tables cascade into `audit_event` or `evidence` and asserts the role cannot delete from any of them, so a later table wired up with `ON DELETE cascade` fails the suite rather than quietly reopening the hole above. A future append-only table fails the suite until its revoke is written into `docs/deployment.md`. + +Both derivations are bounded by what they ask about. The first sees only tables with row security enabled, so an append-only table that is not tenant-scoped — or one where `ENABLE ROW LEVEL SECURITY` was forgotten — is invisible to it, and neither looks at which roles a policy names. That the `file` revoke is on this list at all is that test's doing: it was not in the first draft, and the derivation found it. + +**The setup is run, and so is the posture.** `apps/server/documented-setup.test.ts` takes the SQL out of both documents, runs it in the order they give, applies the migrations as the migrator, drives the product as the runtime role, and then asks PostgreSQL what that role ended up holding — so a document that stops working, or that quietly grants more, fails the suite. That it was written at all is a finding's doing: `docs/deployment.md` carried a setup block that could not apply a migration, because drizzle-kit creates a schema for its journal and the grant for that was in the other document. + +**The posture is run, not just described.** The same test sets the two roles up, then signs a user up, creates a control, records evidence, uploads a file and imports a standard — all through the runtime role. It stops short of attesting and of mapping a control, so those are covered by the ordinary suites rather than by this one. Then it asserts what that role cannot do: disable row security, truncate, alter the schema, rewrite audit history, or change attested evidence. A deployment document nobody has executed is a guess, and this is the difference between the product running on these privileges and being believed to. + +## Consequences + +The three guarantees above stop being qualified. Audit history is append-only against the role, not merely against the code; attested evidence is final against the role; and a compromised request path cannot turn isolation off, because the credential it would use cannot. + +They hold only with the cascade revoke in place. Privileges and policies describe what a role may do; a referential action is something the database does on its own behalf, and it obeys neither. Any future foreign key into a table whose rows are meant to be permanent has to be `restrict`, or the parent has to be out of the role's reach. + +Setting up a database is now two roles instead of one, locally as well as in a deployment, because development that does not match production is how a deployment-only failure gets discovered in a deployment. The grants are a handful of statements and they are written out in both documents. + +Applying migrations is a step of its own, with its own credential. It should not run from the server: several instances starting at once would each try, and the role the server has is deliberately not the one that can. + +Nothing enforces the separation. A deployment can still point both URLs at one owning role, and everything will work — with the older, weaker guarantee — unless that role carries the migrator's `row_security = off`, which the server refuses because every request would then fail. `assertTenantIsolation` refuses a superuser because that breaks isolation outright and silently; ownership does not, so refusing to start would be wrong. What ownership costs is written down instead. + +`GRANT … ON ALL TABLES` is a snapshot. It is the default privileges that keep future tables covered, and the order matters: set the default privileges _before_ the first migration, or the tables that migration creates get nothing. On a database whose tables already exist the snapshot grant is needed as well, which `docs/deployment.md` gives as a one-off outside the setup block — on a fresh database it grants nothing, and a statement that quietly does nothing is one nobody can tell is wrong. + +The setup blocks need a superuser. `ALTER DEFAULT PRIVILEGES FOR ROLE` requires superuser or membership in the role it names, and so does revoking on a table the revoker does not own — a `CREATEROLE` administrator is refused, and is refused in a way that leaves the privilege in place. diff --git a/docs/adr/0015-a-rendered-api-reference.md b/docs/adr/0015-a-rendered-api-reference.md new file mode 100644 index 0000000..5b43ea8 --- /dev/null +++ b/docs/adr/0015-a-rendered-api-reference.md @@ -0,0 +1,31 @@ +# 15. A rendered API reference + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +[ADR 0007](0007-openapi-from-the-schemas.md) published an OpenAPI document at `/api/v1/openapi.json` and ended with the open question: _serve the document, and decide whether anything renders it._ + +A JSON document is what a client reads. It is not what a person reads when they are working out whether this API can do what they need, and "AI-native" is not an argument against a human being able to look. + +## Decision + +`@scalar/hono-api-reference` renders the document at `/api/v1/reference`. + +It takes the document **by URL**, so nothing about how the document is built depends on it — this decision is entirely downstream of ADR 0007 and leaves it untouched. Removing it costs deleting one route. + +It is public, like the document. It describes the API, not anyone's data, and a reference a client cannot read before signing in is harder to use for no benefit. + +**The page loads its bundle from a CDN, and that is worth knowing.** Scalar's rendered HTML fetches `@scalar/api-reference` from jsDelivr. For a product whose point is that self-hosting should be boring, a page that quietly reaches the public internet is a poor default to leave undocumented — an air-gapped deployment gets a blank page and no explanation. `API_REFERENCE_BUNDLE_URL` overrides it, and `docs/deployment.md` says so. + +## Consequences + +One dependency, with one of its own, for a page. That is a real cost for something no code path depends on, and it is why the integration is a single route rather than anything structural. + +The reference is only as good as the document, which is only as good as the operations list `openapi.ts` maintains — and that list is checked against the routes the app actually registers. So a route that is missing from the reference fails the suite rather than quietly going undocumented. + +Nothing self-hosts the bundle. `API_REFERENCE_BUNDLE_URL` lets a deployment point at its own copy, but this repository does not produce one, and doing so would mean serving a JavaScript bundle it does not build. diff --git a/docs/adr/0017-discarding-a-draft-control.md b/docs/adr/0017-discarding-a-draft-control.md new file mode 100644 index 0000000..6526cea --- /dev/null +++ b/docs/adr/0017-discarding-a-draft-control.md @@ -0,0 +1,68 @@ + + +# 17. Discarding a control that was never in effect + +## Status + +Accepted. + +## Context + +A control's lifecycle was `draft → active → retired → draft` when this was written, and nothing else ([ADR 0003](0003-tenant-isolation-with-row-level-security.md) enforces the tenancy; the moves themselves are a rule the API keeps). There is no `DELETE`. + +That left an abandoned draft with no disposal at all. Somebody starts authoring a control, thinks better of it, and the only way to be rid of it is to put it **into effect** and then retire it — which writes two events into audit history saying a control was in effect when it never was, and leaves a `retired` row claiming to have once covered something. The alternative is to leave it in the drafts forever, where it clutters the one list that is supposed to show what is being worked on. + +So one of two things had to change: allow `draft → retired`, or allow a draft to be deleted. + +## Decision + +**A control that was never in effect can be deleted. Nothing else can.** + +`DELETE /api/v1/organizations/{organizationId}/controls/{controlId}` answers `204` when the control never took effect, `409` when it did, and `404` when it is not there or belongs to someone else. + +**A draft has to mean "never took effect", and the database has to hold that.** The first version tested `status = 'draft'` and was wrong: `retired → draft` was then a legal move, so `active → retired → draft` turned a control that _was_ in effect into a plain draft in three ordinary requests, and a status test let it be erased. A review found it by doing exactly that. The subtler version is the same hole in SQL: a policy is re-evaluated per statement, so an `UPDATE … SET status = 'draft'` followed by a `DELETE` in one transaction defeats a status test — the delete really does see a draft. + +`control` therefore carries `activated_at`, when it first became active. For a while the policy tested that column instead of the status, which kept `retired → draft` and moved the whole meaning of "was ever in effect" into a timestamp. Repeated reviews argued the transition was the problem rather than the test, and it was removed: **the lifecycle is `draft → active → retired`, one way.** A retired control stays retired, and what replaces it is a new control, so evidence recorded against the old one keeps meaning what it meant. Reactivation can be added when a workflow needs it; a status that can be revived is probably misnamed. + +So the `DELETE` policy tests `status = 'draft'` again, and it means what it says because of two rules below the API: + +- **A trigger owns `activated_at`.** It stamps a control the first time it becomes active, by whatever path, and refuses any other write — a caller can neither backdate it, forge it, nor clear it. A second review found the need: `UPDATE … SET status = 'draft', activated_at = NULL` in one transaction erased a control that had been in effect, leaving no `deleted` event. A policy cannot prevent that, because `WITH CHECK` sees only the new row; a `BEFORE INSERT OR UPDATE` trigger is the only thing in PostgreSQL that can compare the two. +- **A CHECK ties a draft to a null stamp** (`control_took_effect_unless_draft`). With the stamp immovable, nothing that took effect can become a draft again, and nothing is created active-and-unstamped or retired. +- **The same trigger keeps a retired control retired.** A retired row keeps its stamp, so the CHECK would admit `retired → active`; only a trigger can see that the row was retired before. It is `control_lifecycle_enforce`: the two lifecycle rules that need a row's past, and nothing else. + +The route does not set the column at all, so there is no second place for the rule to be forgotten. + +**Why not `draft → retired`.** `docs/data-model.md` already says what `retired` is for: _a control that once covered a requirement is part of the record_. That is the whole reason retiring beats deleting — there is something worth keeping. A control that never took effect was never relied on, never evidence of coverage, and nothing points at it. There is no record to preserve, so preserving it is not conservatism, it is clutter. And `retired` means "no longer in effect", which misdescribes something that never was: the two would become indistinguishable in exactly the list where the distinction matters. + +**The rule is a policy, not a route.** `control` had one `FOR ALL` policy, which cannot say _delete only these rows_. It is now four per-command policies, and the `DELETE` one admits only a draft. The route answers 409 with something a person can act on; PostgreSQL is what makes the rule true, which is the same shape as evidence finality ([ADR 0012](0012-evidence-and-attestation.md)) and audit append-only ([ADR 0005](0005-audit-history.md)). + +Splitting the policy costs nothing elsewhere: `SELECT … FOR UPDATE` is charged to `UPDATE`, so the handler can still lock any control before deciding about it, whatever its status. + +**A control carrying evidence is refused.** `evidence`'s foreign key to `control` is `ON DELETE restrict` ([ADR 0014](0014-the-runtime-role-owns-nothing.md)), so removing a control that has evidence fails in the database. The route asks first and answers `409 has_evidence`, because a foreign key violation surfacing as a 500 tells a client nothing. + +The route holds `SELECT … FOR UPDATE` on the control while it asks, and an evidence insert needs `FOR KEY SHARE` on that same row for its own foreign key check, so the two cannot interleave. + +That was reasoning when this was written, and [ADR 0020](0020-testing-races.md) turned it into a test — which promptly showed it half wrong. The locks do exclude each other, but the recording took its key share only at the _insert_, having read its control unlocked: a discard landing in between left it inserting against a parent that was gone, and a foreign key violation became a `500`. It now takes the lock on the read and holds it, so whichever request loses the race loses cleanly — `404` when the control went first, `409 has_evidence` when the evidence did. + +**So unattested evidence gained a `DELETE` of its own.** Without it this refusal was a dead end: a control that ever had evidence recorded against it could not be discarded at all, and the only escape was to activate and retire it — the exact thing this ADR exists to avoid. The policy was already there. `evidence`'s `DELETE` policy has admitted only unattested rows since [ADR 0012](0012-evidence-and-attestation.md); no route had ever asked. Attested evidence is still refused, and then the control stays too, which is correct: attested evidence must go on naming what it was evidence of. Discarding evidence takes its `file` rows by cascade, and the bytes stay on the volume, so the audit event names the filenames — the only record left of them, and [ADR 0018](0018-one-history-rather-than-one-per-record.md) is what made that event readable. + +**The deletion is audited, and the history outlives the row.** `audit_event.resource_id` is a plain column rather than a reference, so the events describing a control survive it. A `deleted` event carries `before` and no `after`, mirroring a creation's `after` and no `before`. + +## Consequences + +`audit_event.action` gains a fourth verb. `Change` became a union discriminated on it rather than two optional fields, so that a creation cannot carry a `before` nor a deletion an `after`; both columns were already nullable. + +`control.activated_at` is published on the control: when it first took effect is part of its record, and the status alone no longer says when that was. + +**History can now name a control that no longer exists.** When this was written the only route to those events was `GET /controls/{controlId}/history`, which answered 404 once the control was gone — so discarding a control put its history beyond every route in the product. [ADR 0018](0018-one-history-rather-than-one-per-record.md) closed that: history is its own collection now, and a discarded control's events, including its deletion, stay readable at `?resource={controlId}`. + +**Deleting is not undoing.** A draft removed by one member is gone for another who was looking at it. That was unpreventable when this was written; [ADR 0019](0019-conditional-writes.md) since gave every mutation an optional `If-Match`, so a caller who quotes the version it read is refused with 412 rather than discarding something that moved underneath. It remains unconditional for a caller who does not ask. + +**The mappings go with it.** `control_requirement` cascades from `control`, so discarding a draft removes its requirement mappings. That is right — a mapping is a statement that _this control_ addresses a requirement, and it means nothing without the control — but it is a cascade, and cascades are not audited ([ADR 0010](0010-mapping-controls-to-requirements.md)). + +**Retirement is still what deletion usually means.** This is the narrow exception for records that never claimed anything, not a general disposal mechanism. Standards and requirements are unaffected; the question of how to dispose of an imported standard nobody adopted is still open. + +**Discarding is not refused while impersonating**, unlike attesting. Attesting is a signature and cannot be delegated; discarding is ordinary work an administrator may do on a member's behalf, and the audit event names both. That is a choice rather than an oversight. diff --git a/docs/adr/0018-one-history-rather-than-one-per-record.md b/docs/adr/0018-one-history-rather-than-one-per-record.md new file mode 100644 index 0000000..a4ca277 --- /dev/null +++ b/docs/adr/0018-one-history-rather-than-one-per-record.md @@ -0,0 +1,50 @@ + + +# 18. One history, rather than one per record + +## Status + +Accepted. Replaces the `GET /controls/{controlId}/history` route introduced with [ADR 0005](0005-audit-history.md). + +## Context + +Audit history was readable in one place: `GET /controls/{controlId}/history`. That route looked the control up first and answered 404 when it was not there, so that a control in another organization was an absence rather than an empty list. + +[ADR 0017](0017-discarding-a-draft-control.md) then made a control removable. The history of a discarded control is exactly the history someone would want — what was it, who made it, who threw it away — and it became reachable by nothing. `audit_event.resource_id` is a plain column rather than a reference precisely so that history outlives the record it describes, and the only route to it required the record to still exist. + +Evidence had no history route at all, so the `deleted` event naming the files that went with a discarded piece of evidence — the only record those bytes leave behind — was unreachable too. + +The obvious repair is a history route per entity. That is three routes today and one per entity forever, each duplicating the same paging, each with its own visibility rule, and none of them able to answer _what has happened here lately_. + +## Decision + +**One collection: `GET /api/v1/organizations/{organizationId}/history`.** Newest first, paged like every other collection ([ADR 0006](0006-cursor-paged-collections.md)), and narrowed to one record with `?resource=`. + +**The filter takes an identifier, not a type and an id.** An identifier here says what it is ([ADR 0002](0002-prefixed-identifiers.md)), so `?resource=ctl_…` is unambiguous, and the handler derives `resource_type` from the prefix. That derivation is not cosmetic: the index serving this starts with `(organization_id, resource_type, resource_id)`, followed by `(created_at, id)`. Supplying all three equality conditions lets it serve the requested order without a separate sort. + +**There is no record lookup, and so no 404 for a record.** History outlives records, so there is nothing reliable to look a resource up in — the whole point of the change. A control discarded an hour ago still answers with everything that happened to it, including its own deletion; that is the case the change exists for. + +What returns an empty page is a resource this organization has no history of: one that never existed, or one belonging to someone else. Those two are indistinguishable, and should be — row-level security decides what is visible, and there is nothing to tell apart. The old route's 404 was helpfulness rather than a boundary; it concealed nothing, since an out-of-tenant control produced an empty list either way. + +The organization is still resolved before the handler runs, so a caller who is not a member gets 404 as they do from every other collection ([ADR 0004](0004-organization-in-the-request-path.md)). Removing the record lookup removed record-level absences, not that one. + +**The resource is named in each event.** The per-control route left `resource_type` and `resource_id` out because the URL had already said them. A collection spanning records has to carry them, so the response shape gained both. + +**A cursor cannot cross between filters.** A cursor is a position in an ordering, and narrowing the history makes a different ordering — the same position names different rows. The collection name carries the filter (`history` versus `history/ctl_…`), which is the mechanism ADR 0006 already uses to keep one collection's cursors out of another's. + +The `resource` parameter is therefore read _before_ the cursor is validated. Validating in the other order would check a filtered cursor against the unfiltered ordering and refuse every page after the first. + +## Consequences + +**The whole organization's history is readable by any member.** That is a widening: previously a member could read one control's history at a time, and could not read evidence or standards history at all. It follows the rule the rest of the product already has — membership is the domain API boundary, and its handlers do not restrict actions by `member.role` ([ADR 0004](0004-organization-in-the-request-path.md)) — but it is worth stating rather than arriving at by accident. History carries actor labels, impersonation attribution, and the `before`/`after` of every change, including records since deleted. A product that wanted an audit-reader role would put it here first. + +**A new index.** `(organization_id, created_at, id)`, because the existing one places `resource_id` between the type and the ordering key: a page of everything would otherwise be a sort of everything. + +**The filtered query's use of its index is not verified.** Deriving `resource_type` is what keeps the narrowed read on `audit_event_resource_idx`, and nothing asserts that it does — an `EXPLAIN` on a table of a few dozen rows will sequential-scan whatever indexes exist, so the assertion would pass or fail for the wrong reason. Removing the derivation would leave every test green. + +**Filtering stops here.** No `action`, no actor, no date range. Each wants an index or a scan, and none has a caller yet; `?resource=` earns its place because the alternative is history nobody can reach. + +**No snapshot across pages.** Each page reads the history visible to its query. Newly visible rows ahead of the cursor are not included in the remaining pages; rows committed later with ordering keys behind it may be included. The cursor prevents offsets from shifting, but does not provide an export complete as of a fixed instant ([ADR 0011](0011-reading-a-mapping-from-both-ends.md)). diff --git a/docs/adr/0019-conditional-writes.md b/docs/adr/0019-conditional-writes.md new file mode 100644 index 0000000..3d3c714 --- /dev/null +++ b/docs/adr/0019-conditional-writes.md @@ -0,0 +1,62 @@ + + +# 19. Conditional writes + +## Status + +Accepted. Generalises the `If-Match` introduced for attestation in [ADR 0012](0012-evidence-and-attestation.md). + +## Context + +Attesting evidence required `If-Match` from the start, because a signature has to be of something in particular: without it a client can attest content it never saw, amended by somebody else between the read and the signature. + +Every other mutation was last-writer-wins, and **the loser never found out**. Two people open the same control, both edit the name, the second write silently discards the first. [ADR 0017](0017-discarding-a-draft-control.md) then made discarding possible, which is the same race with a worse ending: a draft one member is reading can be thrown away by another while they read it. + +The machinery to prevent that already existed — a row version, an entity tag, a comparison — in one route. + +## Decision + +**`If-Match` is honoured on every mutation of a record, and required on none of them except attestation.** + +`PATCH` and `DELETE` for both controls and evidence compare the tag when one is given and answer `412` when it no longer matches. A request that sends no tag behaves exactly as before. + +**Optional, deliberately.** Requiring it everywhere would make every write a two-request dance and break the simplest useful client — `curl` renaming a control — for a guarantee that client did not ask for. HTTP already has the shape for this: a conditional request is the caller's choice, and the server's job is to honour it exactly when it is made. Attestation is the exception because there the guarantee _is_ the feature. + +**The version is `xmin`.** It identifies the transaction that wrote the row version. Separate transactions receive different values until transaction IDs wrap; repeated updates within one transaction share a value. `updated_at` would not do: it is set from JavaScript, so it carries milliseconds, and two writes inside one millisecond would share a value — a stale tag that still matched. `xmin` is not durable across a dump and restore, which makes outstanding tags stale; that is the safe direction, since a write is refused and the caller reads again. Freezing does _not_ do that, despite the folklore: PostgreSQL marks a frozen tuple with a flag and leaves the `xmin` it reports alone, which a probe confirms across a `VACUUM FREEZE`. + +**The comparison happens after the row is locked.** Every conditional route takes `SELECT … FOR UPDATE` before comparing, so nothing can move between the test and the write and the version does not need repeating in the `WHERE`. + +The first draft of this got the evidence discard wrong: it compared against an unlocked read and then deleted, which leaves the row free to be amended in between — a review reproduced exactly that with two sessions, and the delete removed evidence the caller had not seen. A conditional write that compares something it does not hold is not a conditional write. + +Evidence reads unlocked _first_, then locks, because `SELECT … FOR UPDATE` is governed by the `UPDATE` policy: an attested row is not there to lock, and "cannot be locked" would come back as "does not exist" rather than "cannot be changed". The unlocked read is what tells 404 from 409; the lock is what decides. Attestation repeats the version in its `WHERE` instead, because it never locks at all, for the same reason. + +**`*` means only if it still exists**, and only as the whole field — never one item of a list, which RFC 9110 does not allow and which would otherwise turn a list into an unconditional write. + +**Attesting does not use this parser at all**, and that is deliberate. It compares the header to the tag exactly, so `*` and a list are both refused even when the list holds the right tag. Everywhere else `If-Match` asks "has this moved?", and a wildcard meaning "only if it still exists" is a reasonable thing to ask. A signature is of something in particular, and "whatever version is there" is not a thing to sign. + +The field is parsed rather than split. An entity tag is opaque and quoted, so a comma or an asterisk between the quotes is part of it: `"old,*,other"` is one tag that matches nothing, and splitting on commas exposes an `*` that was never a wildcard. That was the first draft's other mistake, and it let any write through. Comparison is strong — a weak tag never matches, and nothing here issues one — and anything that is not a well-formed field fails, including a header that is present and empty. A client that sent the header meant something by it, and writing anyway is the wrong way to be wrong. + +**A tag covers the whole representation, not just its row.** Evidence reads back with its files, so attaching one changes the record — and `file` is a separate table, whose insert does not move `evidence`'s version. Without something to say otherwise, a caller could discard evidence carrying an attachment it never saw, and the file would cascade away with it. Attaching therefore touches the evidence row, which the audit event already called an update to the evidence; now the row agrees. + +**Preconditions are evaluated last.** A refusal that would have happened anyway — an illegal transition, a control that has been in effect, one carrying evidence, attested evidence — is answered before the tag is looked at. RFC 9110 §13.2.1 asks for that, and there is a second reason: checking the tag first makes a request that was going to be refused disclose whether the caller's tag matched. + +**ETags are served where a client would get one**: reading a control, reading evidence, and the `PATCH` response of each, so a client can make a second edit without reading again. The published document declares the header on each of those responses — one that asks for `If-Match` and never says where the tag comes from describes half a contract. + +## Consequences + +`PUT /controls/{controlId}/requirements` is conditional too, by the route this paragraph originally ruled out. It replaces a set of mapping rows rather than amending one record, so there is no `xmin` to quote — the **contents are the version**: the sorted, length-prefixed member identifiers, hashed. Members are length-prefixed so that no member can impersonate two, and an empty set has a version of its own rather than colliding with a set holding an empty member; neither is reachable with the identifiers used today, and a version that collides is worse than no version. + +That tag says nothing about _when_. Two equal sets are indistinguishable, which is exactly what a caller asking "is it still what I read?" means. + +**A control therefore has two tags**, and they are not interchangeable: its own row version, and its requirements' set version. Each is served by the route that owns it, and quoting one at the other's route is a mismatch and answers 412 — which is the right answer, since it names a version that resource does not have. + +**The listing reads its page and its version from one snapshot.** Under `read committed` those are two statements and can see two different committed sets, which would hand a client a version for membership it was never shown. `withOrganization` takes a `repeatableRead` option for reads whose answers have to agree with each other. Reading one piece of evidence and listing a control's evidence use it too, for the same reason: a row and its separately queried attachments are two statements, and the tag an attestation quotes has to describe the files shown beside it. No write uses it — a write deciding from what is stored _now_ wants the opposite. + +The version is selected alongside the columns, so one query serves both the body and the tag, and `withoutVersion` strips it before the response. A strict response schema would refuse it in the body ([ADR 0007](0007-openapi-from-the-schemas.md)), which is the backstop if that ever slips. + +Nothing obliges a client to use this, so nothing guarantees a careless one is safe. That is the cost of optional, taken knowingly: the product now offers the guarantee rather than enforcing it, and a client that wants to be careful can be. + +`xmin` is a 32-bit counter and wraps. Two rows can therefore present the same tag, which does not matter — a tag is only ever compared against the row it was read from. What would matter is a row's `xmin` returning to a value a client still holds, which needs the counter to wrap between the read and the write; a stale tag matching wrongly is then possible in theory and not worth engineering against here. diff --git a/docs/adr/0020-testing-races.md b/docs/adr/0020-testing-races.md new file mode 100644 index 0000000..31fd229 --- /dev/null +++ b/docs/adr/0020-testing-races.md @@ -0,0 +1,64 @@ + + +# 20. Testing races + +## Status + +Accepted. + +## Context + +The suite runs on PGlite, in process, needing nothing started — which is most of why it is pleasant to work with, and is written into [development](../development.md) as a feature. + +PGlite is one connection. Two things cannot happen at once, so **no `SELECT … FOR UPDATE` in this codebase had ever been exercised**. Every claim about what a lock prevents was argued from PostgreSQL's documented lock conflicts rather than demonstrated, and several ADRs said so in as many words. + +One of those claims was wrong. [ADR 0019](0019-conditional-writes.md) asserted that comparing an entity tag was atomic because the row was locked; on the evidence discard it was not, and the comparison ran against an unlocked read. An external reviewer found it in minutes by opening two sessions. Nothing in the suite could have. + +## Decision + +**One suite runs against a real PostgreSQL, and only it does.** `apps/server/concurrency.test.ts` is skipped unless `TEST_DATABASE_URL` names a database — so `bun run test` still needs nothing running, and the property that makes the rest of the suite pleasant is kept. + +**Each test forces the interleaving rather than hoping for it.** A second session takes the row lock; the request is started and blocks on it; the second session makes its change and commits; the request is released into a world that moved under it. Timing is never relied on. + +**A request that never blocked fails the test.** This is the part that matters, and the first version got it wrong. Starting a request only schedules it: without waiting for the request to actually block, the other session can finish before the handler has touched the database, and the two never overlap. Every test passed, and removing the locks they were written to exercise changed nothing. The suite now asks PostgreSQL who is waiting — `pg_stat_activity` where `wait_event_type = 'Lock'` — and gives up with an explicit failure if nobody is. + +That check cannot filter by role, incidentally: the application switches role after connecting, so `usename` remains the login user. It filters on the database instead, which is sound because the only session deliberately holding a lock is not itself waiting on one. + +**It runs as a role that owns nothing and bypasses nothing**, set per connection, so the policies are in force as they are in a deployment. A superuser connection would be exempt from row-level security, and several of these handlers depend on it — an attested row cannot be locked, which is why evidence reads unlisted before it locks. + +**The database is wiped every run**, so the file refuses one whose name does not end in `_test`. + +## Consequences + +The locks are now load-bearing in a way that can be checked. Removing `FOR UPDATE` from the control amendment, the control discard or the evidence amendment each fails a test; so does reverting [ADR 0019](0019-conditional-writes.md)'s bug, and so does building an amendment's audit diff from the unlocked read rather than the locked one. Those were arguments; they are now tests. + +**Forcing the order is part of the test.** A lock queue is first-come, so starting one request and waiting for it to join the queue before starting the other decides which wins. The first version of the attach-versus-discard test left that to chance and passed whenever the order happened to be the harmless one — which is the same false negative as not blocking at all, arriving later. + +**It found a second defect immediately.** Without its lock, the control discard answered `204` having deleted nothing — the policy refused the row and the handler never looked. The lock makes that unreachable, but "unreachable" is what the previous bug was also believed to be, so the delete now checks that it matched something, as the evidence discard already did. + +**It kept finding things.** A second round added the same foreign-key race one level down — attaching a file read its evidence unlocked and took the key share only at the insert, so a discard landing in between made it a `500`. Fixing that introduced a regression of its own, caught by review rather than by the suite: a locked read is governed by the `UPDATE` policy, which sees only unattested rows, so evidence attested mid-upload came back as "does not exist" rather than "already attested". The same trap this file warns about two paragraphs above, walked into while fixing something else. + +And forcing a transaction to fail — by taking away the privilege its audit write needs — showed that `insufficient_privilege` was being read as "the evidence was attested in between" wherever it came from. It now checks the table too, so a privilege error elsewhere in the transaction is no longer answered with a confident wrong diagnosis. + +**It found a third defect, in the race it was written to prove.** [ADR 0013](0013-durable-storage.md) argued that recording evidence and discarding its control cannot interleave, because the foreign key check takes `FOR KEY SHARE` and the discard holds `FOR UPDATE`. True as far as it went — but the recording read its control _without_ a lock and took the key share only at the insert, so a discard landing in between turned it into a foreign key violation and a `500`. It now takes that lock on the read and holds it, which makes the loser lose cleanly: `404` if the control went, `409 has_evidence` if the evidence did. + +**Pooling is exercised for the first time.** PGlite is one connection, so a connection being _reused_ by a second tenant had never happened in a test — while tenant isolation rests entirely on a transaction-local setting on a pooled connection. Two tests cover it: interleaved requests from two organizations, and every connection in the pool checked out at once and asserted clean. Sampling one connection is not enough, and was the first version's mistake: a tenant left behind on any other connection went unseen. + +Between transactions the setting is spent but not unset — PostgreSQL leaves it as the empty string. That is safe because no organization identifier equals it, which is the property the tests assert rather than the value. + +**Where an empty locked read is ambiguous.** `SELECT … FOR UPDATE` is governed by the `UPDATE` policy, so a row that policy excludes is not there to lock — and neither is a row somebody deleted while this transaction waited. From the lock alone the two are indistinguishable, and reporting the wrong one is a confident wrong answer: "already attested" for a record that was discarded and never signed. + +It is ambiguous **exactly where the policy governing the locking command restricts which rows exist**, which is three places: amending evidence, discarding evidence, and attaching a file — all three governed by `evidence_tenant_amend`, which sees only unattested rows. Each re-reads without the lock to tell the two apart. Everywhere else the governing policy is tenant-only, so an empty result genuinely means absent: the three locked reads of `control`, the key-share read of `requirement`, and the key-share read of `control` when evidence is recorded. + +Discarding a control is sound for a different reason. Its `DELETE` policy _does_ restrict which rows exist, but the row is locked first, so a delete matching nothing can only mean the predicate refused it — never that somebody else got there. + +**Not everything is covered.** Nothing yet covers connection exhaustion, a request cancelled mid-transaction, or two organizations contending for the same row — which cannot happen, since no row belongs to two. + +**CI runs it.** The `check` job takes a `postgres:18` service and sets `TEST_DATABASE_URL`, so a change that breaks a lock fails there rather than for whoever runs the suite next. That the setup works from nothing — no schema, no role, no rows — is checked by running it against a database created for the purpose, which is CI's situation exactly. + +**One PostgreSQL version, deliberately.** PGlite serves 18.3 in process and everything else runs 18: this suite, CI, and the setup `docs/development.md` prints. It briefly ran 17 here while a local install was, which is how the split was noticed — a difference between the two would have surfaced as one suite disagreeing with another for reasons unrelated to the change being made. Both are 18 now and the suites agree because they are testing the same thing. + +**A second database is now part of a full local setup.** It is optional and documented in `.env.example`, and an unset variable skips silently — which is the point, and also the residual risk: a developer who never sets it sees a green suite that tested none of this. CI setting it is what stops that mattering. A variable that _is_ set but names a database this would refuse to wipe stops the run instead of skipping, because a skip there would be the same failure wearing a disguise. diff --git a/docs/data-model.md b/docs/data-model.md index 8064dd4..c08ff75 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -12,7 +12,7 @@ These tables are owned by [Better Auth](https://better-auth.com) and defined in | -------------- | -------------------------------------------------------------------------------------- | | `user` | A person. Instance-wide identity, independent of any organization | | `account` | A credential or linked social provider; unique per (provider, external id) | -| `session` | An authenticated session, including the organization it is acting in | +| `session` | An authenticated session, and the organization the user last selected | | `verification` | Short-lived tokens for email verification, password reset, and OTP delivery | | `two_factor` | TOTP secrets and backup codes | | `organization` | **The tenant boundary.** Every tenant-owned record belongs to exactly one organization | @@ -33,9 +33,85 @@ Every generated row identifier uses `_`, for example `usr_v1stgx | `organization` | `org_` | 16 | | `member` | `mem_` | 16 | | `two_factor` | `tfa_` | 16 | +| `control` | `ctl_` | 16 | +| `audit_event` | `aud_` | 16 | +| `standard` | `std_` | 16 | +| `requirement` | `req_` | 16 | +| `evidence` | `evd_` | 16 | +| `file` | `fil_` | 16 | A row identifier is not a credential: `session.token` authenticates a session and `verification.value` proves a verification. `invitation` is wider because Better Auth takes an invitation by id. Identifiers are allocated before insertion and reveal no row count. Each `id` column enforces its table's prefix, length, and alphabet with a CHECK constraint, so an identifier belonging to another table — or one carrying uppercase — is rejected rather than stored. Join tables need no separate identifier: `control_requirement` uses `(control_id, requirement_id)` as its primary key. ## Domain entities -Quality and compliance entities — documents, requirements, controls, evidence, risks, audits, findings, incidents, CAPAs, training, suppliers, approvals, and workflows — are not implemented yet. They join the same package and the same migration history, and each carries the organization it belongs to. +Domain tables are tenant-owned: each carries an `organization_id` referencing the organization it belongs to, and deleting an organization deletes its rows. That column records **ownership, not permission** — it says which tenant a row belongs to, never that a given request may read or change it. Authorization still resolves the caller's `member` row (TENANT-01, and see [security](security.md)). + +PostgreSQL enforces the boundary: every table here has row-level security enabled and forced, and is reached through `withOrganization`, which scopes a transaction to one organization. A query that forgets its tenant predicate returns that organization's rows rather than everyone's, and a transaction with no organization set sees nothing. [ADR 0003](adr/0003-tenant-isolation-with-row-level-security.md) records the design; adding a tenant-owned table means adding its policy, and `migrations.test.ts` fails until you do. + +| Table | Holds | +| ------------- | -------------------------------------------------------- | +| `control` | A measure an organization operates to meet a requirement | +| `audit_event` | A change to a record: who, what, when, and from what | + +### Control + +A control is something an organization does to satisfy a requirement — a measure, a practice, a safeguard. It is defined in `schema/control.ts` and is deliberately narrow: the organization it belongs to, a name, an optional description, and a lifecycle status. + +Lifecycle: + +```text +gone ◀── draft ──▶ active ──▶ retired +``` + +- `draft` — being authored; claims nothing. +- `active` — in effect, and may be relied on as evidence of coverage. +- `retired` — no longer in effect, but kept: a control that once covered a requirement is part of the record. Retiring is what deletion should usually be. + +The schema admits exactly these three values and no more. The API answers for the moves between them — the arrows above are the only ones — and setting the status a control already has is a no-op. The lifecycle runs one way, and PostgreSQL holds it to that whatever writes the row ([ADR 0017](adr/0017-discarding-a-draft-control.md)). A control in effect is withdrawn deliberately rather than quietly returned to draft, and a withdrawn one stays withdrawn: what replaces it is a new control, so the one evidence was recorded against keeps meaning what it meant. There is no `active → draft`, and nothing leaves `retired`. A control is always created as a `draft`; the create request cannot choose otherwise. + +**A control that was never in effect can be discarded** ([ADR 0017](adr/0017-discarding-a-draft-control.md)). `DELETE` removes it outright, and answers 409 otherwise. The reason is the one above: retiring preserves a control that was once relied on, and one that never took effect was not — there is nothing to preserve, and calling it `retired` would claim it had been in effect. + +The `DELETE` policy admits only a `draft`, and PostgreSQL is what makes "draft" mean "never took effect" rather than the API. `activated_at` records when a control first became active; a trigger sets it and refuses any other write to it, and a CHECK allows a draft exactly when it is null. So nothing that was in effect can be a draft again — not through the API, and not through raw SQL, including an `UPDATE` and a `DELETE` in one transaction. The trigger has to be a trigger: a policy cannot compare a row to what it used to be, and anything able to clear the stamp could turn a control that was in effect back into a deletable draft. + +A control still carrying evidence is refused too, because `evidence` restricts rather than cascades: attested evidence has to go on naming what it was evidence of. Discarding a control takes its requirement mappings with it, by cascade, and a cascade is not audited. + +The deletion itself is audited, and that history outlives the row: `resource_id` is a plain column, not a reference. It stays readable afterwards at `GET /history?resource={controlId}` — a history reachable only through a live record would disappear exactly when it is most wanted ([ADR 0018](adr/0018-one-history-rather-than-one-per-record.md)). + +Names are not unique within an organization: a name is a label, not an identifier, and two teams authoring similar controls is a state to reconcile rather than one to reject at insert time. A name that is blank or only whitespace is rejected. + +Fields a quality system eventually wants — category, framework, test method, review cadence, effectiveness, owner — are deliberately absent until a workflow needs them. Ownership in particular waits on how domain responsibility should relate to Better Auth membership. + +A control row is mutable: editing one overwrites it. What it was is recorded in `audit_event` rather than kept on the row, so the history of a control is a query rather than a column. Versioned prior states (VERSION-01) — a numbered revision a reader can cite and return to — are still not implemented, and are a separate thing from the change log below. + +### Audit event + +Domain API mutations record changes in the same transaction as the change itself ([ADR 0005](adr/0005-audit-history.md)). A change PostgreSQL makes on its own — a foreign key's cascade removing rows — writes nothing, which is a known gap rather than a decision. It names the actor, the action, the record, and the fields that moved. + +| Column | Holds | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `actor_type`, `actor_id` | Who acted: `user` or `system`, and their identifier | +| `actor_label` | How the actor was named at the time | +| `action` | A verb — `created`, `updated`, `deleted` | +| `resource_type`, `resource_id` | Which record it happened to | +| `before`, `after` | The fields that changed; `before` is null for a creation, `after` for a deletion | +| `created_at` | When the change happened, not when the row was written | + +An administrator impersonating a member is the actor, because they are accountable for what happened; the member is recorded as whose account it went through. + +Neither `actor_id` nor `resource_id` is a foreign key. Both the actor and the record can be deleted, and history that vanishes with them is not history (AUDIT-01) — `actor_label` exists for the same reason, since an identifier alone means nothing to a reader once the row is gone. + +`before` and `after` carry a record's own fields, and for an update only the ones that differ. Record identity is already a column, and bookkeeping timestamps (`created_at`, `updated_at`) describe the write rather than the change. A domain timestamp — when something happened, rather than when it was written — is a field like any other. An update that changes nothing writes no event at all. + +History is readable at `GET /api/v1/organizations/{organizationId}/history`, newest first and paged like every other collection ([ADR 0006](adr/0006-cursor-paged-collections.md)). `?resource={id}` narrows it to one record, named by its identifier alone since the identifier says what kind it is. There is no per-record route and no 404: history outlives what it describes, so there is nothing to look a resource up in, and what a caller may see is decided by the policies ([ADR 0018](adr/0018-one-history-rather-than-one-per-record.md)). + +**The table is append-only to the application.** Row-level security grants a tenant `SELECT` and `INSERT` and names no other command, so no code path can rewrite or erase an event through its tenant context. `TRUNCATE` and the privileges of the role that owns the table are outside row security, so preventing the runtime role from rewriting or erasing history also requires restricted privileges — see [deployment](deployment.md). Deleting an organization still removes its history, through the foreign key's cascade. + +### Changing only what you read + +Amending or discarding a control accepts `If-Match`, and answers `412` when the version it names has moved ([ADR 0019](adr/0019-conditional-writes.md)). A record's version is PostgreSQL's `xmin` — the transaction that last wrote the row — served as an `ETag` on reading a control and on a successful amendment, so a client can make a second edit without reading again. + +The header is optional: omitting it leaves writes last-writer-wins. + +### Not implemented yet + +Documents, risks, audits, findings, incidents, CAPAs, training, suppliers, approvals, and workflows. They join the same package and the same migration history. diff --git a/docs/deployment.md b/docs/deployment.md index afca3d5..524f419 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -3,3 +3,105 @@ How to install, configure, and upgrade Quality Runtime, and which deployment targets are currently supported. No deployment target is supported yet. Docker is the canonical self-hosted target, and Cloudflare Workers is a design target; this document records their status as each becomes real. + +## What a deployment provides + +Quality Runtime needs PostgreSQL, and nothing else: + +| Setting | Holds | +| -------------------- | ------------------------------------------------------------------------------------- | +| `DATABASE_URL` | The database, as a role that owns nothing and has neither `SUPERUSER` nor `BYPASSRLS` | +| `BETTER_AUTH_URL` | The public origin the server is reached at | +| `BETTER_AUTH_SECRET` | At least 32 high-entropy characters | + +The server refuses to start without any of them. `MIGRATION_DATABASE_URL` is not one: migrations are a separate step with a role of their own, described under [Applying migrations](#applying-migrations). + +## Database role + +**The role in `DATABASE_URL` must not be a superuser and must not have `BYPASSRLS`.** + +Tenant isolation is enforced by PostgreSQL row-level security ([ADR 0003](adr/0003-tenant-isolation-with-row-level-security.md)). PostgreSQL exempts superusers and `BYPASSRLS` roles from every policy, so connecting as one — the `postgres` superuser that container images create by default, for instance — silently disables isolation across the whole database. Nothing fails, no error is logged, and every tenant can read every other tenant's data. + +So the server checks at start-up and refuses to run when the role is exempt, when a domain table's row security is not both enabled and forced — the database was not migrated, or someone altered it — or when the connection has `row_security = off`, the migrator's setting. + +The role must also own nothing. [Two roles](#two-roles) below provides the role creation and grant statements; [development](development.md#database) adapts them for the local PostgreSQL container. + +To check the connection role directly: + +```sql +SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user; +-- both must be false +``` + +### Two roles + +**The role the server connects as must own nothing.** + +`FORCE ROW LEVEL SECURITY` makes the policies apply to a table's owner, which is enough for tenant isolation ([ADR 0003](adr/0003-tenant-isolation-with-row-level-security.md)). It is not enough for anything else: an owner can `TRUNCATE` a table, `ALTER TABLE … DISABLE ROW LEVEL SECURITY`, or change the schema, and none of those is subject to a policy. A deployment whose server owns its tables has history and attestations that are protected from its code and from nothing else. + +So there are two roles ([ADR 0014](adr/0014-the-runtime-role-owns-nothing.md)): + +```sql +CREATE ROLE qualityruntime_migrator LOGIN PASSWORD '…'; -- owns the schema, applies migrations +CREATE ROLE qualityruntime LOGIN PASSWORD '…'; -- the server connects as this + +-- The migrator owns the tables, and FORCE holds an owner to the policies, so a +-- statement with no tenant context would see no rows. Off makes that an error. +ALTER ROLE qualityruntime_migrator SET row_security = off; + +-- drizzle-kit keeps its journal in a schema of its own, which it creates. +GRANT CREATE ON DATABASE qualityruntime TO qualityruntime_migrator; +GRANT CREATE, USAGE ON SCHEMA public TO qualityruntime_migrator; +GRANT USAGE ON SCHEMA public TO qualityruntime; + +-- Every table the migrator creates from now on is readable and writable by the +-- server, without a grant per migration. Set before the first migration: this +-- is a rule for tables yet to be created, not a grant on the ones there are. +ALTER DEFAULT PRIVILEGES FOR ROLE qualityruntime_migrator IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO qualityruntime; +``` + +Run this block as a superuser, and the `REVOKE` block below as a superuser too. `ALTER DEFAULT PRIVILEGES FOR ROLE` requires superuser or membership in the role it names; `REVOKE` requires ownership of each table, so superuser or membership in `qualityruntime_migrator`. + +Getting that wrong is quiet. A `REVOKE` run by a role that merely belongs to the _grantee_ raises no error and removes nothing — PostgreSQL warns and moves on — so the block appears to have worked and the privilege is still held. Only a role with no claim at all gets a refusal. + +All of this assumes a fresh database. On one whose tables already exist, the default privileges above cover nothing that is already there, so run `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO qualityruntime;` once as well. It is deliberately not part of the block above — on a fresh database it would silently do nothing, and a statement that does nothing is one nobody can tell is wrong. + +`MIGRATION_DATABASE_URL` is the migrator's; `DATABASE_URL` is the server's. Migrations are applied as a separate step, by a role the server never uses. + +`row_security = off` does not bypass row security: it rejects queries subject to it, even when a tenant setting is present and matches every row. Schema changes are unaffected. A data migration that intentionally works within one tenant must enable row security for its transaction (`SET LOCAL row_security = on`) and set the transaction-local organization context before accessing domain rows. This applies the tenant policies; it does not grant cross-tenant access. The role default takes effect on login, so changing that default affects subsequent connections. + +Then, once the tables exist, take back what no policy would ever allow anyway: + +```sql +REVOKE UPDATE, DELETE ON "audit_event" FROM qualityruntime; -- append-only (ADR 0005) +REVOKE UPDATE, DELETE ON "file" FROM qualityruntime; -- attached for good +REVOKE UPDATE ON "control_requirement" FROM qualityruntime; -- a link is made or unmade +REVOKE DELETE ON "organization" FROM qualityruntime; -- see below +``` + +The last one is different in kind. A foreign key's `ON DELETE cascade` is a referential action: it is subject to neither row-level security nor the privileges on the table it cascades into. Every tenant-owned table references `organization`, so `DELETE FROM "organization"` would take the audit log and every attestation with it, around the revokes above. Removing the privilege on the parent is what closes that, and the server offers no route that would do it. + +Removing a tenant is therefore an operator's job, done deliberately as the migrator. That is the intent: it is not an action a customer's own administrator should be able to take through the API. + +The others are not decoration either. Row-level security already makes such a statement match nothing; the revoke turns a silent no-op into a refusal, which is what a bug in this area deserves. `apps/server/privileges.test.ts` derives the list from the policies themselves, so a future table with no `UPDATE` policy fails the suite until its revoke is written here. + +Nothing else is granted: the server holds no `TRUNCATE`, owns no table, and cannot change the schema. + +`apps/server/documented-setup.test.ts` executes the two fenced blocks above, applies the migrations as the migrator, drives the product as the runtime role, and then asks PostgreSQL what that role ended up holding: its privileges on every table in every schema, what it may create, what roles it belongs to, and its role attributes. A block edited into something that does not work fails there, and so does one that hands the runtime role more than it should have. Keep them as two fenced `sql` blocks in this order; that is what the test reads, and SQL it cannot read is an error rather than a skip. + +Two things it does not establish. It reaches the roles with `SET ROLE` on one session rather than by connecting, so `LOGIN` is checked as an attribute, while passwords and actual login are not tested. And the one-off grant for an existing database is not executed, because on the fresh database the test builds it would do nothing — which is exactly why it is not in a block. + +What survives are PostgreSQL's own defaults, which neither block revokes: the role can create temporary tables, and it can create large objects, which sit outside row-level security entirely. Nothing here uses either. `REVOKE TEMP ON DATABASE … FROM PUBLIC` takes away the first; it does not touch the second, for which PostgreSQL offers no privilege to revoke — `lo_compat_privileges` and the large object's own ownership are the only levers, and neither is worth pulling for a feature nothing uses. + +## The API reference + +`/api/v1/reference` renders the OpenAPI document for a person to read. The browser loads its JavaScript from a CDN ([ADR 0015](adr/0015-a-rendered-api-reference.md)). If users' browsers cannot reach the CDN, set `API_REFERENCE_BUNDLE_URL` to a browser-accessible URL hosting your own copy of `@scalar/api-reference`. The server does not fetch this bundle; `/api/v1/openapi.json` is unaffected. + +## Applying migrations + +```sh +MIGRATION_DATABASE_URL=… bun run db:migrate +``` + +Run it as a step of its own before starting the server, not from the server: several instances starting at once would each try, and the role that applies migrations is deliberately not the one the server has. diff --git a/docs/development.md b/docs/development.md index 2115f12..04c12d9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,20 +44,62 @@ docker run -d --name qualityruntime-postgres \ -p 5432:5432 postgres:18 ``` +Then create the two roles a deployment uses, because local development should run the way production does ([ADR 0014](adr/0014-the-runtime-role-owns-nothing.md)). One owns the schema and applies migrations; the other is what the server connects as, and owns nothing: + +```sh +docker exec -i qualityruntime-postgres psql -U postgres -d qualityruntime <<'SQL' +CREATE ROLE qualityruntime_migrator LOGIN PASSWORD 'qualityruntime'; +CREATE ROLE qualityruntime LOGIN PASSWORD 'qualityruntime'; +-- A query row security would filter fails, rather than matching nothing. +ALTER ROLE qualityruntime_migrator SET row_security = off; + +GRANT CREATE, USAGE ON SCHEMA public TO qualityruntime_migrator; +GRANT USAGE ON SCHEMA public TO qualityruntime; +-- drizzle-kit keeps its migration journal in a schema of its own. +GRANT CREATE ON DATABASE qualityruntime TO qualityruntime_migrator; + +ALTER DEFAULT PRIVILEGES FOR ROLE qualityruntime_migrator IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO qualityruntime; +SQL +``` + +After the first `bun run db:migrate`, take back the privileges no policy would allow — as `postgres`, since revoking on a table needs ownership or superuser: + +```sh +docker exec -i qualityruntime-postgres psql -U postgres -d qualityruntime <<'SQL' +REVOKE UPDATE, DELETE ON "audit_event" FROM qualityruntime; +REVOKE UPDATE, DELETE ON "file" FROM qualityruntime; +REVOKE UPDATE ON "control_requirement" FROM qualityruntime; +-- Every tenant-owned table cascades from this one, and a cascade answers to +-- neither row-level security nor the privileges on what it cascades into. +REVOKE DELETE ON "organization" FROM qualityruntime; +SQL +``` + +Neither role may be a superuser or hold `BYPASSRLS`: PostgreSQL exempts both from the policies that isolate tenants, and the server refuses to start as one. `docs/deployment.md` explains why the separation matters. `apps/server/documented-setup.test.ts` runs both blocks above exactly as they appear and then drives the product on what they produced, so keep them as shell blocks wrapping a `<<'SQL'` heredoc — that is what it reads. + `.env` is ignored, as are `.env.local` and `.env.*.local`. Mode-specific `.env.` files are **not** ignored; never put secrets in one. Change a table in `packages/db/schema/`, then generate and apply its migration: ```sh bun run db:generate --name=add_controls # writes packages/db/migrations/NNNN_add_controls.sql -bun run db:migrate # applies pending migrations as MIGRATION_DATABASE_URL +bun run db:migrate # applies pending migrations as the migrator ``` -Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the Drizzle config loads the root `.env` either way. `db:generate` needs no database; `db:migrate` connects as `MIGRATION_DATABASE_URL` and fails if it is unset, never falling back to `DATABASE_URL`. +`db:migrate` connects as `MIGRATION_DATABASE_URL` and fails if it is unset or empty. It never falls back to `DATABASE_URL`: that is the role that owns nothing, and a migration run as anything but the migrator loses the `row_security = off` guard above. `.env.example` has it. + +Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the Drizzle config loads the root `.env` either way. `db:generate` needs no database. + +For a change drizzle-kit cannot express — row-level security policies, backfills, anything hand-written — generate an empty migration instead and write the SQL yourself: -Always pass `--name`: it names the file and its `tag` in `migrations/meta/_journal.json` together. That file is drizzle-kit's, so never hand-edit it. Add the SPDX header to the generated `.sql`. Never edit a migration that may already have been applied; add a new one. See `AGENTS.md` for the rules that govern migration history, and test migrations against realistic existing data when the change is non-trivial. +```sh +bun run db:generate -- --custom --name=control_tenant_isolation +``` + +Always pass `--name`: it names the file and its `tag` in `migrations/meta/_journal.json` together. That file is drizzle-kit's, so never hand-edit it — `--custom` is how a hand-written migration gets its entry. Add the SPDX header to the generated `.sql`. Never edit a migration that may already have been applied; add a new one. See `AGENTS.md` for the rules that govern migration history, and test migrations against realistic existing data when the change is non-trivial. -`packages/db/schema/migrations.test.ts` applies the migrations to PostgreSQL (via PGlite, so nothing needs to be running) and checks the constraints they create. `apps/server/auth.test.ts` checks structural compatibility with Better Auth and exercises selected Better Auth writes against that migrated schema. +`packages/db/schema/migrations.test.ts` applies the migrations to PostgreSQL (via PGlite, so nothing needs to be running) and checks the constraints they create. `packages/db/enforcement.test.ts` does the same for tenant isolation and every rule PostgreSQL keeps on its own, acting as a non-superuser role — PGlite's default connection is a superuser, and PostgreSQL exempts superusers from row-level security, so a test written against it would pass with the policies deleted ([ADR 0003](adr/0003-tenant-isolation-with-row-level-security.md)). `apps/server/auth.test.ts` checks structural compatibility with Better Auth and exercises selected Better Auth writes against that migrated schema. After upgrading `better-auth`, run `bun run test`, then compare `packages/db/schema/auth.ts` against Drizzle reference output from the matching `auth` CLI version, generated from `authOptions` by a module exporting a built `auth` instance. Neither test compares column types, indexes, or foreign keys against the library — [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) records that gap. @@ -69,7 +111,23 @@ bun run dev # http://localhost:3000, restarting on change It runs from the repository root so Bun loads the root `.env`, and it refuses to start when `DATABASE_URL`, `BETTER_AUTH_URL`, or `BETTER_AUTH_SECRET` is missing rather than failing on the first request that needs one. -`apps/server` mounts [Better Auth](https://better-auth.com) at `/api/auth/*`. `authOptions` in `apps/server/auth.ts` is the schema contract — it decides which tables exist, and `auth.test.ts` derives its expectations from that same object. Better Auth refuses to start when the Drizzle schema object disagrees with it; that check reads the schema in code, not the live database, so applying migrations is still on you. +`apps/server` mounts [Better Auth](https://better-auth.com) at `/api/auth/*`, and this product's own API at `/api/v1`. Tenant-owned resources — controls, and the history of what happened to them — sit under `/api/v1/organizations/:organizationId` behind `organizationContext`, which resolves the caller's membership and binds `withOrganization` to that organization ([ADR 0004](adr/0004-organization-in-the-request-path.md)); a route mounted outside that prefix has no `withOrganization` on its context and fails rather than serving unscoped rows. `apps/server/organization.test.ts` and `controls.test.ts` drive the stack over HTTP as a non-superuser role, so the policies apply there too; request bodies and query strings are validated with [Zod](https://zod.dev) through `validation.ts`, which owns what a rejection looks like, and collections are paged by cursor through `pagination.ts`, each naming the ordering it is read in ([ADR 0006](adr/0006-cursor-paged-collections.md)). `responses.ts` holds the shapes a response takes, as both what routes build and the schemas that describe them; `openapi.ts` assembles those into the document served at `/api/v1/openapi.json` ([ADR 0007](adr/0007-openapi-from-the-schemas.md)). Adding a route means adding its operation there too — `openapi.test.ts` derives what the app serves and fails until the two agree. A mutating handler also records what changed through `c.var.audit`, on the same transaction as the change ([ADR 0005](adr/0005-audit-history.md)); `audit.test.ts` covers that, including that the history cannot be rewritten. `authOptions` in `apps/server/auth.ts` is the schema contract — it decides which tables exist, and `auth.test.ts` derives its expectations from that same object. Better Auth refuses to start when the Drizzle schema object disagrees with it; that check reads the schema in code, not the live database, so applying migrations is still on you. + +## Testing races + +Most of the suite runs on PGlite, which is a single connection: two things cannot happen at once, so nothing that depends on a lock has ever been exercised there. `apps/server/concurrency.test.ts` is the exception. It needs a real server, and it is skipped unless `TEST_DATABASE_URL` names one: + +```sh +docker exec qualityruntime-postgres createdb -U postgres qualityruntime_test +# then, in .env +TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime_test +``` + +These commands use the PostgreSQL container from the setup above. For a local PostgreSQL installation, adjust the database creation command and connection URL. + +It **wipes** that database on every run, so it refuses one whose name does not end in `_test`. The lock-race tests force the interleaving rather than hoping for it: a second session takes the row lock, the request is started and waits on it, the second session commits, and the request is released into a world that moved under it. A test that never blocked fails, so it cannot pass without exercising the intended race. + +Add to it whenever a handler's correctness rests on a lock. [ADR 0020](adr/0020-testing-races.md) records why this suite exists and what it does not cover. ## Before finishing diff --git a/docs/product.md b/docs/product.md index bc5da0a..1df6644 100644 --- a/docs/product.md +++ b/docs/product.md @@ -2,4 +2,80 @@ Why Quality Runtime exists: mission, users, product principles, what it is and is not, and the open-source, AI, and hosted-product philosophy. -This document will be expanded as the product takes shape. +## Why it exists + +Organizations that must demonstrate conformity — to a standard, a regulator, a customer's security review — mostly do it with documents. A spreadsheet of controls, a folder of screenshots, a policy nobody has read since it was approved, and a fortnight of work before every audit reassembling the story of what actually happened. + +The information exists. It is just not a system. Nothing can answer _which requirements has nobody taken up_, or _what evidence supports this control_, or _who attested this and when_, without a person going and looking. + +Quality Runtime exists to make that a system: one where the relationships are real, the history is kept, and the questions can be asked by a program rather than a person. Not a place to file documents — a runtime that knows what is required, what is being done about it, and what evidence there is. + +The shape of the thing is a loop: + +```text +requirement → control → evidence +``` + +A requirement is what a standard asks for. A control is what the organization does about it. Evidence is what shows the control was operated. Everything else this product may grow — audits, risks, findings, CAPAs, training, suppliers — hangs off that loop or is a variation of it. + +## Who it is for + +**The person accountable for conformity** — a quality manager, a compliance lead, whoever has to say "yes, we do that, and here is why you should believe me". They need to see what is covered and what is not, and to produce a defensible record without assembling it by hand. + +**The people who actually operate the controls** — engineers, administrators, anyone who performs the review or runs the restore test. For them the product must be quick and out of the way, or the evidence stops arriving. + +**Auditors and reviewers**, who need to follow a claim back to the thing that supports it, and to know that what they are reading has not been quietly changed since it was attested. + +**Programs.** Scripts, integrations, and AI agents are first-class users, not an afterthought: most evidence is produced by systems, and a product that can only be operated by a person in a browser will always be behind. + +## Product principles + +These are not aspirations. Each one is already a decision somewhere in `docs/adr/`, and the reasoning lives there. + +**Enforce it where it cannot be forgotten.** Tenant isolation, an append-only audit log and the finality of an attested record are enforced by PostgreSQL, not by application code that has to remember. Application code is where mistakes live; a policy is where a guarantee lives. + +**Record what happened rather than overwrite it.** History is not a feature. A control that was in effect and is now retired, and an attestation made by a person who has since left, are both part of the record — so retiring is what deletion usually means, and attribution survives the actor. + +**Say what you know, and not more.** A control mapped to a requirement means somebody _intends_ it to address that requirement. It does not mean the requirement is met, and the product does not let that word creep in. A compliance score computed from mappings would be a number that means nothing, arrived at confidently. + +**Model small, and add when something needs it.** Every entity here is narrower than a quality system eventually wants: no owner on a control, no rationale on a mapping, no validity period on evidence. A field added when a workflow needs it is cheaper than one that turned out to mean the wrong thing. The absences are deliberate and written down. + +**Make self-hosting boring.** PostgreSQL, and nothing else mandatory: no queue, no object store, no search cluster, no second service to operate. Anything that would become mandatory has to earn it. + +**Be readable by a program.** The API describes itself, the identifiers say what they are, the errors carry codes, and the collections page the same way. An AI agent should be able to work the product from its own description, without a human explaining the conventions first. + +**Say what is not true yet.** The documents here record gaps as plainly as features — what is unaudited, what is last-writer-wins, what nobody has verified. A product that hides its edges is one nobody can safely build on. + +## What it is not + +**Not a document management system.** Controlled documents — revisions, approvals, effective dates, supersession — are a deep subsystem and a familiar one, and building it first would have delayed the loop that actually distinguishes this product. It is postponed deliberately, not forgotten. + +**Not a compliance score.** Nothing here computes a percentage of conformity. The product answers concrete questions — which clauses nobody has taken up, what evidence supports this control — and leaves the judgement to the person whose name goes on it. + +**Not a checklist that certifies anything.** Using Quality Runtime does not make an organization conformant, and no output of it is an audit opinion. It keeps the record; people and auditors decide what the record means. + +**Not a hosted-only product.** The open-source runtime is the product, not a demonstration of it. + +## AI + +AI is expected to read, write, map, draft and operate through the same API everything else uses. Requests currently record the authenticated user as the actor; the `system` actor type is reserved for background work and has no writer yet. It does not identify programs using a person's session. + +What AI may not do is become an implicit source of truth. Anything it produces is inspectable and attributable, and nothing it generates bypasses the enforcement everything else is subject to: a control drafted by a model is a draft, and becomes active only through the same deliberate transition anything else makes, recorded with who made it. + +Nothing yet _requires_ a person for that transition — the lifecycle makes it deliberate and the audit record makes it attributable, but no rule says an agent may not put a control into effect. Whether some acts should require a human is a real question, and `member.role` exists but does not gate domain API actions today. + +What it cannot currently tell is a program holding a person's credentials from that person. There are no machine credentials distinct from a human session, so anything with the cookie is that human as far as the system knows. For a record whose whole value is that somebody vouched, that is a gap worth naming. + +## The open-source boundary + +Everything generally useful to anyone operating Quality Runtime themselves belongs in this repository, and it should be genuinely useful with no hosted service involved. + +A managed service is planned, and what belongs to it is the operation rather than the product: billing, metering, provisioning, entitlements, internal cloud operations. It extends the runtime; it does not redefine it, and it does not hold back capability the self-hosted product needs. + +## Status + +The schema for the whole loop is in place, and PostgreSQL enforces its tenancy and finality: standards, requirements, controls, mappings, evidence, attestation and files. The API serves the first part of it: controls can be created, changed, moved through their lifecycle and — while they never took effect — discarded, and every change is audited and readable as history. Standards, mappings, evidence and files are not yet reachable through the API. + +There is no user interface, no deployment artifact, and none of the entities beyond that loop. + +`README.md` states the maturity honestly. Nothing here is stable: the API, the schema and the shape of the model may all still change, and the documents in `docs/adr/` record why each is what it is so that changing one is a decision rather than a guess. diff --git a/docs/security.md b/docs/security.md index ee09ae5..aebb56b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,6 +6,30 @@ For vulnerability reporting, see [`.github/SECURITY.md`](../.github/SECURITY.md) ## Tenant authorization -`session.activeOrganizationId` records which tenant a request is acting in. It is **context, not authorization**. Tenant authorization must use the caller’s current `member` row — membership and role — resolved per request (TENANT-01). +A request names the organization it acts in **in its path** — `/api/v1/organizations/{organizationId}/…` — and `organizationContext` resolves that to the caller's `member` row before any handler runs ([ADR 0004](adr/0004-organization-in-the-request-path.md)). Membership, resolved per request, authorizes domain API access (TENANT-01); these handlers do not yet restrict actions by role. A caller who is not a member gets 404 rather than 403, so an identifier cannot be probed for membership. -Never treat possession of a session carrying an organization id as proof of access to that organization, and never filter by it alone. +`session.activeOrganizationId` is a different thing: the organization the user last selected, remembered so a UI can offer it again. It is **a preference, not a scope and not a permission** — the routes never read it, and nothing should treat possession of a session carrying an organization id as proof of access to that organization, or filter by it alone. + +## Tenant isolation + +Authorization decides whether a caller may act in an organization. Isolation makes the answer stick: once a request is scoped to an organization, the database will not let it read or write outside one. + +The two are not interchangeable. Isolation contains a query that forgets its tenant predicate, and a code path that reaches a tenant-owned table with no tenant context at all. It does **not** second-guess the authorization decision — hand `withOrganization` an organization the caller has no membership in and it will faithfully scope to that organization. Resolving the caller's `member` row remains the thing that decides access. + +PostgreSQL enforces it. Every tenant-owned table has row-level security enabled and forced, with a policy comparing `organization_id` to a transaction-local setting, and domain code reaches those tables only through `withOrganization`: + +```ts +const controls = await withOrganization(db, organizationId, (tx) => tx.select().from(control)); +``` + +A transaction with no organization set sees nothing and can write nothing, so forgetting the context fails closed. [ADR 0003](adr/0003-tenant-isolation-with-row-level-security.md) records the design and its limits. + +**The application must connect to PostgreSQL as a non-superuser role without `BYPASSRLS`.** PostgreSQL exempts both from every policy, and no migration can prevent it. The server checks at startup and refuses to run as either, with `row_security = off`, or when row security is not enabled and forced on every domain table. See [deployment](deployment.md). + +Better Auth's tables are outside this: it resolves a user's memberships before any organization is known, so `member` and `invitation` carry no policy and are reached through Better Auth's own authorization. + +`audit_event` is isolated the same way but narrower: its policies name `SELECT` and `INSERT` and nothing else, so a tenant can read and add to its history and no application code path can rewrite or erase it ([ADR 0005](adr/0005-audit-history.md)). + +**Any member can read all of it.** `GET /history` serves the organization's whole audit history — actor labels, impersonation attribution, and the `before`/`after` of every change, including records since deleted ([ADR 0018](adr/0018-one-history-rather-than-one-per-record.md)). Membership is the authorization boundary for the domain API; its handlers do not gate actions on `member.role`. Better Auth applies its own authorization to organization administration. That is a deliberate widening and the first place a reader-level role would be needed. Row security does not govern `TRUNCATE` or a table owner's privileges, so protecting the history from the runtime role itself is a matter of grants — see [deployment](deployment.md). + +Three other tables name their commands rather than covering them all at once, and in each case the `DELETE` policy — or its absence — is where the rule lives. `evidence` admits only unattested rows, so what was signed cannot be removed; `file` has no `DELETE` policy at all, and a trigger refuses an attachment to attested evidence. `control` admits only a draft, and a draft is held to be one that never took effect: a trigger owns `activated_at` and a CHECK ties a draft to its being null, so nothing that was in effect can become a draft again ([ADR 0017](adr/0017-discarding-a-draft-control.md)). Where a route reaches one of these, it answers with something a caller can act on; the policy is what makes the rule true.