put the postgres credentials somewhere findable

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:24:05 +00:00
co-authored by Claude Opus 5
parent 3ca7f5f331
commit 9f15de3448
2 changed files with 79 additions and 27 deletions
+73 -18
View File
@@ -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}`,
};
}
}
}
+6 -9
View File
@@ -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