give each member a postgres role instead of a docker daemon

A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.

Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:

  - a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
    but CANNOT read any application table — privileges are owner-only, so
    has_table_privilege('users','UPDATE') is false. The capability model was never
    reachable from here.
  - the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
    the source, so they fall through to scram-sha-256. Verified with a wrong password.
  - revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
    only revoking from PUBLIC is a lock.

So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.

Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.

KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.

Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.

NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:14:02 +00:00
co-authored by Claude Opus 5
parent 41cdd1e63a
commit 2273941e71
3 changed files with 312 additions and 6 deletions
+24 -2
View File
@@ -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 };
}