Schema check: keys as the store relies on them, typed columns, per-table privileges

Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.

Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.

Columns: each carries its declared type, compared as a type OID through
to_regtype.

Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.

The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 18:55:45 +02:00
commit 731ce08cfa
3 changed files with 327 additions and 141 deletions

View file

@ -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<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE")[]>> = {
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<Pool, "query" | "end">) {}
@ -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<void> {
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")) {

View file

@ -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] ?? ""),
})),
];

View file

@ -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<Set<string>> {
const called = new Set<string>();
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<void>> = [];
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<Record<string, readonly string[]>>): Promise<void> {
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<string> {
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");
});
});