Phase 1: Fix all critical release blockers (B1-B10)
B1: Remove duplicate get_redis() — singleton no longer overwritten B2: Plugin routes now enforce activation status via require_active_plugin() B3: Fix UploadFile ForwardRef error — remove functools.wraps from wrap_plugin_route B4: DMS upload uses true streaming via save_stream() instead of RAM accumulation B5: Worker on_startup registers plugin event handlers + webhook dispatcher B6: Implement send_password_reset_email job, remove raw token logging B7: Webhook SSRF protection (IP validation, no redirects), secret removed from response B8: RLS repair migration 0044 + separate crm_runtime DB user (NOSUPERUSER, NOBYPASSRLS) B9: Fix .env.docker.example AUTH_SECRET → SECRET_KEY B10: Remove Redis default password, remove exposed DB/Redis ports Also: add frontend_url to config, add SMTP settings to .env.docker.example, update prestart.sh to use MIGRATION_DATABASE_URL for alembic.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""RLS repair + separate DB runtime user.
|
||||
|
||||
Revision ID: 0044
|
||||
Revises: 0043
|
||||
Created: 2026-07-26
|
||||
|
||||
This migration:
|
||||
1. Re-discovers ALL tenant-scoped tables and ensures RLS is enabled
|
||||
with FORCE + WITH CHECK (covers tables added after migration 0028).
|
||||
2. Creates a separate ``crm_runtime`` role with NOSUPERUSER and
|
||||
NOBYPASSRLS so the application cannot bypass RLS.
|
||||
3. Grants only DML permissions (SELECT/INSERT/UPDATE/DELETE) to
|
||||
``crm_runtime`` on all tenant-scoped tables.
|
||||
|
||||
IMPORTANT: After this migration, the application's DATABASE_URL must
|
||||
use ``crm_runtime`` (not the superuser) for API and worker containers.
|
||||
Migration/DDL operations continue to use the owner user (crm_user).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
revision = "0044"
|
||||
down_revision = "0043"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _discover_tenant_tables(conn) -> list[str]:
|
||||
"""Return all table names in the public schema that have a tenant_id column."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT table_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND column_name = 'tenant_id' "
|
||||
"ORDER BY table_name"
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
|
||||
def _discover_existing_policies(conn, table_name: str) -> list[str]:
|
||||
"""Return all policy names on *table_name* that contain 'tenant' or 'isolation'."""
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT policyname FROM pg_policies "
|
||||
"WHERE schemaname = 'public' AND tablename = :t "
|
||||
"AND (policyname LIKE '%tenant%' OR policyname LIKE '%isolation%')"
|
||||
),
|
||||
{"t": table_name},
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. RLS Repair: ensure all tenant tables have RLS + WITH CHECK ──
|
||||
tenant_tables = _discover_tenant_tables(conn)
|
||||
logger.info("RLS repair: discovered %d tenant-scoped tables: %s", len(tenant_tables), tenant_tables)
|
||||
|
||||
for table_name in tenant_tables:
|
||||
# Enable RLS
|
||||
op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
|
||||
# Force RLS (applies to table owner too)
|
||||
op.execute(f'ALTER TABLE "{table_name}" FORCE ROW LEVEL SECURITY')
|
||||
|
||||
# Drop existing tenant policies
|
||||
existing_policies = _discover_existing_policies(conn, table_name)
|
||||
for policy_name in existing_policies:
|
||||
op.execute(f'DROP POLICY IF EXISTS "{policy_name}" ON "{table_name}"')
|
||||
logger.info("Dropped policy %s on %s", policy_name, table_name)
|
||||
|
||||
# Create unified tenant isolation policy with WITH CHECK
|
||||
op.execute(
|
||||
f'CREATE POLICY tenant_isolation ON "{table_name}" '
|
||||
f"USING (tenant_id = current_setting('app.tenant_id', true)::uuid) "
|
||||
f"WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid)"
|
||||
)
|
||||
logger.info("Created/updated tenant_isolation policy on %s (USING + WITH CHECK)", table_name)
|
||||
|
||||
# ── 2. Create crm_runtime role (NOSUPERUSER, NOBYPASSRLS) ──
|
||||
# Use DO block for idempotent creation
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DO $$ "
|
||||
"BEGIN "
|
||||
" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_runtime') THEN "
|
||||
" CREATE ROLE crm_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE "
|
||||
" NOREPLICATION NOBYPASSRLS; "
|
||||
" END IF; "
|
||||
"END $$;"
|
||||
)
|
||||
)
|
||||
logger.info("Ensured crm_runtime role exists (NOSUPERUSER, NOBYPASSRLS)")
|
||||
|
||||
# ── 3. Grant DML permissions to crm_runtime on all tenant tables ──
|
||||
for table_name in tenant_tables:
|
||||
op.execute(
|
||||
f'GRANT SELECT, INSERT, UPDATE, DELETE ON "{table_name}" TO crm_runtime'
|
||||
)
|
||||
|
||||
# Grant usage on sequences (for SERIAL/IDENTITY columns)
|
||||
op.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_runtime")
|
||||
|
||||
logger.info("Granted DML permissions to crm_runtime on %d tables", len(tenant_tables))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Revoke permissions from crm_runtime
|
||||
tenant_tables = _discover_tenant_tables(conn)
|
||||
for table_name in tenant_tables:
|
||||
op.execute(f'REVOKE SELECT, INSERT, UPDATE, DELETE ON "{table_name}" FROM crm_runtime')
|
||||
op.execute("REVOKE USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public FROM crm_runtime")
|
||||
|
||||
# Drop crm_runtime role
|
||||
op.execute("DROP ROLE IF EXISTS crm_runtime")
|
||||
logger.info("Dropped crm_runtime role")
|
||||
|
||||
# Note: RLS policies are NOT reverted here to avoid weakening security.
|
||||
# Migration 0028's downgrade handles the original set of tables.
|
||||
Reference in New Issue
Block a user