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:
@@ -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:
|
// 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.
|
// 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
|
// ~/.zshenv PGHOST/PGPORT/PGUSER/PGPASSWORD, so every pg command works with no flags — and an
|
||||||
// keys — see os-user-ssh.ts for why every write into a 700 home goes through
|
// obvious place to read your own password back from.
|
||||||
// `sudo install`.
|
// ~/.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 ──
|
// ── What keeps them out of everything else ──
|
||||||
//
|
//
|
||||||
@@ -264,6 +268,7 @@ export async function provisionPostgresRole(params: {
|
|||||||
|
|
||||||
const home = osUserHome(params.email);
|
const home = osUserHome(params.email);
|
||||||
const pgpass = join(home, '.pgpass');
|
const pgpass = join(home, '.pgpass');
|
||||||
|
const zshenv = join(home, '.zshenv');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await db.execute<{ rolname: string }>(
|
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`
|
// 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
|
// run as ourselves reports "missing" for a file sitting right there, and would rotate their password on
|
||||||
// every single reprovision.
|
// 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;
|
const password = passwordSet ? generatePassword() : null;
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
@@ -291,18 +300,64 @@ export async function provisionPostgresRole(params: {
|
|||||||
|
|
||||||
if (passwordSet && password) {
|
if (passwordSet && password) {
|
||||||
const { host, port } = pgpassTarget(process.env.POSTGRES_URL);
|
const { host, port } = pgpassTarget(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.
|
// `*` 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`,
|
content: `${host}:${port}:*:${params.osUser}:${password}\n`,
|
||||||
dest: pgpass,
|
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,
|
uid: params.uid,
|
||||||
gid: params.gid,
|
gid: params.gid,
|
||||||
mode: '600',
|
mode: '600',
|
||||||
});
|
});
|
||||||
if (!written.ok) {
|
if (!written.ok) {
|
||||||
return { ok: false, error: `role ${params.osUser} exists but ~/.pgpass could not be written: ${written.out}` };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `role ${params.osUser} exists but ${file.label} could not be written: ${written.out}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,16 +115,13 @@ fi
|
|||||||
#
|
#
|
||||||
# Nobody else can read your data, and you cannot reach Officer's own database.
|
# 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
|
# Nothing is set here. PGHOST, PGPORT, PGUSER and PGPASSWORD are in ~/.zshenv, which zsh reads before
|
||||||
# this machine, and without this psql fails with "No such file or directory", which reads like Postgres
|
# this file and for non-interactive shells too — so scripts and agents get them as well. That is also
|
||||||
# is not installed rather than like it is one flag away.
|
# 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
|
# PGHOST matters more than it looks: Postgres runs in a container published on loopback, so there is no
|
||||||
# — one fact, in one place, that cannot come to disagree with itself.
|
# unix socket on this machine, and without it psql fails with "No such file or directory" — which reads
|
||||||
if [ -f "$HOME/.pgpass" ]; then
|
# like Postgres is not installed rather than like it is one flag away.
|
||||||
export PGHOST="${$(head -1 "$HOME/.pgpass")%%:*}"
|
|
||||||
export PGPORT="$(head -1 "$HOME/.pgpass" | cut -d: -f2)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Docker ──
|
# ── Docker ──
|
||||||
# Officer no longer provisions a rootless daemon per account (2026-08-13) — a Postgres role covers the
|
# Officer no longer provisions a rootless daemon per account (2026-08-13) — a Postgres role covers the
|
||||||
|
|||||||
Reference in New Issue
Block a user