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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user