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:
Agent Zero
2026-07-26 20:45:42 +02:00
parent 7a14973c68
commit 5ec1fc9b05
32 changed files with 1781 additions and 76 deletions
@@ -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.