Isolate the storage CI stack, prove least-privilege provisioning, drop the secret before discovery
CI / full-gate (push) Successful in 2m58s

This commit is contained in:
2026-08-19 00:20:17 +02:00
parent ae8f105360
commit 6db14a2205
8 changed files with 99 additions and 17 deletions
+8 -3
View File
@@ -2,11 +2,14 @@
// 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";
import { derivePassword, NAME_PREFIX, orphanNames, provisionSql, quoteIdentifier, storageName } from "./storage.ts";
export interface ProvisionOptions {
adminUrl: string; // needs CREATEDB + CREATEROLE, not superuser
connectionLimit: number;
// Databases to keep closed to PUBLIC on every run. init.sql seeds this for the Ory databases, but
// it runs once on an empty data dir — an existing volume would keep the default grant forever.
lockdownDatabases?: string[];
pluginIds: string[];
secret: string;
}
@@ -36,9 +39,11 @@ export async function provisionStorage(options: ProvisionOptions): Promise<Provi
for (const statement of plan) await sql.unsafe(statement); // provisionSql quotes what it interpolates
provisioned.push(name);
}
for (const database of options.lockdownDatabases ?? []) {
await sql.unsafe(`REVOKE CONNECT ON DATABASE ${quoteIdentifier(database)} FROM PUBLIC`);
}
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 };
return { orphans: orphanNames(existing.map((row) => row.datname), provisioned), provisioned };
} finally {
await sql.end();
}
+35
View File
@@ -10,6 +10,7 @@ import {
derivePassword,
isValidStoragePluginId,
MAX_STORAGE_PLUGIN_ID,
orphanNames,
provisionSql,
quoteIdentifier,
quoteLiteral,
@@ -71,10 +72,17 @@ test("only the plugins that asked for storage are provisioned", () => {
);
});
test("an orphan is a plugin_ database no installed plugin claims", () => {
const existing = ["plugin_gone", "plugin_here", "kratos", "ory"];
assert.deepEqual(orphanNames(existing, ["plugin_here"]), ["plugin_gone"]); // Ory's are not ours to report
assert.deepEqual(orphanNames(existing, ["plugin_here", "plugin_gone"]), []);
});
test("provisioning creates the role and the database when neither exists", () => {
const plan = { connectionLimit: 10, databaseExists: false, name: "plugin_things", password: "pw", roleExists: false };
assert.deepEqual(provisionSql(plan), [
`CREATE ROLE "plugin_things" ${ATTRIBUTES} PASSWORD 'pw'`,
`GRANT "plugin_things" TO CURRENT_USER`, // else a CREATEROLE (non-superuser) account cannot own it
`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"`,
@@ -163,3 +171,30 @@ test("provisions a database its plugin can use and a peer plugin cannot reach",
await admin.end();
}
});
// README tells an operator CREATEDB + CREATEROLE is enough and superuser is more than it needs.
// That is a promise about their production credentials, so prove it rather than assert it.
test("provisions through a CREATEDB + CREATEROLE account, without superuser", integration, async () => {
const pluginId = "storage-itest-lowpriv";
const provisioner = "storage-itest-provisioner";
const admin = postgres(ADMIN_URL, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
await admin.unsafe(`DROP ROLE IF EXISTS ${quoteIdentifier(provisioner)}`);
await admin.unsafe(`CREATE ROLE ${quoteIdentifier(provisioner)} LOGIN CREATEDB CREATEROLE PASSWORD 'itest-provisioner'`);
const asProvisioner = new URL(ADMIN_URL);
asProvisioner.username = provisioner;
asProvisioner.password = "itest-provisioner";
await provisionStorage({ adminUrl: asProvisioner.href, connectionLimit: 10, pluginIds: [pluginId], secret: SECRET });
const owner = buildCredentials(baseUrlOf(ADMIN_URL), pluginId, SECRET);
await queryAs(owner.url, "CREATE TABLE IF NOT EXISTS notes (body text)");
const rows = (await queryAs(owner.url, "SELECT 1 AS ok")) as { ok: number }[];
assert.deepEqual(rows.map((row) => row.ok), [1]); // the plugin owns and can use what it was given
} finally {
const name = quoteIdentifier(storageName(pluginId));
await admin.unsafe(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
await admin.unsafe(`DROP ROLE IF EXISTS ${name}`);
await admin.unsafe(`DROP ROLE IF EXISTS ${quoteIdentifier(provisioner)}`);
await admin.end();
}
});
+10 -1
View File
@@ -58,6 +58,12 @@ export function quoteLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
// A database this host provisioned once and no installed plugin claims any more. Nothing drops it,
// so naming it is the only way an operator finds it again.
export function orphanNames(existing: string[], provisioned: string[]): string[] {
return existing.filter((name) => name.startsWith(NAME_PREFIX) && !provisioned.includes(name)).sort();
}
export interface ProvisionPlan {
connectionLimit: number;
databaseExists: boolean;
@@ -78,7 +84,10 @@ export function provisionSql(plan: ProvisionPlan): string[] {
const attributes = `LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT ${plan.connectionLimit} PASSWORD ${quoteLiteral(plan.password)}`;
return [
plan.roleExists ? `ALTER ROLE ${identifier} WITH ${attributes}` : `CREATE ROLE ${identifier} ${attributes}`,
...(plan.databaseExists ? [] : [`CREATE DATABASE ${identifier} OWNER ${identifier}`]),
// CREATE DATABASE ... OWNER needs SET ROLE on the owner, and PG16+ gives a CREATEROLE account
// ADMIN but *not* SET on the roles it creates — so it grants itself membership first. A
// superuser could skip this; issuing it anyway is what keeps a least-privilege account working.
...(plan.databaseExists ? [] : [`GRANT ${identifier} TO CURRENT_USER`, `CREATE DATABASE ${identifier} OWNER ${identifier}`]),
`REVOKE ALL ON DATABASE ${identifier} FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE ${identifier} TO ${identifier}`,
];