PORT, PUBLIC_URL and POSTGRES_URL are names half the software in the world uses. Bun auto-loads .env into EVERY process started in the platform directory, so a bare PORT reaches the sidecars, the tools they spawn and anything an agent runs from here — and a bare POSTGRES_URL reaches things with no business holding a database credential. Four parts. officer-setup now writes OFFICERDEV_-prefixed names, with the reasoning in the generated file so whoever opens it later knows why. Every consumer reads `OFFICERDEV_X ?? X` — eleven sites across the databases, the DAV router, officer-url.mjs, os-user-postgres and two scripts. The bare name is the FALLBACK, never the source, which is what makes this safe to ship before every machine has been converted. `bun run fix:env` converts a file in place. Idempotent, preserves comments and `export`, leaves the value untouched including an `=` inside it, and refuses to touch names belonging to other software — prefixing PATH or NODE_ENV does not rename a variable, it deletes it. And it runs on every restart from the server settings screen. There rather than at boot because a restart is the one moment the file can be rewritten and take effect in the same breath: the process about to start is the one that reads it. Non-fatal, since the fallback means the worst case is that nothing changed. gen-index reads either spelling out of the file directly, because it is run by hand as often as by the setup and a bare shell has no .env loaded. Verified by converting this machine's own .env: PORT, PUBLIC_URL and the database all still resolve, gen:index still finds its URL, 1152 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
496 lines
24 KiB
TypeScript
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.OFFICERDEV_POSTGRES_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.OFFICERDEV_POSTGRES_URL ?? 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.OFFICERDEV_POSTGRES_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 });
|
|
}
|
|
}
|