Skip to content

fix(deploy): no install can connect to another install's database - #183

Merged
sinashamsizadeh merged 10 commits into
mainfrom
fix/db-connect-isolation
Oct 10, 2026
Merged

sinashamsizadeh merged 10 commits into
mainfrom
fix/db-connect-isolation

Conversation

@sinashamsizadeh

Copy link
Copy Markdown
Collaborator

Root cause

deploy/05-create-databases.sh creates one role and one database per install on ONE shared PostgreSQL cluster, but
never revoked CONNECT from PUBLIC. So install A's role could connect to install B's database (and B's drill
database); only schema permissions stopped it from reading. The db-maturity spike reproduced that a broader read role
would read the other install's visitor phone numbers. One layer of defense between installs was missing.
Part of Daneshbonyan re-evaluation track 7 (evaluator reason 4: database maturity).

Design doc

docs/features/db-maturity/RESEARCH.md sections 5.8 and 6 item 8 (work row F), ADR-028 in docs/engineering/DECISIONS.md.

What changed:

  • New deploy/05-connect-isolation.sql: REVOKE CONNECT ON DATABASE ... FROM PUBLIC and GRANT CONNECT ... TO <that install's role>, inside one transaction, with psql :"var" quoted identifiers. It fails closed if a variable is
    missing.
  • deploy/05-create-databases.sh checks the file exists before creating anything and applies it, as the superuser
    with ON_ERROR_STOP, to padyar_<slug> and padyar_<slug>_drill on every run. Old installs are covered on the next
    run; re-runs change nothing. No new role, no pg_read_all_data, no membership.
  • docs/engineering/SECURITY.md: the rule; how to apply it on an existing server with the two SQL lines per database
    (the safe path, because re-running 05 also resets the app password); open sessions stay connected until they end
    (what to do); installs without a drill database yet; and that it is NOT applied on the production server yet.
  • One line each in deploy/README.md and the runbook.

Tests

  • Oracle written by the reviewing agent from the task contract before it saw the code
    (test_connect_isolation_oracle.py, not committed; runs the REAL 05 as root in a container): 28 failed / 26 passed
    on the base; 54/54 on the first patch.
  • Committed: tests/postgres/test_deploy_05_isolation.py runs the real 05 in a throwaway postgres:16 container for two
    installs, twice, and also runs the OLD 05 (an exact copy of the script at 01183a6) followed by the new one: role A
    cannot connect to B or B_drill, every install keeps its own access. tests/postgres/test_connect_isolation.py
    applies the same SQL file over DATABASE_URL. Both skip cleanly without Docker / psql / a superuser.
  • Not run: anything on the production server.

Security

Removes a cross-install connect path. The app and the metrics exporter need no CONNECT on another install's database
(checked by the final reviewer). Identifiers in the SQL are quoted psql variables, so a slug cannot inject SQL. The
manual path runs in one transaction, so a mistyped role cannot leave a database without its own role's CONNECT.

AI assistance

Written by Claude agents in a Herdr team: implementer ops-t1-impl (Opus 5.5) wrote the SQL, script change, tests and
docs; reviewer ops-t1-rev (Opus 5.5) wrote the oracle first and reviewed (accepted; its low, a missing old-install
test, was fixed and shown red against a mutation); final reviewer drill-t1-final (Opus 5.5, xhigh) reviewed the whole
diff (accepted; its low, the missing transaction, was fixed); lead foreman7 (Opus 5.5) re-ran the oracle on the base
and the patch in its own container. Log entry in docs/engineering/AI_ASSISTANCE_LOG.md.

Human review

sinashamsizadeh and others added 10 commits October 8, 2026 11:21
Several installs share one PostgreSQL cluster, one role and one database
(plus <database>_drill) each. PostgreSQL gives CONNECT on every new database
to PUBLIC, and 05 never took it back, so install A's role could open install
B's databases; only the schema permissions stopped it from reading.

