fix: stop the application database role reusing the admin password

docker-compose.yml passed POSTGRES_PASSWORD as APP_DB_PASSWORD, so the DML-only
dtf_app role and the owning administrator shared one credential and the
privilege separation bootstrap.py sets up was decorative.

APP_DB_PASSWORD is now its own required variable, and bootstrap refuses to run
when it matches the administrator password, in both the URL and discrete-field
configuration forms.

Deploying this requires APP_DB_PASSWORD to be set in the stack environment
first; db-init rotates the role to it on the same deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-18 18:32:55 -03:00
parent 67aa6c970c
commit 0f16868f2d
2 changed files with 16 additions and 3 deletions

View File

@@ -5,7 +5,9 @@ x-app-environment: &app-environment
DATABASE_HOST: db DATABASE_HOST: db
DATABASE_NAME: dtf DATABASE_NAME: dtf
DATABASE_USER: dtf_app DATABASE_USER: dtf_app
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} # Must differ from POSTGRES_PASSWORD: the runtime role is DML-only, and reusing
# the administrator credential would make that restriction meaningless.
DATABASE_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET} S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
@@ -62,7 +64,7 @@ services:
DATABASE_ADMIN_USER: dtf_admin DATABASE_ADMIN_USER: dtf_admin
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
APP_DB_USER: dtf_app APP_DB_USER: dtf_app
APP_DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD}
networks: [backend] networks: [backend]
deploy: deploy:
replicas: 1 replicas: 1

View File

@@ -1,6 +1,7 @@
"""One-shot schema/role setup. Only this job receives database admin credentials.""" """One-shot schema/role setup. Only this job receives database admin credentials."""
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
import psycopg import psycopg
from psycopg import sql from psycopg import sql
@@ -16,8 +17,18 @@ def admin_connect():
return psycopg.connect(os.environ['DATABASE_ADMIN_URL']) return psycopg.connect(os.environ['DATABASE_ADMIN_URL'])
def admin_password():
if os.environ.get('DATABASE_ADMIN_HOST'):
return os.environ.get('DATABASE_ADMIN_PASSWORD')
url = os.environ.get('DATABASE_ADMIN_URL', '')
return urlparse(url).password
def main(): def main():
role = os.environ['APP_DB_USER'] role = os.environ['APP_DB_USER']
password = os.environ['APP_DB_PASSWORD']
if password == admin_password():
raise RuntimeError('Application and database administrator passwords must differ')
with admin_connect() as c: with admin_connect() as c:
admin, database = c.execute('SELECT current_user,current_database()').fetchone() admin, database = c.execute('SELECT current_user,current_database()').fetchone()
if role == admin: if role == admin:
@@ -25,7 +36,7 @@ def main():
if not c.execute('SELECT 1 FROM pg_roles WHERE rolname=%s', (role,)).fetchone(): if not c.execute('SELECT 1 FROM pg_roles WHERE rolname=%s', (role,)).fetchone():
c.execute(sql.SQL('CREATE ROLE {} LOGIN').format(sql.Identifier(role))) c.execute(sql.SQL('CREATE ROLE {} LOGIN').format(sql.Identifier(role)))
c.execute(sql.SQL('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format( c.execute(sql.SQL('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format(
sql.Identifier(role), sql.Literal(os.environ['APP_DB_PASSWORD']))) sql.Identifier(role), sql.Literal(password)))
c.execute(Path(__file__).with_name('schema.sql').read_text()) c.execute(Path(__file__).with_name('schema.sql').read_text())
c.execute(sql.SQL('REVOKE CREATE ON SCHEMA public FROM PUBLIC')) c.execute(sql.SQL('REVOKE CREATE ON SCHEMA public FROM PUBLIC'))
c.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role))) c.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role)))