Keep role re-assertion within a non-superuser provisioner's rights, and test the second boot
CI / full-gate (push) Successful in 2m58s

This commit is contained in:
2026-08-19 00:44:49 +02:00
parent 6db14a2205
commit c5c9cce2b6
12 changed files with 173 additions and 63 deletions
+63 -1
View File
@@ -5,7 +5,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
import { differentServer, ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), {
@@ -151,3 +154,62 @@ test("ensureJwks generates a key only when the file is absent", () => {
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written
});
// --- Plugin storage provisioning -----------------------------------------------------
// The provisioner is injected, so the branch decisions are testable without a Postgres.
const SILENT = createLogger({ level: "none" });
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
function recordingProvisioner(result: ProvisionResult = EMPTY) {
const calls: ProvisionOptions[] = [];
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
}
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
const { calls, provision } = recordingProvisioner();
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
});
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.pluginIds, []);
});
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
const { calls, provision } = recordingProvisioner();
await assert.rejects(
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
/PLUGIN_DB_ADMIN_URL.*things/s,
);
assert.deepEqual(calls, []);
});
test("the connection limit and derived secret reach the provisioner", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls[0]?.connectionLimit, 25);
assert.equal(calls[0]?.secret, "real");
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
});
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
// unchecked it surfaces inside a plugin as "password authentication failed", naming neither.
test("provisioning refuses when the two storage URLs name different servers", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
await assert.rejects(provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision), /one server.*db-a:5432 vs db-b:5432/s);
assert.deepEqual(calls, []);
});
test("the same server spelled with an implicit port still agrees", () => {
assert.equal(differentServer("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
assert.equal(differentServer("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
assert.equal(differentServer("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
});
+24 -7
View File
@@ -18,10 +18,6 @@ import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>;
// Kept closed to PUBLIC on every boot, not just on a fresh volume — a plugin role would otherwise
// reach the auth plane's catalogs and connection slots (ory/postgres/init/init.sql seeds the same).
const ORY_DATABASES = ["hydra", "keto", "kratos"];
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
export function identityPayload(email: string, password: string) {
@@ -169,17 +165,20 @@ async function main() {
// 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> {
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): 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({
// Provisioned here, connected to from web: a different server means the role exists in one place
// and is looked for in another, surfacing as "password authentication failed" inside a plugin.
const serverMismatch = differentServer(adminUrl, env["PLUGIN_DB_URL"]);
if (serverMismatch) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL must name one server (${serverMismatch})`);
const result = await provision({
adminUrl,
connectionLimit: resolvePluginDbConnectionLimit(env),
lockdownDatabases: ORY_DATABASES,
pluginIds: ids,
secret: resolvePluginDbSecret(env),
});
@@ -190,6 +189,24 @@ async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log): Pr
}
}
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
// own boot error to raise, naming the plugin that wanted storage).
export function differentServer(adminUrl: string, webUrl: string | undefined): string | null {
if (!webUrl) return null;
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
return `${admin} vs ${web}`;
}
function safeHostPort(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
} catch {
return null;
}
}
// 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> {