phase2: 4 DB roles (crm_migration/api/worker/auth) + docker-compose updated + GRANT USAGE + RLS verified with unprivileged role
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""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"))
|
||||
+3
-3
@@ -76,10 +76,10 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# App/worker uses crm_runtime (NOSUPERUSER, NOBYPASSRLS) — RLS enforced
|
||||
# App/worker uses crm_api (NOSUPERUSER, NOBYPASSRLS) — RLS enforced
|
||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||
# Migration/DDL uses crm_user (owner, can bypass RLS for DDL)
|
||||
MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://${POSTGRES_USER:-crm_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||
# Migration/DDL uses crm_migration (owner, can bypass RLS for DDL)
|
||||
MIGRATION_DATABASE_URL: ${MIGRATION_DATABASE_URL:-postgresql+asyncpg://crm_migration:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-crm_db}}
|
||||
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD}@redis:6379/0}
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://localhost:5173}
|
||||
|
||||
Reference in New Issue
Block a user