Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL
CI / full-gate (push) Failing after 23s

This commit is contained in:
2026-08-19 00:08:35 +02:00
parent bba048e38f
commit ae8f105360
11 changed files with 313 additions and 129 deletions
+45
View File
@@ -0,0 +1,45 @@
// The connecting half of plugin storage: runs the DDL storage.ts plans. Imported by bootstrap
// alone — the only process holding superuser credentials, which is why the driver stops here.
import postgres from "postgres";
import { derivePassword, NAME_PREFIX, provisionSql, storageName } from "./storage.ts";
export interface ProvisionOptions {
adminUrl: string; // needs CREATEDB + CREATEROLE, not superuser
connectionLimit: number;
pluginIds: string[];
secret: string;
}
export interface ProvisionResult {
orphans: string[]; // provisioned once, but no installed plugin claims them any more
provisioned: string[];
}
// Idempotent, and it drops nothing: an uninstalled plugin keeps its data until an operator removes
// it deliberately. Orphans are reported rather than removed, so nobody has to guess they exist.
export async function provisionStorage(options: ProvisionOptions): Promise<ProvisionResult> {
const sql = postgres(options.adminUrl, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
const provisioned: string[] = [];
for (const pluginId of options.pluginIds) {
const name = storageName(pluginId);
const [role] = await sql`SELECT 1 FROM pg_roles WHERE rolname = ${name}`;
const [database] = await sql`SELECT 1 FROM pg_database WHERE datname = ${name}`;
const plan = provisionSql({
connectionLimit: options.connectionLimit,
databaseExists: database !== undefined,
name,
password: derivePassword(options.secret, pluginId),
roleExists: role !== undefined,
});
for (const statement of plan) await sql.unsafe(statement); // provisionSql quotes what it interpolates
provisioned.push(name);
}
const existing = await sql<{ datname: string }[]>`SELECT datname FROM pg_database WHERE starts_with(datname, ${NAME_PREFIX})`;
const orphans = existing.map((row) => row.datname).filter((name) => !provisioned.includes(name)).sort();
return { orphans, provisioned };
} finally {
await sql.end();
}
}
+36 -8
View File
@@ -4,16 +4,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import postgres from "postgres";
import { provisionStorage } from "./storage-provisioning.ts";
import {
buildCredentials,
derivePassword,
isValidStoragePluginId,
MAX_STORAGE_PLUGIN_ID,
provisionSql,
provisionStorage,
quoteIdentifier,
quoteLiteral,
storageName,
storagePluginIds,
} from "./storage.ts";
const SECRET = "a-test-secret";
@@ -61,23 +62,42 @@ test("quoting doubles an embedded quote", () => {
assert.equal(quoteLiteral("we'ird"), "'we''ird'");
});
const ATTRIBUTES = "LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT 10";
test("only the plugins that asked for storage are provisioned", () => {
assert.deepEqual(
storagePluginIds([{ apiVersion: "1.0.0", id: "a", storage: true }, { apiVersion: "1.0.0", id: "b" }, { apiVersion: "1.0.0", id: "c", storage: true }]),
["a", "c"],
);
});
test("provisioning creates the role and the database when neither exists", () => {
assert.deepEqual(provisionSql("plugin_things", "pw", { databaseExists: false, roleExists: false }), [
`CREATE ROLE "plugin_things" LOGIN PASSWORD 'pw'`,
const plan = { connectionLimit: 10, databaseExists: false, name: "plugin_things", password: "pw", roleExists: false };
assert.deepEqual(provisionSql(plan), [
`CREATE ROLE "plugin_things" ${ATTRIBUTES} PASSWORD 'pw'`,
`CREATE DATABASE "plugin_things" OWNER "plugin_things"`,
`REVOKE ALL ON DATABASE "plugin_things" FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE "plugin_things" TO "plugin_things"`,
]);
});
test("re-provisioning re-sets the password and creates nothing twice", () => {
assert.deepEqual(provisionSql("plugin_things", "rotated", { databaseExists: true, roleExists: true }), [
`ALTER ROLE "plugin_things" WITH LOGIN PASSWORD 'rotated'`,
// Re-asserting the attributes, not just the password, is what makes "idempotent" mean the role
// cannot drift — a CREATEDB granted by hand out of band is taken back on the next boot.
test("re-provisioning re-asserts every attribute and creates nothing twice", () => {
const plan = { connectionLimit: 10, databaseExists: true, name: "plugin_things", password: "rotated", roleExists: true };
assert.deepEqual(provisionSql(plan), [
`ALTER ROLE "plugin_things" WITH ${ATTRIBUTES} PASSWORD 'rotated'`,
`REVOKE ALL ON DATABASE "plugin_things" FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE "plugin_things" TO "plugin_things"`,
]);
});
// The limit is interpolated unquoted, so a non-integer would corrupt the statement text.
test("a non-integer connection limit is refused, not interpolated", () => {
const plan = { connectionLimit: 1.5, databaseExists: false, name: "plugin_things", password: "pw", roleExists: false };
assert.throws(() => provisionSql(plan), /connectionLimit must be an integer/);
});
// --- Integration: the statements above, against a real Postgres -----------------------
// Opt-in via PLUGIN_DB_ADMIN_URL (a superuser DSN); the unit gate runs no Postgres. What the unit
// tests cannot prove lives here: the owner may create tables, and a peer role is locked out.
@@ -107,7 +127,7 @@ test("provisions a database its plugin can use and a peer plugin cannot reach",
const base = baseUrlOf(ADMIN_URL);
const admin = postgres(ADMIN_URL, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
await provisionStorage({ adminUrl: ADMIN_URL, pluginIds: ids, secret: SECRET });
await provisionStorage({ adminUrl: ADMIN_URL, connectionLimit: 10, pluginIds: ids, secret: SECRET });
const owner = buildCredentials(base, "storage-itest-a", SECRET);
await queryAs(owner.url, "CREATE TABLE IF NOT EXISTS notes (body text)");
@@ -121,11 +141,19 @@ test("provisions a database its plugin can use and a peer plugin cannot reach",
await assert.rejects(queryAs(peer.href, "SELECT 1"), /permission denied|not permitted/i);
// Re-running is idempotent, and a rotated secret lands on the existing role.
await provisionStorage({ adminUrl: ADMIN_URL, pluginIds: ids, secret: "a-rotated-secret" });
const rerun = await provisionStorage({ adminUrl: ADMIN_URL, connectionLimit: 10, pluginIds: ids, secret: "a-rotated-secret" });
// Scoped to this test's own ids: another plugin's database on the same server is not this
// test's business, and asserting otherwise would make the suite order-dependent.
for (const id of ids) assert.ok(!rerun.orphans.includes(storageName(id)), `${id} is still installed`);
const rotated = buildCredentials(base, "storage-itest-a", "a-rotated-secret");
const kept = (await queryAs(rotated.url, "SELECT body FROM notes")) as { body: string }[];
assert.deepEqual(kept.map((row) => row.body), ["persisted"]); // rotating the secret keeps the data
await assert.rejects(queryAs(owner.url, "SELECT 1"), /password authentication failed/i);
// Uninstalling drops nothing, so what is left behind must be named — including when the LAST
// storage plugin goes and there is nothing left to provision.
const uninstalled = await provisionStorage({ adminUrl: ADMIN_URL, connectionLimit: 10, pluginIds: [], secret: "a-rotated-secret" });
for (const id of ids) assert.ok(uninstalled.orphans.includes(storageName(id)), `${id}'s database is reported`);
} finally {
for (const id of ids) {
const name = quoteIdentifier(storageName(id));
+24 -43
View File
@@ -1,12 +1,12 @@
// Per-plugin Postgres storage (README → Plugin storage). Only bootstrap provisions, because only it
// is given superuser credentials; web derives the same passwords and never sees them.
// Per-plugin Postgres storage: the naming, credential and DDL rules (README → Plugin storage).
// Pure — the connecting half lives in storage-provisioning.ts, so `web` never loads a driver.
import { createHmac } from "node:crypto";
import postgres from "postgres";
import type { Plugin } from "./plugin.ts";
// Database and role share one name, so reconnecting needs nothing looked up. The prefix also keeps
// a plugin id from ever naming an Ory database.
const NAME_PREFIX = "plugin_";
export const NAME_PREFIX = "plugin_";
// Postgres truncates an identifier at 63 bytes, which would silently collide two long ids.
export const MAX_STORAGE_PLUGIN_ID = 63 - NAME_PREFIX.length;
@@ -29,6 +29,10 @@ export function isValidStoragePluginId(pluginId: string): boolean {
return pluginId.length <= MAX_STORAGE_PLUGIN_ID;
}
export function storagePluginIds(plugins: Plugin[]): string[] {
return plugins.filter((plugin) => plugin.storage).map((plugin) => plugin.id);
}
// Derived, never stored — which is what keeps the host free of state it would have to persist.
export function derivePassword(secret: string, pluginId: string): string {
return createHmac("sha256", secret).update(pluginId).digest("base64url");
@@ -54,51 +58,28 @@ export function quoteLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export interface ProvisionState {
export interface ProvisionPlan {
connectionLimit: number;
databaseExists: boolean;
name: string;
password: string;
roleExists: boolean;
}
// One plugin's full plan, in order. The password is re-set on every run so rotating the secret needs
// no separate step, and PUBLIC loses CONNECT so no other plugin's role can reach this database.
export function provisionSql(name: string, password: string, state: ProvisionState): string[] {
const identifier = quoteIdentifier(name);
const secret = quoteLiteral(password);
// One plugin's full plan, in order. Both role branches state the same attributes, so every boot
// re-asserts them: a privilege granted by hand out of band does not survive silently.
export function provisionSql(plan: ProvisionPlan): string[] {
if (!Number.isSafeInteger(plan.connectionLimit)) {
throw new Error(`storage: connectionLimit must be an integer, got ${plan.connectionLimit}`); // interpolated unquoted
}
const identifier = quoteIdentifier(plan.name);
// NOSUPERUSER/NOCREATEDB/NOCREATEROLE: a plugin owns its own database and nothing beyond it.
// The limit bounds one plugin's pools so they cannot starve Ory, which shares this server.
const attributes = `LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT ${plan.connectionLimit} PASSWORD ${quoteLiteral(plan.password)}`;
return [
state.roleExists
? `ALTER ROLE ${identifier} WITH LOGIN PASSWORD ${secret}`
: `CREATE ROLE ${identifier} LOGIN PASSWORD ${secret}`,
...(state.databaseExists ? [] : [`CREATE DATABASE ${identifier} OWNER ${identifier}`]),
plan.roleExists ? `ALTER ROLE ${identifier} WITH ${attributes}` : `CREATE ROLE ${identifier} ${attributes}`,
...(plan.databaseExists ? [] : [`CREATE DATABASE ${identifier} OWNER ${identifier}`]),
`REVOKE ALL ON DATABASE ${identifier} FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE ${identifier} TO ${identifier}`,
];
}
export interface ProvisionOptions {
adminUrl: string; // superuser DSN — the rights to create a database and a role
pluginIds: string[];
secret: string;
}
// Idempotent, and it drops nothing: an uninstalled plugin keeps its data until an operator removes
// it deliberately.
export async function provisionStorage(options: ProvisionOptions): Promise<string[]> {
const sql = postgres(options.adminUrl, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
const names: string[] = [];
for (const pluginId of options.pluginIds) {
const name = storageName(pluginId);
const [role] = await sql`SELECT 1 FROM pg_roles WHERE rolname = ${name}`;
const [database] = await sql`SELECT 1 FROM pg_database WHERE datname = ${name}`;
const plan = provisionSql(name, derivePassword(options.secret, pluginId), {
databaseExists: database !== undefined,
roleExists: role !== undefined,
});
for (const statement of plan) await sql.unsafe(statement); // provisionSql quotes what it interpolates
names.push(name);
}
return names;
} finally {
await sql.end();
}
}