diff --git a/src/store/postgres.ts b/src/store/postgres.ts index ab0765a..ea47ca0 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -1,14 +1,7 @@ import { randomUUID } from "node:crypto"; import { Pool, type PoolClient, type QueryResultRow } from "pg"; import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js"; -import { - CREATE_SCHEMA_SQL, - REQUIRED_COLUMNS, - REQUIRED_KEYS, - REQUIRED_RELATIONS, - REQUIRED_TABLE_PRIVILEGES, - REQUIRED_TABLES, -} from "./schema.js"; +import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_RELATIONS } from "./schema.js"; import { type CreateAccountInput, type CreateAuthAttemptInput, @@ -291,6 +284,24 @@ function mapAuthAttemptRow(row: AuthAttemptRow): StoredAuthAttempt { return attempt; } +/** + * What the store's statements do to each table, and so the privileges its + * database role needs: it reads what it returns or looks up, inserts what it + * creates, updates what it changes or upserts, and deletes nothing. + * audit_events is written and never read back. + */ +export const STORE_TABLE_PRIVILEGES: Readonly> = { + accounts: ["SELECT", "INSERT", "UPDATE"], + linked_identities: ["SELECT", "INSERT", "UPDATE"], + sessions: ["SELECT", "INSERT", "UPDATE"], + auth_attempts: ["SELECT", "INSERT", "UPDATE"], + private_command_receipts: ["SELECT", "INSERT"], + auth_completions: ["SELECT", "INSERT", "UPDATE"], + capability_grants: ["SELECT", "INSERT"], + entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"], + audit_events: ["INSERT"], +}; + export class PostgresStore implements HeimdallStore { constructor(private readonly pool: Pick) {} @@ -300,52 +311,62 @@ export class PostgresStore implements HeimdallStore { /** * Proves, before Heimdall serves, that the database can take its requests: - * every relation, column and key the schema makes is there, and the role - * may read, insert and update every table. A missing piece fails with code + * every relation the schema makes, every column with its declared type, + * every unique key as the store relies on it, and every privilege in + * STORE_TABLE_PRIVILEGES. A missing or altered piece fails with code * SCHEMA_MISSING and a missing privilege with SCHEMA_PRIVILEGES; neither * error names the piece. */ async checkSchema(): Promise { + const privileges = Object.entries(STORE_TABLE_PRIVILEGES).flatMap(([table, wanted]) => + wanted.map((privilege) => ({ table, privilege })) + ); const result = await this.pool.query<{ kind: "missing" | "privilege" }>( ` SELECT 'missing' AS kind FROM unnest($1::text[]) AS name WHERE to_regclass(name) IS NULL UNION ALL - SELECT 'missing' FROM unnest($2::text[], $3::text[]) AS required(table_name, column_name) + SELECT 'missing' FROM unnest($2::text[], $3::text[], $4::text[]) AS required(table_name, column_name, type_name) WHERE NOT EXISTS ( SELECT 1 FROM pg_attribute a WHERE a.attrelid = to_regclass(required.table_name) AND a.attname = required.column_name AND a.attnum > 0 AND NOT a.attisdropped + AND a.atttypid = to_regtype(required.type_name) ) UNION ALL - SELECT 'missing' FROM unnest($4::text[], $5::text[]) AS required(table_name, columns) + SELECT 'missing' FROM unnest($5::text[], $6::text[], $7::text[], $8::text[]) AS required(table_name, columns, index_name, predicate) WHERE NOT EXISTS ( SELECT 1 FROM pg_index i WHERE i.indrelid = to_regclass(required.table_name) + AND (required.index_name = '' OR i.indexrelid = to_regclass(required.index_name)) AND i.indisunique - AND i.indpred IS NULL - AND i.indnatts = cardinality(string_to_array(required.columns, ',')) + AND i.indimmediate + AND coalesce(regexp_replace(lower(pg_get_expr(i.indpred, i.indrelid)), '[()[:space:]]', '', 'g'), '') = required.predicate + AND i.indnkeyatts = cardinality(string_to_array(required.columns, ',')) AND ( SELECT string_agg(a.attname, ',' ORDER BY a.attname) - FROM unnest(i.indkey::int2[]) AS key(attnum) + FROM unnest(i.indkey::int2[]) WITH ORDINALITY AS key(attnum, position) JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = key.attnum + WHERE key.position <= i.indnkeyatts ) = required.columns ) UNION ALL - SELECT 'privilege' FROM unnest($6::text[]) AS required(table_name) - CROSS JOIN unnest($7::text[]) AS wanted(privilege) + SELECT 'privilege' FROM unnest($9::text[], $10::text[]) AS required(table_name, privilege) WHERE to_regclass(required.table_name) IS NOT NULL - AND NOT has_table_privilege(to_regclass(required.table_name), wanted.privilege) + AND NOT has_table_privilege(to_regclass(required.table_name), required.privilege) `, [ REQUIRED_RELATIONS, REQUIRED_COLUMNS.map((required) => required.table), REQUIRED_COLUMNS.map((required) => required.column), + REQUIRED_COLUMNS.map((required) => required.type), REQUIRED_KEYS.map((required) => required.table), REQUIRED_KEYS.map((required) => required.columns), - REQUIRED_TABLES, - REQUIRED_TABLE_PRIVILEGES, + REQUIRED_KEYS.map((required) => required.index), + REQUIRED_KEYS.map((required) => required.predicate), + privileges.map((required) => required.table), + privileges.map((required) => required.privilege), ] ); if (result.rows.some((row) => row.kind === "missing")) { diff --git a/src/store/schema.ts b/src/store/schema.ts index de39316..f385031 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -169,10 +169,24 @@ const createdTables = [...schemaText.matchAll(/^CREATE TABLE IF NOT EXISTS (\w+) lines: match[2]!.split("\n").map((line) => line.trim().replace(/,$/, "")), })); -/** The sorted, comma-separated columns of a `PRIMARY KEY (a, b)` or `UNIQUE(a, b)` line. */ +/** Sorted and comma-separated, the form the schema check compares key columns in. */ +function columnList(text: string): string { + return text.split(",").map((column) => column.trim()).sort().join(","); +} + +/** The columns of a `PRIMARY KEY (a, b)` or `UNIQUE(a, b)` line, as a column list. */ function keyColumns(line: string, keyword: "PRIMARY KEY" | "UNIQUE"): string | undefined { const match = new RegExp(`^${keyword}\\s*\\(([^)]*)\\)`).exec(line); - return match ? match[1]!.split(",").map((column) => column.trim()).sort().join(",") : undefined; + return match ? columnList(match[1]!) : undefined; +} + +/** + * An index predicate in the form the schema check compares: lower case, with + * no parentheses or white space. Postgres stores `a IS NOT NULL AND b IS NULL` + * back as `((a IS NOT NULL) AND (b IS NULL))`; both reduce to the same text. + */ +export function comparablePredicate(predicate: string): string { + return predicate.toLowerCase().replace(/[()\s]/g, ""); } /* @@ -186,35 +200,49 @@ export const REQUIRED_RELATIONS = [ ...schemaText.matchAll(/^CREATE (?:UNIQUE )?(?:TABLE|INDEX) IF NOT EXISTS (\w+)/gm), ].map((match) => match[1]!); -/** Every table the schema creates. The store reads and writes each one. */ -export const REQUIRED_TABLES = createdTables.map(({ table }) => table); - -/** What the store does to its tables: it reads, inserts and updates, and never deletes. */ -export const REQUIRED_TABLE_PRIVILEGES = ["SELECT", "INSERT", "UPDATE"]; - -/** Every column a created table declares, and every column the schema adds to an existing table. */ +/** + * Every column a created table declares and every column the schema adds to + * an existing table, each with its declared type: the store reads and writes + * the values as that type, and a column retyped under it fails its queries. + */ export const REQUIRED_COLUMNS = [ ...createdTables.flatMap(({ table, lines }) => lines .filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line)) - .map((line) => ({ table, column: /^(\w+)/.exec(line)![1]! })) + .map((line) => { + const [, column, type] = /^(\w+) (\w+)/.exec(line)!; + return { table, column: column!, type: type! }; + }) ), - ...[...schemaText.matchAll(/^ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+)/gm)].map((match) => ({ + ...[...schemaText.matchAll(/^ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+) (\w+)/gm)].map((match) => ({ table: match[1]!, column: match[2]!, + type: match[3]!, })), ]; /** - * Every primary key and unique constraint a created table declares, by its - * sorted columns. Each must stand as a unique, non-partial index on exactly - * those columns: the store's ON CONFLICT clauses and the foreign keys need - * nothing more, and without it every such write fails. + * Every unique key the schema makes: the primary keys and unique constraints + * of the created tables, and each unique index it creates by name with that + * index's predicate. The store relies on each as an immediate unique index + * whose key columns are exactly these, with exactly this predicate: ON + * CONFLICT refuses a deferrable arbiter and cannot infer a partial or wider + * one, and the partial attempt index is what keeps one unconsumed completion + * per attempt (R21.1). `index` is empty for a constraint, which may be + * satisfied by any such index on its table. */ -export const REQUIRED_KEYS = createdTables.flatMap(({ table, lines }) => - lines.flatMap((line) => { - const columns = - /^(\w+) .*\bPRIMARY KEY\b/.exec(line)?.[1] ?? keyColumns(line, "PRIMARY KEY") ?? keyColumns(line, "UNIQUE"); - return columns ? [{ table, columns }] : []; - }) -); +export const REQUIRED_KEYS = [ + ...createdTables.flatMap(({ table, lines }) => + lines.flatMap((line) => { + const columns = + /^(\w+) .*\bPRIMARY KEY\b/.exec(line)?.[1] ?? keyColumns(line, "PRIMARY KEY") ?? keyColumns(line, "UNIQUE"); + return columns ? [{ table, columns, index: "", predicate: "" }] : []; + }) + ), + ...[...schemaText.matchAll(/^CREATE UNIQUE INDEX IF NOT EXISTS (\w+)\s+ON (\w+)\(([^)]*)\)(?:\s+WHERE ([^;]+))?;/gm)].map((match) => ({ + table: match[2]!, + columns: columnList(match[3]!), + index: match[1]!, + predicate: comparablePredicate(match[4] ?? ""), + })), +]; diff --git a/tests/store.test.ts b/tests/store.test.ts index b208956..ffec529 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -1,17 +1,11 @@ import { randomUUID } from "node:crypto"; import { Pool } from "pg"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { newDb } from "pg-mem"; import { type HeimdallConfig } from "../src/config.js"; import { createStore } from "../src/store/index.js"; -import { createPostgresStore, PostgresStore } from "../src/store/postgres.js"; -import { - REQUIRED_COLUMNS, - REQUIRED_KEYS, - REQUIRED_RELATIONS, - REQUIRED_TABLE_PRIVILEGES, - REQUIRED_TABLES, -} from "../src/store/schema.js"; +import { createPostgresStore, PostgresStore, STORE_TABLE_PRIVILEGES } from "../src/store/postgres.js"; +import { comparablePredicate, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_RELATIONS } from "../src/store/schema.js"; describe("PostgresStore", () => { it("round-trips core auth records through postgres", async () => { @@ -222,44 +216,41 @@ describe("createPostgresStore", () => { }); describe("PostgresStore.checkSchema", () => { - it("requires every table, index, column and key the schema makes", () => { + it("derives every relation, typed column and unique key from the schema", () => { expect(REQUIRED_RELATIONS).toEqual( expect.arrayContaining(["accounts", "audit_events", "auth_completions_attempt_unconsumed_unique_idx", "audit_events_lookup_idx"]) ); expect(REQUIRED_RELATIONS).toHaveLength(17); - expect(REQUIRED_TABLES).toEqual([ - "accounts", - "linked_identities", - "sessions", - "auth_attempts", - "private_command_receipts", - "auth_completions", - "capability_grants", - "entitlement_snapshots", - "audit_events", - ]); expect(REQUIRED_COLUMNS).toHaveLength(85); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ - { table: "auth_completions", column: "code" }, - { table: "auth_completions", column: "mode" }, - { table: "linked_identities", column: "profile_json" }, - { table: "auth_completions", column: "attempt_id" }, + { table: "auth_completions", column: "code", type: "TEXT" }, + { table: "sessions", column: "access_revision", type: "INTEGER" }, + { table: "linked_identities", column: "profile_json", type: "JSONB" }, + { table: "entitlement_snapshots", column: "is_allowed", type: "BOOLEAN" }, + { table: "auth_completions", column: "attempt_id", type: "TEXT" }, ]) ); - // The keys the store's ON CONFLICT clauses and the foreign keys rely on. + expect(REQUIRED_KEYS).toHaveLength(12); expect(REQUIRED_KEYS).toEqual( expect.arrayContaining([ - { table: "linked_identities", columns: "provider,provider_user_id" }, - { table: "sessions", columns: "id" }, - { table: "private_command_receipts", columns: "app_slug,idempotency_key" }, - { table: "entitlement_snapshots", columns: "account_id,provider,scope" }, - { table: "accounts", columns: "id" }, - { table: "auth_completions", columns: "code" }, + { table: "linked_identities", columns: "provider,provider_user_id", index: "", predicate: "" }, + { table: "private_command_receipts", columns: "app_slug,idempotency_key", index: "", predicate: "" }, + { table: "entitlement_snapshots", columns: "account_id,provider,scope", index: "", predicate: "" }, + { + table: "auth_completions", + columns: "app_slug,attempt_id", + index: "auth_completions_attempt_unconsumed_unique_idx", + predicate: comparablePredicate("attempt_id IS NOT NULL AND consumed_at IS NULL"), + }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(11); - expect(REQUIRED_TABLE_PRIVILEGES).toEqual(["SELECT", "INSERT", "UPDATE"]); + }); + + it("compares a predicate in the form Postgres stores it back", () => { + expect(comparablePredicate("((attempt_id IS NOT NULL) AND (consumed_at IS NULL))")).toBe( + comparablePredicate("attempt_id IS NOT NULL AND consumed_at IS NULL") + ); }); function storeAnswering(kinds: string[]) { @@ -282,16 +273,92 @@ describe("PostgresStore.checkSchema", () => { }); }); +/** + * Every operation the store offers, once, in an order that makes each one + * meaningful. Returns the names it called, so a test can prove it covered the + * store. Throws on the first operation the database refuses. + */ +async function exerciseStore(store: PostgresStore): Promise> { + const called = new Set(); + const s = new Proxy(store, { + get(target, name, receiver) { + const value = Reflect.get(target, name, receiver) as unknown; + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + called.add(String(name)); + return (value as (...a: unknown[]) => unknown).apply(target, args); + }; + }, + }); + const now = "2026-04-26T12:00:00.000Z"; + const later = "2999-01-01T00:00:00.000Z"; + const account = await s.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" }); + await s.touchAccount(account.id, now, { displayName: "Probe 2" }); + await s.findAccountById(account.id); + const identity = { + accountId: account.id, + provider: "discord" as const, + providerUserId: "u1", + scopes: ["identify"], + profileJson: {}, + createdAt: now, + updatedAt: now, + }; + await s.upsertLinkedIdentity(identity); + await s.upsertLinkedIdentity({ ...identity, username: "again" }); + await s.findAccountByLinkedIdentity("discord", "u1"); + await s.findStoredLinkedIdentity("discord", "u1"); + await s.findStoredLinkedIdentityForAccount(account.id, "discord"); + await s.listStoredLinkedIdentitiesForAccount(account.id); + await s.listLinkedIdentitiesForAccount(account.id); + await s.createCapabilityGrant({ + accountId: account.id, + scopeType: "app", + scopeId: "ghostlight", + capability: "app_access", + source: "probe", + status: "active", + createdAt: now, + updatedAt: now, + }); + await s.listActiveGrants(account.id, "ghostlight", now); + const session = { id: randomUUID(), accountId: account.id, appSlug: "ghostlight" as const, createdAt: now, lastSeenAt: now, expiresAt: later, accessRevision: 1, claimsJson: {} }; + await s.createSession(session); + await s.createSession({ ...session, lastSeenAt: later }); + await s.findSession("ghostlight", session.id); + await s.revokeSession("ghostlight", session.id, account.id, 1, now); + const attempt = await s.createAuthAttempt({ appSlug: "ghostlight", provider: "discord", mode: "sign_in", returnTo: "https://x.test/", createdAt: now, expiresAt: later }); + await s.findAuthAttempt("ghostlight", attempt.handle); + await s.updateAuthAttempt("ghostlight", attempt.handle, { status: "completed", at: now }); + const receipt = { appSlug: "ghostlight" as const, idempotencyKey: "k", requestFingerprint: "f", status: "accepted", contentSchema: "c", envelopeBase64: "e", createdAt: now, expiresAt: later }; + await s.createPrivateCommandReceipt(receipt); + await s.createPrivateCommandReceipt(receipt); + await s.findPrivateCommandReceipt("ghostlight", "k"); + const completion = { appSlug: "ghostlight" as const, provider: "discord" as const, mode: "sign_in" as const, accountId: account.id, sessionId: session.id, returnTo: "https://x.test/", createdAt: now, expiresAt: later, payloadJson: {} }; + const byCode = await s.createAuthCompletion(completion); + await s.consumeAuthCompletion("ghostlight", byCode.code, now); + await s.createAuthCompletion({ ...completion, attemptId: attempt.handle }); + await s.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, now); + const snapshot = { accountId: account.id, provider: "discord" as const, scope: "ghostlight:probe", evaluatedAt: now, isAllowed: true, reasonCode: "ok", rawSummaryJson: {} }; + await s.upsertEntitlementSnapshot(snapshot); + await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false }); + await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now }); + return called; +} + // Against a real Postgres, through the startup path itself: createStore, with // and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role -// that may create databases and roles; each case gets its own database. +// that may create databases and roles. Each case clones a template database +// that already holds the schema, and runs as its own login role. const adminUrl = process.env.HEIMDALL_TEST_PG_ADMIN_URL; -describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_ADMIN_URL)", () => { +describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_ADMIN_URL)", { timeout: 120_000 }, () => { const refusal = (code: string) => `Postgres storage could not be prepared (${code}); check GC_ACCESS_DATABASE_URL_FILE.`; + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + const template = `heimdall_template_${suffix}`; + const role = `heimdall_app_${suffix}`; + const rolePassword = "CANARYrolepw"; + const admin = adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined; const cleanup: Array<() => Promise> = []; - afterEach(async () => { - for (const step of cleanup.splice(0).reverse()) await step(); - }); function urlFor(database: string, user?: string, password?: string): string { const url = new URL(adminUrl!); @@ -301,104 +368,174 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD return url.toString(); } - /** A fresh database with the schema applied by its owner, and an owner pool on it. */ - async function appliedDatabase(): Promise<{ name: string; pool: Pool }> { + beforeAll(async () => { + await admin!.query(`CREATE DATABASE ${template}`); + const pool = new Pool({ connectionString: urlFor(template) }); + await new PostgresStore(pool).ensureSchema(); + await pool.end(); + await admin!.query(`CREATE ROLE ${role} LOGIN PASSWORD '${rolePassword}'`); + }, 120_000); + + afterEach(async () => { + for (const step of cleanup.splice(0).reverse()) await step(); + }); + + afterAll(async () => { + await admin!.query(`DROP DATABASE IF EXISTS ${template}`); + await admin!.query(`DROP ROLE IF EXISTS ${role}`); + await admin!.end(); + }, 120_000); + + /** A clone of the applied template, and a pool on it as its owner (the admin role). */ + async function appliedDatabase(): Promise<{ name: string; owner: Pool }> { const name = `heimdall_check_${randomUUID().replace(/-/g, "")}`; - const admin = new Pool({ connectionString: adminUrl }); - await admin.query(`CREATE DATABASE ${name}`); - cleanup.push(async () => { - await admin.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); - await admin.end(); - }); - const pool = new Pool({ connectionString: urlFor(name) }); + await admin!.query(`CREATE DATABASE ${name} TEMPLATE ${template}`); + const owner = new Pool({ connectionString: urlFor(name), max: 2 }); // The forced drop ends this fixture pool's connections, possibly while // they are still closing; that is cleanup, not a finding. - pool.on("error", () => undefined); + owner.on("error", () => undefined); cleanup.push(async () => { - await pool.end(); + await owner.end(); + await admin!.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`); }); - await new PostgresStore(pool).ensureSchema(); - return { name, pool }; + return { name, owner }; } + /** Grants the app role exactly `privileges`, per table, and nothing else. */ + async function grant(owner: Pool, privileges: Readonly>): Promise { + await owner.query(`GRANT USAGE ON SCHEMA public TO ${role}`); + for (const [table, wanted] of Object.entries(privileges)) { + if (wanted.length > 0) await owner.query(`GRANT ${wanted.join(", ")} ON ${table} TO ${role}`); + } + } + + /** + * Starts Heimdall's store on `url` and reports "started" or the refusal's + * message. A refusal must also leave no session of its own behind: the + * refused store's pool is closed before the error is thrown. + */ async function start(url: string, applySchemaOnStartup: boolean): Promise { + const tagged = new URL(url); + const tag = `heimdall_start_${randomUUID().replace(/-/g, "").slice(0, 16)}`; + tagged.searchParams.set("application_name", tag); try { - const store = await createStore({ storage: { backend: "postgres", databaseUrl: url, applySchemaOnStartup } } as HeimdallConfig); + const store = await createStore({ + storage: { backend: "postgres", databaseUrl: tagged.toString(), applySchemaOnStartup }, + } as HeimdallConfig); await store.close(); return "started"; } catch (error) { + let sessions = -1; + for (let attempt = 0; attempt < 50 && sessions !== 0; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100)); + const result = await admin!.query<{ n: number }>( + "SELECT count(*)::int AS n FROM pg_stat_activity WHERE application_name = $1", + [tag] + ); + sessions = result.rows[0]!.n; + } + expect(sessions, "a refused store left its connection open").toBe(0); return (error as Error).message; } } - it("starts on an applied database, applying or not", async () => { + it("starts on an applied database as its owner, applying or not", async () => { const { name } = await appliedDatabase(); expect(await start(urlFor(name), false)).toBe("started"); expect(await start(urlFor(name), true)).toBe("started"); }); - it("refuses a role that may only read, by code and without its password", async () => { - const { name, pool } = await appliedDatabase(); - const role = `heimdall_ro_${randomUUID().replace(/-/g, "").slice(0, 12)}`; - await pool.query(`CREATE ROLE ${role} LOGIN PASSWORD 'CANARYrolepw'`); + it("serves every store operation with exactly the privileges it checks for", async () => { + const { name, owner } = await appliedDatabase(); + await grant(owner, STORE_TABLE_PRIVILEGES); + + expect(await start(urlFor(name, role, rolePassword), false)).toBe("started"); + const pool = new Pool({ connectionString: urlFor(name, role, rolePassword) }); cleanup.push(async () => { - await pool.query(`REVOKE ALL ON ALL TABLES IN SCHEMA public FROM ${role}`); - await pool.query(`DROP ROLE IF EXISTS ${role}`); + await pool.end(); }); - await pool.query(`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${role}`); + const called = await exerciseStore(new PostgresStore(pool)); + const offered = Object.getOwnPropertyNames(PostgresStore.prototype).filter( + (method) => !["constructor", "ensureSchema", "checkSchema", "close"].includes(method) + ); + expect([...called].sort()).toEqual(offered.sort()); + }); - const message = await start(urlFor(name, role, "CANARYrolepw"), false); - expect(message).toBe(refusal("SCHEMA_PRIVILEGES")); - expect(message).not.toContain("CANARY"); + it("refuses each privilege taken away, and the store needs each one", async () => { + const pairs = Object.entries(STORE_TABLE_PRIVILEGES).flatMap(([table, wanted]) => wanted.map((privilege) => ({ table, privilege }))); + expect(Object.keys(STORE_TABLE_PRIVILEGES).sort()).toEqual( + REQUIRED_RELATIONS.filter((relation) => REQUIRED_COLUMNS.some((column) => column.table === relation)).sort() + ); + for (const { table, privilege } of pairs) { + const { name, owner } = await appliedDatabase(); + await grant(owner, { ...STORE_TABLE_PRIVILEGES, [table]: STORE_TABLE_PRIVILEGES[table]!.filter((p) => p !== privilege) }); - // The refused store closed its pool: no session of the role outlives the refusal. - let sessions = -1; - for (let attempt = 0; attempt < 20 && sessions !== 0; attempt += 1) { - if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100)); - const result = await pool.query<{ n: number }>("SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = $1", [role]); - sessions = result.rows[0]!.n; + const message = await start(urlFor(name, role, rolePassword), false); + expect(message, `${table} without ${privilege}`).toBe(refusal("SCHEMA_PRIVILEGES")); + expect(message).not.toContain("CANARY"); + + const pool = new Pool({ connectionString: urlFor(name, role, rolePassword) }); + const failure = await exerciseStore(new PostgresStore(pool)).then( + () => undefined, + (error: unknown) => (error as { code?: string }).code + ); + await pool.end(); + expect(failure, `the store runs without ${privilege} on ${table}, so the check need not ask for it`).toBe("42501"); + for (const step of cleanup.splice(0).reverse()) await step(); } - expect(sessions).toBe(0); + }); + + it("refuses to apply the schema as a role that may not, and closes the store", async () => { + const { name, owner } = await appliedDatabase(); + await grant(owner, STORE_TABLE_PRIVILEGES); + + expect(await start(urlFor(name, role, rolePassword), true)).toBe(refusal("42501")); }); it("refuses a table whose primary key was dropped, even after applying the schema", async () => { - const { name, pool } = await appliedDatabase(); - await pool.query("ALTER TABLE auth_completions DROP CONSTRAINT auth_completions_pkey"); + const { name, owner } = await appliedDatabase(); + await owner.query("ALTER TABLE auth_completions DROP CONSTRAINT auth_completions_pkey"); expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); expect(await start(urlFor(name), true)).toBe(refusal("SCHEMA_MISSING")); }); - it("refuses a unique key the store's ON CONFLICT needs, replaced by a partial one", async () => { - const { name, pool } = await appliedDatabase(); - await pool.query("ALTER TABLE entitlement_snapshots DROP CONSTRAINT entitlement_snapshots_account_id_provider_scope_key"); - await pool.query( - "CREATE UNIQUE INDEX entitlement_snapshots_partial ON entitlement_snapshots(account_id, provider, scope) WHERE is_allowed" + // Each damage below leaves a database whose shape still resembles the + // schema but on which a store write fails or R21.1 no longer holds. + it.each([ + ["a key made DEFERRABLE (ON CONFLICT refuses it as an arbiter)", + "ALTER TABLE private_command_receipts DROP CONSTRAINT private_command_receipts_pkey; ALTER TABLE private_command_receipts ADD PRIMARY KEY (app_slug, idempotency_key) DEFERRABLE INITIALLY IMMEDIATE"], + ["a key narrowed to one column, the other only INCLUDEd", + "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key; CREATE UNIQUE INDEX li_include ON linked_identities(provider) INCLUDE (provider_user_id)"], + ["a key widened by an expression column", + "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key; CREATE UNIQUE INDEX li_expr ON linked_identities(provider, provider_user_id, lower(username))"], + ["a key replaced by a partial one", + "ALTER TABLE entitlement_snapshots DROP CONSTRAINT entitlement_snapshots_account_id_provider_scope_key; CREATE UNIQUE INDEX es_partial ON entitlement_snapshots(account_id, provider, scope) WHERE is_allowed"], + ["a key replaced by a plain index on the same columns", + "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key; CREATE INDEX li_plain ON linked_identities(provider, provider_user_id)"], + ["a key replaced by one on other columns of the same count", + "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key; CREATE UNIQUE INDEX li_other ON linked_identities(provider, username)"], + ["the attempt index replaced by a plain one of the same name", + "DROP INDEX auth_completions_attempt_unconsumed_unique_idx; CREATE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)"], + ["the attempt index made unique on every row (no predicate)", + "DROP INDEX auth_completions_attempt_unconsumed_unique_idx; CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)"], + ["the attempt index given another predicate", + "DROP INDEX auth_completions_attempt_unconsumed_unique_idx; CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL"], + ["a column retyped", "ALTER TABLE sessions ALTER COLUMN access_revision TYPE TEXT"], + ["a created table's column dropped", "ALTER TABLE auth_completions DROP COLUMN mode"], + ])("refuses %s", async (_damage, sql) => { + const { name, owner } = await appliedDatabase(); + await owner.query(sql); + + expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); + }); + + it("accepts a key index that carries extra INCLUDE columns", async () => { + const { name, owner } = await appliedDatabase(); + await owner.query( + "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key; CREATE UNIQUE INDEX li_covering ON linked_identities(provider, provider_user_id) INCLUDE (username)" ); - expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); - }); - - it("refuses a unique key replaced by a plain index on the same columns", async () => { - const { name, pool } = await appliedDatabase(); - await pool.query("ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key"); - await pool.query("CREATE INDEX linked_identities_plain ON linked_identities(provider, provider_user_id)"); - - expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); - }); - - it("refuses a unique key replaced by one on other columns of the same count", async () => { - const { name, pool } = await appliedDatabase(); - await pool.query("ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key"); - await pool.query("CREATE UNIQUE INDEX linked_identities_other ON linked_identities(provider, username)"); - - expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); - }); - - it("refuses a created table's dropped column", async () => { - const { name, pool } = await appliedDatabase(); - await pool.query("ALTER TABLE auth_completions DROP COLUMN mode"); - - expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); + expect(await start(urlFor(name), false)).toBe("started"); }); });