From 989b8d760a0d21e6cb351d427062e59cb988c7a4 Mon Sep 17 00:00:00 2001 From: "quality-runtime[bot]" <330432719+quality-runtime[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:34:40 +0200 Subject: [PATCH] feat: add the domain tables and enforce tenancy in PostgreSQL Adds every tenant-owned table behind the requirement -> control -> evidence loop: control, standard, requirement, control_requirement, evidence, file and audit_event, with the shared column builders and prefixed-identifier helpers they use. The point of the package is what PostgreSQL enforces rather than what code remembers. Every domain table has row-level security enabled and forced, so the policies bind the table owner too. Foreign keys are composite on (id, organization_id), so a row referencing another tenant's row cannot be formed at all. Where the rule depends on the row, policies name each command: audit_event is append-only, evidence can be amended or discarded only until it is attested, a file is never detached and a requirement mapping never rewritten, and only a draft control may be deleted. Two migrations. 0000 is generated from the schema and replaces the one already in history, which no database has applied; 0001 is hand-written and holds what drizzle-kit cannot express together: FORCE, the policies, and two triggers. The triggers exist where a policy cannot do the job. A policy is a test, not a lock: under read committed it reads a snapshot, so file_evidence_open locks the evidence FOR NO KEY UPDATE on every attachment and refuses one to attested evidence, rather than relying on each writer to take that lock. A policy also cannot see a row's past, so control_lifecycle_enforce owns activated_at and keeps a retired control retired; with a CHECK tying a draft to a null activated_at, the lifecycle is draft -> active -> retired, one way, whatever writes the row. Both pin search_path and qualify their tables, so a temporary table cannot stand in for evidence. Append-only and signed rows also get shape checks they can never be corrected past: user attribution must be a user identifier, the system carries none, and audit before/after must be JSON objects. withOrganization opens the transaction the policies read, and refuses to nest because a savepoint does not scope SET LOCAL. assertTenantIsolation now refuses to start on a connection that bypasses row security, runs with row_security = off, or finds a domain table whose row security is not enabled and forced. db:migrate connects only as MIGRATION_DATABASE_URL, so a migration never runs as the server's role. Verified in a clean checkout of main with only this change applied: bun run check, bun run test (148 tests, including the existing server tests against this package) and reuse lint pass. packages/db/enforcement.test.ts exercises each rule as a non-superuser role. Comments cite ADRs by path; those describe decisions spanning the schema and the API, and land with the documentation later in this series. Signed-off-by: quality-runtime[bot] <330432719+quality-runtime[bot]@users.noreply.github.com> --- .env.example | 4 + docs/data-model.md | 4 +- docs/development.md | 6 +- docs/security.md | 2 - packages/db/client.ts | 17 +- packages/db/drizzle.config.ts | 20 +- packages/db/enforcement.test.ts | 701 ++++++ packages/db/id.ts | 12 +- packages/db/index.ts | 8 +- .../db/migrations/0000_auth_and_tenancy.sql | 125 -- packages/db/migrations/0000_schema.sql | 264 +++ .../migrations/0001_tenancy_and_finality.sql | 240 ++ .../db/migrations/meta/0000_snapshot.json | 1052 ++++++++- .../db/migrations/meta/0001_snapshot.json | 1964 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 11 +- packages/db/schema/audit.ts | 153 ++ packages/db/schema/auth.ts | 31 +- packages/db/schema/columns.ts | 36 + packages/db/schema/control-requirement.ts | 63 + packages/db/schema/control.ts | 100 + packages/db/schema/evidence.ts | 101 + packages/db/schema/file.ts | 81 + packages/db/schema/index.ts | 6 + packages/db/schema/migrations.test.ts | 276 ++- packages/db/schema/standard.ts | 143 ++ packages/db/tenant.ts | 186 ++ 26 files changed, 5427 insertions(+), 179 deletions(-) create mode 100644 packages/db/enforcement.test.ts delete mode 100644 packages/db/migrations/0000_auth_and_tenancy.sql create mode 100644 packages/db/migrations/0000_schema.sql create mode 100644 packages/db/migrations/0001_tenancy_and_finality.sql create mode 100644 packages/db/migrations/meta/0001_snapshot.json create mode 100644 packages/db/schema/audit.ts create mode 100644 packages/db/schema/columns.ts create mode 100644 packages/db/schema/control-requirement.ts create mode 100644 packages/db/schema/control.ts create mode 100644 packages/db/schema/evidence.ts create mode 100644 packages/db/schema/file.ts create mode 100644 packages/db/schema/standard.ts create mode 100644 packages/db/tenant.ts diff --git a/.env.example b/.env.example index 5e80585..ab1973a 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ DATABASE_URL=postgres://postgres:postgres@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 + # Public origin the server is reached at. BETTER_AUTH_URL=http://localhost:3000 diff --git a/docs/data-model.md b/docs/data-model.md index 14354e2..8064dd4 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -21,7 +21,7 @@ These tables are owned by [Better Auth](https://better-auth.com) and defined in ## Identifiers -Every record's primary key uses `_`, for example `usr_v1stgxr8z5jdhi6b`. The prefix identifies the record type, and the random portion is a lowercase base36 value generated with [Nano ID](https://github.com/ai/nanoid). [ADR 0002](adr/0002-prefixed-identifiers.md) documents the decision. `packages/db/id.ts` is the single place where identifiers are generated. +Every generated row identifier uses `_`, for example `usr_v1stgxr8z5jdhi6b`. The prefix identifies the record type, and the random portion is a lowercase base36 value generated with [Nano ID](https://github.com/ai/nanoid). [ADR 0002](adr/0002-prefixed-identifiers.md) documents the decision. `packages/db/id.ts` is the single place where identifiers are generated. | Table | Prefix | Random length | | -------------- | ------ | ------------- | @@ -34,7 +34,7 @@ Every record's primary key uses `_`, for example `usr_v1stgxr8z5 | `member` | `mem_` | 16 | | `two_factor` | `tfa_` | 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 table enforces its own prefix, length, and alphabet with a CHECK constraint, so an identifier belonging to another table — or one carrying uppercase — is rejected rather than stored. +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 diff --git a/docs/development.md b/docs/development.md index 444425e..2115f12 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,8 +25,6 @@ bun run lint # lint only bun run test # run tests ``` -Commands shown in this document work as written; do not assume undocumented ones exist. - ## Editor `.vscode/` carries the shared setup; VS Code offers the recommended extensions on first open. @@ -52,10 +50,10 @@ 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 to DATABASE_URL +bun run db:migrate # applies pending migrations as MIGRATION_DATABASE_URL ``` -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` fails if `DATABASE_URL` is unset. +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`. 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. diff --git a/docs/security.md b/docs/security.md index 3300b2a..ee09ae5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,5 +9,3 @@ For vulnerability reporting, see [`.github/SECURITY.md`](../.github/SECURITY.md) `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). Never treat possession of a session carrying an organization id as proof of access to that organization, and never filter by it alone. - -This document will be expanded as authentication, tenancy, and file access are implemented. diff --git a/packages/db/client.ts b/packages/db/client.ts index d9a9449..b40209a 100644 --- a/packages/db/client.ts +++ b/packages/db/client.ts @@ -1,17 +1,28 @@ // SPDX-FileCopyrightText: 2026 Quality Runtime contributors // SPDX-License-Identifier: Apache-2.0 +import type { ExtractTablesWithRelations } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; +import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres"; +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; import type { Client, Pool } from "pg"; import * as schema from "./schema/index.ts"; /** - * A Drizzle handle bound to a connection. + * A Drizzle handle on this schema. * * Query code takes this type rather than a connection of its own, so a test can - * hand it a handle bound to a throwaway database. + * hand it a handle bound to a throwaway database. A transaction satisfies it, + * which is what lets a query run inside `withOrganization`. + * + * The driver is a parameter because the handles here come from two: + * node-postgres in a deployment, PGlite in tests. */ -export type Database = ReturnType; +export type Database = PgDatabase< + Q, + typeof schema, + ExtractTablesWithRelations +>; /** * Binds Drizzle to a connection the caller owns. diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 97208fd..9a9833d 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -12,8 +12,8 @@ const packageDir = relative(process.cwd(), fileURLToPath(new URL(".", import.met // The repository keeps one `.env` at its root; drizzle-kit only reads the // working directory's. Loading it here makes the commands behave the same from -// the root and from this package. The file is optional when DATABASE_URL -// is supplied by the environment. +// the root and from this package. The file is optional when +// MIGRATION_DATABASE_URL is supplied by the environment. try { process.loadEnvFile(fileURLToPath(new URL("../../.env", import.meta.url))); } catch (error) { @@ -31,10 +31,20 @@ export default defineConfig({ // added without one is spelled. Must match `createDatabase`. casing: "snake_case", dbCredentials: { - // Only read by commands that connect, so `generate` works without it. + /** + * Only read by commands that connect, so `generate` works without it. + * + * The migrator's, and never the server's `DATABASE_URL` as a fallback: + * migrations are applied by the role that owns the schema and runs with + * `row_security = off`, so a data migration that forgot its tenant fails + * rather than quietly matching nothing (ADR 0014). Keep this credential + * separate from the runtime role's. + */ get url() { - const url = process.env.DATABASE_URL; - if (!url) throw new Error("DATABASE_URL is not set; it is required to reach the database."); + const url = process.env.MIGRATION_DATABASE_URL; + if (!url) { + throw new Error("MIGRATION_DATABASE_URL is not set; migrations connect as the migrator."); + } return url; }, }, diff --git a/packages/db/enforcement.test.ts b/packages/db/enforcement.test.ts new file mode 100644 index 0000000..0428af4 --- /dev/null +++ b/packages/db/enforcement.test.ts @@ -0,0 +1,701 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tenant isolation and database-enforced lifecycle rules. + * + * PGlite's default role is a superuser, which bypasses row-level security. + * After migration and fixture setup, tests use a non-superuser role, except + * where a case is about the superuser itself: a start-up refusal, or an + * operator removing a tenant. That role owns `control` to exercise FORCE ROW + * LEVEL SECURITY on an owner; deployment runtime roles should own nothing. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { createId } from "./id.ts"; +import * as schema from "./schema/index.ts"; +import { + auditEvent, + control, + controlRequirement, + evidence, + file, + organization, + requirement, + standard, +} from "./schema/index.ts"; +import { assertTenantIsolation, withOrganization } from "./tenant.ts"; + +const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url)); + +/** Stands in for the role a deployment connects as: no superuser, no BYPASSRLS. */ +const applicationRole = "qualityruntime_app"; + +/** Bound to the schema and casing `createDatabase` uses, so the handle matches + * the one the server passes to `withOrganization`. */ +let db: ReturnType; + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +/** Two tenants, each with one control, created before the role drops. */ +let tenantA: string; +let tenantB: string; +let controlA: string; + +beforeAll(async () => { + const client = new PGlite(); + db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + + // Seeded as the superuser, so the fixtures do not depend on what is being + // tested. Both tenants get a control; neither should ever see the other's. + const [a, b] = await db + .insert(organization) + .values([ + { name: "Acme", slug: createId("organization") }, + { name: "Globex", slug: createId("organization") }, + ]) + .returning(); + tenantA = a!.id; + tenantB = b!.id; + const [row] = await db + .insert(control) + .values([ + { organizationId: tenantA, name: "Access review" }, + { organizationId: tenantB, name: "Supplier audit" }, + ]) + .returning(); + controlA = row!.id; + + // The application role: not a superuser, and the owner of `control`, so + // FORCE is what makes that table's policies apply to it. + await client.exec(` + create role ${applicationRole} nosuperuser nobypassrls; + grant all on all tables in schema public to ${applicationRole}; + alter table "control" owner to ${applicationRole}; + set role ${applicationRole}; + `); +}, 60_000); + +afterAll(() => db.$client.close()); + +/** + * PostgreSQL raises `insufficient_privilege` when a write fails a policy's WITH + * CHECK. Drizzle wraps the message, so the code on the cause is what identifies + * it; matching the text would pass for any failed query. + */ +const insufficientPrivilege = "42501"; + +/** The PostgreSQL error code a statement was rejected with. */ +async function rejectedWith(statement: Promise): Promise { + try { + await statement; + } catch (error) { + return (error as { cause?: { code?: string } }).cause?.code; + } + throw new Error("expected the statement to be rejected, but it succeeded"); +} + +/** PostgreSQL's code for a trigger or a restricting foreign key refusing a write. */ +const restrictViolation = "23001"; + +/** PostgreSQL's code for a CHECK constraint refusing a row. */ +const checkViolation = "23514"; + +/** Rows of `control` the current role can see, whatever tenant they belong to. */ +const visibleControls = () => db.select().from(control); + +describe("assertTenantIsolation", () => { + it("accepts a role that policies apply to", async () => { + await expect(assertTenantIsolation(db)).resolves.toBeUndefined(); + }); + + it("rejects a role that bypasses row-level security", async () => { + // The superuser PGlite connects as by default, and the one a container + // image hands you: every policy in the database is inert for it. + await db.$client.exec("reset role;"); + + await expect(assertTenantIsolation(db)).rejects.toThrow(/bypasses row-level security/); + + await db.$client.exec(`set role ${applicationRole};`); + }); + + it("rejects a database whose table does not force row-level security", async () => { + // The role owns `control`, so it can do this — and as the owner, it would + // then be exempt from that table's policies. + await db.$client.exec('alter table "control" no force row level security;'); + try { + await expect(assertTenantIsolation(db)).rejects.toThrow(/not enabled and forced on control/); + } finally { + await db.$client.exec('alter table "control" force row level security;'); + } + }); + + it("rejects a connection with row_security off, the migrator's setting", async () => { + await db.$client.exec("set row_security = off;"); + try { + await expect(assertTenantIsolation(db)).rejects.toThrow(/row_security = off/); + } finally { + await db.$client.exec("reset row_security;"); + } + }); +}); + +describe("row-level security", () => { + it("hides every tenant's rows when no organization is set", async () => { + // The dangerous default is the opposite: a forgotten context showing + // everything. NULL never equals an organization id, so the table is empty. + expect(await visibleControls()).toEqual([]); + }); + + it("refuses a write outside a tenant context", async () => { + const insert = db.insert(control).values({ organizationId: tenantA, name: "Smuggled" }); + + expect(await rejectedWith(insert)).toBe(insufficientPrivilege); + }); +}); + +describe("withOrganization", () => { + it("shows only the organization's own rows", async () => { + const rows = await withOrganization(db, tenantA, (tx) => tx.select().from(control)); + + expect(rows.map((row) => row.name)).toEqual(["Access review"]); + }); + + it("hides another organization's row from a read by id", async () => { + const rows = await withOrganization(db, tenantB, (tx) => + tx.select().from(control).where(eq(control.id, controlA)), + ); + + expect(rows).toEqual([]); + }); + + it("silently changes nothing when another organization updates by id", async () => { + // An UPDATE cannot see the row, so it matches nothing. The caller learns + // this from the empty result, not from an error — which is the correct + // shape: a tenant should not be able to probe for another's identifiers. + const updated = await withOrganization(db, tenantB, (tx) => + tx + .update(control) + .set({ name: "Renamed by Globex" }) + .where(eq(control.id, controlA)) + .returning(), + ); + + expect(updated).toEqual([]); + const [after] = await withOrganization(db, tenantA, (tx) => + tx.select().from(control).where(eq(control.id, controlA)), + ); + expect(after?.name).toBe("Access review"); + }); + + it("deletes nothing when another organization deletes by id", async () => { + const deleted = await withOrganization(db, tenantB, (tx) => + tx.delete(control).where(eq(control.id, controlA)).returning(), + ); + + expect(deleted).toEqual([]); + }); + + it("refuses a row labelled with another organization", async () => { + // The forged column is the whole attack: a caller legitimately inside + // tenant A asking the database to file a row under tenant B. + const insert = withOrganization(db, tenantA, (tx) => + tx.insert(control).values({ organizationId: tenantB, name: "Planted" }), + ); + + expect(await rejectedWith(insert)).toBe(insufficientPrivilege); + }); + + it("refuses to move an existing row to another organization", async () => { + const update = withOrganization(db, tenantA, (tx) => + tx.update(control).set({ organizationId: tenantB }).where(eq(control.id, controlA)), + ); + + expect(await rejectedWith(update)).toBe(insufficientPrivilege); + }); + + it("writes rows the organization can then read", async () => { + await withOrganization(db, tenantA, (tx) => + tx.insert(control).values({ organizationId: tenantA, name: "Backup restore test" }), + ); + + const rows = await withOrganization(db, tenantA, (tx) => tx.select().from(control)); + expect(rows.map((row) => row.name).sort()).toEqual(["Access review", "Backup restore test"]); + }); + + it("leaves no context behind for the next transaction", async () => { + // The connection is pooled and reused, so a leaked setting would hand the + // next request another tenant's rows. + await withOrganization(db, tenantA, async (tx) => tx.select().from(control)); + + expect(await visibleControls()).toEqual([]); + }); + + it("leaves no context behind when the transaction rolls back", async () => { + const failed = withOrganization(db, tenantA, async () => { + throw new Error("work failed"); + }); + + await expect(failed).rejects.toThrow("work failed"); + expect(await visibleControls()).toEqual([]); + }); + + it("rolls back the work it wrapped", async () => { + const before = await withOrganization(db, tenantA, (tx) => tx.select().from(control)); + + const failed = withOrganization(db, tenantA, async (tx) => { + await tx.insert(control).values({ organizationId: tenantA, name: "Abandoned" }); + throw new Error("work failed"); + }); + await expect(failed).rejects.toThrow("work failed"); + + const after = await withOrganization(db, tenantA, (tx) => tx.select().from(control)); + expect(after).toHaveLength(before.length); + }); + + it("refuses to nest, leaving the outer transaction on its own tenant", async () => { + // A savepoint does not scope SET LOCAL: were this allowed, releasing the + // inner savepoint would leave tenant B in force and the rest of the outer + // transaction would quietly read and write as the wrong tenant. + const rows = await withOrganization(db, tenantA, async (tx) => { + // @ts-expect-error a transaction is not a handle `withOrganization` takes + await expect(withOrganization(tx, tenantB, async () => undefined)).rejects.toThrow( + /cannot nest/, + ); + return tx.select().from(control); + }); + + expect(rows.every((row) => row.organizationId === tenantA)).toBe(true); + }); + + it("binds the organization rather than interpolating it", async () => { + // A value that would end the statement if it were pasted into the SQL. + const injected = 'org_x\'; drop table "control"; --'; + + const rows = await withOrganization(db, injected, (tx) => tx.select().from(control)); + + expect(rows).toEqual([]); + expect(await db.execute(sql`select count(*)::int as count from "control"`)).toBeDefined(); + }); +}); + +describe("control.activated_at", () => { + it("is stamped by the database when a control takes effect", async () => { + const [row] = await withOrganization(db, tenantA, (tx) => + tx + .insert(control) + .values({ organizationId: tenantA, name: "Live", status: "active" }) + .returning(), + ); + + expect(row?.activatedAt).toBeInstanceOf(Date); + }); + + it("cannot be backdated on insert", async () => { + // Only the database says when a control took effect. + const insert = withOrganization(db, tenantA, (tx) => + tx.insert(control).values({ + organizationId: tenantA, + name: "Forged", + activatedAt: new Date("2020-01-01T00:00:00Z"), + }), + ); + + expect(await rejectedWith(insert)).toBe(restrictViolation); + }); + + it("cannot be set on a control that never took effect", async () => { + const update = withOrganization(db, tenantA, (tx) => + tx.update(control).set({ activatedAt: new Date() }).where(eq(control.id, controlA)), + ); + + expect(await rejectedWith(update)).toBe(restrictViolation); + }); + + it("cannot be supplied alongside the activation itself", async () => { + const insert = withOrganization(db, tenantA, (tx) => + tx.insert(control).values({ + organizationId: tenantA, + name: "Forged live", + status: "active", + activatedAt: new Date("2020-01-01T00:00:00Z"), + }), + ); + + expect(await rejectedWith(insert)).toBe(restrictViolation); + }); + + it("cannot be cleared once stamped", async () => { + // Clearing it would let a control that was in effect pass as a draft, and + // the DELETE policy admits drafts. + const cleared = withOrganization(db, tenantA, async (tx) => { + const [row] = await tx + .insert(control) + .values({ organizationId: tenantA, name: "Once live", status: "active" }) + .returning(); + await tx + .update(control) + .set({ status: "draft", activatedAt: null }) + .where(eq(control.id, row!.id)); + }); + + expect(await rejectedWith(cleared)).toBe(restrictViolation); + }); + + it("cannot be moved once stamped", async () => { + const moved = withOrganization(db, tenantA, async (tx) => { + const [row] = await tx + .insert(control) + .values({ organizationId: tenantA, name: "Live, then redated", status: "active" }) + .returning(); + await tx + .update(control) + .set({ activatedAt: new Date("2020-01-01T00:00:00Z") }) + .where(eq(control.id, row!.id)); + }); + + expect(await rejectedWith(moved)).toBe(restrictViolation); + }); + + it("keeps a retired control retired", async () => { + // A retired control keeps its stamp, so the CHECK alone would admit it + // becoming active again; the trigger is what makes retirement final. + const revived = withOrganization(db, tenantA, async (tx) => { + const [row] = await tx + .insert(control) + .values({ organizationId: tenantA, name: "Retired for good", status: "active" }) + .returning(); + await tx.update(control).set({ status: "retired" }).where(eq(control.id, row!.id)); + await tx.update(control).set({ status: "active" }).where(eq(control.id, row!.id)); + }); + + expect(await rejectedWith(revived)).toBe(restrictViolation); + }); + + it("is required of a retired control", async () => { + // Retired means no longer in effect, so what never was cannot be. + const insert = withOrganization(db, tenantA, (tx) => + tx.insert(control).values({ organizationId: tenantA, name: "Never live", status: "retired" }), + ); + + expect(await rejectedWith(insert)).toBe(checkViolation); + }); +}); + +describe("attested evidence", () => { + /** Evidence on tenant A's control, attested. */ + const attested = () => + withOrganization(db, tenantA, async (tx) => { + const [row] = await tx + .insert(evidence) + .values({ + organizationId: tenantA, + controlId: controlA, + title: "Signed", + occurredAt: new Date(), + }) + .returning(); + await tx + .update(evidence) + .set({ attestedAt: new Date(), attestedById: createId("user"), attestedByLabel: "Ada" }) + .where(eq(evidence.id, row!.id)); + return row!.id; + }); + + it("cannot be amended", async () => { + const evidenceId = await attested(); + + const amended = await withOrganization(db, tenantA, (tx) => + tx + .update(evidence) + .set({ title: "Rewritten" }) + .where(eq(evidence.id, evidenceId)) + .returning(), + ); + + expect(amended).toEqual([]); + }); + + it("cannot be discarded", async () => { + const evidenceId = await attested(); + + const discarded = await withOrganization(db, tenantA, (tx) => + tx.delete(evidence).where(eq(evidence.id, evidenceId)).returning(), + ); + + expect(discarded).toEqual([]); + }); + + it("keeps the control it names", async () => { + // `controlA` is a draft that never took effect, so the DELETE policy + // admits it; the restricting foreign key is what refuses. + await attested(); + + const removed = withOrganization(db, tenantA, (tx) => + tx.delete(control).where(eq(control.id, controlA)), + ); + + expect(await rejectedWith(removed)).toBe(restrictViolation); + }); +}); + +describe("attachments", () => { + /** Evidence on tenant A's control, attested when asked. */ + const newEvidence = (attested: boolean) => + withOrganization(db, tenantA, async (tx) => { + const [row] = await tx + .insert(evidence) + .values({ + organizationId: tenantA, + controlId: controlA, + title: "Minutes", + occurredAt: new Date(), + }) + .returning(); + if (attested) { + await tx + .update(evidence) + .set({ attestedAt: new Date(), attestedById: createId("user"), attestedByLabel: "Ada" }) + .where(eq(evidence.id, row!.id)); + } + return row!.id; + }); + + const attach = (evidenceId: string) => + withOrganization(db, tenantA, (tx) => + tx + .insert(file) + .values({ + organizationId: tenantA, + evidenceId, + filename: "minutes.pdf", + contentType: "application/pdf", + bytes: 1, + checksum: "a".repeat(64), + }) + .returning(), + ); + + it("attach to evidence nobody has attested", async () => { + expect(await attach(await newEvidence(false))).toHaveLength(1); + }); + + it("are refused on attested evidence", async () => { + expect(await rejectedWith(attach(await newEvidence(true)))).toBe(restrictViolation); + }); + + it("are refused on another organization's evidence", async () => { + const evidenceId = await newEvidence(false); + + const insert = withOrganization(db, tenantB, (tx) => + tx.insert(file).values({ + organizationId: tenantB, + evidenceId, + filename: "smuggled.pdf", + contentType: "application/pdf", + bytes: 1, + checksum: "a".repeat(64), + }), + ); + + expect(await rejectedWith(insert)).toBe(restrictViolation); + }); + + it("cannot be smuggled past the check with a table of the same name", async () => { + // Unqualified names resolve in `pg_temp` first, and any role may create + // temporary tables by default. A trigger that said `evidence` rather than + // `public.evidence` would lock this impostor and let the real, attested + // row gain a file. + const evidenceId = await newEvidence(true); + + const smuggled = withOrganization(db, tenantA, async (tx) => { + await tx.execute( + sql.raw(`create temporary table "evidence" ( + "id" text, "organization_id" text, "attested_at" timestamptz + ) on commit drop`), + ); + await tx.execute( + sql`insert into pg_temp."evidence" values (${evidenceId}, ${tenantA}, null)`, + ); + return tx.insert(file).values({ + organizationId: tenantA, + evidenceId, + filename: "smuggled.pdf", + contentType: "application/pdf", + bytes: 1, + checksum: "a".repeat(64), + }); + }); + + expect(await rejectedWith(smuggled)).toBe(restrictViolation); + }); + + it("are never detached", async () => { + const evidenceId = await newEvidence(false); + const [attached] = await attach(evidenceId); + + const deleted = await withOrganization(db, tenantA, (tx) => + tx.delete(file).where(eq(file.id, attached!.id)).returning(), + ); + + // Matched nothing, rather than removed: the row is still there. + expect(deleted).toEqual([]); + const kept = await withOrganization(db, tenantA, (tx) => + tx.select().from(file).where(eq(file.id, attached!.id)), + ); + expect(kept).toHaveLength(1); + }); +}); + +describe("control_requirement", () => { + it("is never rewritten in place", async () => { + // Made or unmade, never moved: a link rewritten in place would change + // what a control answers to without either end hearing about it. + const updated = await withOrganization(db, tenantA, async (tx) => { + const [issue] = await tx + .insert(standard) + .values({ organizationId: tenantA, name: "ISO 9001", edition: "2015" }) + .returning(); + const [first, second] = await tx + .insert(requirement) + .values([ + { + organizationId: tenantA, + standardId: issue!.id, + reference: "7.5.2", + title: "Creating", + position: 1, + }, + { + organizationId: tenantA, + standardId: issue!.id, + reference: "7.5.3", + title: "Control", + position: 2, + }, + ]) + .returning(); + await tx + .insert(controlRequirement) + .values({ organizationId: tenantA, controlId: controlA, requirementId: first!.id }); + + return tx + .update(controlRequirement) + .set({ requirementId: second!.id }) + .where(eq(controlRequirement.controlId, controlA)) + .returning(); + }); + + expect(updated).toEqual([]); + }); +}); + +describe("audit_event", () => { + /** An event with its attribution overridden, recorded in tenant A. */ + const record = (actor: Partial) => + withOrganization(db, tenantA, (tx) => + tx.insert(auditEvent).values({ + organizationId: tenantA, + actorType: "user", + actorId: createId("user"), + action: "updated", + resourceType: "control", + resourceId: controlA, + ...actor, + }), + ); + + it("records a user by identifier", async () => { + await expect(record({})).resolves.toBeDefined(); + }); + + it.each([ + ["a list", { after: [] }], + ["a bare value", { before: 42 }], + ])("refuses %s as the fields that changed", async (_case, change) => { + const recorded = record(change as unknown as Partial); + + expect(await rejectedWith(recorded)).toBe(checkViolation); + }); + + it("records the system without one", async () => { + await expect(record({ actorType: "system", actorId: null })).resolves.toBeDefined(); + }); + + it.each([ + ["no user at all", { actorId: null }], + ["an identifier that is not a user's", { actorId: "" }], + ["a label for nobody", { onBehalfOfId: null, onBehalfOfLabel: "Ada" }], + ["somebody who is not a user", { onBehalfOfId: "" }], + ["the system under some name", { actorType: "system" as const, actorId: "cron" }], + ])("refuses attribution to %s", async (_case, actor) => { + // A CHECK passes when its expression is NULL, so the missing identifiers + // are the cases most worth pinning. + expect(await rejectedWith(record(actor))).toBe(checkViolation); + }); +}); + +describe("removing a tenant", () => { + it("deletes signed evidence, its control and its history with the organization", async () => { + // An operator's act, never the server's (ADR 0014), but one that must work: + // `evidence` restricts deleting its control, and the organization cascades + // into both. PostgreSQL has to settle the two branches without the + // restriction refusing a control whose evidence is going too. + await db.$client.exec("reset role;"); + try { + const [tenant] = await db + .insert(organization) + .values({ name: "Initech", slug: createId("organization") }) + .returning(); + const organizationId = tenant!.id; + const [live] = await db + .insert(control) + .values({ organizationId, name: "TPS reports", status: "active" }) + .returning(); + const [signed] = await db + .insert(evidence) + .values({ + organizationId, + controlId: live!.id, + title: "Cover sheet", + occurredAt: new Date(), + }) + .returning(); + await db.insert(file).values({ + organizationId, + evidenceId: signed!.id, + filename: "cover.pdf", + contentType: "application/pdf", + bytes: 1, + checksum: "a".repeat(64), + }); + await db + .update(evidence) + .set({ attestedAt: new Date(), attestedById: createId("user"), attestedByLabel: "Bill" }) + .where(eq(evidence.id, signed!.id)); + await db.insert(auditEvent).values({ + organizationId, + actorType: "user", + actorId: createId("user"), + action: "attested", + resourceType: "evidence", + resourceId: signed!.id, + }); + + await db.delete(organization).where(eq(organization.id, organizationId)); + + for (const table of [control, evidence, file, auditEvent]) { + const left = await db.select().from(table).where(eq(table.organizationId, organizationId)); + expect(left).toEqual([]); + } + } finally { + await db.$client.exec(`set role ${applicationRole};`); + } + }); +}); diff --git a/packages/db/id.ts b/packages/db/id.ts index 9d90924..ce23e1c 100644 --- a/packages/db/id.ts +++ b/packages/db/id.ts @@ -5,7 +5,7 @@ * Prefixed identifiers: `usr_v1stgxr8z5jdhi6b`. * * The prefix identifies the record type, so an identifier is self-describing in - * a log line or an export. `schema/auth.ts` enforces the format with a CHECK + * a log line or an export. `schema/columns.ts` enforces the format with a CHECK * per table, rejecting inserts with the wrong prefix, alphabet, or length. * * Reasoning: `docs/adr/0002-prefixed-identifiers.md`. @@ -48,6 +48,13 @@ export const idFormats = { organization: { prefix: "org", length: 16 }, member: { prefix: "mem", length: 16 }, twoFactor: { prefix: "tfa", length: 16 }, + + control: { prefix: "ctl", length: 16 }, + auditEvent: { prefix: "aud", length: 16 }, + standard: { prefix: "std", length: 16 }, + requirement: { prefix: "req", length: 16 }, + evidence: { prefix: "evd", length: 16 }, + file: { prefix: "fil", length: 16 }, } as const; export type IdType = keyof typeof idFormats; @@ -80,7 +87,8 @@ export function idPattern(type: IdType): string { * one, which the schema test catches before a deployment can. */ export function generateId({ model }: { model: string }): string { - if (!(model in idFormats)) { + // `Object.hasOwn`, not `in`: `in` also admits inherited names like `toString`. + if (!Object.hasOwn(idFormats, model)) { throw new Error(`No identifier format is defined for the "${model}" table.`); } return createId(model as IdType); diff --git a/packages/db/index.ts b/packages/db/index.ts index e6b6e42..a35a5bc 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -2,5 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 export { createDatabase, type Database } from "./client.ts"; -export { createId, generateId, type IdType } from "./id.ts"; +export { createId, generateId, idPattern, type IdType } from "./id.ts"; export * as schema from "./schema/index.ts"; +export { + assertTenantIsolation, + type RootDatabase, + type TenantTransaction, + withOrganization, +} from "./tenant.ts"; diff --git a/packages/db/migrations/0000_auth_and_tenancy.sql b/packages/db/migrations/0000_auth_and_tenancy.sql deleted file mode 100644 index a18e552..0000000 --- a/packages/db/migrations/0000_auth_and_tenancy.sql +++ /dev/null @@ -1,125 +0,0 @@ --- SPDX-FileCopyrightText: 2026 Quality Runtime contributors --- SPDX-License-Identifier: Apache-2.0 - -CREATE TABLE "account" ( - "id" text PRIMARY KEY NOT NULL, - "account_id" text NOT NULL, - "provider_id" text NOT NULL, - "user_id" text NOT NULL, - "access_token" text, - "refresh_token" text, - "id_token" text, - "access_token_expires_at" timestamp with time zone, - "refresh_token_expires_at" timestamp with time zone, - "scope" text, - "password" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "account_id_format" CHECK ("id" ~ '^acc_[0-9a-z]{16}$') -); ---> statement-breakpoint -CREATE TABLE "invitation" ( - "id" text PRIMARY KEY NOT NULL, - "organization_id" text NOT NULL, - "email" text NOT NULL, - "role" text, - "status" text DEFAULT 'pending' NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "inviter_id" text NOT NULL, - CONSTRAINT "invitation_id_format" CHECK ("id" ~ '^inv_[0-9a-z]{24}$') -); ---> statement-breakpoint -CREATE TABLE "member" ( - "id" text PRIMARY KEY NOT NULL, - "organization_id" text NOT NULL, - "user_id" text NOT NULL, - "role" text DEFAULT 'member' NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "member_id_format" CHECK ("id" ~ '^mem_[0-9a-z]{16}$') -); ---> statement-breakpoint -CREATE TABLE "organization" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "slug" text NOT NULL, - "logo" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "metadata" text, - CONSTRAINT "organization_slug_unique" UNIQUE("slug"), - CONSTRAINT "organization_id_format" CHECK ("id" ~ '^org_[0-9a-z]{16}$') -); ---> statement-breakpoint -CREATE TABLE "session" ( - "id" text PRIMARY KEY NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "token" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "ip_address" text, - "user_agent" text, - "user_id" text NOT NULL, - "active_organization_id" text, - "impersonated_by" text, - CONSTRAINT "session_token_unique" UNIQUE("token"), - CONSTRAINT "session_id_format" CHECK ("id" ~ '^ses_[0-9a-z]{16}$') -); ---> statement-breakpoint -CREATE TABLE "two_factor" ( - "id" text PRIMARY KEY NOT NULL, - "secret" text NOT NULL, - "backup_codes" text NOT NULL, - "user_id" text NOT NULL, - "verified" boolean DEFAULT true, - "failed_verification_count" integer DEFAULT 0, - "locked_until" timestamp with time zone, - CONSTRAINT "two_factor_id_format" CHECK ("id" ~ '^tfa_[0-9a-z]{16}$') -); ---> statement-breakpoint -CREATE TABLE "user" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "email" text NOT NULL, - "email_verified" boolean DEFAULT false NOT NULL, - "image" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "role" text, - "banned" boolean DEFAULT false, - "ban_reason" text, - "ban_expires" timestamp with time zone, - "two_factor_enabled" boolean DEFAULT false, - CONSTRAINT "user_email_unique" UNIQUE("email"), - CONSTRAINT "user_id_format" CHECK ("id" ~ '^usr_[0-9a-z]{16}$') -); ---> statement-breakpoint -CREATE TABLE "verification" ( - "id" text PRIMARY KEY NOT NULL, - "identifier" text NOT NULL, - "value" text NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "verification_id_format" CHECK ("id" ~ '^ver_[0-9a-z]{16}$') -); ---> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_active_organization_id_organization_id_fk" FOREIGN KEY ("active_organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "account_provider_id_account_id_uidx" ON "account" USING btree ("provider_id","account_id");--> statement-breakpoint -CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "invitation_organization_id_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint -CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint -CREATE INDEX "invitation_inviter_id_idx" ON "invitation" USING btree ("inviter_id");--> statement-breakpoint -CREATE UNIQUE INDEX "member_organization_id_user_id_uidx" ON "member" USING btree ("organization_id","user_id");--> statement-breakpoint -CREATE INDEX "member_user_id_idx" ON "member" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "session_active_organization_id_idx" ON "session" USING btree ("active_organization_id");--> statement-breakpoint -CREATE INDEX "two_factor_secret_idx" ON "two_factor" USING btree ("secret");--> statement-breakpoint -CREATE INDEX "two_factor_user_id_idx" ON "two_factor" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/packages/db/migrations/0000_schema.sql b/packages/db/migrations/0000_schema.sql new file mode 100644 index 0000000..f19f3d1 --- /dev/null +++ b/packages/db/migrations/0000_schema.sql @@ -0,0 +1,264 @@ +-- SPDX-FileCopyrightText: 2026 Quality Runtime contributors +-- SPDX-License-Identifier: Apache-2.0 + +CREATE TABLE "audit_event" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "actor_type" text NOT NULL, + "actor_id" text, + "actor_label" text, + "on_behalf_of_id" text, + "on_behalf_of_label" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text NOT NULL, + "before" jsonb, + "after" jsonb, + "created_at" timestamp with time zone DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT "audit_event_id_format" CHECK ("id" ~ '^aud_[0-9a-z]{16}$'), + CONSTRAINT "audit_event_actor_type_valid" CHECK ("actor_type" in ('user', 'system')), + CONSTRAINT "audit_event_actor_is_identified" CHECK (("actor_type" = 'user' and "actor_id" is not null and "actor_id" ~ '^usr_[0-9a-z]{16}$') or ("actor_type" = 'system' and "actor_id" is null)), + CONSTRAINT "audit_event_on_behalf_of_is_identified" CHECK (("on_behalf_of_id" is not null and "on_behalf_of_id" ~ '^usr_[0-9a-z]{16}$') or ("on_behalf_of_id" is null and "on_behalf_of_label" is null)), + CONSTRAINT "audit_event_action_present" CHECK ("audit_event"."action" ~ '[^[:space:]]'), + CONSTRAINT "audit_event_resource_type_present" CHECK ("audit_event"."resource_type" ~ '[^[:space:]]'), + CONSTRAINT "audit_event_resource_id_present" CHECK ("audit_event"."resource_id" ~ '[^[:space:]]'), + CONSTRAINT "audit_event_before_is_object" CHECK ("audit_event"."before" is null or jsonb_typeof("audit_event"."before") = 'object'), + CONSTRAINT "audit_event_after_is_object" CHECK ("audit_event"."after" is null or jsonb_typeof("audit_event"."after") = 'object') +); +--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "account_id_format" CHECK ("id" ~ '^acc_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "inviter_id" text NOT NULL, + CONSTRAINT "invitation_id_format" CHECK ("id" ~ '^inv_[0-9a-z]{24}$') +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "member_id_format" CHECK ("id" ~ '^mem_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "metadata" text, + CONSTRAINT "organization_slug_unique" UNIQUE("slug"), + CONSTRAINT "organization_id_format" CHECK ("id" ~ '^org_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "active_organization_id" text, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token"), + CONSTRAINT "session_id_format" CHECK ("id" ~ '^ses_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "two_factor" ( + "id" text PRIMARY KEY NOT NULL, + "secret" text NOT NULL, + "backup_codes" text NOT NULL, + "user_id" text NOT NULL, + "verified" boolean DEFAULT true, + "failed_verification_count" integer DEFAULT 0, + "locked_until" timestamp with time zone, + CONSTRAINT "two_factor_id_format" CHECK ("id" ~ '^tfa_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "role" text, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp with time zone, + "two_factor_enabled" boolean DEFAULT false, + CONSTRAINT "user_email_unique" UNIQUE("email"), + CONSTRAINT "user_id_format" CHECK ("id" ~ '^usr_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "verification_id_format" CHECK ("id" ~ '^ver_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "control" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "status" text DEFAULT 'draft' NOT NULL, + "activated_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "control_id_organization_id_key" UNIQUE("id","organization_id"), + CONSTRAINT "control_id_format" CHECK ("id" ~ '^ctl_[0-9a-z]{16}$'), + CONSTRAINT "control_status_valid" CHECK ("status" in ('draft', 'active', 'retired')), + CONSTRAINT "control_took_effect_unless_draft" CHECK (("control"."status" = 'draft') = ("control"."activated_at" is null)), + CONSTRAINT "control_name_present" CHECK ("control"."name" ~ '[^[:space:]]') +); +--> statement-breakpoint +CREATE TABLE "evidence" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "control_id" text NOT NULL, + "title" text NOT NULL, + "description" text, + "occurred_at" timestamp with time zone NOT NULL, + "attested_at" timestamp with time zone, + "attested_by_id" text, + "attested_by_label" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "evidence_id_organization_id_key" UNIQUE("id","organization_id"), + CONSTRAINT "evidence_id_format" CHECK ("id" ~ '^evd_[0-9a-z]{16}$'), + CONSTRAINT "evidence_title_present" CHECK ("evidence"."title" ~ '[^[:space:]]'), + CONSTRAINT "evidence_attestation_complete" CHECK (( + "attested_at" is null + and "attested_by_id" is null + and "attested_by_label" is null + ) or ( + "attested_at" is not null + and "attested_by_id" is not null + and "attested_by_id" ~ '^usr_[0-9a-z]{16}$' + )) +); +--> statement-breakpoint +CREATE TABLE "file" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "evidence_id" text NOT NULL, + "filename" text NOT NULL, + "content_type" text NOT NULL, + "bytes" integer NOT NULL, + "checksum" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "file_id_format" CHECK ("id" ~ '^fil_[0-9a-z]{16}$'), + CONSTRAINT "file_filename_present" CHECK ("file"."filename" ~ '[^[:space:]]'), + CONSTRAINT "file_content_type_shape" CHECK ("content_type" ~ '^[A-Za-z0-9!#$%&*+.^_|~-]+/[A-Za-z0-9!#$%&*+.^_|~-]+$'), + CONSTRAINT "file_bytes_positive" CHECK ("file"."bytes" > 0), + CONSTRAINT "file_checksum_is_sha256" CHECK ("file"."checksum" ~ '^[0-9a-f]{64}$') +); +--> statement-breakpoint +CREATE TABLE "control_requirement" ( + "organization_id" text NOT NULL, + "control_id" text NOT NULL, + "requirement_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "control_requirement_control_id_requirement_id_pk" PRIMARY KEY("control_id","requirement_id") +); +--> statement-breakpoint +CREATE TABLE "requirement" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "standard_id" text NOT NULL, + "reference" text NOT NULL, + "title" text NOT NULL, + "text" text, + "position" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "requirement_id_organization_id_key" UNIQUE("id","organization_id"), + CONSTRAINT "requirement_id_format" CHECK ("id" ~ '^req_[0-9a-z]{16}$'), + CONSTRAINT "requirement_reference_present" CHECK ("reference" ~ '[^[:space:]]'), + CONSTRAINT "requirement_title_present" CHECK ("title" ~ '[^[:space:]]') +); +--> statement-breakpoint +CREATE TABLE "standard" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "edition" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "standard_id_organization_id_key" UNIQUE("id","organization_id"), + CONSTRAINT "standard_id_format" CHECK ("id" ~ '^std_[0-9a-z]{16}$'), + CONSTRAINT "standard_name_present" CHECK ("name" ~ '[^[:space:]]'), + CONSTRAINT "standard_edition_present" CHECK ("edition" ~ '[^[:space:]]') +); +--> statement-breakpoint +ALTER TABLE "audit_event" ADD CONSTRAINT "audit_event_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_active_organization_id_organization_id_fk" FOREIGN KEY ("active_organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "control" ADD CONSTRAINT "control_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "evidence" ADD CONSTRAINT "evidence_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "evidence" ADD CONSTRAINT "evidence_control_fk" FOREIGN KEY ("control_id","organization_id") REFERENCES "public"."control"("id","organization_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file" ADD CONSTRAINT "file_evidence_fk" FOREIGN KEY ("evidence_id","organization_id") REFERENCES "public"."evidence"("id","organization_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "control_requirement" ADD CONSTRAINT "control_requirement_control_fk" FOREIGN KEY ("control_id","organization_id") REFERENCES "public"."control"("id","organization_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "control_requirement" ADD CONSTRAINT "control_requirement_requirement_fk" FOREIGN KEY ("requirement_id","organization_id") REFERENCES "public"."requirement"("id","organization_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "requirement" ADD CONSTRAINT "requirement_standard_fk" FOREIGN KEY ("standard_id","organization_id") REFERENCES "public"."standard"("id","organization_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "standard" ADD CONSTRAINT "standard_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_event_resource_idx" ON "audit_event" USING btree ("organization_id","resource_type","resource_id","created_at","id");--> statement-breakpoint +CREATE INDEX "audit_event_organization_id_created_at_id_idx" ON "audit_event" USING btree ("organization_id","created_at","id");--> statement-breakpoint +CREATE UNIQUE INDEX "account_provider_id_account_id_uidx" ON "account" USING btree ("provider_id","account_id");--> statement-breakpoint +CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "invitation_organization_id_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "invitation_inviter_id_idx" ON "invitation" USING btree ("inviter_id");--> statement-breakpoint +CREATE UNIQUE INDEX "member_organization_id_user_id_uidx" ON "member" USING btree ("organization_id","user_id");--> statement-breakpoint +CREATE INDEX "member_user_id_idx" ON "member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_active_organization_id_idx" ON "session" USING btree ("active_organization_id");--> statement-breakpoint +CREATE INDEX "two_factor_secret_idx" ON "two_factor" USING btree ("secret");--> statement-breakpoint +CREATE INDEX "two_factor_user_id_idx" ON "two_factor" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint +CREATE INDEX "control_organization_id_created_at_id_idx" ON "control" USING btree ("organization_id","created_at","id");--> statement-breakpoint +CREATE INDEX "evidence_organization_id_control_id_occurred_at_id_idx" ON "evidence" USING btree ("organization_id","control_id","occurred_at","id");--> statement-breakpoint +CREATE INDEX "file_organization_id_evidence_id_created_at_id_idx" ON "file" USING btree ("organization_id","evidence_id","created_at","id");--> statement-breakpoint +CREATE INDEX "control_requirement_organization_id_requirement_id_idx" ON "control_requirement" USING btree ("organization_id","requirement_id","control_id");--> statement-breakpoint +CREATE UNIQUE INDEX "requirement_standard_id_reference_uidx" ON "requirement" USING btree ("standard_id","reference");--> statement-breakpoint +CREATE INDEX "requirement_organization_id_standard_id_position_id_idx" ON "requirement" USING btree ("organization_id","standard_id","position","id");--> statement-breakpoint +CREATE UNIQUE INDEX "standard_organization_id_name_edition_uidx" ON "standard" USING btree ("organization_id","name","edition");--> statement-breakpoint +CREATE INDEX "standard_organization_id_created_at_id_idx" ON "standard" USING btree ("organization_id","created_at","id"); \ No newline at end of file diff --git a/packages/db/migrations/0001_tenancy_and_finality.sql b/packages/db/migrations/0001_tenancy_and_finality.sql new file mode 100644 index 0000000..617e040 --- /dev/null +++ b/packages/db/migrations/0001_tenancy_and_finality.sql @@ -0,0 +1,240 @@ +-- SPDX-FileCopyrightText: 2026 Quality Runtime contributors +-- SPDX-License-Identifier: Apache-2.0 + +-- What PostgreSQL enforces, rather than what the application remembers. +-- +-- Hand-written. drizzle-kit can declare policies but not FORCE, functions or +-- triggers, and splitting one security boundary across two mechanisms would be +-- worse than keeping all of it in one readable file. +-- +-- `withOrganization` in ../tenant.ts opens a transaction and sets +-- `qualityruntime.organization_id` for its duration; every policy here decides +-- what that transaction may see and write. Outside one, `current_setting(…, +-- true)` yields NULL on a fresh connection and the empty string on a pooled one +-- that has held a tenant before — PostgreSQL restores a custom setting to '' +-- rather than forgetting it. Neither equals a row's organization, so a code +-- path that forgot to open a tenant context sees an empty database rather than +-- every tenant's. See docs/adr/0003-tenant-isolation-with-row-level-security.md. +-- +-- A policy is a *test*, not a lock, wherever it reads a row other than the one +-- being written — or code reads something first and writes on the strength of +-- it. Under `read committed` that read sees a snapshot taken before a +-- concurrent transaction committed, so it also needs a row lock the other +-- writer contends for. (A command's own target row is different: PostgreSQL +-- locks it and re-checks the predicate against the latest version.) +-- docs/adr/0020-testing-races.md names each place that matters and which lock +-- it takes. + +-- Without FORCE, PostgreSQL exempts a table's owner. A deployment separates the +-- migrator that owns the schema from the role the server connects as, so the +-- server is not an owner — but FORCE means the guarantee does not depend on +-- that being got right. Superusers and BYPASSRLS roles are exempt even with it, +-- which `assertTenantIsolation` refuses to start under. +ALTER TABLE "control" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "control" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "standard" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "standard" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "requirement" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "requirement" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "control_requirement" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "control_requirement" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "evidence" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "evidence" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "file" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "file" FORCE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "audit_event" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "audit_event" FORCE ROW LEVEL SECURITY;--> statement-breakpoint + +-- Tenancy, where that is the whole rule. One policy covering every command: +-- WITH CHECK applies the same test to writes, so a transaction cannot label a +-- row with another tenant's id. +CREATE POLICY "standard_tenant_isolation" ON "standard" + USING ("organization_id" = current_setting('qualityruntime.organization_id', true)) + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "requirement_tenant_isolation" ON "requirement" + USING ("organization_id" = current_setting('qualityruntime.organization_id', true)) + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +-- A mapping is a statement about a control and a requirement that are already +-- known to be this tenant's — the composite foreign keys see to that — so the +-- link needs nothing narrower than tenancy. It is made or unmade, never +-- changed: there is no UPDATE policy, because rewriting a link in place would +-- move a mapping without either end hearing about it. +CREATE POLICY "control_requirement_tenant_read" ON "control_requirement" FOR SELECT + USING ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "control_requirement_tenant_map" ON "control_requirement" FOR INSERT + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "control_requirement_tenant_unmap" ON "control_requirement" FOR DELETE + USING ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +-- Audit history is append-only to the application. No UPDATE or DELETE policy +-- exists, so no code path can rewrite or erase an event through its tenant +-- context — not a rule the handlers follow, a rule they cannot break. +-- `TRUNCATE` and a table owner's privileges are outside row security, so +-- docs/deployment.md also revokes those. See docs/adr/0005-audit-history.md. +CREATE POLICY "audit_event_tenant_read" ON "audit_event" FOR SELECT + USING ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "audit_event_tenant_append" ON "audit_event" FOR INSERT + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +-- Evidence is final once attested: the UPDATE and DELETE policies see only +-- unattested rows, so correcting an attestation means recording new evidence +-- rather than editing the old. Naming each command is what allows that — one +-- policy covering all four could not say it. See +-- docs/adr/0012-evidence-and-attestation.md. +-- +-- A consequence worth knowing: `SELECT … FOR UPDATE` is governed by the UPDATE +-- policy, so an attested row cannot be *locked* either. A handler that locks +-- before deciding would read "cannot be locked" as "does not exist", which is +-- why the evidence handlers read unlocked first to tell 404 from 409. +CREATE POLICY "evidence_tenant_read" ON "evidence" FOR SELECT + USING ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "evidence_tenant_record" ON "evidence" FOR INSERT + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "evidence_tenant_amend" ON "evidence" FOR UPDATE + USING ( + "organization_id" = current_setting('qualityruntime.organization_id', true) + AND "attested_at" IS NULL + ) + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "evidence_tenant_discard" ON "evidence" FOR DELETE + USING ( + "organization_id" = current_setting('qualityruntime.organization_id', true) + AND "attested_at" IS NULL + );--> statement-breakpoint + +-- A file follows its evidence: nothing may be attached to an attested record, +-- and nothing is ever detached — a file goes only when its unattested evidence +-- is discarded, by the foreign key's cascade. A row whose attachments could +-- still change is not final, and the rule above would be hollow without this. +-- +-- So the policies are tenancy alone: there is no UPDATE policy, because a file +-- row describes bytes already written, and no DELETE policy, because nothing +-- detaches. Whether the evidence is still open is the trigger's to decide, +-- because it needs a lock and a policy cannot take one. +CREATE POLICY "file_tenant_read" ON "file" FOR SELECT + USING ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "file_tenant_attach" ON "file" FOR INSERT + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +-- An attachment locks its evidence `FOR NO KEY UPDATE` and requires it +-- unattested. A test alone is not enough: under `read committed` it would see +-- the draft while an attestation sat uncommitted beside it, and the `FOR KEY +-- SHARE` the foreign key takes does not conflict with the attestation's +-- `UPDATE`. This lock does, so the insert waits for the signature and then +-- sees it. Here rather than in the handler so that no insert path can forget +-- it. +-- +-- The lock is itself governed by evidence's UPDATE policy, which admits only +-- this tenant's unattested rows, so not finding the row covers all three ways +-- it can be closed: attested, another tenant's, or not there. +-- +-- Both functions here pin `search_path` with `pg_temp` last and qualify every +-- table. Left implicit, `pg_temp` is searched *first* for relations, and any +-- role may create a temporary table: an unqualified `evidence` could then be +-- an impostor holding a fake unattested row, and the real one would gain a +-- file. +CREATE FUNCTION "file_evidence_open"() RETURNS trigger + LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$ +BEGIN + PERFORM FROM "public"."evidence" + WHERE "id" = NEW."evidence_id" + AND "organization_id" = NEW."organization_id" + AND "attested_at" IS NULL + FOR NO KEY UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'file can only be attached to unattested evidence' + USING ERRCODE = 'restrict_violation'; + END IF; + RETURN NEW; +END; +$$;--> statement-breakpoint + +CREATE TRIGGER "file_evidence_open" + BEFORE INSERT ON "file" + FOR EACH ROW EXECUTE FUNCTION "file_evidence_open"();--> statement-breakpoint + +-- A control that was in effect is part of the record and is retired rather than +-- removed. One that never took effect was never relied on, and an abandoned +-- draft would otherwise have no disposal at all, because the lifecycle offers no +-- `draft → retired`. See docs/adr/0017-discarding-a-draft-control.md. +-- +-- So only a draft may be deleted. `control_took_effect_unless_draft` and +-- `control_lifecycle_enforce` below are what let that status test mean "never +-- took effect": no control that was in effect can be a draft again, by any +-- statement. +-- +-- `SELECT … FOR UPDATE` is charged to UPDATE, so the amend policy is also what +-- lets a handler lock a control before deciding about it, whatever its status. +CREATE POLICY "control_tenant_read" ON "control" FOR SELECT + USING ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "control_tenant_author" ON "control" FOR INSERT + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "control_tenant_amend" ON "control" FOR UPDATE + USING ("organization_id" = current_setting('qualityruntime.organization_id', true)) + WITH CHECK ("organization_id" = current_setting('qualityruntime.organization_id', true));--> statement-breakpoint + +CREATE POLICY "control_tenant_discard" ON "control" FOR DELETE + USING ( + "organization_id" = current_setting('qualityruntime.organization_id', true) + AND "status" = 'draft' + );--> statement-breakpoint + +-- The two lifecycle rules that need a row's past, which only a trigger can +-- see — WITH CHECK and a CHECK constraint see the new row alone. Everything +-- else about which states are valid is `control_took_effect_unless_draft`. +-- +-- `activated_at` is set when a control first takes effect, and never again — +-- by the database, not by the application. The CHECK ties a draft to a null +-- stamp, so anything able to clear it could turn a control that was in effect +-- back into a deletable draft: `UPDATE … SET status = 'draft', activated_at = +-- NULL` then a `DELETE`. Assigning the value here, and only here, also means +-- activation by any path is recorded, and nobody can backdate it or stamp a +-- control that never took effect. +-- +-- Retirement is terminal. A retired control keeps its stamp, so the CHECK +-- would admit it becoming active again; what replaces it is a new control, +-- so evidence recorded against the old one keeps meaning what it meant. +CREATE FUNCTION "control_lifecycle_enforce"() RETURNS trigger + LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$ +BEGIN + -- Refused rather than ignored: a caller that tried is doing something it + -- should hear about. + IF TG_OP = 'INSERT' THEN + IF NEW."activated_at" IS NOT NULL THEN + RAISE EXCEPTION 'control.activated_at is set by the database and cannot be written' + USING ERRCODE = 'restrict_violation'; + END IF; + ELSE + IF NEW."activated_at" IS DISTINCT FROM OLD."activated_at" THEN + RAISE EXCEPTION 'control.activated_at is set by the database and cannot be written' + USING ERRCODE = 'restrict_violation'; + END IF; + IF OLD."status" = 'retired' AND NEW."status" IS DISTINCT FROM OLD."status" THEN + RAISE EXCEPTION 'a retired control stays retired' + USING ERRCODE = 'restrict_violation'; + END IF; + END IF; + + -- Taking effect is what sets it, whoever does the taking. + IF NEW."status" = 'active' AND NEW."activated_at" IS NULL THEN + NEW."activated_at" := clock_timestamp(); + END IF; + + RETURN NEW; +END; +$$;--> statement-breakpoint + +CREATE TRIGGER "control_lifecycle_enforce" + BEFORE INSERT OR UPDATE ON "control" + FOR EACH ROW EXECUTE FUNCTION "control_lifecycle_enforce"(); diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json index a69ef0f..4312031 100644 --- a/packages/db/migrations/meta/0000_snapshot.json +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -1,9 +1,219 @@ { - "id": "6fbb5aa9-0f18-4228-80f9-d0226a2c76c7", + "id": "a0fc0c97-da14-4df2-a05e-c945f1e0a77b", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { + "public.audit_event": { + "name": "audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_behalf_of_id": { + "name": "on_behalf_of_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_behalf_of_label": { + "name": "on_behalf_of_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "clock_timestamp()" + } + }, + "indexes": { + "audit_event_resource_idx": { + "name": "audit_event_resource_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_event_organization_id_created_at_id_idx": { + "name": "audit_event_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_event_organization_id_organization_id_fk": { + "name": "audit_event_organization_id_organization_id_fk", + "tableFrom": "audit_event", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audit_event_id_format": { + "name": "audit_event_id_format", + "value": "\"id\" ~ '^aud_[0-9a-z]{16}$'" + }, + "audit_event_actor_type_valid": { + "name": "audit_event_actor_type_valid", + "value": "\"actor_type\" in ('user', 'system')" + }, + "audit_event_actor_is_identified": { + "name": "audit_event_actor_is_identified", + "value": "(\"actor_type\" = 'user' and \"actor_id\" is not null and \"actor_id\" ~ '^usr_[0-9a-z]{16}$') or (\"actor_type\" = 'system' and \"actor_id\" is null)" + }, + "audit_event_on_behalf_of_is_identified": { + "name": "audit_event_on_behalf_of_is_identified", + "value": "(\"on_behalf_of_id\" is not null and \"on_behalf_of_id\" ~ '^usr_[0-9a-z]{16}$') or (\"on_behalf_of_id\" is null and \"on_behalf_of_label\" is null)" + }, + "audit_event_action_present": { + "name": "audit_event_action_present", + "value": "\"audit_event\".\"action\" ~ '[^[:space:]]'" + }, + "audit_event_resource_type_present": { + "name": "audit_event_resource_type_present", + "value": "\"audit_event\".\"resource_type\" ~ '[^[:space:]]'" + }, + "audit_event_resource_id_present": { + "name": "audit_event_resource_id_present", + "value": "\"audit_event\".\"resource_id\" ~ '[^[:space:]]'" + }, + "audit_event_before_is_object": { + "name": "audit_event_before_is_object", + "value": "\"audit_event\".\"before\" is null or jsonb_typeof(\"audit_event\".\"before\") = 'object'" + }, + "audit_event_after_is_object": { + "name": "audit_event_after_is_object", + "value": "\"audit_event\".\"after\" is null or jsonb_typeof(\"audit_event\".\"after\") = 'object'" + } + }, + "isRLSEnabled": false + }, "public.account": { "name": "account", "schema": "", @@ -898,6 +1108,846 @@ } }, "isRLSEnabled": false + }, + "public.control": { + "name": "control", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "control_organization_id_created_at_id_idx": { + "name": "control_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "control_organization_id_organization_id_fk": { + "name": "control_organization_id_organization_id_fk", + "tableFrom": "control", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "control_id_organization_id_key": { + "name": "control_id_organization_id_key", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "control_id_format": { + "name": "control_id_format", + "value": "\"id\" ~ '^ctl_[0-9a-z]{16}$'" + }, + "control_status_valid": { + "name": "control_status_valid", + "value": "\"status\" in ('draft', 'active', 'retired')" + }, + "control_took_effect_unless_draft": { + "name": "control_took_effect_unless_draft", + "value": "(\"control\".\"status\" = 'draft') = (\"control\".\"activated_at\" is null)" + }, + "control_name_present": { + "name": "control_name_present", + "value": "\"control\".\"name\" ~ '[^[:space:]]'" + } + }, + "isRLSEnabled": false + }, + "public.evidence": { + "name": "evidence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "control_id": { + "name": "control_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attested_at": { + "name": "attested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attested_by_id": { + "name": "attested_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attested_by_label": { + "name": "attested_by_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "evidence_organization_id_control_id_occurred_at_id_idx": { + "name": "evidence_organization_id_control_id_occurred_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "control_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "evidence_organization_id_organization_id_fk": { + "name": "evidence_organization_id_organization_id_fk", + "tableFrom": "evidence", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "evidence_control_fk": { + "name": "evidence_control_fk", + "tableFrom": "evidence", + "tableTo": "control", + "columnsFrom": [ + "control_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "evidence_id_organization_id_key": { + "name": "evidence_id_organization_id_key", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "evidence_id_format": { + "name": "evidence_id_format", + "value": "\"id\" ~ '^evd_[0-9a-z]{16}$'" + }, + "evidence_title_present": { + "name": "evidence_title_present", + "value": "\"evidence\".\"title\" ~ '[^[:space:]]'" + }, + "evidence_attestation_complete": { + "name": "evidence_attestation_complete", + "value": "(\n \"attested_at\" is null\n and \"attested_by_id\" is null\n and \"attested_by_label\" is null\n ) or (\n \"attested_at\" is not null\n and \"attested_by_id\" is not null\n and \"attested_by_id\" ~ '^usr_[0-9a-z]{16}$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.file": { + "name": "file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_id": { + "name": "evidence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "file_organization_id_evidence_id_created_at_id_idx": { + "name": "file_organization_id_evidence_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "evidence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_evidence_fk": { + "name": "file_evidence_fk", + "tableFrom": "file", + "tableTo": "evidence", + "columnsFrom": [ + "evidence_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_id_format": { + "name": "file_id_format", + "value": "\"id\" ~ '^fil_[0-9a-z]{16}$'" + }, + "file_filename_present": { + "name": "file_filename_present", + "value": "\"file\".\"filename\" ~ '[^[:space:]]'" + }, + "file_content_type_shape": { + "name": "file_content_type_shape", + "value": "\"content_type\" ~ '^[A-Za-z0-9!#$%&*+.^_|~-]+/[A-Za-z0-9!#$%&*+.^_|~-]+$'" + }, + "file_bytes_positive": { + "name": "file_bytes_positive", + "value": "\"file\".\"bytes\" > 0" + }, + "file_checksum_is_sha256": { + "name": "file_checksum_is_sha256", + "value": "\"file\".\"checksum\" ~ '^[0-9a-f]{64}$'" + } + }, + "isRLSEnabled": false + }, + "public.control_requirement": { + "name": "control_requirement", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "control_id": { + "name": "control_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requirement_id": { + "name": "requirement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "control_requirement_organization_id_requirement_id_idx": { + "name": "control_requirement_organization_id_requirement_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requirement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "control_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "control_requirement_control_fk": { + "name": "control_requirement_control_fk", + "tableFrom": "control_requirement", + "tableTo": "control", + "columnsFrom": [ + "control_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "control_requirement_requirement_fk": { + "name": "control_requirement_requirement_fk", + "tableFrom": "control_requirement", + "tableTo": "requirement", + "columnsFrom": [ + "requirement_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "control_requirement_control_id_requirement_id_pk": { + "name": "control_requirement_control_id_requirement_id_pk", + "columns": [ + "control_id", + "requirement_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.requirement": { + "name": "requirement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "standard_id": { + "name": "standard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requirement_standard_id_reference_uidx": { + "name": "requirement_standard_id_reference_uidx", + "columns": [ + { + "expression": "standard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requirement_organization_id_standard_id_position_id_idx": { + "name": "requirement_organization_id_standard_id_position_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "standard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requirement_standard_fk": { + "name": "requirement_standard_fk", + "tableFrom": "requirement", + "tableTo": "standard", + "columnsFrom": [ + "standard_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requirement_id_organization_id_key": { + "name": "requirement_id_organization_id_key", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "requirement_id_format": { + "name": "requirement_id_format", + "value": "\"id\" ~ '^req_[0-9a-z]{16}$'" + }, + "requirement_reference_present": { + "name": "requirement_reference_present", + "value": "\"reference\" ~ '[^[:space:]]'" + }, + "requirement_title_present": { + "name": "requirement_title_present", + "value": "\"title\" ~ '[^[:space:]]'" + } + }, + "isRLSEnabled": false + }, + "public.standard": { + "name": "standard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "edition": { + "name": "edition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "standard_organization_id_name_edition_uidx": { + "name": "standard_organization_id_name_edition_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "edition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "standard_organization_id_created_at_id_idx": { + "name": "standard_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "standard_organization_id_organization_id_fk": { + "name": "standard_organization_id_organization_id_fk", + "tableFrom": "standard", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "standard_id_organization_id_key": { + "name": "standard_id_organization_id_key", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "standard_id_format": { + "name": "standard_id_format", + "value": "\"id\" ~ '^std_[0-9a-z]{16}$'" + }, + "standard_name_present": { + "name": "standard_name_present", + "value": "\"name\" ~ '[^[:space:]]'" + }, + "standard_edition_present": { + "name": "standard_edition_present", + "value": "\"edition\" ~ '[^[:space:]]'" + } + }, + "isRLSEnabled": false } }, "enums": {}, diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..8ae2cd2 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1964 @@ +{ + "id": "6e0737ba-8973-4e3f-b9a9-d5f74a60dae3", + "prevId": "a0fc0c97-da14-4df2-a05e-c945f1e0a77b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_event": { + "name": "audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_behalf_of_id": { + "name": "on_behalf_of_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_behalf_of_label": { + "name": "on_behalf_of_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "clock_timestamp()" + } + }, + "indexes": { + "audit_event_resource_idx": { + "name": "audit_event_resource_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_event_organization_id_created_at_id_idx": { + "name": "audit_event_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "audit_event_organization_id_organization_id_fk": { + "name": "audit_event_organization_id_organization_id_fk", + "tableFrom": "audit_event", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audit_event_id_format": { + "name": "audit_event_id_format", + "value": "\"id\" ~ '^aud_[0-9a-z]{16}$'" + }, + "audit_event_actor_type_valid": { + "name": "audit_event_actor_type_valid", + "value": "\"actor_type\" in ('user', 'system')" + }, + "audit_event_actor_is_identified": { + "name": "audit_event_actor_is_identified", + "value": "(\"actor_type\" = 'user' and \"actor_id\" is not null and \"actor_id\" ~ '^usr_[0-9a-z]{16}$') or (\"actor_type\" = 'system' and \"actor_id\" is null)" + }, + "audit_event_on_behalf_of_is_identified": { + "name": "audit_event_on_behalf_of_is_identified", + "value": "(\"on_behalf_of_id\" is not null and \"on_behalf_of_id\" ~ '^usr_[0-9a-z]{16}$') or (\"on_behalf_of_id\" is null and \"on_behalf_of_label\" is null)" + }, + "audit_event_action_present": { + "name": "audit_event_action_present", + "value": "\"audit_event\".\"action\" ~ '[^[:space:]]'" + }, + "audit_event_resource_type_present": { + "name": "audit_event_resource_type_present", + "value": "\"audit_event\".\"resource_type\" ~ '[^[:space:]]'" + }, + "audit_event_resource_id_present": { + "name": "audit_event_resource_id_present", + "value": "\"audit_event\".\"resource_id\" ~ '[^[:space:]]'" + }, + "audit_event_before_is_object": { + "name": "audit_event_before_is_object", + "value": "\"audit_event\".\"before\" is null or jsonb_typeof(\"audit_event\".\"before\") = 'object'" + }, + "audit_event_after_is_object": { + "name": "audit_event_after_is_object", + "value": "\"audit_event\".\"after\" is null or jsonb_typeof(\"audit_event\".\"after\") = 'object'" + } + }, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_provider_id_account_id_uidx": { + "name": "account_provider_id_account_id_uidx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_id_format": { + "name": "account_id_format", + "value": "\"id\" ~ '^acc_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_inviter_id_idx": { + "name": "invitation_inviter_id_idx", + "columns": [ + { + "expression": "inviter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": [ + "inviter_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "invitation_id_format": { + "name": "invitation_id_format", + "value": "\"id\" ~ '^inv_[0-9a-z]{24}$'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_id_user_id_uidx": { + "name": "member_organization_id_user_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "member_id_format": { + "name": "member_id_format", + "value": "\"id\" ~ '^mem_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "organization_id_format": { + "name": "organization_id_format", + "value": "\"id\" ~ '^org_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "session_active_organization_id_idx": { + "name": "session_active_organization_id_idx", + "columns": [ + { + "expression": "active_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "active_organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "session_id_format": { + "name": "session_id_format", + "value": "\"id\" ~ '^ses_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "two_factor_secret_idx": { + "name": "two_factor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "two_factor_user_id_idx": { + "name": "two_factor_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "two_factor_id_format": { + "name": "two_factor_id_format", + "value": "\"id\" ~ '^tfa_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "user_id_format": { + "name": "user_id_format", + "value": "\"id\" ~ '^usr_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "verification_id_format": { + "name": "verification_id_format", + "value": "\"id\" ~ '^ver_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + }, + "public.control": { + "name": "control", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "control_organization_id_created_at_id_idx": { + "name": "control_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "control_organization_id_organization_id_fk": { + "name": "control_organization_id_organization_id_fk", + "tableFrom": "control", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "control_id_organization_id_key": { + "name": "control_id_organization_id_key", + "columns": [ + "id", + "organization_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "control_id_format": { + "name": "control_id_format", + "value": "\"id\" ~ '^ctl_[0-9a-z]{16}$'" + }, + "control_status_valid": { + "name": "control_status_valid", + "value": "\"status\" in ('draft', 'active', 'retired')" + }, + "control_took_effect_unless_draft": { + "name": "control_took_effect_unless_draft", + "value": "(\"control\".\"status\" = 'draft') = (\"control\".\"activated_at\" is null)" + }, + "control_name_present": { + "name": "control_name_present", + "value": "\"control\".\"name\" ~ '[^[:space:]]'" + } + }, + "isRLSEnabled": false + }, + "public.evidence": { + "name": "evidence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "control_id": { + "name": "control_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attested_at": { + "name": "attested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attested_by_id": { + "name": "attested_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attested_by_label": { + "name": "attested_by_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "evidence_organization_id_control_id_occurred_at_id_idx": { + "name": "evidence_organization_id_control_id_occurred_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "control_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "evidence_organization_id_organization_id_fk": { + "name": "evidence_organization_id_organization_id_fk", + "tableFrom": "evidence", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "evidence_control_fk": { + "name": "evidence_control_fk", + "tableFrom": "evidence", + "columnsFrom": [ + "control_id", + "organization_id" + ], + "tableTo": "control", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "evidence_id_organization_id_key": { + "name": "evidence_id_organization_id_key", + "columns": [ + "id", + "organization_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "evidence_id_format": { + "name": "evidence_id_format", + "value": "\"id\" ~ '^evd_[0-9a-z]{16}$'" + }, + "evidence_title_present": { + "name": "evidence_title_present", + "value": "\"evidence\".\"title\" ~ '[^[:space:]]'" + }, + "evidence_attestation_complete": { + "name": "evidence_attestation_complete", + "value": "(\n \"attested_at\" is null\n and \"attested_by_id\" is null\n and \"attested_by_label\" is null\n ) or (\n \"attested_at\" is not null\n and \"attested_by_id\" is not null\n and \"attested_by_id\" ~ '^usr_[0-9a-z]{16}$'\n )" + } + }, + "isRLSEnabled": false + }, + "public.file": { + "name": "file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_id": { + "name": "evidence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "file_organization_id_evidence_id_created_at_id_idx": { + "name": "file_organization_id_evidence_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "evidence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "file_evidence_fk": { + "name": "file_evidence_fk", + "tableFrom": "file", + "columnsFrom": [ + "evidence_id", + "organization_id" + ], + "tableTo": "evidence", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_id_format": { + "name": "file_id_format", + "value": "\"id\" ~ '^fil_[0-9a-z]{16}$'" + }, + "file_filename_present": { + "name": "file_filename_present", + "value": "\"file\".\"filename\" ~ '[^[:space:]]'" + }, + "file_content_type_shape": { + "name": "file_content_type_shape", + "value": "\"content_type\" ~ '^[A-Za-z0-9!#$%&*+.^_|~-]+/[A-Za-z0-9!#$%&*+.^_|~-]+$'" + }, + "file_bytes_positive": { + "name": "file_bytes_positive", + "value": "\"file\".\"bytes\" > 0" + }, + "file_checksum_is_sha256": { + "name": "file_checksum_is_sha256", + "value": "\"file\".\"checksum\" ~ '^[0-9a-f]{64}$'" + } + }, + "isRLSEnabled": false + }, + "public.control_requirement": { + "name": "control_requirement", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "control_id": { + "name": "control_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requirement_id": { + "name": "requirement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "control_requirement_organization_id_requirement_id_idx": { + "name": "control_requirement_organization_id_requirement_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requirement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "control_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "control_requirement_control_fk": { + "name": "control_requirement_control_fk", + "tableFrom": "control_requirement", + "columnsFrom": [ + "control_id", + "organization_id" + ], + "tableTo": "control", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "control_requirement_requirement_fk": { + "name": "control_requirement_requirement_fk", + "tableFrom": "control_requirement", + "columnsFrom": [ + "requirement_id", + "organization_id" + ], + "tableTo": "requirement", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "control_requirement_control_id_requirement_id_pk": { + "name": "control_requirement_control_id_requirement_id_pk", + "columns": [ + "control_id", + "requirement_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.requirement": { + "name": "requirement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "standard_id": { + "name": "standard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requirement_standard_id_reference_uidx": { + "name": "requirement_standard_id_reference_uidx", + "columns": [ + { + "expression": "standard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "requirement_organization_id_standard_id_position_id_idx": { + "name": "requirement_organization_id_standard_id_position_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "standard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "requirement_standard_fk": { + "name": "requirement_standard_fk", + "tableFrom": "requirement", + "columnsFrom": [ + "standard_id", + "organization_id" + ], + "tableTo": "standard", + "columnsTo": [ + "id", + "organization_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requirement_id_organization_id_key": { + "name": "requirement_id_organization_id_key", + "columns": [ + "id", + "organization_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "requirement_id_format": { + "name": "requirement_id_format", + "value": "\"id\" ~ '^req_[0-9a-z]{16}$'" + }, + "requirement_reference_present": { + "name": "requirement_reference_present", + "value": "\"reference\" ~ '[^[:space:]]'" + }, + "requirement_title_present": { + "name": "requirement_title_present", + "value": "\"title\" ~ '[^[:space:]]'" + } + }, + "isRLSEnabled": false + }, + "public.standard": { + "name": "standard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "edition": { + "name": "edition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "standard_organization_id_name_edition_uidx": { + "name": "standard_organization_id_name_edition_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "edition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "standard_organization_id_created_at_id_idx": { + "name": "standard_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "standard_organization_id_organization_id_fk": { + "name": "standard_organization_id_organization_id_fk", + "tableFrom": "standard", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "standard_id_organization_id_key": { + "name": "standard_id_organization_id_key", + "columns": [ + "id", + "organization_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "standard_id_format": { + "name": "standard_id_format", + "value": "\"id\" ~ '^std_[0-9a-z]{16}$'" + }, + "standard_name_present": { + "name": "standard_name_present", + "value": "\"name\" ~ '[^[:space:]]'" + }, + "standard_edition_present": { + "name": "standard_edition_present", + "value": "\"edition\" ~ '[^[:space:]]'" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 09097ea..175979e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -5,8 +5,15 @@ { "idx": 0, "version": "7", - "when": 1789690350523, - "tag": "0000_auth_and_tenancy", + "when": 1789758741423, + "tag": "0000_schema", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1789758742287, + "tag": "0001_tenancy_and_finality", "breakpoints": true } ] diff --git a/packages/db/schema/audit.ts b/packages/db/schema/audit.ts new file mode 100644 index 0000000..bd3a168 --- /dev/null +++ b/packages/db/schema/audit.ts @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Audit history: who changed what, when (AUDIT-01). + * + * The model and its rules are in `docs/data-model.md`; the reasoning is in + * `docs/adr/0005-audit-history.md`. This file records how it is enforced. + */ + +import { sql } from "drizzle-orm"; +import { check, index, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { organization } from "./auth.ts"; +import { idPattern } from "../id.ts"; +import { id, idFormat } from "./columns.ts"; + +/** A user's identifier, shaped as one. Rendered into DDL, so a literal. */ +const userId = idPattern("user"); + +/** + * What kind of thing made the change. + * + * Only `user` is written today. `system` is reserved for background work + * because the history API publishes this enum: adding a value later affects + * clients that switch on it, even though widening the CHECK is straightforward. + */ +export const actorTypes = ["user", "system"] as const; + +export type ActorType = (typeof actorTypes)[number]; + +/** Rendered into DDL, so `sql.raw`; the values are compile-time literals. */ +const actorTypeValues = actorTypes.map((type) => `'${type}'`).join(", "); + +/** Record fields as they stood, excluding identity and bookkeeping timestamps. */ +export type AuditFields = Record; + +export const auditEvent = pgTable( + "audit_event", + { + id: id("auditEvent"), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + /** + * Who acted. `actorId` is not a foreign key: `user` rows are deletable, and + * history that vanishes with the actor is not history (AUDIT-01). The + * CHECK below asks only that it be shaped like a user's identifier — a + * user since deleted still attributes, by design. + * `actorLabel` is how they were named at the time, kept because an + * identifier alone tells a later reader nothing once the row is gone. + */ + actorType: text("actor_type").$type().notNull(), + actorId: text("actor_id"), + actorLabel: text("actor_label"), + /** + * Who the actor was acting as, when that is someone else. An administrator + * impersonating a member is still the one accountable for what happened, so + * they are the actor; this says whose account it happened through. Null for + * the ordinary case. + */ + onBehalfOfId: text("on_behalf_of_id"), + onBehalfOfLabel: text("on_behalf_of_label"), + /** What happened, as a verb: `created`, `updated`. */ + action: text("action").notNull(), + /** Which record it happened to. Not a foreign key, for the same reason. */ + resourceType: text("resource_type").notNull(), + resourceId: text("resource_id").notNull(), + /** + * The fields that changed, and nothing else — `before` is null for a + * creation and `after` for a deletion. Record identity, bookkeeping + * timestamps (`created_at`, `updated_at`), and unchanged fields are left + * out. Domain timestamps, such as when evidence occurred or was attested, + * belong here. + */ + before: jsonb("before").$type(), + after: jsonb("after").$type(), + /** + * When the change happened. + * + * `clock_timestamp()` rather than `now()`, which is the transaction's start + * time: two requests changing the same record serialize on its row lock, so + * the one that starts first can commit second, and transaction-start order + * would put its history the wrong way round. There is no `updated_at` — + * these rows never change. + */ + createdAt: timestamp("created_at", { withTimezone: true }) + .default(sql`clock_timestamp()`) + .notNull(), + }, + (table) => [ + idFormat("audit_event", "auditEvent"), + check("audit_event_actor_type_valid", sql.raw(`"actor_type" in (${actorTypeValues})`)), + // A user acted or the system did; either way the row says which. A user + // is named by a user's identifier, not merely something present: the row + // can never be corrected. The system has no identity yet, so it carries + // none — whatever one is invented gets its own shape here. Labels stay + // optional: a name is not always known, an identifier is. + // + // The `is not null` is load-bearing: a CHECK holds when its expression is + // NULL, and a pattern match against NULL is NULL rather than false. + check( + "audit_event_actor_is_identified", + sql.raw( + `("actor_type" = 'user' and "actor_id" is not null and "actor_id" ~ '${userId}') ` + + `or ("actor_type" = 'system' and "actor_id" is null)`, + ), + ), + // Somebody's account, or nobody's: a label describing nobody is not + // attribution either. + check( + "audit_event_on_behalf_of_is_identified", + sql.raw( + `("on_behalf_of_id" is not null and "on_behalf_of_id" ~ '${userId}') ` + + `or ("on_behalf_of_id" is null and "on_behalf_of_label" is null)`, + ), + ), + // What happened, and to what. Only shape: an append-only row that is + // blank here stays blank forever. + check("audit_event_action_present", sql`${table.action} ~ '[^[:space:]]'`), + check("audit_event_resource_type_present", sql`${table.resourceType} ~ '[^[:space:]]'`), + check("audit_event_resource_id_present", sql`${table.resourceId} ~ '[^[:space:]]'`), + // Fields by name, as `AuditFields` says — never a bare value or a list, + // which a writer bug would otherwise leave in the history for good. + check( + "audit_event_before_is_object", + sql`${table.before} is null or jsonb_typeof(${table.before}) = 'object'`, + ), + check( + "audit_event_after_is_object", + sql`${table.after} is null or jsonb_typeof(${table.after}) = 'object'`, + ), + // The history of one record, in the order it is read: the ordering key + // extends the lookup so a page is an index scan rather than a sort of + // everything that matched (ADR 0006). The leading column still serves the + // cascade when an organization is deleted. + index("audit_event_resource_idx").on( + table.organizationId, + table.resourceType, + table.resourceId, + table.createdAt, + table.id, + ), + // An organization's whole history, in the order it is read. The index + // above cannot serve this: `resource_id` sits between the type and the + // ordering key, so a page of everything would be a sort of everything + // (ADR 0006). + index("audit_event_organization_id_created_at_id_idx").on( + table.organizationId, + table.createdAt, + table.id, + ), + ], +); diff --git a/packages/db/schema/auth.ts b/packages/db/schema/auth.ts index fcae9a4..0dbfa20 100644 --- a/packages/db/schema/auth.ts +++ b/packages/db/schema/auth.ts @@ -29,10 +29,8 @@ * which is in use. Add them alongside whichever one arrives first. */ -import { sql } from "drizzle-orm"; import { boolean, - check, index, integer, pgTable, @@ -40,28 +38,7 @@ import { timestamp, uniqueIndex, } from "drizzle-orm/pg-core"; -import { createId, type IdType, idPattern } from "../id.ts"; - -/** A prefixed primary key, generated here when the writer does not supply one. */ -const id = (model: IdType) => - text("id") - .primaryKey() - .$defaultFn(() => createId(model)); - -/** - * Rejects an identifier that does not carry this table's prefix and shape. - * `sql.raw` because a CHECK is DDL and cannot take a bound parameter. - */ -const idFormat = (table: string, model: IdType) => - check(`${table}_id_format`, sql.raw(`"id" ~ '${idPattern(model)}'`)); - -const createdAt = () => timestamp("created_at", { withTimezone: true }).defaultNow().notNull(); - -const updatedAt = () => - timestamp("updated_at", { withTimezone: true }) - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(); +import { createdAt, id, idFormat, updatedAt } from "./columns.ts"; /** Fields beyond the Better Auth core come from the `admin` and `twoFactor` plugins. */ export const user = pgTable( @@ -97,9 +74,9 @@ export const session = pgTable( .notNull() .references(() => user.id, { onDelete: "cascade" }), /** - * Which tenant the session is currently acting in — request context, not - * authorization. Access is decided by the caller's `member` row, never by - * this column alone. + * The organization last selected in this session. This is a preference, + * not proof of membership; access must be checked against `member`, and + * domain routes name their organization themselves rather than read it. */ activeOrganizationId: text("active_organization_id").references(() => organization.id, { onDelete: "set null", diff --git a/packages/db/schema/columns.ts b/packages/db/schema/columns.ts new file mode 100644 index 0000000..bd0a14c --- /dev/null +++ b/packages/db/schema/columns.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Column and constraint builders every schema file reuses. + * + * Not exported from `index.ts`: that module is the table map Drizzle and Better + * Auth look tables up in, and these are neither. + */ + +import { sql } from "drizzle-orm"; +import { check, text, timestamp } from "drizzle-orm/pg-core"; +import { createId, type IdType, idPattern } from "../id.ts"; + +/** Drizzle generates omitted ids in application code; raw SQL must supply one. */ +export const id = (model: IdType) => + text("id") + .primaryKey() + .$defaultFn(() => createId(model)); + +/** + * Rejects an identifier that does not carry this table's prefix and shape. + * `sql.raw` because a CHECK is DDL and cannot take a bound parameter. + */ +export const idFormat = (table: string, model: IdType) => + check(`${table}_id_format`, sql.raw(`"id" ~ '${idPattern(model)}'`)); + +export const createdAt = () => + timestamp("created_at", { withTimezone: true }).defaultNow().notNull(); + +/** Database default on insert; Drizzle refreshes it on update, raw SQL updates must. */ +export const updatedAt = () => + timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(); diff --git a/packages/db/schema/control-requirement.ts b/packages/db/schema/control-requirement.ts new file mode 100644 index 0000000..324b36a --- /dev/null +++ b/packages/db/schema/control-requirement.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Which controls answer to which requirements. + * + * The join that gives a control a reason and a requirement something answering + * to it. Whether that amounts to coverage is a judgement this table does not + * make: a link says a control is meant to address a requirement, nothing more. + * Reasoning: `docs/adr/0010-mapping-controls-to-requirements.md`. + */ + +import { foreignKey, index, pgTable, primaryKey, text } from "drizzle-orm/pg-core"; +import { createdAt } from "./columns.ts"; +import { control } from "./control.ts"; +import { requirement } from "./standard.ts"; + +export const controlRequirement = pgTable( + "control_requirement", + { + /** + * Carried once and shared by both references below, so a control and a + * requirement from different organizations cannot be joined (TENANT-01). + * Those references are what tie it to the organization; one of its own + * would add nothing. + */ + organizationId: text("organization_id").notNull(), + controlId: text("control_id").notNull(), + requirementId: text("requirement_id").notNull(), + /** When the mapping was made. A link is never updated; no policy allows it. */ + createdAt: createdAt(), + }, + (table) => [ + /** + * No surrogate identifier: the pair is the identity, and a second row for + * the same pair would say nothing the first does not. `schema/index.test.ts` + * exempts a join table keyed this way (ADR 0002). + */ + primaryKey({ columns: [table.controlId, table.requirementId] }), + /** + * Both references are composite, and both borrow the same + * `organization_id`: a row can only exist if the control and the + * requirement it names are in that one organization (ADR 0008). + */ + foreignKey({ + name: "control_requirement_control_fk", + columns: [table.controlId, table.organizationId], + foreignColumns: [control.id, control.organizationId], + }).onDelete("cascade"), + foreignKey({ + name: "control_requirement_requirement_fk", + columns: [table.requirementId, table.organizationId], + foreignColumns: [requirement.id, requirement.organizationId], + }).onDelete("cascade"), + // The other direction — which controls are mapped to a requirement — and the + // organization leads it so the policy and the cascade both use it. + index("control_requirement_organization_id_requirement_id_idx").on( + table.organizationId, + table.requirementId, + table.controlId, + ), + ], +); diff --git a/packages/db/schema/control.ts b/packages/db/schema/control.ts new file mode 100644 index 0000000..13f3b57 --- /dev/null +++ b/packages/db/schema/control.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Controls: tenant-owned measures with a one-way lifecycle. + * + * What a control is, which fields are deliberately absent, and what each + * lifecycle status means are in `docs/data-model.md`. This file records how + * that model is enforced. + */ + +import { sql } from "drizzle-orm"; +import { check, index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"; +import { organization } from "./auth.ts"; +import { createdAt, id, idFormat, updatedAt } from "./columns.ts"; + +/** + * The lifecycle a control may be in: `draft → active → retired`, one way + * (`docs/data-model.md` gives each its meaning). The API answers for the moves + * with errors a caller can act on; the database makes them one way — a draft + * is exactly a control that never took effect, and a retired one stays + * retired. + */ +export const controlStatuses = ["draft", "active", "retired"] as const; + +export type ControlStatus = (typeof controlStatuses)[number]; + +/** Rendered into DDL, so `sql.raw`; the values are compile-time literals. */ +const statusValues = controlStatuses.map((status) => `'${status}'`).join(", "); + +export const control = pgTable( + "control", + { + id: id("control"), + /** + * Which tenant owns the row (TENANT-01) — ownership, not permission: a + * request still has to prove membership. Cascading keeps a deleted + * organization from leaving rows no one can reach or authorize. + * + * A row-level security policy compares this to the organization + * `withOrganization` sets, so reaching this table outside one sees nothing; + * `migrations/0001_tenancy_and_finality.sql` has it. + */ + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + /** + * A CHECK rather than a PostgreSQL enum: the list will change while the + * product is young, and dropping a CHECK is a plain migration where + * removing or renaming an enum value is not. + */ + status: text("status").$type().default("draft").notNull(), + /** + * When this record first became `active` in Quality Runtime, or null if it + * never has. Not a real-world effective date: a control that has operated + * for years is stamped when it is first activated here. + * + * Set and held by a trigger rather than by the application, so it cannot + * be backdated, forged or cleared: a policy cannot compare a row to what + * it used to be. `migrations/0001_tenancy_and_finality.sql` has it + * (ADR 0017). + */ + activatedAt: timestamp("activated_at", { withTimezone: true }), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + idFormat("control", "control"), + check("control_status_valid", sql.raw(`"status" in (${statusValues})`)), + // A draft is exactly a control that never took effect. With the stamp + // held by the trigger, this makes the lifecycle one way: an active or + // retired control keeps its stamp, so it can never be a draft again, and + // nothing is created active-and-unstamped or retired. The DELETE policy + // can then read the status and mean what it says. + check( + "control_took_effect_unless_draft", + sql`(${table.status} = 'draft') = (${table.activatedAt} is null)`, + ), + // A control no one can name is a data-entry accident, not a draft. The + // predicate asks for one non-whitespace character rather than trimming: + // PostgreSQL's one-argument `btrim` strips spaces only, so a name of tabs + // or newlines would pass. + check("control_name_present", sql`${table.name} ~ '[^[:space:]]'`), + // Target for tenant-scoped references from mappings and evidence. The id + // alone is unique, but PostgreSQL needs uniqueness on the referenced pair + // too (ADR 0008). + unique("control_id_organization_id_key").on(table.id, table.organizationId), + // Every read is scoped to one tenant and ordered newest first, so the + // ordering key belongs in the index: it serves the cursor comparison and + // the sort together (ADR 0006). Its leading column still serves the + // cascade when an organization is deleted. + index("control_organization_id_created_at_id_idx").on( + table.organizationId, + table.createdAt, + table.id, + ), + ], +); diff --git a/packages/db/schema/evidence.ts b/packages/db/schema/evidence.ts new file mode 100644 index 0000000..7c345fa --- /dev/null +++ b/packages/db/schema/evidence.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Evidence: a record that a control was actually operated. + * + * The first finalised record here. What attestation means, and why an attested + * row cannot be changed by the application at all, is in + * `docs/adr/0012-evidence-and-attestation.md`. + */ + +import { sql } from "drizzle-orm"; +import { check, foreignKey, index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"; +import { organization } from "./auth.ts"; +import { idPattern } from "../id.ts"; +import { createdAt, id, idFormat, updatedAt } from "./columns.ts"; +import { control } from "./control.ts"; + +export const evidence = pgTable( + "evidence", + { + id: id("evidence"), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + controlId: text("control_id").notNull(), + /** What it is: `Q3 access review minutes`, `Restore test of 12 March`. */ + title: text("title").notNull(), + description: text("description"), + /** + * When the thing this evidences happened — not when the row was written. + * Evidence of a review done last quarter is evidence about last quarter, + * whenever someone got round to recording it. The API refuses a future one: + * nothing that has not happened is evidence that it did. + */ + occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(), + /** + * Who vouched for it, and when. Null until someone does. + * + * `attestedById` is not a foreign key — the CHECK below asks only that it + * be shaped like a user's identifier — and `attestedByLabel` keeps the name + * as it stood, for the reason audit history does the same: a record of who + * vouched for something is worth nothing if it disappears with them + * (AUDIT-01). + */ + attestedAt: timestamp("attested_at", { withTimezone: true }), + attestedById: text("attested_by_id"), + attestedByLabel: text("attested_by_label"), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + idFormat("evidence", "evidence"), + check("evidence_title_present", sql`${table.title} ~ '[^[:space:]]'`), + // Attested means somebody attested. Either nothing is set, or there is a + // time and a user's identifier behind it — a time with no one, a name + // with no time, and a malformed identifier are all half an attestation. + // The `is not null` is not redundant: a CHECK holds when its expression is + // NULL, and a comparison against NULL is NULL rather than false. The label + // stays optional, as it is in audit history. + check( + "evidence_attestation_complete", + sql.raw(`( + "attested_at" is null + and "attested_by_id" is null + and "attested_by_label" is null + ) or ( + "attested_at" is not null + and "attested_by_id" is not null + and "attested_by_id" ~ '${idPattern("user")}' + )`), + ), + /** + * The control it is evidence for, and the tenant boundary in one + * constraint (ADR 0008). + * + * Restricting rather than cascading. A cascade is a referential action, so + * it answers to neither row-level security nor table privileges: deleting + * a control would take its attested evidence with it and leave no trace, + * around the policy that makes an attestation final (ADR 0012). Evidence + * has to be disposed of deliberately, and attested evidence cannot be. + */ + foreignKey({ + name: "evidence_control_fk", + columns: [table.controlId, table.organizationId], + foreignColumns: [control.id, control.organizationId], + }).onDelete("restrict"), + // What `file` references, so an attachment names evidence in one + // organization and nothing else. A constraint rather than a unique index so + // that it is part of `CREATE TABLE`, and therefore already there when the + // referencing table's foreign key is added (ADR 0008). + unique("evidence_id_organization_id_key").on(table.id, table.organizationId), + // A control's evidence, in the order the things it records happened. + index("evidence_organization_id_control_id_occurred_at_id_idx").on( + table.organizationId, + table.controlId, + table.occurredAt, + table.id, + ), + ], +); diff --git a/packages/db/schema/file.ts b/packages/db/schema/file.ts new file mode 100644 index 0000000..2ecec96 --- /dev/null +++ b/packages/db/schema/file.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * What a file is. The bytes live in durable storage, keyed by this row's id. + * + * PostgreSQL is the authority on whether a file exists, who it belongs to and + * who may read it; storage knows only bytes under a key (DATA-01). Reasoning: + * `docs/adr/0013-durable-storage.md`. + */ + +import { sql } from "drizzle-orm"; +import { check, foreignKey, index, integer, pgTable, text } from "drizzle-orm/pg-core"; +import { createdAt, id, idFormat } from "./columns.ts"; +import { evidence } from "./evidence.ts"; + +/** + * `type/subtype`, and nothing after it. + * + * A conservative subset of RFC 9110's token: no quote, no backtick, and no + * parameters. A parameter could not matter here — every file is served as an + * attachment with `nosniff`, so nothing ever interprets a `charset`. + */ +const mediaType = "^[A-Za-z0-9!#$%&*+.^_|~-]+/[A-Za-z0-9!#$%&*+.^_|~-]+$"; + +export const file = pgTable( + "file", + { + id: id("file"), + /** + * Carried for the policies, and kept honest by the composite reference to + * evidence rather than one of its own: evidence already names the + * organization, and a file goes with its evidence. + */ + organizationId: text("organization_id").notNull(), + /** What it is attached to. Nothing here is a file on its own. */ + evidenceId: text("evidence_id").notNull(), + /** What the uploader called it. For display; never a path (ADR 0013). */ + filename: text("filename").notNull(), + /** What the uploader said it is. For a `Content-Type` on the way back. */ + contentType: text("content_type").notNull(), + /** Counted while writing, not taken from a header. */ + bytes: integer("bytes").notNull(), + /** + * Lowercase hex SHA-256 of the bytes as stored. + * + * Storage has no row-level security to lean on, so this is what makes a + * change to the bytes of an attested record detectable rather than silent. + */ + checksum: text("checksum").notNull(), + createdAt: createdAt(), + }, + (table) => [ + idFormat("file", "file"), + check("file_filename_present", sql`${table.filename} ~ '[^[:space:]]'`), + /** + * A media type, and only that. + * + * This value is written into a `Content-Type` header when the file is read + * back. A newline in one is a way to write a header of your own, and a + * value the runtime refuses to put in a header at all makes the file + * permanently unreadable: there is no UPDATE policy here, and no DELETE + * policy to remove the row with either. So the shape is a + * constraint rather than a rule the API is trusted to remember. + */ + check("file_content_type_shape", sql.raw(`"content_type" ~ '${mediaType}'`)), + check("file_bytes_positive", sql`${table.bytes} > 0`), + check("file_checksum_is_sha256", sql`${table.checksum} ~ '^[0-9a-f]{64}$'`), + foreignKey({ + name: "file_evidence_fk", + columns: [table.evidenceId, table.organizationId], + foreignColumns: [evidence.id, evidence.organizationId], + }).onDelete("cascade"), + index("file_organization_id_evidence_id_created_at_id_idx").on( + table.organizationId, + table.evidenceId, + table.createdAt, + table.id, + ), + ], +); diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 8048066..47e0eeb 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -8,4 +8,10 @@ * tables up by the key they are exported under, so every table belongs here. */ +export * from "./audit.ts"; export * from "./auth.ts"; +export * from "./control.ts"; +export * from "./evidence.ts"; +export * from "./file.ts"; +export * from "./control-requirement.ts"; +export * from "./standard.ts"; diff --git a/packages/db/schema/migrations.test.ts b/packages/db/schema/migrations.test.ts index 98fa931..d0f24ba 100644 --- a/packages/db/schema/migrations.test.ts +++ b/packages/db/schema/migrations.test.ts @@ -2,20 +2,24 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Applies the checked-in migrations to a real PostgreSQL engine and exercises - * the constraints they create. PGlite is PostgreSQL compiled to WebAssembly, so - * this runs in `vp test` with no database to start and nothing to clean up. + * Applies the checked-in migrations and exercises the constraints they create. + * PGlite is PostgreSQL compiled to WebAssembly, so this runs in `vp test` with + * no database to start. It has a single connection, so nothing concurrent is + * decided here: `apps/server/concurrency.test.ts` does that against a server. */ import { fileURLToPath } from "node:url"; import { PGlite } from "@electric-sql/pglite"; -import { eq, getTableName, is, Table } from "drizzle-orm"; +import { eq, getTableColumns, getTableName, inArray, is, Table } 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 { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import { createId } from "../id.ts"; import * as schema from "./index.ts"; +import * as auth from "./auth.ts"; import { account, member, organization, session, user } from "./auth.ts"; +import { control } from "./control.ts"; +import { requirement, standard } from "./standard.ts"; /** The same folder `drizzle-kit migrate` applies, resolved from this module. */ const migrationsFolder = fileURLToPath(new URL("../migrations", import.meta.url)); @@ -27,6 +31,8 @@ beforeAll(async () => { await migrate(db, { migrationsFolder }); }, 60_000); +afterAll(() => db.$client.close()); + /** The constraint PostgreSQL rejected the statement with. */ async function rejectedBy(statement: Promise): Promise { try { @@ -56,6 +62,66 @@ describe("migrations", () => { expect(rows.map((row) => row.table_name).sort()).toEqual(declared); }); + it("protect every domain table with forced row-level security", async () => { + // Derived, so the next domain table cannot arrive without a policy — nor + // without the column one would read: every table that is not Better + // Auth's must belong to an organization. Better Auth's own tables are + // excluded: it reads `member` and `invitation` to work out which + // organizations a user belongs to, which happens before any organization + // is known and so outside every tenant context. + const authTables = new Set( + Object.values(auth) + .filter((value) => is(value, Table)) + .map((table) => getTableName(table as Table)), + ); + const domain = Object.values(schema) + .filter((value) => is(value, Table)) + .map((table) => table as Table) + .filter((table) => !authTables.has(getTableName(table))); + expect( + domain.filter((table) => !("organizationId" in getTableColumns(table))).map(getTableName), + ).toEqual([]); + const tenantOwned = domain.map(getTableName).sort(); + // Guards the derivation itself: an empty list would make this test vacuous. + expect(tenantOwned).toEqual([ + "audit_event", + "control", + "control_requirement", + "evidence", + "file", + "requirement", + "standard", + ]); + + const { rows } = await db.$client.query<{ + relname: string; + relrowsecurity: boolean; + relforcerowsecurity: boolean; + policies: number; + }>( + `select c.relname, c.relrowsecurity, c.relforcerowsecurity, + (select count(*)::int from pg_policy p where p.polrelid = c.oid) as policies + from pg_class c join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' and c.relname = any($1)`, + [tenantOwned], + ); + + // FORCE matters as much as ENABLE: without it PostgreSQL exempts the table + // owner, and the guarantee would rest on the server never being one. How many + // policies a table needs is its own business — `audit_event` names SELECT + // and INSERT separately to be append-only — but having none would leave it + // unreadable rather than protected. + expect(rows.sort((a, b) => a.relname.localeCompare(b.relname))).toEqual( + tenantOwned.map((relname) => ({ + relname, + relrowsecurity: true, + relforcerowsecurity: true, + policies: expect.any(Number) as number, + })), + ); + expect(rows.every((row) => row.policies > 0)).toBe(true); + }); + it("give every timestamp a time zone", async () => { const { rows } = await db.$client.query<{ table_name: string; column_name: string }>( `select table_name, column_name from information_schema.columns @@ -151,3 +217,203 @@ describe("tenant integrity", () => { expect(after?.activeOrganizationId).toBeNull(); }); }); + +describe("standards and requirements", () => { + const newStandard = async (organizationId: string, edition = "2015") => { + const [row] = await db + .insert(standard) + .values({ organizationId, name: "ISO 9001", edition }) + .returning(); + return row!; + }; + + const newRequirement = (parent: { id: string; organizationId: string }, reference = "7.5.3") => ({ + organizationId: parent.organizationId, + standardId: parent.id, + reference, + title: "Control of documented information", + position: 1, + }); + + it("holds one row per issue of a standard", async () => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + await newStandard(org!.id); + + // A second edition is a different standard; a second copy of one is not. + await newStandard(org!.id, "2026"); + const duplicate = db + .insert(standard) + .values({ organizationId: org!.id, name: "ISO 9001", edition: "2015" }); + + expect(await rejectedBy(duplicate)).toBe("standard_organization_id_name_edition_uidx"); + }); + + it("lets two organizations each hold the same standard", async () => { + const [a] = await db.insert(organization).values(newOrganization()).returning(); + const [b] = await db.insert(organization).values(newOrganization()).returning(); + + await newStandard(a!.id); + const theirs = await newStandard(b!.id); + + expect(theirs.id).toMatch(/^std_[0-9a-z]{16}$/); + }); + + it("refuses a requirement filed under another organization's standard", async () => { + // The whole reason the foreign key is composite: this is the shape a + // cross-tenant reference would take, and it has to be impossible rather + // than merely wrong (TENANT-01). + const [a] = await db.insert(organization).values(newOrganization()).returning(); + const [b] = await db.insert(organization).values(newOrganization()).returning(); + const theirs = await newStandard(a!.id); + + const smuggled = db + .insert(requirement) + .values({ ...newRequirement(theirs), organizationId: b!.id }); + + expect(await rejectedBy(smuggled)).toBe("requirement_standard_fk"); + }); + + it("refuses the same clause twice in one standard", async () => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const parent = await newStandard(org!.id); + await db.insert(requirement).values(newRequirement(parent)); + + const duplicate = db.insert(requirement).values({ ...newRequirement(parent), position: 2 }); + + expect(await rejectedBy(duplicate)).toBe("requirement_standard_id_reference_uidx"); + }); + + it("allows the same clause reference under a different standard", async () => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const first = await newStandard(org!.id); + const second = await newStandard(org!.id, "2026"); + + await db.insert(requirement).values(newRequirement(first)); + await db.insert(requirement).values(newRequirement(second)); + + // Scoped to these two: other cases in this file have written 7.5.3 too. + const rows = await db + .select() + .from(requirement) + .where(inArray(requirement.standardId, [first.id, second.id])); + expect(rows.map((row) => row.reference)).toEqual(["7.5.3", "7.5.3"]); + }); + + it.each([ + ["a standard with no name", () => ({ name: " ", edition: "2015" }), "standard_name_present"], + [ + "a standard with no edition", + () => ({ name: "ISO 9001", edition: "" }), + "standard_edition_present", + ], + ])("refuses %s", async (_case, values, constraint) => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + + const rejected = db.insert(standard).values({ organizationId: org!.id, ...values() }); + + expect(await rejectedBy(rejected)).toBe(constraint); + }); + + it("allows two requirements to share a position", async () => { + // Uniqueness here would mean renumbering every later clause to insert one, + // and a swap would need a spare value to pass through. The cost is that + // `position` alone is not an order, which is why the index carries the id. + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const parent = await newStandard(org!.id); + + await db.insert(requirement).values(newRequirement(parent, "7.5.3")); + await db.insert(requirement).values(newRequirement(parent, "7.5.4")); + + const rows = await db + .select() + .from(requirement) + .where(eq(requirement.standardId, parent.id)) + .orderBy(requirement.position, requirement.id); + expect(rows.map((row) => row.position)).toEqual([1, 1]); + }); + + it("deletes a standard's requirements with the standard", async () => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const parent = await newStandard(org!.id); + await db.insert(requirement).values(newRequirement(parent)); + + await db.delete(standard).where(eq(standard.id, parent.id)); + + const rows = await db.select().from(requirement).where(eq(requirement.standardId, parent.id)); + expect(rows).toEqual([]); + }); + + it("deletes both with the organization", async () => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const parent = await newStandard(org!.id); + await db.insert(requirement).values(newRequirement(parent)); + + await db.delete(organization).where(eq(organization.id, org!.id)); + + expect(await db.select().from(standard).where(eq(standard.organizationId, org!.id))).toEqual( + [], + ); + expect( + await db.select().from(requirement).where(eq(requirement.organizationId, org!.id)), + ).toEqual([]); + }); +}); + +describe("control", () => { + /** A control needs a tenant, so every case here starts from a fresh one. */ + const inNewOrganization = async () => { + const [org] = await db.insert(organization).values(newOrganization()).returning(); + return org!.id; + }; + + it("starts life as a draft", async () => { + const organizationId = await inNewOrganization(); + + const [row] = await db + .insert(control) + .values({ organizationId, name: "Access review" }) + .returning(); + + expect(row?.id).toMatch(/^ctl_[0-9a-z]{16}$/); + expect(row?.status).toBe("draft"); + expect(row?.description).toBeNull(); + }); + + it("rejects a status outside the lifecycle", async () => { + const organizationId = await inNewOrganization(); + // Cast past the union the column declares: the point is what reaches the + // database when something upstream does not type-check. + const values = { organizationId, name: "Access review", status: "approved" as "active" }; + + expect(await rejectedBy(db.insert(control).values(values))).toBe("control_status_valid"); + }); + + it("rejects a name that is only whitespace", async () => { + const organizationId = await inNewOrganization(); + + // Tabs and newlines included: `btrim` alone would let those through. + for (const name of ["", " ", "\t", "\n", " \t\n "]) { + expect(await rejectedBy(db.insert(control).values({ organizationId, name }))).toBe( + "control_name_present", + ); + } + }); + + it("rejects a control belonging to no existing tenant", async () => { + const values = { organizationId: createId("organization"), name: "Access review" }; + + expect(await rejectedBy(db.insert(control).values(values))).toBe( + "control_organization_id_organization_id_fk", + ); + }); + + it("deletes a tenant's controls with the tenant", async () => { + const organizationId = await inNewOrganization(); + await db.insert(control).values({ organizationId, name: "Access review" }); + + await db.delete(organization).where(eq(organization.id, organizationId)); + + const rows = await db.select().from(control).where(eq(control.organizationId, organizationId)); + expect(rows).toEqual([]); + }); +}); diff --git a/packages/db/schema/standard.ts b/packages/db/schema/standard.ts new file mode 100644 index 0000000..21dddb1 --- /dev/null +++ b/packages/db/schema/standard.ts @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Standards and the requirements they state. + * + * What a standard is, why an edition is part of its identity, and why every + * organization holds its own copy are in `docs/data-model.md`; the reasoning is + * in `docs/adr/0008-standards-and-requirements.md`. This file records how the + * model is enforced. + */ + +import { sql } from "drizzle-orm"; +import { + check, + foreignKey, + index, + integer, + pgTable, + text, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { organization } from "./auth.ts"; +import { createdAt, id, idFormat, updatedAt } from "./columns.ts"; + +/** Rejects a value that is blank or only whitespace, the way `control` does. */ +const present = (table: string, column: string) => + check(`${table}_${column}_present`, sql.raw(`"${column}" ~ '[^[:space:]]'`)); + +export const standard = pgTable( + "standard", + { + id: id("standard"), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + /** What it is called: `ISO 9001`, `SOC 2`, `Information security policy`. */ + name: text("name").notNull(), + /** + * Which issue of it. Part of the identity, not a detail: ISO 9001:2015 and + * its successor state different requirements, and a record of conformity + * means nothing without saying to what. + */ + edition: text("edition").notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + idFormat("standard", "standard"), + present("standard", "name"), + present("standard", "edition"), + // One row per issue of a standard. Holding ISO 9001:2015 twice is a + // duplicate to reconcile, not two things. + uniqueIndex("standard_organization_id_name_edition_uidx").on( + table.organizationId, + table.name, + table.edition, + ), + // The target of `requirement`'s composite foreign key needs uniqueness on + // the pair, even though id alone is unique. A suitable unique index would + // also work, but this constraint is created with the table, before the + // generated migration adds foreign keys and then standalone indexes. + unique("standard_id_organization_id_key").on(table.id, table.organizationId), + index("standard_organization_id_created_at_id_idx").on( + table.organizationId, + table.createdAt, + table.id, + ), + ], +); + +export const requirement = pgTable( + "requirement", + { + id: id("requirement"), + /** + * Carried rather than reached through `standard`, so one policy shape fits + * every tenant-owned table (ADR 0003). The composite foreign key below is + * what keeps it honest. + */ + organizationId: text("organization_id").notNull(), + standardId: text("standard_id").notNull(), + /** + * How the standard itself refers to this requirement — `7.5.3`, `A.5.1`, + * `CC6.1`. Not an identifier this system generates, and not unique beyond + * the standard that uses it. + */ + reference: text("reference").notNull(), + title: text("title").notNull(), + /** + * The requirement as stated. + * + * Nullable on purpose: the text of a published standard is usually + * copyrighted, and a deployment may hold a licence to read it without a + * right to store or redistribute it. A requirement is still worth tracking + * by reference and title alone. + */ + text: text("text"), + /** + * Where it falls in the standard's own order. Clause references do not sort + * — `7.10` precedes `7.9` lexically — and rendering a standard out of order + * is rendering a different document. + * + * Not unique within a standard, deliberately: inserting a clause between + * two others, or swapping a pair, would otherwise need every row after it + * renumbered in the same statement. Ties are therefore possible, so the + * order is `(position, id)` and never `position` alone. + */ + position: integer("position").notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + idFormat("requirement", "requirement"), + present("requirement", "reference"), + present("requirement", "title"), + /** + * The tenant boundary and the parent in one constraint. Referencing + * `(id, organization_id)` rather than `id` alone makes a requirement + * belonging to one organization and a standard belonging to another + * impossible to write, rather than merely wrong (TENANT-01). + */ + foreignKey({ + name: "requirement_standard_fk", + columns: [table.standardId, table.organizationId], + foreignColumns: [standard.id, standard.organizationId], + }).onDelete("cascade"), + // What `control_requirement` references; see `standard` above. + unique("requirement_id_organization_id_key").on(table.id, table.organizationId), + // A standard does not state the same clause twice. + uniqueIndex("requirement_standard_id_reference_uidx").on(table.standardId, table.reference), + // A standard's requirements, in the order the standard states them. The + // identifier is part of the key because positions may tie, and an order + // that is not total cannot be paged (ADR 0006). + index("requirement_organization_id_standard_id_position_id_idx").on( + table.organizationId, + table.standardId, + table.position, + table.id, + ), + ], +); diff --git a/packages/db/tenant.ts b/packages/db/tenant.ts new file mode 100644 index 0000000..eb42e25 --- /dev/null +++ b/packages/db/tenant.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tenant context: the one way domain code reaches tenant-owned tables. + * + * Reasoning: `docs/adr/0003-tenant-isolation-with-row-level-security.md`. + */ + +import { type ExtractTablesWithRelations, getTableName, is, sql, Table } from "drizzle-orm"; +import { type PgQueryResultHKT, PgTransaction } from "drizzle-orm/pg-core"; +import type { Database } from "./client.ts"; +import * as auth from "./schema/auth.ts"; +import * as schema from "./schema/index.ts"; + +type Schema = typeof schema; +type Relations = ExtractTablesWithRelations; + +/** + * A `Database` that is not already a transaction — what a tenant context has to + * start from. + * + * `rollback?: never` excludes a `PgTransaction`, which structurally satisfies + * `Database` and declares `rollback`. The runtime check in `withOrganization` + * is the real guard; this reports the mistake at the call site instead. + */ +export type RootDatabase = Database & { + rollback?: never; +}; + +/** The scoped handle `withOrganization` hands its callback. */ +export type TenantTransaction = PgTransaction< + Q, + Schema, + Relations +>; + +/** + * The session setting the row-level security policies read. Namespaced so it + * cannot collide with a PostgreSQL parameter or another extension's. + */ +const organizationSetting = "qualityruntime.organization_id"; + +/** + * Every table this package defines except Better Auth's, which resolve a + * user's memberships before any organization is known. Derived, so a new + * domain table is checked without anyone remembering to list it; + * `schema/migrations.test.ts` holds each of them to carrying `organization_id`. + */ +const authTables = new Set( + Object.values(auth).flatMap((value) => (is(value, Table) ? [getTableName(value)] : [])), +); +const domainTables = Object.values(schema).flatMap((value) => + is(value, Table) && !authTables.has(getTableName(value)) ? [getTableName(value)] : [], +); + +/** + * Fails unless row-level security is in force on this connection. + * + * Domain code relies on the policies rather than repeating a tenant predicate + * in every query. At startup, this checks the connection role and the tables + * for configuration that disables row security or prevents ordinary requests: + * + * - The role is a superuser or `BYPASSRLS` — the `postgres` superuser a + * container image creates by default, say — and every policy is inert. No + * migration can prevent that. + * - A domain table is missing, or its row security is not both enabled and + * forced: the database was not migrated, or someone altered it. A table with + * row security disabled ignores its policies entirely. + * - `row_security` is off, which is the migrator's setting (ADR 0014). That + * fails closed rather than leaking, but every request would then fail; one + * clear refusal now is better. + * + * This checks row-security flags, not policy presence or definitions, table + * ownership, or grants. Migrations and tests establish the policies; deployment + * role setup supplies the privilege restrictions. + */ +export async function assertTenantIsolation( + db: RootDatabase, +): Promise { + // Every PostgreSQL driver returns `{ rows }`, but the handle is generic over + // the driver's result type, so TypeScript cannot see that from here. + type Role = { role: string; exempt: boolean; rowSecurity: string }; + const { rows: roles } = (await db.execute( + sql`select current_user as role, (rolsuper or rolbypassrls) as exempt, + current_setting('row_security') as "rowSecurity" + from pg_catalog.pg_roles where rolname = current_user`, + )) as unknown as { rows: Role[] }; + const [role] = roles; + + if (!role) { + throw new Error("Could not determine whether the database role bypasses row-level security."); + } + if (role.exempt) { + throw new Error( + `The database role "${role.role}" bypasses row-level security, which disables tenant ` + + "isolation. Connect as a role that is neither a superuser nor BYPASSRLS; see docs/deployment.md.", + ); + } + if (role.rowSecurity === "off") { + throw new Error( + `The database role "${role.role}" runs with row_security = off, which is the migrator's ` + + "setting. Connect as the server's own role; see docs/deployment.md.", + ); + } + + type Enforced = { table: string }; + const { rows: enforced } = (await db.execute( + sql`select c.relname as "table" + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' and c.relrowsecurity and c.relforcerowsecurity + and c.relname in (${sql.join( + domainTables.map((name) => sql`${name}`), + sql`, `, + )})`, + )) as unknown as { rows: Enforced[] }; + const found = new Set(enforced.map((row) => row.table)); + const unenforced = domainTables.filter((name) => !found.has(name)).sort(); + + if (unenforced.length > 0) { + throw new Error( + `Row-level security is not enabled and forced on ${unenforced.join(", ")}, so tenant ` + + "isolation does not hold there. Apply the migrations, and do not alter row security by hand.", + ); + } +} + +/** + * Runs `work` in a transaction scoped to one organization. + * + * Row-level security decides what the transaction can see and write, so a query + * that forgets its `where organization_id = …` returns that tenant's rows + * rather than everyone's, and a write cannot label a row with another tenant's + * id. Domain code should take the transaction rather than a raw handle. + * + * This is **not** authorization: it scopes a request that has already been + * authorized. Whether the caller may act in this organization is decided from + * their `member` row, before this is called (TENANT-01). + * + * `set_config(…, true)` is `SET LOCAL` as a function, so the value is bound + * rather than interpolated into SQL, and PostgreSQL discards it when the + * transaction ends — including on rollback, and before the connection returns + * to the pool. + * + * Throws when handed a transaction. Drizzle would open a savepoint, and a + * savepoint does not scope `SET LOCAL`: releasing it leaves the new + * organization in force, so the rest of the outer transaction would silently + * run as the wrong tenant. Code inside a tenant context already has the + * transaction it needs and must not re-scope it. + */ +export async function withOrganization( + db: RootDatabase, + organizationId: string, + work: (tx: TenantTransaction) => Promise, + { + repeatableRead = false, + }: { + /** + * Read everything from one snapshot rather than a fresh one per statement. + * + * Under `read committed` — the default — two statements in the same + * transaction can see two different committed states. That is right for a + * write deciding from what is stored now, and wrong for a read whose + * answers have to agree with each other: a page of a collection and a + * version describing that collection, for one. + * + * Set at `BEGIN`, so it cannot be turned on once a statement has run. + */ + repeatableRead?: boolean; + } = {}, +): Promise { + if (is(db, PgTransaction)) { + throw new Error("withOrganization cannot nest: a savepoint does not scope the organization."); + } + + return db.transaction( + async (tx) => { + await tx.execute(sql`select set_config(${organizationSetting}, ${organizationId}, true)`); + return work(tx); + }, + // Pass undefined, not {}: the PGlite adapter treats any config as a + // SET TRANSACTION request, and an empty one produces invalid SQL. + repeatableRead ? { isolationLevel: "repeatable read" } : undefined, + ); +}