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:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
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}
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||||
# Migration/DDL uses crm_user (owner, can bypass RLS for DDL)
|
# Migration/DDL uses crm_migration (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_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}
|
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD}@redis:6379/0}
|
||||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)}
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://localhost:5173}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://localhost:5173}
|
||||||
|
|||||||
Reference in New Issue
Block a user