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:
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user