Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL
This commit is contained in:
+55
-37
@@ -8,12 +8,15 @@
|
||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolvePluginDbSecret } from "../config.ts";
|
||||
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
|
||||
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
||||
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
|
||||
import { provisionStorage } from "../plugin-host/storage.ts";
|
||||
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
|
||||
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
|
||||
import { storagePluginIds } from "../plugin-host/storage.ts";
|
||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
||||
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
||||
|
||||
@@ -152,42 +155,57 @@ async function main() {
|
||||
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
|
||||
await runWithLog(log, async () => {
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||
|
||||
const plugins = await discoverPlugins();
|
||||
|
||||
// A database and login role for each plugin that asked for one. It happens here because
|
||||
// bootstrap holds the stack's only superuser credentials — web derives the same password from
|
||||
// PLUGIN_DB_SECRET and connects as the plugin's own role, never as a superuser.
|
||||
const storagePlugins = plugins.filter((plugin) => plugin.storage).map((plugin) => plugin.id);
|
||||
if (storagePlugins.length > 0) {
|
||||
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
|
||||
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${storagePlugins.join(", ")}`);
|
||||
const provisioned = await provisionStorage({ adminUrl, pluginIds: storagePlugins, secret: resolvePluginDbSecret(env) });
|
||||
log.info("plugin storage provisioned", { databases: provisioned.join(", ") });
|
||||
}
|
||||
|
||||
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
const declared = declaredPermissions(plugins).map((decl) => decl.name);
|
||||
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
if (ignored.length > 0) {
|
||||
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
|
||||
}
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
email,
|
||||
fetchImpl: tracedFetch,
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password,
|
||||
permissions,
|
||||
});
|
||||
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
await provisionPluginStorage(env, plugins, log);
|
||||
await seedAdminAndPermissions(env, plugins, log);
|
||||
});
|
||||
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
|
||||
}
|
||||
|
||||
// A database and login role for each plugin that asked for one. It happens here because bootstrap
|
||||
// holds the stack's only provisioning credentials — web derives the same password and connects as
|
||||
// the plugin's own role.
|
||||
async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log): Promise<void> {
|
||||
const ids = storagePluginIds(plugins);
|
||||
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
|
||||
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
|
||||
// last storage plugin is exactly when an orphaned database needs naming.
|
||||
if (ids.length === 0 && !adminUrl) return;
|
||||
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
|
||||
const result = await provisionStorage({
|
||||
adminUrl,
|
||||
connectionLimit: resolvePluginDbConnectionLimit(env),
|
||||
pluginIds: ids,
|
||||
secret: resolvePluginDbSecret(env),
|
||||
});
|
||||
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
|
||||
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
|
||||
if (result.orphans.length > 0) {
|
||||
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
|
||||
}
|
||||
}
|
||||
|
||||
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
|
||||
const declared = declaredPermissions(plugins).map((decl) => decl.name);
|
||||
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
if (ignored.length > 0) {
|
||||
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
|
||||
}
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
email,
|
||||
fetchImpl: tracedFetch,
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password,
|
||||
permissions,
|
||||
});
|
||||
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
|
||||
+19
-1
@@ -17,6 +17,24 @@ export function resolvePluginDbSecret(env: Env, enforce?: boolean): string {
|
||||
return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false));
|
||||
}
|
||||
|
||||
// Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place.
|
||||
export function resolvePluginDbConnectionLimit(env: Env): number {
|
||||
return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10);
|
||||
}
|
||||
|
||||
// PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role.
|
||||
// Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and
|
||||
// leave a superuser password in the environment plugin code can read.
|
||||
function readCredentiallessUrl(env: Env, key: string): string | undefined {
|
||||
const value = readOptionalUrl(env, key);
|
||||
if (value === undefined) return undefined;
|
||||
const url = new URL(value);
|
||||
if (url.username || url.password) {
|
||||
throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
|
||||
cacheTemplates: boolean;
|
||||
@@ -168,7 +186,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
|
||||
// which is also why the secret is only enforced once a URL is configured.
|
||||
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
|
||||
pluginDbUrl: readOptionalUrl(env, "PLUGIN_DB_URL"),
|
||||
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
|
||||
port: readPort(env),
|
||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -3,11 +3,20 @@
|
||||
// verified by booting postgres in CI/e2e; this catches edits.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
|
||||
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||
const ORY_DATABASES = ["hydra", "keto", "kratos"]; // one DB per Ory service
|
||||
|
||||
function sourceFiles(dir = "src"): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(new URL(`../${dir}/`, import.meta.url), { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) out.push(...sourceFiles(`${dir}/${entry.name}`));
|
||||
else if (entry.name.endsWith(".ts")) out.push(`${dir}/${entry.name}`);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
test("init SQL gives each Ory service its own database, and leaves plugin databases to bootstrap", () => {
|
||||
const sql = read("ory/postgres/init/init.sql");
|
||||
for (const db of ORY_DATABASES) {
|
||||
@@ -16,4 +25,24 @@ test("init SQL gives each Ory service its own database, and leaves plugin databa
|
||||
// This file runs once, on an empty data dir — a plugin database added here would never appear for
|
||||
// a plugin dropped in later. bootstrap provisions them on every boot instead.
|
||||
assert.doesNotMatch(sql, /plugin_/i, "no plugin database is seeded here");
|
||||
// PUBLIC keeps CONNECT unless it is revoked, which would put every plugin role on the auth plane.
|
||||
for (const db of ORY_DATABASES) {
|
||||
assert.match(sql, new RegExp(`REVOKE CONNECT ON DATABASE ${db} FROM PUBLIC`, "i"), `${db} is closed to PUBLIC`);
|
||||
}
|
||||
});
|
||||
|
||||
// AGENTS.md records that the driver runs the provisioning DDL in bootstrap "and nothing else".
|
||||
// discovery.ts once imported a validator from the module that held it, quietly putting the driver in
|
||||
// web's graph — the claim outlived the fact, which is what this catches.
|
||||
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
||||
const files = sourceFiles();
|
||||
assert.ok(files.length > 40, "walks the source tree");
|
||||
assert.deepEqual(
|
||||
files.filter((f) => /^import .*"postgres"/m.test(read(f))), // an import line, not a mention of one
|
||||
["src/plugin-host/storage-provisioning.ts", "src/plugin-host/storage.test.ts"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
files.filter((f) => !f.endsWith(".test.ts") && /from "[^"]*storage-provisioning\.ts"/.test(read(f))),
|
||||
["src/auth/bootstrap.ts"],
|
||||
);
|
||||
});
|
||||
|
||||
+15
-5
@@ -13,7 +13,7 @@ import { createKratosAdmin } from "./auth/kratos-admin.ts";
|
||||
import { createKratosPublic } from "./auth/kratos-public.ts";
|
||||
import { createLogger, tracedFetch } from "./logger.ts";
|
||||
import { loadMenuConfig } from "./ui/menu-config.ts";
|
||||
import { buildCredentials } from "./plugin-host/storage.ts";
|
||||
import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts";
|
||||
|
||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
||||
@@ -48,15 +48,25 @@ log.info("locales loaded", { locales: i18n.available.join(", ") });
|
||||
// A plugin's database credentials are derived, never stored — so the only thing that can be missing
|
||||
// is the server itself. Refuse at boot rather than at that plugin's first query, hours later.
|
||||
const pluginDbUrl = config.pluginDbUrl;
|
||||
const declaresStorage = plugins.filter((plugin) => plugin.storage).map((plugin) => plugin.id);
|
||||
const declaresStorage = storagePluginIds(plugins);
|
||||
if (declaresStorage.length > 0 && pluginDbUrl === undefined) {
|
||||
throw new Error(`config: PLUGIN_DB_URL must be set — these plugins declare storage: ${declaresStorage.join(", ")}`);
|
||||
}
|
||||
|
||||
// Derive every plugin's credentials first, then drop the secret: onBoot is the window in which
|
||||
// plugin code could read it out of the environment and derive a *peer's* password. Order matters —
|
||||
// deleting after the hooks would protect nothing. Defence in depth, not a boundary (AGENTS.md).
|
||||
const storageCredentials = new Map<string, StorageCredentials>();
|
||||
if (pluginDbUrl !== undefined) {
|
||||
for (const id of declaresStorage) storageCredentials.set(id, buildCredentials(pluginDbUrl, id, config.pluginDbSecret));
|
||||
}
|
||||
delete process.env["PLUGIN_DB_SECRET"];
|
||||
|
||||
// plugin onBoot — after discovery, before listen; a throw aborts boot.
|
||||
await runBootHooks(plugins, (plugin) =>
|
||||
plugin.storage && pluginDbUrl !== undefined ? { storage: buildCredentials(pluginDbUrl, plugin.id, config.pluginDbSecret) } : {},
|
||||
);
|
||||
await runBootHooks(plugins, (plugin) => {
|
||||
const storage = storageCredentials.get(plugin.id);
|
||||
return storage ? { storage } : {};
|
||||
});
|
||||
|
||||
const server = createApp({
|
||||
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
||||
|
||||
Reference in New Issue
Block a user