"""Create 4 separate DB roles for strict separation. crm_migration: Schema owner, runs Alembic, BypassRLS - Owns all tables, sequences, functions - Can bypass RLS for migrations - Never used by the API crm_auth: Login bootstrap only - Reads users, user_tenants, tenants, roles, groups - NO RLS on system tables (already disabled) - No general CRM data access crm_api: Application runtime - NOBYPASSRLS, NOSUPERUSER - SELECT, INSERT, UPDATE, DELETE on all tables - Tenant context is mandatory (RLS enforces it) crm_worker: Background jobs - NOBYPASSRLS, NOSUPERUSER - Same data access as crm_api - Tenant context set per job Revision ID: 0070 Revises: 0069 """ from alembic import op from sqlalchemy import text revision = "0070" down_revision = "0069" branch_labels = None depends_on = None def upgrade() -> None: conn = op.get_bind() # 1. Create crm_migration role (schema owner, bypass RLS) conn.execute(text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migration') THEN CREATE ROLE crm_migration WITH LOGIN NOINHERIT; END IF; END $$; """)) conn.execute(text("ALTER ROLE crm_migration WITH BYPASSRLS")) # 2. Create crm_auth role (login bootstrap, no RLS on system tables) conn.execute(text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_auth') THEN CREATE ROLE crm_auth WITH LOGIN NOINHERIT; END IF; END $$; """)) conn.execute(text("ALTER ROLE crm_auth WITH NOBYPASSRLS")) # Grant read access to system tables only conn.execute(text("GRANT SELECT ON users, user_tenants, tenants, roles, user_groups, groups TO crm_auth")) # 3. Create crm_api role (application runtime, NOBYPASSRLS) conn.execute(text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_api') THEN CREATE ROLE crm_api WITH LOGIN NOINHERIT; END IF; END $$; """)) conn.execute(text("ALTER ROLE crm_api WITH NOBYPASSRLS NOSUPERUSER")) # Grant data access on all existing tables conn.execute(text("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_api")) conn.execute(text("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_api")) # Default privileges for future tables conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_api")) conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT USAGE, SELECT ON SEQUENCES TO crm_api")) # 4. Create crm_worker role (background jobs, NOBYPASSRLS) conn.execute(text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_worker') THEN CREATE ROLE crm_worker WITH LOGIN NOINHERIT; END IF; END $$; """)) conn.execute(text("ALTER ROLE crm_worker WITH NOBYPASSRLS NOSUPERUSER")) conn.execute(text("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_worker")) conn.execute(text("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_worker")) conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_worker")) conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT USAGE, SELECT ON SEQUENCES TO crm_worker")) # 5. Grant USAGE on schema to all roles conn.execute(text("GRANT USAGE ON SCHEMA public TO crm_api, crm_worker, crm_auth, crm_migration")) # 6. Set passwords (same as crm_user for now — will be changed in docker-compose) # Passwords are set via environment variables in prestart.sh def downgrade() -> None: conn = op.get_bind() conn.execute(text("DROP ROLE IF EXISTS crm_worker")) conn.execute(text("DROP ROLE IF EXISTS crm_api")) conn.execute(text("DROP ROLE IF EXISTS crm_auth")) conn.execute(text("DROP ROLE IF EXISTS crm_migration"))