[Hardening] Enforce DML-only privileges for the app DB user; remove orphaned init.sql #102
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Project docs (root
CLAUDE.mdandwebapp/CLAUDE.md) describe a two-role database model: a DML-onlyquestboardapp user and a DDL-capablequestboard_migraterole for Alembic. The runtime does not enforce this:POSTGRES_USER=questboardis created by the postgres Docker entrypoint as a SUPERUSER — the app connects with full DDL and admin rights.webapp/postgres/init.sh(mounted atdocker-compose.yml:20), which only creates/updates thequestboard_migraterole and grants it privileges (init.sh:9-25). It contains no REVOKE/GRANT tightening for the app user.webapp/postgres/init.sqlis a dead file: it is mounted nowhere (repo-wide, the only reference to it is a mention inwebapp/CLAUDE.md), it hardcodes the passwordchangeme_migrate(init.sql:10), and — contrary to what one might expect — the app-user tightening exists there only as a SQL comment (init.sql:20-24), not as executable statements.So a SQL-injection or app-level compromise has superuser DB access, and the documented privilege model is fiction.
Fix / Spec
webapp/postgres/init.sh, after the existing migrate-role block, add the app-user tightening for fresh installs: (Adjust exact statements as needed; the key outcomes are: app user is not superuser, cannot CREATE TABLE, retains DML on current and future tables/sequences created by the migrate role.)webapp/postgres/init.sql.docs/OPERATIONS.mdwith the one-timeALTER ROLE ... NOSUPERUSER+ REVOKE/GRANT statements for deployments whose data dir predates this change (init scripts only run on empty data dirs).DATABASE_MIGRATE_URL(webapp/backend/app/config.py:33—database_migrate_url, documented in.env.example:45) so migrations keep working after the tightening; fix the Alembic env if it turns out to use the app URL.Acceptance criteria
docker compose up(empty volume), connecting asquestboardand runningCREATE TABLE t (id int)fails with a permission error; normal app CRUD works.alembic upgrade headsucceeds (migrate role).webapp/postgres/init.sqlno longer exists; no references remain.docs/OPERATIONS.mddocuments the manual statements for existing installs.References
webapp/postgres/init.sh(current init; migrate role only)webapp/postgres/init.sql(orphaned; hardcoded password at:10; tightening only as comment at:20-24)docker-compose.yml:20(init.sh mount — the only init mount)webapp/backend/app/config.py:33(database_migrate_urlfor Alembic),.env.example:41/:45Filed from the July 2026 full-project review.
Picking this up as part of a v3.3.0 push. Landing on branch
hardening/infra-docstogether with #92 and #113 (grouped by component to keep the diffs reviewable). Verifying the privilege tightening against a throwaway Postgres on an empty data dir — app user must failCREATE TABLE, migrate user must succeed, and the app user must still be able to DML a migrate-created table via default privileges.Fixed on
main(commit8ffef01, merged via9448593) — but not the way the issue specified, because the specified approach is impossible in PostgreSQL.Why the spec couldn't work: the issue's
ALTER ROLE $POSTGRES_USER NOSUPERUSERcrashes the postgres entrypoint on every fresh install. Postgres permanently marks the initdb bootstrap role (which isPOSTGRES_USER) as requiring SUPERUSER and refuses to strip it —ERROR: The bootstrap user must have the SUPERUSER attribute, regardless of which role issues the ALTER. And since a superuser bypasses all GRANT/REVOKE checks, the whole REVOKE/GRANT scaffolding is inert as long as the app connects as the bootstrap role.What shipped instead — stop making the app user the bootstrap role.
POSTGRES_USERis now a dedicated superuserquestboard_admin(break-glass admin, never used by the app or Alembic).init.shcreates two purpose-built roles:questboard_migrate(DDL, owns the schema, used by Alembic andpg_dump) andquestboard(LOGIN, NOSUPERUSER/NOCREATEDB/NOCREATEROLE, DML-only viaDATABASE_URL). No rename hackery, no NOLOGIN roles, break-glass superuser login preserved.Backups fixed in the same change — this was a hidden landmine:
pg_dumpin bothcli.py(backup_now) and the scheduledreminder_tasks.pybackup task built their connection fromsettings.database_url(the app role), which only worked because that role was a superuser. Both now usesettings.database_migrate_url(the schema owner), so backups keep working after the app role is demoted.Verified end-to-end on a throwaway
postgres:16-alpine(empty data dir), independently re-run before merge:questboard_adminrolsuper=t canlogin=t;questboard/questboard_migraterolsuper=falembic upgrade headas the migrate role → successCREATE TABLE→ERROR: permission denied for schema publicSELECTon the real migratedcampaignstable → success (default-privilege propagation confirmed)pg_dump --format=customas the migrate role → exit 0, 94584-byte archive containing all real tablesinit.sqldeleted (was mounted nowhere, hardcoded a password, and held the "tightening" only as a comment).docs/OPERATIONS.mdgains an "Existing Installs" runbook for deployments whose data dir predates this split (init scripts only run once, on an empty data dir): renamequestboard→questboard_adminkeeping superuser, create a fresh non-superuserquestboard, apply the grants.alembic/env.pyalready connects viadatabase_migrate_url— no change needed there.