import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { UserRole } from 'officerdb'; import { sql } from 'drizzle-orm'; import postgres from 'postgres'; import { db } from 'officerdb/db'; import { osUserHome, runAs } from './os-user'; // A Postgres login role for a member, named the same as their Linux account. // // This is what replaced rootless Docker per member (disabled 2026-08-13). The use case was always "let me // run a database to develop against" and a container per member was an expensive way to answer it — one // daemon, one image cache and one subuid range each. A role on the cluster that is already running costs // a row in `pg_authid`. // // ── What the member gets ── // // CREATEDB as many databases as they like, owned by them. Not one database provisioned for them: // the ask was to create databases in their own name, not to be handed one. // ~/.zshenv PGHOST/PGPORT/PGUSER/PGPASSWORD, so every pg command works with no flags — and an // obvious place to read your own password back from. // ~/.pgpass the same credential in the form libpq reads by itself, for everything that is not a // zsh process. // // Both 600, owned by them, written the same way as their SSH keys — see os-user-ssh.ts for why every // write into a 700 home goes through `sudo install`. // // ── What keeps them out of everything else ── // // Measured on postgres:18-alpine, 2026-08-13, because every part of this was wrong when assumed: // // - A fresh `CREATE ROLE … LOGIN` CAN connect to `officer`. `pg_database.datacl` is NULL, which means // default privileges, which for a database means PUBLIC holds CONNECT and TEMPORARY. That is why // `ensureAppDatabaseClosed` exists and why it runs before any role is created. // - Once connected it can NOT read an application table. Table privileges default to owner-only and // nothing grants to PUBLIC — `has_table_privilege('users','UPDATE')` is false. So the permission model // was never reachable from here; the exposure was catalogue metadata, not data. // - Revoking from the ROLE does not help. Postgres privileges are additive and there is no DENY, so a // PUBLIC grant is not overridden by a role-level revoke. Revoking from PUBLIC is the only lock. // // ── Keeping members out of EACH OTHER'S databases ── // // Harder than it sounds, and the two obvious routes are both dead ends. `datacl` is NOT inherited from the // template — measured: closing `template1` and creating a database from it still produced a NULL `datacl`, // so PUBLIC gets CONNECT on every database a member makes. And `CREATE DATABASE` fires no event trigger, // because it is a global object, so nothing can react at the moment of creation. // // What does work is a `login` event trigger (PG17+) installed in `template1`. Event triggers live in a // per-database catalogue, and `CREATE DATABASE` copies the template's catalogues — so every database a // member creates carries it, automatically, with no naming convention, no sweep and no window. // // ── Why the function lives in its own schema ── // // The first version put it in `public` and a member DEFEATED IT IN ONE STATEMENT: // // DROP FUNCTION public.officer_owner_only() CASCADE; -- takes the event trigger with it // // They could not drop or disable the trigger — `must be owner of event trigger` — but in PG15+ the // `public` schema is owned by `pg_database_owner`, which resolves to THEM inside their own database, and a // schema owner may drop objects in it that they do not own. Both objects were owned by `postgres` and it // made no difference. // // So the function goes in a schema owned by the platform with PUBLIC revoked. Measured after the move — // every route refused: DROP EVENT TRIGGER, ALTER … DISABLE, DROP FUNCTION, DROP SCHEMA, ALTER SCHEMA … // OWNER TO, CREATE OR REPLACE over the top, and `PGOPTIONS=-c event_triggers=off` (that GUC is superuser // only). A superuser can still use it, which is the recovery path if this ever locks something out. /** * Which platform roles get a database role at all. * * `Developer` only. This is the gate rootless Docker was under before it was disabled, inherited by the * thing that replaced it rather than invented for it — a member who was not trusted to run containers is * not thereby trusted to run databases. * * `Admin` is deliberately NOT included. Administering the platform is not developing on it, and the two * are separate roles precisely so they can be held separately. Add it here if that turns out to be wrong; * do not add it at a call site. * * The owner never reaches this — they run as the service account and already have the cluster. */ export const rolePermitsDevTools = (role: UserRole): boolean => role === 'Developer'; /** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */ const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/; // Deliberately alphanumeric. This value is interpolated into `PASSWORD '…'` as a literal, because // CREATE ROLE is a utility statement and cannot take a bind parameter — so the safety has to come from // the alphabet rather than from escaping. No quote, no backslash, nothing to terminate the literal with. const PASSWORD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const PASSWORD_LENGTH = 40; // Every attribute stated, including the negatives, and shared by CREATE and ALTER so the two cannot drift. // The defaults are already NO* for all of them — writing them down is what makes a hand-made change to a // member's role show up as a difference rather than as the way it always was. const ROLE_ATTRIBUTES = 'LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOREPLICATION NOBYPASSRLS'; /** Uniform over the alphabet by rejection sampling — `% n` on a byte would bias the first 4 characters. */ export function generatePassword(length = PASSWORD_LENGTH): string { const max = 256 - (256 % PASSWORD_ALPHABET.length); let out = ''; while (out.length < length) { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); for (const byte of bytes) { if (byte >= max) continue; out += PASSWORD_ALPHABET[byte % PASSWORD_ALPHABET.length]; if (out.length === length) break; } } return out; } /** * Where `.pgpass` should point, taken from the platform's own connection string. * * The member connects to the same cluster the platform does, so deriving it means one fact rather than two * that can disagree. Postgres is published on loopback only, which is why 127.0.0.1 is the fallback rather * than a guess. */ export function pgpassTarget(postgresUrl: string | undefined): { host: string; port: string } { if (!postgresUrl) return { host: '127.0.0.1', port: '5432' }; try { const url = new URL(postgresUrl); return { host: url.hostname || '127.0.0.1', port: url.port || '5432' }; } catch { return { host: '127.0.0.1', port: '5432' }; } } async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> { const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() }; } /** Same shape as os-user-ssh's: content, owner and mode in one step, so the file never exists at the umask. */ async function installFile(params: { content: string; dest: string; uid: number; gid: number; mode: string }) { const dir = await mkdtemp(join(tmpdir(), 'officer-pg-')); const staged = join(dir, 'staged'); try { await writeFile(staged, params.content, { mode: 0o600 }); return await sudo([ 'install', '-o', String(params.uid), '-g', String(params.gid), '-m', params.mode, staged, params.dest, ]); } finally { await rm(dir, { recursive: true, force: true }); } } /** * Shut PUBLIC out of the platform's own database. * * Runs here, before any role is created, rather than once in the setup script. The guarantee then lives * with the code that creates the hazard: an install whose setup predates this, or was run by hand, or was * restored from a dump, still cannot end up with a member who can connect to `officer`. * * Idempotent — REVOKE on a privilege that is already absent succeeds and changes nothing. * * The platform is unaffected: it connects as a superuser, which bypasses privilege checks entirely. That * is also the reason this is safe to run against a live server. */ export type AppDatabaseClosedResult = { ok: true; database: string } | { ok: false; error: string }; export async function ensureAppDatabaseClosed(): Promise { try { const [row] = await db.execute<{ current_database: string }>(sql`select current_database()`); const database = row?.current_database; if (!database) return { ok: false, error: 'could not determine the current database' }; // TEMPORARY as well as CONNECT. CONNECT is the lock; TEMPORARY is the only other thing PUBLIC holds by // default, and leaving it grants a connected superuser-adjacent role nothing but a way to fill a disk. await db.execute(sql`REVOKE CONNECT, TEMPORARY ON DATABASE ${sql.identifier(database)} FROM PUBLIC`); return { ok: true, database }; } catch (ex) { return { ok: false, error: `could not close the platform database to PUBLIC: ${asMessage(ex)}` }; } } const asMessage = (ex: unknown): string => (ex instanceof Error ? ex.message : String(ex)); /** The isolation schema, function and login trigger. Ordered; each is idempotent on its own. */ const TEMPLATE_ISOLATION_SQL = [ `CREATE SCHEMA IF NOT EXISTS officer AUTHORIZATION CURRENT_USER`, `REVOKE ALL ON SCHEMA officer FROM PUBLIC`, // SECURITY INVOKER (the default) with a pinned search_path. DEFINER is not needed — every catalogue this // reads is world-readable — and not using it removes the search_path-shadowing class of problem outright. `CREATE OR REPLACE FUNCTION officer.owner_only() RETURNS event_trigger LANGUAGE plpgsql SET search_path = pg_catalog AS $fn$ BEGIN IF (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN RETURN; END IF; IF pg_has_role(current_user, (SELECT datdba FROM pg_database WHERE datname = current_database()), 'USAGE') THEN RETURN; END IF; RAISE EXCEPTION 'permission denied for database "%"', current_database() USING HINT = 'Officer: a database is private to the account that created it.'; END $fn$`, // No IF NOT EXISTS for event triggers, so drop and recreate. The gap is inside template1 and only matters // to a CREATE DATABASE landing in the same instant, which cannot happen — see the connection note below. `DROP EVENT TRIGGER IF EXISTS officer_owner_only_login`, `CREATE EVENT TRIGGER officer_owner_only_login ON login EXECUTE FUNCTION officer.owner_only()`, ]; /** * Install the isolation trigger into `template1`, so every database created afterwards inherits it. * * ── Why this opens its own connection, and closes it immediately ── * * The platform's pool is connected to `officer`; a connection cannot change database. And `CREATE DATABASE` * requires that NO other session is connected to the template it copies — so a pooled or long-lived * connection to `template1` would make every member's `createdb` fail with "source database is being * accessed by other users". Hence one connection, `max: 1`, ended in a `finally`. * * Only ever runs at provisioning time, which is rare enough that the race with a member running `createdb` * in the same second is worth naming and not worth engineering around: it surfaces as a retryable error on * their side. */ export async function ensureTemplateIsolation(): Promise<{ ok: true } | { ok: false; error: string }> { const url = process.env.POSTGRES_URL; if (!url) return { ok: false, error: 'POSTGRES_URL is not set' }; let target: string; try { const parsed = new URL(url); parsed.pathname = '/template1'; target = parsed.toString(); } catch { return { ok: false, error: 'POSTGRES_URL could not be parsed' }; } const client = postgres(target, { max: 1, idle_timeout: 5, connection: { application_name: 'officer-provision' } }); try { for (const statement of TEMPLATE_ISOLATION_SQL) await client.unsafe(statement); return { ok: true }; } catch (ex) { return { ok: false, error: `could not install database isolation into template1: ${asMessage(ex)}` }; } finally { await client.end({ timeout: 5 }); } } export type PostgresRoleResult = | { ok: true; role: string; created: boolean; passwordSet: boolean } | { ok: false; error: string }; /** * Give the account a Postgres login role of the same name. * * Idempotent, and the two halves are idempotent differently. The ROLE is adopted if it exists. The * PASSWORD is only (re)set when `.pgpass` is missing, because we do not store it anywhere else — so a * reprovision of a working account must not rotate a credential the member may have pasted into an * application config. A missing `.pgpass` is the one case where rotating is the only way to get back to a * known state, and there is nothing to lose by then. * * Never throws. Same posture as its neighbours in `provisionOsAccount`: an account with no database role * is still a working account. */ export async function provisionPostgresRole(params: { email: string; osUser: string; uid: number; gid: number; }): Promise { // Both before anything else, and before the role can exist to take advantage of either being absent. const closed = await ensureAppDatabaseClosed(); if (!closed.ok) return { ok: false, error: closed.error }; const isolated = await ensureTemplateIsolation(); if (!isolated.ok) return { ok: false, error: isolated.error }; // Re-asserted here even though `validateUsername` already ran at the route. This string reaches SQL as an // identifier, and the distance between that check and this one is a whole call chain — the kind of gap // where a future caller arrives without having passed the first one. if (!ROLE_NAME_RE.test(params.osUser)) { return { ok: false, error: `'${params.osUser}' is not a usable Postgres role name` }; } const home = osUserHome(params.email); const pgpass = join(home, '.pgpass'); const zshenv = join(home, '.zshenv'); try { const existing = await db.execute<{ rolname: string }>( sql`select rolname from pg_roles where rolname = ${params.osUser}`, ); const created = existing.length === 0; // Tested AS THE MEMBER, through runAs — a 700 home is unreadable to the service user, so a `test -f` // run as ourselves reports "missing" for a file sitting right there, and would rotate their password on // every single reprovision. // // BOTH files, because the password is not stored anywhere else: if either is missing we cannot rewrite // it from the other, and rotating is the only route back to a state where the two agree. const present = await Promise.all( [pgpass, zshenv].map(async (path) => (await runAs(params.osUser, ['test', '-f', path]).exited) === 0), ); const passwordSet = created || present.some((found) => !found); const password = passwordSet ? generatePassword() : null; if (created) { await db.execute(sql.raw(`CREATE ROLE "${params.osUser}" ${ROLE_ATTRIBUTES} PASSWORD '${password}'`)); } else { // Re-asserted rather than assumed. An adopted role may have been altered by hand between runs, and // this is the only place that states what a member's role is allowed to be. await db.execute(sql.raw(`ALTER ROLE "${params.osUser}" ${ROLE_ATTRIBUTES}`)); if (passwordSet) await db.execute(sql.raw(`ALTER ROLE "${params.osUser}" PASSWORD '${password}'`)); } if (passwordSet && password) { const { host, port } = pgpassTarget(process.env.POSTGRES_URL); // Written twice, on purpose, and they are not redundant. // // ~/.zshenv the readable one. Asked for by name: somewhere obvious to look when you want to know // your own credentials, rather than a dotfile you have to be told about. `.zshenv` // rather than `.zshrc` for two reasons — zsh sources it for NON-interactive shells too, // so a script, a cron entry or an agent turn gets it, and `.zshrc` is a shared template // that `seedShellConfig` only updates while it still matches byte-for-byte. Writing a // per-member password into that file would strand every member on the template they // were created with. // // ~/.pgpass the one libpq reads on its own, with no shell involved at all. Anything that is not a // zsh process — a systemd unit, python's psycopg, a compiled binary — finds this and // nothing else. // // Both 600. psql ignores a .pgpass that is group- or world-readable, exactly like ssh and // authorized_keys, so there it is a requirement rather than caution. const files: Array<{ content: string; dest: string; label: string }> = [ { // `*` for the database field: they may create as many as they like and every one of them is theirs. content: `${host}:${port}:*:${params.osUser}:${password}\n`, dest: pgpass, label: '~/.pgpass', }, { content: [ '# Officer — your Postgres credentials.', '#', '# Written when your account was created. Officer rewrites this file, so put your own settings', '# in ~/.zshrc.local instead.', '#', '# With these set, no flags are needed: createdb myapp • psql myapp • dropdb myapp', '# Make as many databases as you like. They are yours, and nobody else can reach them.', `export PGHOST=${host}`, `export PGPORT=${port}`, `export PGUSER=${params.osUser}`, `export PGPASSWORD=${password}`, '', ].join('\n'), dest: zshenv, label: '~/.zshenv', }, ]; for (const file of files) { const written = await installFile({ content: file.content, dest: file.dest, uid: params.uid, gid: params.gid, mode: '600', }); if (!written.ok) { return { ok: false, error: `role ${params.osUser} exists but ${file.label} could not be written: ${written.out}`, }; } } } return { ok: true, role: params.osUser, created, passwordSet }; } catch (ex) { return { ok: false, error: `could not provision the Postgres role ${params.osUser}: ${asMessage(ex)}` }; } } /** * Remove a member's role, and every database they own. * * `DROP ROLE` refuses while the role owns anything, which is the common case for exactly the accounts this * is called for. The databases go first and they go permanently — this is called from account deletion, * where the alternative is a cluster that accumulates unreachable databases owned by a name nobody holds. * * Deliberately NOT wired into `deprovisionOsAccount` yet: that function's contract is that a failure * leaves the account intact and retryable, and dropping databases is not reversible. See the call site. */ export type DropRoleResult = { ok: true; removed: boolean; reassigned: string[] } | { ok: false; error: string }; /** Legal database name. Separate from ROLE_NAME_RE because a member names these, not us. */ const DB_NAME_RE = /^[a-zA-Z0-9._-]{1,63}$/; /** * Take a member's Postgres role away, keeping their data. * * ── Why this exists, and why it is not optional ── * * Exactly the trap `deprovisionOsAccount` was written for, in a different id space. `provisionPostgresRole` * ADOPTS a role that already exists — so a role left behind by a deleted member is inherited whole, with its * databases, by the next member who happens to get the same username. `useradd` hands out the lowest free * uid; the owner hands out usernames, and reusing one is likelier, not less. * * ── Preserve, not destroy ── * * Their databases are REASSIGNED to the platform role, not dropped. `severMemberTree` chowns a member's * files to the service user rather than deleting them, and a database is the same kind of thing: the owner * removing an account has not necessarily asked to destroy the work in it. Dropping is also the one choice * that cannot be walked back. * * The sequence per database is the documented idiom and both halves are needed: * * REASSIGN OWNED BY TO moves the objects — tables, schemas, functions * DROP OWNED BY removes what is left, which after a reassign is only the * privilege GRANTS. Without it `DROP ROLE` still refuses, * because an ACL entry naming the role is a dependency too. * * It has to run INSIDE each database, because both statements act only on the one they are connected to — * hence a connection per database rather than a loop over `db`. */ export async function dropPostgresRole(osUser: string): Promise { if (!ROLE_NAME_RE.test(osUser)) return { ok: false, error: `'${osUser}' is not a usable Postgres role name` }; const url = process.env.POSTGRES_URL; if (!url) return { ok: false, error: 'POSTGRES_URL is not set' }; try { const present = await db.execute<{ rolname: string }>(sql`select rolname from pg_roles where rolname = ${osUser}`); // Already gone is success, so a retry after a partial teardown finishes rather than refuses. if (present.length === 0) return { ok: true, removed: false, reassigned: [] }; const [platform] = await db.execute<{ current_user: string }>(sql`select current_user`); const owner = platform?.current_user; if (!owner || !ROLE_NAME_RE.test(owner)) return { ok: false, error: 'could not determine the platform role' }; const owned = await db.execute<{ datname: string }>( sql`select datname from pg_database where pg_get_userbyid(datdba) = ${osUser}`, ); const reassigned: string[] = []; for (const row of owned) { if (!DB_NAME_RE.test(row.datname)) { return { ok: false, error: `refusing to touch database '${row.datname}': unexpected name` }; } // The database itself first: REASSIGN OWNED does not move database ownership, only objects inside one. await db.execute(sql.raw(`ALTER DATABASE "${row.datname}" OWNER TO "${owner}"`)); const moved = await reassignInsideDatabase({ url, database: row.datname, from: osUser, to: owner }); if (!moved.ok) return moved; reassigned.push(row.datname); } // And in the platform's own database, where they own nothing but may still hold a grant. await db.execute(sql.raw(`REASSIGN OWNED BY "${osUser}" TO "${owner}"`)); await db.execute(sql.raw(`DROP OWNED BY "${osUser}"`)); await db.execute(sql.raw(`DROP ROLE IF EXISTS "${osUser}"`)); return { ok: true, removed: true, reassigned }; } catch (ex) { return { ok: false, error: `could not remove the Postgres role ${osUser}: ${asMessage(ex)}` }; } } async function reassignInsideDatabase(params: { url: string; database: string; from: string; to: string; }): Promise<{ ok: true } | { ok: false; error: string }> { let target: string; try { const parsed = new URL(params.url); parsed.pathname = `/${params.database}`; target = parsed.toString(); } catch { return { ok: false, error: 'POSTGRES_URL could not be parsed' }; } const client = postgres(target, { max: 1, idle_timeout: 5, connection: { application_name: 'officer-deprovision' } }); try { await client.unsafe(`REASSIGN OWNED BY "${params.from}" TO "${params.to}"`); await client.unsafe(`DROP OWNED BY "${params.from}"`); return { ok: true }; } catch (ex) { return { ok: false, error: `could not reassign objects in '${params.database}': ${asMessage(ex)}` }; } finally { await client.end({ timeout: 5 }); } }