deploy/05-connect-isolation.sql holds the two statements (REVOKE CONNECT ...
FROM PUBLIC; GRANT CONNECT ... TO <role>) with psql variables. 05 runs it
for the database and the drill database of every install, as the postgres
superuser, with the file on stdin (read by root, so the postgres user needs
no access to the checkout). It is safe to repeat. No new role, no
pg_read_all_data (RESEARCH.md 5.8, ADR-028 item 7).

Tests (tests/postgres):
- test_deploy_05_isolation.py runs the REAL 05 as root in a throwaway
  postgres:16 container for two installs, twice. Deny: neither role opens
  the other's database or drill database. Allow: each opens its own two and
  still writes and reads in its own app and observability schemas. ACLs
  give PUBLIC no CONNECT. No role besides the two, no predefined role. The
  SQL file closes an install made by the old 05. Skips without Docker.
- test_connect_isolation.py applies the same SQL file with psql on the
  DATABASE_URL server to two installs set up the 05 way; same deny/allow,
  and a second run changes no ACL. Skips without a superuser or psql.
Both were red at 01183a6 (the old 05 let alpha into beta's databases).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nstall

SECURITY.md gets a "Database Isolation Between Installs" section: only the
install's own role may connect to its database and its drill database;
never pg_read_all_data; the tests that prove it. It says plainly that the
rule is not applied on the production server yet.

For an existing install, the safe path is the two SQL lines per database
(deploy/05-connect-isolation.sql, or by hand): no new password, no data
change, and an open session keeps working (checked on postgres:16: the
open session still answered, a new one got "User does not have CONNECT
privilege"). It also says that re-running 05 resets the role's password,
so DATABASE_URL in .env must be updated and the app restarted
(SPEC-F Amendment 1). One line in the runbook's "Existing installs".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Human review: pending. Not applied on the production server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every 05 test started from databases the new 05 created. So a 05 that
isolated only the databases it creates would pass them all, and the
real upgrade path (an install made before this unit) was never tested.

The new case makes two installs with the old script (a byte-identical
copy of 01183a6's deploy/05-create-databases.sh, kept next to the test
because CI checks out one commit), checks the hole is open, runs the
new 05 over them, and asserts neither role can open the other's
database or drill database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The REVOKE stops new connections only, so a session opened before it
stays open. SECURITY.md now says to run the pg_stat_activity check
again after the two lines, and if a row is left, to restart the
install that owns that role or end the sessions with
pg_terminate_backend. The check leaves out the postgres superuser,
which may always connect, so a row means a role the rule locks out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The postgres:16 entrypoint first starts a short init server that listens
on the unix socket only, then stops it and starts the real one. The
old poll (psql over the socket) could pass on that init server, so 05
could start while no server was up, and the test went red for no
reason. pg_isready -h 127.0.0.1 only passes on the real server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved to f83e7f0 (#180 off-site SFTP panel, #181 backup retention
pools, #182 recovery objectives). One conflict, in
docs/engineering/AI_ASSISTANCE_LOG.md: both sides added entries at the
top. Kept both: the F entry first, unchanged, then main's H2 and H1
entries, each with its own review line. deploy/README.md and
docs/engineering/DEPLOYMENT_RUNBOOK.md merged on their own; each is
main plus the one F line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the manual path the operator types role= by hand. With a typo the
GRANT failed but the REVOKE before it was already committed, so PUBLIC
lost CONNECT and the named role never got it. The two statements now
run in one transaction, so a failed GRANT takes the REVOKE back too.

The new test applies the file with a role that does not exist and
checks the database ACL is the same before and after. It was red
without the transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ill db

The four by-hand lines now run between BEGIN and COMMIT, like the SQL
file. If psql answers ROLLBACK, a name was wrong and nothing changed.
An install made before the restore drill has no _drill database: leave
it out, because the next 05 run creates it with the rule already on.
Inside one transaction a missing drill database would roll back the
lines for the main database too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sinashamsizadeh
sinashamsizadeh marked this pull request as ready for review October 10, 2026 12:03
@sinashamsizadeh
sinashamsizadeh merged commit b95e3bd into main Oct 10, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant