From 9f15de3448f38863798d26b33ec1b8545356398f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 13 Aug 2026 12:24:05 +0000 Subject: [PATCH] put the postgres credentials somewhere findable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The password now lands in ~/.zshenv as PGHOST/PGPORT/PGUSER/PGPASSWORD, as well as in ~/.pgpass. Asked for on the grounds that it is an easier place to remember, which is a real requirement — a credential you cannot find is one you will ask about every time. .zshenv rather than the .zshrc that was asked for, for two reasons, neither about secrecy: - zsh sources .zshrc for INTERACTIVE shells only. Verified: `zsh -c` prints an empty PGUSER when it is set there, and the right one from .zshenv. A script, a cron entry or an agent turn running psql would silently get nothing. - .zshrc is a shared template and seedShellConfig only updates it while it still matches byte-for-byte, so members keep their edits. A per-member password in it would strand every member on the template they were created with — a silent maintenance break rather than a tradeoff. Both files are still written because they are not redundant: .pgpass is what libpq reads with no shell involved, so it is the one that works for psycopg, a systemd unit or a compiled binary. The rotate condition now covers both — either missing means we cannot reconstruct it from the other, so we regenerate. Also drops the `head -1 ~/.pgpass` parsing I had put in the zshrc template. It was a hack, and the values are in the environment before that file is read now anyway. Verified: zsh sourcing order and both shell types, transpiles. Still no tsgo. Co-Authored-By: Claude Opus 5 --- src/servers/os-user-postgres.ts | 91 ++++++++++++++++++++++++++------- src/servers/shell-skel/zshrc | 15 +++--- 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/src/servers/os-user-postgres.ts b/src/servers/os-user-postgres.ts index 05b7ffaa..d083852c 100644 --- a/src/servers/os-user-postgres.ts +++ b/src/servers/os-user-postgres.ts @@ -17,9 +17,13 @@ import { osUserHome, runAs } from './os-user'; // // 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`. +// ~/.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 ── // @@ -264,6 +268,7 @@ export async function provisionPostgresRole(params: { const home = osUserHome(params.email); const pgpass = join(home, '.pgpass'); + const zshenv = join(home, '.zshenv'); try { const existing = await db.execute<{ rolname: string }>( @@ -274,10 +279,14 @@ export async function provisionPostgresRole(params: { // 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; + // + // 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 || !hasPgpass; + const passwordSet = created || present.some((found) => !found); const password = passwordSet ? generatePassword() : null; if (created) { @@ -291,18 +300,64 @@ export async function provisionPostgresRole(params: { 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}` }; + + // 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}`, + }; + } } } diff --git a/src/servers/shell-skel/zshrc b/src/servers/shell-skel/zshrc index ab799832..d83c12eb 100644 --- a/src/servers/shell-skel/zshrc +++ b/src/servers/shell-skel/zshrc @@ -115,16 +115,13 @@ fi # # 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. +# Nothing is set here. PGHOST, PGPORT, PGUSER and PGPASSWORD are in ~/.zshenv, which zsh reads before +# this file and for non-interactive shells too — so scripts and agents get them as well. That is also +# where to look if you want to read your own password; `cat ~/.zshenv`. # -# 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 +# PGHOST matters more than it looks: Postgres runs in a container published on loopback, so there is no +# unix socket on this machine, and without it psql fails with "No such file or directory" — which reads +# like Postgres is not installed rather than like it is one flag away. # ── Docker ── # Officer no longer provisions a rootless daemon per account (2026-08-13) — a Postgres role covers the