provision-env-db: revoke PUBLIC connect on the owner database too (#108)
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 7s
secrets-guard / encrypted (push) Successful in 5s
shell-lint / shellcheck (push) Successful in 7s

This commit is contained in:
emi 2026-07-26 07:17:20 +00:00
parent 7583258509
commit 5c41ee4dad
2 changed files with 61 additions and 2 deletions

View file

@ -149,8 +149,16 @@ it. That guard is why the superuser check above should never fail; run it
anyway. anyway.
If the first command SUCCEEDS, stop. `CONNECT` was not revoked and beta would be If the first command SUCCEEDS, stop. `CONNECT` was not revoked and beta would be
able to read production data; re-run the provisioning script and re-check before able to open a session against the production database; re-run the provisioning
going further. script and re-check before going further.
This is not hypothetical — it happened on the live cutover. The first version of
the script revoked `PUBLIC` connect on the *guest's* database only, and a fresh
Postgres database carries `=Tc` (TEMP **and** CONNECT) for `PUBLIC`, so
`thermograph_beta` could still reach `thermograph`. The script now hardens the
owner's database too, granting the owner's read-only role an explicit `CONNECT`
first so the revoke cannot strip `ops/dbq.sh` of its access. The check above is
what caught it.
**Rollback:** `DROP DATABASE thermograph_beta; DROP ROLE thermograph_beta;` **Rollback:** `DROP DATABASE thermograph_beta; DROP ROLE thermograph_beta;`
prod is unaffected either way. prod is unaffected either way.
@ -239,12 +247,23 @@ serving; nothing has moved yet.
``` ```
ssh agent@169.58.46.181 ssh agent@169.58.46.181
sudo caddy validate --config /etc/caddy/Caddyfile # BEFORE reloading sudo caddy validate --config /etc/caddy/Caddyfile # BEFORE reloading
# `validate` runs as root and CREATES any log file the config names, owned by
# root — which the caddy user then cannot open, and the reload fails. Fix the
# ownership before reloading, or the first reload rejects the whole config:
sudo chown caddy:caddy /var/log/caddy/beta.log
sudo systemctl reload caddy sudo systemctl reload caddy
``` ```
Validate before reload, always: a malformed Caddyfile takes `thermograph.org` Validate before reload, always: a malformed Caddyfile takes `thermograph.org`
down with it, and prod is on this box now. down with it, and prod is on this box now.
The `chown` is not optional and it bit this cutover. `caddy validate` as root
left `/var/log/caddy/beta.log` as `root:root`, so the reload failed with
`permission denied` on the log writer. (The root-owned `centralis.log` sitting
in that directory is the same mistake, made earlier.) Note what did NOT happen:
Caddy rejects a bad config atomically and keeps serving the old one, so
`thermograph.org` stayed up throughout — verify it anyway.
Caddy cannot issue the certificate until DNS moves (step 7), so expect the beta Caddy cannot issue the certificate until DNS moves (step 7), so expect the beta
hostname to fail TLS until then. That is fine and expected. hostname to fail TLS until then. That is fine and expected.

View file

@ -156,6 +156,46 @@ ALTER DEFAULT PRIVILEGES FOR ROLE :"role" IN SCHEMA public
GRANT SELECT ON SEQUENCES TO :"rorole"; GRANT SELECT ON SEQUENCES TO :"rorole";
SQL SQL
# --- harden the OWNER environment's database too -------------------------------
#
# Revoking CONNECT from PUBLIC on the guest's own database is only half the job,
# and the missing half is the half that matters. A fresh Postgres database
# carries `=Tc` for PUBLIC — TEMP *and* CONNECT — so the moment a second role
# exists on the instance, that role can connect to every database that still has
# the default. Provisioning beta and stopping here left `thermograph_beta` able
# to open a session against prod's database; caught live by the runbook's own
# isolation check, which is why that check exists.
#
# Order matters: grant the owner's read-only role an EXPLICIT connect first, so
# the revoke below cannot strip the ad-hoc query path (ops/dbq.sh) of its access.
# The owner's app role is the database owner and a superuser, so it is unaffected
# either way.
OWNER_DB=$(
# shellcheck disable=SC1090 # sourced above; re-resolving prod in a subshell
thermograph_topology prod >/dev/null 2>&1
printf '%s' "$TG_DB_NAME"
)
OWNER_RO=$(
thermograph_topology prod >/dev/null 2>&1
printf '%s_ro' "$TG_DB_USER"
)
# Restore this run's environment — thermograph_topology overwrote the TG_* vars.
thermograph_topology "$ENV_NAME"
if [ "$OWNER_DB" != "$TG_DB_NAME" ]; then
echo "==> Hardening the owner database '${OWNER_DB}' against PUBLIC connects"
docker exec -i "$DB_CID" \
psql -v ON_ERROR_STOP=1 -U thermograph -d postgres \
-v ownerdb="$OWNER_DB" -v ownerro="$OWNER_RO" <<'SQL'
-- Only if that read-only role actually exists (it predates this script).
SELECT format('GRANT CONNECT ON DATABASE %I TO %I', :'ownerdb', :'ownerro')
WHERE EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'ownerro')
\gexec
REVOKE CONNECT ON DATABASE :"ownerdb" FROM PUBLIC;
SQL
fi
echo "==> OK: ${TG_DB_USER} owns ${TG_DB_NAME} (timescaledb enabled, PUBLIC revoked)" echo "==> OK: ${TG_DB_USER} owns ${TG_DB_NAME} (timescaledb enabled, PUBLIC revoked)"
echo " Verify isolation:" echo " Verify isolation:"
echo " docker exec $DB_CID psql -U ${TG_DB_USER} -d thermograph -c 'select 1' # must FAIL" echo " docker exec $DB_CID psql -U ${TG_DB_USER} -d thermograph -c 'select 1' # must FAIL"