diff --git a/backend/alembic/versions/0b470d9e6cf1_create_db_roles.py b/backend/alembic/versions/0b470d9e6cf1_create_db_roles.py new file mode 100644 index 00000000..d8d81327 --- /dev/null +++ b/backend/alembic/versions/0b470d9e6cf1_create_db_roles.py @@ -0,0 +1,102 @@ +"""create_db_roles + +Revision ID: 0b470d9e6cf1 +Revises: a9f3b2c1d4e5 +Create Date: 2026-04-10 03:58:10.207919 + +""" +import os +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision: str = '0b470d9e6cf1' +down_revision: Union[str, None] = 'a9f3b2c1d4e5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Passwords from env vars. For local dev, defaults are sufficient. + # For production (Railway), set DB_APP_ROLE_PASSWORD and + # DB_ADMIN_ROLE_PASSWORD as environment variables before running migrations. + # Passwords must not contain single quotes. + app_pw = os.environ.get("DB_APP_ROLE_PASSWORD", "app_secret_change_me") + admin_pw = os.environ.get("DB_ADMIN_ROLE_PASSWORD", "admin_secret_change_me") + + # Fetch the current database name dynamically — avoids hardcoding + # (the DB is named 'resolutionflow' in dev, potentially different elsewhere). + conn = op.get_bind() + db_name = conn.execute(text("SELECT current_database()")).scalar() + + # ── Application role ──────────────────────────────────────────────────── + # Subject to RLS. Used by FastAPI at runtime via DATABASE_URL. + op.execute(f""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_app') THEN + CREATE ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}'; + ELSE + ALTER ROLE resolutionflow_app LOGIN PASSWORD '{app_pw}'; + END IF; + END $$ + """) + op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_app") + op.execute("GRANT USAGE ON SCHEMA public TO resolutionflow_app") + op.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public " + "TO resolutionflow_app" + ) + op.execute( + "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO resolutionflow_app" + ) + # Ensure future tables automatically get the same permissions + op.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public " + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO resolutionflow_app" + ) + op.execute( + "ALTER DEFAULT PRIVILEGES IN SCHEMA public " + "GRANT USAGE, SELECT ON SEQUENCES TO resolutionflow_app" + ) + + # ── Admin role ────────────────────────────────────────────────────────── + # BYPASSRLS. Used by Alembic (DATABASE_URL_SYNC) and /admin/* endpoints + # (ADMIN_DATABASE_URL) after Task 11. + op.execute(f""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'resolutionflow_admin') THEN + CREATE ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}'; + ELSE + ALTER ROLE resolutionflow_admin LOGIN PASSWORD '{admin_pw}'; + END IF; + END $$ + """) + op.execute("GRANT resolutionflow_app TO resolutionflow_admin") + op.execute("ALTER ROLE resolutionflow_admin BYPASSRLS") + op.execute(f"GRANT CONNECT ON DATABASE {db_name} TO resolutionflow_admin") + + +def downgrade() -> None: + conn = op.get_bind() + db_name = conn.execute(text("SELECT current_database()")).scalar() + + op.execute( + "REVOKE ALL ON ALL TABLES IN SCHEMA public FROM resolutionflow_app" + ) + op.execute( + "REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM resolutionflow_app" + ) + op.execute( + f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_app" + ) + op.execute( + f"REVOKE CONNECT ON DATABASE {db_name} FROM resolutionflow_admin" + ) + op.execute("DROP ROLE IF EXISTS resolutionflow_admin") + op.execute("DROP ROLE IF EXISTS resolutionflow_app")