← back to Grant
scripts/rotate-pg-grant.sh
164 lines
#!/usr/bin/env bash
# rotate-pg-grant.sh — Lane B PG rotation for Grant only.
#
# Creates (or updates) a scoped PG user `grant_app_user` with permissions only
# on the `grant_app` database, swaps Grant's .env.local DATABASE_URL to use it,
# and registers the password in ~/Projects/secrets-manager/.env via the
# canonical secrets-manager CLI. Leaves shared dw_admin untouched.
#
# Usage:
# ./rotate-pg-grant.sh
# (the script will prompt for the new password via `read -s`, no echo;
# nothing lands in shell history or argv)
#
# Per Steve's standing rules:
# - Never $-interpolate secrets into bash command lines (we use stdin pipes
# into psql + python heredocs)
# - Use /secrets skill for routing (we call cli.js add at the end)
# - Local-only — Kamatera mirror not touched
set -euo pipefail
GRANT_DIR="${GRANT_DIR:-$HOME/Projects/Grant}"
SECRETS_CLI="$HOME/Projects/secrets-manager/cli.js"
if [[ ! -f "$GRANT_DIR/.env.local" ]]; then
echo "ERROR: $GRANT_DIR/.env.local does not exist — aborting." >&2
exit 1
fi
echo
echo "Lane B: scoped Grant PG user rotation"
echo " - creates/updates grant_app_user (login, scoped to grant_app DB)"
echo " - updates $GRANT_DIR/.env.local DATABASE_URL"
echo " - registers in secrets-manager"
echo " - leaves shared dw_admin untouched"
echo
# Read password silently — no echo to terminal, no entry in shell history.
# Suggested: 32+ chars, alphanumeric + symbols, generate with `openssl rand -base64 32`
read -s -p "New password for grant_app_user (input hidden): " NEW_PG
echo
read -s -p "Confirm: " CONFIRM
echo
if [[ "$NEW_PG" != "$CONFIRM" ]]; then
echo "Passwords do not match — aborting." >&2
unset NEW_PG CONFIRM
exit 1
fi
unset CONFIRM
if [[ ${#NEW_PG} -lt 16 ]]; then
echo "Password must be at least 16 chars — aborting." >&2
unset NEW_PG
exit 1
fi
# Step 1 — apply role change via psql, password sent on stdin (never in argv).
# psql reads SQL from stdin via `-f -`; the SQL itself contains the password
# but the SQL never lands in any shell argv.
echo "Applying PG role change..."
NEW_PG="$NEW_PG" python3 - <<'PYEOF'
import os, subprocess, sys, secrets
pw = os.environ['NEW_PG']
# audit P2-7: wrap the password in a RANDOM dollar-quote tag instead of a
# single-quoted f-string literal. A password containing a single quote,
# backslash, or "$$" could otherwise break out of the literal and inject SQL.
# The tag is random and asserted absent from the password, so the literal
# boundary is unforgeable — and the password still never touches argv (stdin).
_tag = 'pw_' + secrets.token_hex(8)
if _tag in pw:
print('dollar-quote tag collision (1-in-2^64) — re-run', file=sys.stderr)
sys.exit(1)
_lit = f"${_tag}${pw}${_tag}$"
sql = f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grant_app_user') THEN
CREATE ROLE grant_app_user WITH LOGIN PASSWORD {_lit};
ELSE
ALTER ROLE grant_app_user WITH LOGIN PASSWORD {_lit};
END IF;
END
$$;
GRANT CONNECT ON DATABASE grant_app TO grant_app_user;
\\c grant_app
GRANT USAGE ON SCHEMA public TO grant_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO grant_app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO grant_app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO grant_app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO grant_app_user;
"""
r = subprocess.run(
['psql', '-d', 'postgres', '-v', 'ON_ERROR_STOP=1', '-X'],
input=sql, text=True, capture_output=True,
)
if r.returncode != 0:
print('psql failed:', r.stderr, file=sys.stderr)
sys.exit(1)
print(r.stdout.strip() or 'role + grants OK')
PYEOF
# Step 2 — verify the new user can actually connect
echo "Verifying connection..."
NEW_PG="$NEW_PG" python3 - <<'PYEOF'
import os, subprocess, sys
pw = os.environ['NEW_PG']
url = f"postgresql://grant_app_user:{pw}@127.0.0.1:5432/grant_app"
r = subprocess.run(
['psql', url, '-c', 'SELECT current_user, current_database()'],
capture_output=True, text=True,
)
if r.returncode != 0:
print('verify failed:', r.stderr, file=sys.stderr)
sys.exit(1)
print(r.stdout.strip())
PYEOF
# Step 3 — update Grant's .env.local DATABASE_URL
echo "Updating $GRANT_DIR/.env.local..."
NEW_PG="$NEW_PG" GRANT_DIR="$GRANT_DIR" python3 - <<'PYEOF'
import os
pw = os.environ['NEW_PG']
env = os.path.join(os.environ['GRANT_DIR'], '.env.local')
new_url = f"postgresql://grant_app_user:{pw}@127.0.0.1:5432/grant_app"
with open(env) as f:
lines = f.readlines()
found = False
for i, line in enumerate(lines):
if line.startswith('DATABASE_URL='):
lines[i] = f'DATABASE_URL={new_url}\n'
found = True
break
if not found:
lines.append(f'\nDATABASE_URL={new_url}\n')
# atomic write
tmp = env + '.tmp'
with open(tmp, 'w') as f:
f.writelines(lines)
os.chmod(tmp, 0o600)
os.replace(tmp, env)
print(f'wrote DATABASE_URL → {env}')
PYEOF
# Step 4 — register in secrets-manager (canonical routing destination)
if [[ -f "$SECRETS_CLI" ]]; then
echo "Registering in secrets-manager..."
printf '%s' "GRANT_APP_DB_PASSWORD=$NEW_PG" | node "$SECRETS_CLI" import-paste 2>&1 | tail -5 || \
echo " (secrets-manager add failed — manual entry in master .env may be needed)"
else
echo " (secrets-manager CLI not found at $SECRETS_CLI — skipping registry)"
fi
# Final cleanup
unset NEW_PG
echo
echo "✅ Done. Grant now connects as grant_app_user (scoped to grant_app DB)."
echo " Shared dw_admin password is unchanged."
echo " Old DW2024SecurePass is still on disk in Time Machine snapshots —"
echo " schedule a global dw_admin rotation in a maintenance window when ready."