Isolate the storage CI stack, prove least-privilege provisioning, drop the secret before discovery
CI / full-gate (push) Successful in 2m58s
CI / full-gate (push) Successful in 2m58s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user