deploy/stack.yaml passes DATABASE_URL_FILE, AWS_ACCESS_KEY_ID_FILE, OPERATOR_PASSWORD_FILE and the provider tokens as Swarm secret paths, but the runtime only ever read the plain names. That stack could not start: the database URL and R2 credentials were absent, and operator login raised KeyError, so it returned 500 instead of the intended 503. local/secrets.py resolves every <NAME>_FILE into <NAME> before configuration is read, from the API, worker and bootstrap entrypoints. It fails closed on an unreadable or empty secret and on a name supplied both directly and as a file, because starting with a credential nobody intended is worse than not starting. Only one trailing newline is stripped, so a generated password keeps any whitespace that belongs to it, and no value reaches an error message. The stack also passed OPERATOR_USER while the Kanban authenticates by email; it now passes OPERATOR_EMAIL, matching the runtime. The release gate checked this by searching local/secrets.py for the literal "DATABASE_URL_FILE", which would pass for any file containing that string. It now loads the module and makes it resolve every secret the stack declares, and asserts it fails closed on a missing one. Four marker strings that stopped matching when R2 support landed are removed rather than left to rot; the two that still describe real blockers stay, so the gate continues to refuse a release while payment and messaging adapters are fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
51 lines
2.3 KiB
Python
51 lines
2.3 KiB
Python
"""One-shot schema/role setup. Only this job receives database admin credentials."""
|
|
import os
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
import psycopg
|
|
from psycopg import sql
|
|
from .secrets import load as load_secret_files
|
|
|
|
|
|
def admin_connect():
|
|
if os.environ.get('DATABASE_ADMIN_HOST'):
|
|
return psycopg.connect(
|
|
host=os.environ['DATABASE_ADMIN_HOST'],
|
|
dbname=os.environ['DATABASE_ADMIN_NAME'],
|
|
user=os.environ['DATABASE_ADMIN_USER'],
|
|
password=os.environ['DATABASE_ADMIN_PASSWORD'],
|
|
)
|
|
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():
|
|
load_secret_files()
|
|
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:
|
|
admin, database = c.execute('SELECT current_user,current_database()').fetchone()
|
|
if role == admin:
|
|
raise RuntimeError('Application and database administrator must differ')
|
|
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('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format(
|
|
sql.Identifier(role), sql.Literal(password)))
|
|
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('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role)))
|
|
c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
|
c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
|
c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
|
|
print('Local schema migrated; runtime role has DML only.')
|
|
|
|
if __name__ == '__main__': main()
|