This commit is contained in:
+16
-1
@@ -8,8 +8,10 @@
|
||||
// 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 { discoverPlugins } from "../plugin-host/discovery.ts";
|
||||
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
|
||||
import { provisionStorage } from "../plugin-host/storage.ts";
|
||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
||||
|
||||
@@ -151,9 +153,22 @@ async function main() {
|
||||
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(await discoverPlugins()).map((decl) => decl.name);
|
||||
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(", ") });
|
||||
|
||||
+16
-3
@@ -44,10 +44,11 @@ test("long-running Ory services declare readiness healthchecks", () => {
|
||||
`${svc} probes :${port}/health/ready`);
|
||||
});
|
||||
|
||||
test("web waits for kratos, keto and hydra to be healthy before starting", () => {
|
||||
test("web waits for kratos, keto, hydra and postgres to be healthy before starting", () => {
|
||||
assert.match(webBlock, /depends_on:/, "web declares dependencies");
|
||||
// hydra: the OAuth2 login/consent handler talks to its admin API.
|
||||
for (const svc of ["kratos", "keto", "hydra"])
|
||||
// hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring
|
||||
// `storage` opens its connection in onBoot, before the server listens.
|
||||
for (const svc of ["kratos", "keto", "hydra", "postgres"])
|
||||
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
|
||||
`web waits for ${svc} healthy`);
|
||||
});
|
||||
@@ -78,6 +79,18 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
|
||||
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
|
||||
});
|
||||
|
||||
test("the provisioning superuser DSN reaches bootstrap only, never web", () => {
|
||||
// web runs plugin code, which can read its own environment — so the credentials that may CREATE
|
||||
// DATABASE/ROLE must never be there. web gets the credential-free base URL and derives each
|
||||
// plugin's own password from the shared secret instead.
|
||||
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
|
||||
const overrideWeb = override.slice(override.indexOf("\n web:"), override.indexOf("\n bootstrap:"));
|
||||
assert.match(boot, /PLUGIN_DB_ADMIN_URL:/, "bootstrap is given the superuser DSN");
|
||||
for (const [name, block] of [["base", webBlock], ["dev override", overrideWeb]] as const)
|
||||
assert.doesNotMatch(block, /PLUGIN_DB_ADMIN_URL/, `${name} web never sees it`);
|
||||
assert.match(webBlock, /PLUGIN_DB_URL:\s*\$\{PLUGIN_DB_URL/, "base wires web's base URL from env");
|
||||
});
|
||||
|
||||
test("a one-shot bootstrap seeds the stack before web starts", () => {
|
||||
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
|
||||
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { loadConfig, resolvePluginDbSecret } from "./config.ts";
|
||||
|
||||
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only
|
||||
// thing a hardened deploy must supply.
|
||||
@@ -9,6 +9,16 @@ const secureEnv = {
|
||||
REQUIRE_SECURE_SECRETS: "true",
|
||||
};
|
||||
|
||||
// web reads the secret through loadConfig and bootstrap through resolvePluginDbSecret; the two
|
||||
// deriving different passwords is invisible until a plugin's connection is refused at boot. Compose
|
||||
// passes an unset variable through as "", which is the case that actually drifted.
|
||||
test("web and bootstrap resolve the same plugin storage secret", () => {
|
||||
for (const env of [{}, { PLUGIN_DB_SECRET: "" }, { PLUGIN_DB_SECRET: "a-real-secret" }]) {
|
||||
assert.equal(loadConfig(env).pluginDbSecret, resolvePluginDbSecret(env), `for ${JSON.stringify(env)}`);
|
||||
}
|
||||
assert.match(resolvePluginDbSecret({ PLUGIN_DB_SECRET: "" }), /dev-insecure/); // empty is unset, not a secret
|
||||
});
|
||||
|
||||
test("loads dev defaults when the environment is empty", () => {
|
||||
const c = loadConfig({});
|
||||
assert.equal(c.port, 3000);
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
||||
export type LogLevel = (typeof LOG_LEVELS)[number];
|
||||
|
||||
const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret";
|
||||
|
||||
// bootstrap resolves the plugin-storage secret through this, web through loadConfig — and the two
|
||||
// must agree exactly or web connects with a password the role was never given. Compose passes an
|
||||
// unset variable through as "", so empty means throwaway here as it does in readSecret.
|
||||
export function resolvePluginDbSecret(env: Env): string {
|
||||
return env["PLUGIN_DB_SECRET"] || DEV_PLUGIN_DB_SECRET;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
|
||||
cacheTemplates: boolean;
|
||||
@@ -24,6 +33,8 @@ export interface Config {
|
||||
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
|
||||
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
|
||||
port: number;
|
||||
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||
@@ -150,6 +161,12 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
|
||||
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
|
||||
// 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: readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
|
||||
pluginDbUrl: readOptionalUrl(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
|
||||
|
||||
@@ -27,13 +27,19 @@ test("a missing plugins/ dir means zero plugins, not an error (clean clone)", as
|
||||
});
|
||||
|
||||
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
|
||||
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") });
|
||||
const dir = scaffold(t, {
|
||||
"beta/plugin.ts": full("beta"),
|
||||
"alpha/plugin.ts": full("alpha"),
|
||||
"gamma/plugin.ts": `export default { apiVersion: "1.0.0", storage: true };`,
|
||||
});
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
|
||||
assert.equal(plugins[0]?.apiVersion, "1.0.0");
|
||||
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
|
||||
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
||||
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
|
||||
assert.equal(plugins[2]?.storage, true);
|
||||
});
|
||||
|
||||
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
|
||||
@@ -48,6 +54,9 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "1.0.0", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
|
||||
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
|
||||
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "1.0.0", storage: true };` }, match: /storage.*56 characters/s },
|
||||
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID } from "./storage.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
@@ -66,6 +67,13 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
||||
const shape = shapeError(manifest);
|
||||
if (shape) { fail(shape); continue; }
|
||||
|
||||
// The folder name becomes a Postgres identifier, which truncates past 63 bytes — two long ids
|
||||
// would then share one database. Only checked for a plugin that asked for storage.
|
||||
if (manifest.storage === true && !isValidStoragePluginId(id)) {
|
||||
fail(`declares storage, so its folder name must be at most ${MAX_STORAGE_PLUGIN_ID} characters`);
|
||||
continue;
|
||||
}
|
||||
|
||||
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
|
||||
}
|
||||
|
||||
@@ -131,6 +139,8 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
for (const slot of ["home", "dashboard"] as const) {
|
||||
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
||||
}
|
||||
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one".
|
||||
if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
|
||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
|
||||
@@ -12,14 +12,17 @@ function plugin(id: string, hooks: PluginHooks): Plugin {
|
||||
|
||||
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
|
||||
const calls: string[] = [];
|
||||
const scoped: string[] = []; // each hook is handed a context built for its own plugin
|
||||
const bootContextFor = (built: Plugin) => { scoped.push(built.id); return {}; };
|
||||
await runBootHooks([
|
||||
plugin("a", { onBoot: () => void calls.push("a") }),
|
||||
plugin("b", {}), // no onBoot → skipped
|
||||
plugin("c", { onBoot: async () => void calls.push("c") }),
|
||||
]);
|
||||
], bootContextFor);
|
||||
assert.deepEqual(calls, ["a", "c"]);
|
||||
assert.deepEqual(scoped, ["a", "c"]); // and built only for the plugins that have one
|
||||
|
||||
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })]), /boom/);
|
||||
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })], () => ({})), /boom/);
|
||||
});
|
||||
|
||||
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
|
||||
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||
import type { BootContext, Plugin, RouteResult } from "./plugin.ts";
|
||||
|
||||
// After discovery, before the server listens. A throw aborts boot.
|
||||
export async function runBootHooks(plugins: Plugin[]): Promise<void> {
|
||||
for (const plugin of plugins) await plugin.hooks?.onBoot?.();
|
||||
// After discovery, before the server listens. A throw aborts boot. Each hook gets a context built
|
||||
// for its own plugin, so one plugin is never handed another's storage credentials.
|
||||
export async function runBootHooks(plugins: Plugin[], bootContextFor: (plugin: Plugin) => BootContext): Promise<void> {
|
||||
for (const plugin of plugins) {
|
||||
const onBoot = plugin.hooks?.onBoot;
|
||||
if (onBoot) await onBoot(bootContextFor(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
||||
|
||||
export { definePlugin, isValidPermissionName } from "./plugin.ts";
|
||||
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||
export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||
// A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a
|
||||
// client — the plugin depends on whichever driver it prefers (README → Plugin storage).
|
||||
export type { StorageCredentials } from "./storage.ts";
|
||||
export type { RequestContext, User } from "../http/context.ts";
|
||||
export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
|
||||
// Bump major on a breaking manifest/handler change, minor on an additive one.
|
||||
export const HOST_API_VERSION = "1.0.0";
|
||||
@@ -60,9 +61,14 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
|
||||
export interface BootContext {
|
||||
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
|
||||
}
|
||||
|
||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||
export interface PluginHooks {
|
||||
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
||||
onBoot?: (host: BootContext) => Promise<void> | void; // after discovery, before the server listens
|
||||
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
||||
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
||||
}
|
||||
@@ -80,6 +86,9 @@ export interface PluginManifest {
|
||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||
permissions?: PermissionDecl[];
|
||||
routes?: Route[];
|
||||
// Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext.
|
||||
// The host provisions and locks it down but owns no schema inside it, and never drops it.
|
||||
storage?: boolean;
|
||||
}
|
||||
|
||||
// A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Guards the per-plugin storage rules: the shared database/role name, the derived password, the DSN
|
||||
// a plugin receives and the provisioning statements. The integration test runs only when a superuser
|
||||
// DSN is supplied, so the unit suite needs no Postgres.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
buildCredentials,
|
||||
derivePassword,
|
||||
isValidStoragePluginId,
|
||||
MAX_STORAGE_PLUGIN_ID,
|
||||
provisionSql,
|
||||
provisionStorage,
|
||||
quoteIdentifier,
|
||||
quoteLiteral,
|
||||
storageName,
|
||||
} from "./storage.ts";
|
||||
|
||||
const SECRET = "a-test-secret";
|
||||
|
||||
test("the database and the role share one plugin_-prefixed name", () => {
|
||||
assert.equal(storageName("things"), "plugin_things");
|
||||
assert.equal(storageName("my-plugin"), "plugin_my-plugin");
|
||||
});
|
||||
|
||||
test("a storage plugin's id must leave the identifier under Postgres' 63 bytes", () => {
|
||||
assert.equal(MAX_STORAGE_PLUGIN_ID, 56); // 63 - "plugin_"
|
||||
assert.ok(isValidStoragePluginId("a".repeat(MAX_STORAGE_PLUGIN_ID)));
|
||||
assert.ok(!isValidStoragePluginId("a".repeat(MAX_STORAGE_PLUGIN_ID + 1)));
|
||||
});
|
||||
|
||||
test("the password is derived, so the same one is reachable without storing it", () => {
|
||||
const derived = derivePassword(SECRET, "things");
|
||||
assert.equal(derived, derivePassword(SECRET, "things"));
|
||||
assert.notEqual(derived, derivePassword(SECRET, "other"));
|
||||
assert.notEqual(derived, derivePassword("a-rotated-secret", "things"));
|
||||
assert.match(derived, /^[A-Za-z0-9_-]{43}$/); // base64url of 32 bytes — needs no escaping in a DSN
|
||||
});
|
||||
|
||||
test("credentials name the plugin's own database, user and password", () => {
|
||||
const credentials = buildCredentials("postgres://postgres:5432", "things", SECRET);
|
||||
assert.deepEqual(credentials, {
|
||||
database: "plugin_things",
|
||||
host: "postgres",
|
||||
password: derivePassword(SECRET, "things"),
|
||||
port: 5432,
|
||||
url: `postgres://plugin_things:${derivePassword(SECRET, "things")}@postgres:5432/plugin_things`,
|
||||
user: "plugin_things",
|
||||
});
|
||||
});
|
||||
|
||||
test("the base URL's connection parameters survive into the DSN", () => {
|
||||
const credentials = buildCredentials("postgres://db.example?sslmode=require", "things", SECRET);
|
||||
assert.equal(credentials.port, 5432); // absent ⇒ Postgres' default, never NaN
|
||||
assert.equal(credentials.host, "db.example");
|
||||
assert.match(credentials.url, /@db\.example\/plugin_things\?sslmode=require$/);
|
||||
});
|
||||
|
||||
test("quoting doubles an embedded quote", () => {
|
||||
assert.equal(quoteIdentifier('we"ird'), '"we""ird"');
|
||||
assert.equal(quoteLiteral("we'ird"), "'we''ird'");
|
||||
});
|
||||
|
||||
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'`,
|
||||
`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'`,
|
||||
`REVOKE ALL ON DATABASE "plugin_things" FROM PUBLIC`,
|
||||
`GRANT ALL PRIVILEGES ON DATABASE "plugin_things" TO "plugin_things"`,
|
||||
]);
|
||||
});
|
||||
|
||||
// --- 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.
|
||||
|
||||
const ADMIN_URL = process.env["PLUGIN_DB_ADMIN_URL"] ?? "";
|
||||
const integration = ADMIN_URL ? {} : { skip: "set PLUGIN_DB_ADMIN_URL to a superuser DSN to run" };
|
||||
|
||||
function baseUrlOf(adminUrl: string): string {
|
||||
const url = new URL(adminUrl);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.pathname = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function queryAs(url: string, statement: string): Promise<unknown> {
|
||||
const sql = postgres(url, { connect_timeout: 10, max: 1, onnotice: () => {} });
|
||||
try {
|
||||
return await sql.unsafe(statement);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
test("provisions a database its plugin can use and a peer plugin cannot reach", integration, async () => {
|
||||
const ids = ["storage-itest-a", "storage-itest-b"];
|
||||
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 });
|
||||
|
||||
const owner = buildCredentials(base, "storage-itest-a", SECRET);
|
||||
await queryAs(owner.url, "CREATE TABLE IF NOT EXISTS notes (body text)");
|
||||
await queryAs(owner.url, "INSERT INTO notes (body) VALUES ('persisted')");
|
||||
const rows = (await queryAs(owner.url, "SELECT body FROM notes")) as { body: string }[];
|
||||
assert.deepEqual(rows.map((row) => row.body), ["persisted"]);
|
||||
|
||||
// A peer holds valid credentials for its OWN database and still cannot reach this one.
|
||||
const peer = new URL(buildCredentials(base, "storage-itest-b", SECRET).url);
|
||||
peer.pathname = `/${storageName("storage-itest-a")}`;
|
||||
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 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);
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
const name = quoteIdentifier(storageName(id));
|
||||
await admin.unsafe(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
||||
await admin.unsafe(`DROP ROLE IF EXISTS ${name}`);
|
||||
}
|
||||
await admin.end();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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.
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
import postgres from "postgres";
|
||||
|
||||
// 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_";
|
||||
|
||||
// Postgres truncates an identifier at 63 bytes, which would silently collide two long ids.
|
||||
export const MAX_STORAGE_PLUGIN_ID = 63 - NAME_PREFIX.length;
|
||||
|
||||
// `url` pre-assembles the other fields as a DSN, which most drivers take directly.
|
||||
export interface StorageCredentials {
|
||||
database: string;
|
||||
host: string;
|
||||
password: string;
|
||||
port: number;
|
||||
url: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export function storageName(pluginId: string): string {
|
||||
return `${NAME_PREFIX}${pluginId}`;
|
||||
}
|
||||
|
||||
export function isValidStoragePluginId(pluginId: string): boolean {
|
||||
return pluginId.length <= MAX_STORAGE_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");
|
||||
}
|
||||
|
||||
// `baseUrl` names the server and its connection parameters, and carries no credentials of its own.
|
||||
export function buildCredentials(baseUrl: string, pluginId: string, secret: string): StorageCredentials {
|
||||
const name = storageName(pluginId);
|
||||
const password = derivePassword(secret, pluginId);
|
||||
const url = new URL(baseUrl);
|
||||
url.username = name;
|
||||
url.password = password;
|
||||
url.pathname = `/${name}`;
|
||||
return { database: name, host: url.hostname, password, port: Number(url.port) || 5432, url: url.href, user: name };
|
||||
}
|
||||
|
||||
// CREATE ROLE/DATABASE bind no parameters, so the name and password are quoted into the statement.
|
||||
export function quoteIdentifier(name: string): string {
|
||||
return `"${name.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function quoteLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export interface ProvisionState {
|
||||
databaseExists: boolean;
|
||||
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);
|
||||
return [
|
||||
state.roleExists
|
||||
? `ALTER ROLE ${identifier} WITH LOGIN PASSWORD ${secret}`
|
||||
: `CREATE ROLE ${identifier} LOGIN PASSWORD ${secret}`,
|
||||
...(state.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();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import { 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
|
||||
|
||||
test("init SQL gives each Ory service its own database", () => {
|
||||
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) {
|
||||
assert.match(sql, new RegExp(`CREATE DATABASE ${db}\\b`, "i"), `creates ${db}`);
|
||||
}
|
||||
// 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");
|
||||
});
|
||||
|
||||
+13
-1
@@ -13,6 +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";
|
||||
|
||||
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
|
||||
@@ -44,7 +45,18 @@ log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) =>
|
||||
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
|
||||
log.info("locales loaded", { locales: i18n.available.join(", ") });
|
||||
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
// 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);
|
||||
if (declaresStorage.length > 0 && pluginDbUrl === undefined) {
|
||||
throw new Error(`config: PLUGIN_DB_URL must be set — these plugins declare storage: ${declaresStorage.join(", ")}`);
|
||||
}
|
||||
|
||||
// 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) } : {},
|
||||
);
|
||||
|
||||
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