Files
platform/src/servers/os-user-postgres.ts
T
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00

496 lines
24 KiB
TypeScript

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<AppDatabaseClosedResult> {
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<PostgresRoleResult> {
// 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 <member> TO <platform> moves the objects — tables, schemas, functions
* DROP OWNED BY <member> 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<DropRoleResult> {
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 });
}
}