diff --git a/src/servers/api/users/provision-os.ts b/src/servers/api/users/provision-os.ts index 90bd473f..15dcf5da 100644 --- a/src/servers/api/users/provision-os.ts +++ b/src/servers/api/users/provision-os.ts @@ -3,6 +3,7 @@ import { ensureOsUser, osUserHome } from '@@/os-user'; import { provisionSshAccess } from '@@/os-user-ssh'; import { seedShellConfig } from '@@/os-user-shell'; import { provisionClaudeCli } from '@@/os-user-claude'; +import { provisionPostgresRole } from '@@/os-user-postgres'; // Disabled 2026-08-13 — see the commented-out step in provisionOsAccount below. // import { provisionRootlessDocker } from '@@/os-user-docker'; import { provisionUserDirs } from '@@/data-path'; @@ -78,6 +79,19 @@ export async function provisionOsAccount(params: { // cannot do it for them and must not try, because the alternative is lending them the owner's credential. const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser }); + // A Postgres login role of the same name, with CREATEDB. What replaced rootless Docker for the + // "let me run a database to develop against" case, at roughly none of the cost. + // + // Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function + // that creates the role rather than in the setup script, so it cannot be skipped by an install that was + // set up before this existed. See os-user-postgres.ts. + const postgres = await provisionPostgresRole({ + email: params.email, + osUser: account.osUser, + uid: account.uid, + gid: account.gid, + }); + // ── Rootless Docker: DISABLED 2026-08-13, code kept ── // // Every member got their own rootless daemon, unconditionally, on the argument that "can I run a database @@ -105,9 +119,17 @@ export async function provisionOsAccount(params: { // Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt, // which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are // in the log. - for (const step of [claude, shell] as const) { + for (const step of [claude, shell, postgres] as const) { if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`); } - const error = !ssh.ok ? ssh.error : !claude.ok ? claude.error : !shell.ok ? shell.error : null; + const error = !ssh.ok + ? ssh.error + : !claude.ok + ? claude.error + : !shell.ok + ? shell.error + : !postgres.ok + ? postgres.error + : null; return { osUser: account.osUser, sshPublicKey, error }; } diff --git a/src/servers/os-user-postgres.ts b/src/servers/os-user-postgres.ts new file mode 100644 index 00000000..f1007211 --- /dev/null +++ b/src/servers/os-user-postgres.ts @@ -0,0 +1,264 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { sql } from 'drizzle-orm'; +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. +// ~/.pgpass so `psql` never prompts. Mode 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 capability 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. +// +// ── The residue, stated rather than implied ── +// +// A database one member creates is readable-as-metadata by another: `datacl` is NOT inherited from the +// template (measured — closing `template1` and creating from it still produced a NULL `datacl`), and +// `CREATE DATABASE` fires no event trigger, so nothing can close a member's database at the moment they +// create it. A second member can connect and read table and column NAMES from the catalogue. They cannot +// read a row, and they cannot create anything (PG15+ removed PUBLIC's CREATE on `public`). +// +// Closing that needs either a sweep or a `pg_hba.conf` rule per member, and both are decisions rather +// than details. Not built. Do not let this comment be read as "handled". + +/** 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)); + +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 { + // Before anything else, and before the role can exist to take advantage of it. + const closed = await ensureAppDatabaseClosed(); + if (!closed.ok) return { ok: false, error: closed.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'); + + 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. + const probe = runAs(params.osUser, ['test', '-f', pgpass]); + const hasPgpass = (await probe.exited) === 0; + + const passwordSet = created || !hasPgpass; + 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); + // `*` for the database field: they may create as many as they like and every one of them is theirs. + // psql ignores a .pgpass that is group- or world-readable, exactly like ssh and authorized_keys, so + // 600 is a requirement rather than caution. + const written = await installFile({ + content: `${host}:${port}:*:${params.osUser}:${password}\n`, + dest: pgpass, + uid: params.uid, + gid: params.gid, + mode: '600', + }); + if (!written.ok) { + return { ok: false, error: `role ${params.osUser} exists but ~/.pgpass 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; dropped: string[] } | { ok: false; error: string }; + +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` }; + + try { + const owned = await db.execute<{ datname: string }>( + sql`select datname from pg_database where pg_get_userbyid(datdba) = ${osUser}`, + ); + for (const row of owned) { + if (!ROLE_NAME_RE.test(row.datname) && !/^[a-zA-Z0-9._-]+$/.test(row.datname)) continue; + await db.execute(sql.raw(`DROP DATABASE IF EXISTS "${row.datname}" WITH (FORCE)`)); + } + await db.execute(sql.raw(`DROP ROLE IF EXISTS "${osUser}"`)); + return { ok: true, dropped: owned.map((row) => row.datname) }; + } catch (ex) { + return { ok: false, error: `could not drop the Postgres role ${osUser}: ${asMessage(ex)}` }; + } +} diff --git a/src/servers/shell-skel/zshrc b/src/servers/shell-skel/zshrc index 6f1eed63..ab799832 100644 --- a/src/servers/shell-skel/zshrc +++ b/src/servers/shell-skel/zshrc @@ -105,11 +105,31 @@ else %F{blue}❯%f ' fi -# ── Docker ── -# Your own rootless daemon, if Officer provisioned one. Containers you start run as your account in your own -# user namespace — root inside them is you outside them, and you cannot see anyone else's containers. +# ── Postgres ── +# Officer gave your account a Postgres role named the same as your login, and a ~/.pgpass holding its +# password — so psql never prompts you and you never have to know it. # -# Set from $XDG_RUNTIME_DIR rather than a hard-coded uid so this line is the same in every account's file. +# createdb myapp as many as you like; each one is yours +# psql myapp connect to one +# dropdb myapp +# +# Nobody else can read your data, and you cannot reach Officer's own database. +# +# PGHOST is set because Postgres runs in a container published on loopback: there is no unix socket on +# this machine, and without this psql fails with "No such file or directory", which reads like Postgres +# is not installed rather than like it is one flag away. +# +# Read out of ~/.pgpass rather than hard-coded, for the same reason the PATH lines above are $HOME-relative +# — one fact, in one place, that cannot come to disagree with itself. +if [ -f "$HOME/.pgpass" ]; then + export PGHOST="${$(head -1 "$HOME/.pgpass")%%:*}" + export PGPORT="$(head -1 "$HOME/.pgpass" | cut -d: -f2)" +fi + +# ── Docker ── +# Officer no longer provisions a rootless daemon per account (2026-08-13) — a Postgres role covers the +# case it was really there for, at none of the cost. Kept because it costs one stat and does the right +# thing if you install rootless Docker yourself. if [ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" ]; then export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" fi