"""B1.3 — row-level security on every tenant-owned table Enables **and forces** RLS on the 15 tables carrying a `tenant_id`, with a policy that compares the row's tenant against a session variable the application sets per request. Why this exists when B1.1 already filters queries: the ORM listener attaches to entity loading, so it cannot filter aggregates — `query(X).count()` compiles to `SELECT count(*) FROM (SELECT ...)` and comes back unfiltered. RLS applies in the database regardless of how the query was composed, so it closes a hole the listener structurally cannot. FORCE matters. Without it the policy does not apply to the table's owner, and migrations run as the owner — so the tables would look protected while the application, if it ever connected as that role, saw everything. This migration is inert until the application sets `docqube.tenant_id`, and takes effect fully only once the app connects as a NOSUPERUSER / NOBYPASSRLS role (B1.4). A superuser connection bypasses RLS unconditionally. Revision ID: b1_3_row_level_security Revises: b1_0_explicit_superadmin """ from alembic import op revision = "b1_3_row_level_security" down_revision = "b1_0_explicit_superadmin" branch_labels = None depends_on = None POLICY = "docqube_tenant_isolation" # Derived from the schema when this was written; asserted against # information_schema by tests/probes/test_rls.py so drift is caught rather than # assumed. TENANT_TABLES = [ "activity_logs", "chat_sessions", "chatbot_documents", "drive_activities", "drive_files", "drive_folders", "notifications", "projects", "roles", "signing_requests", "tenant_signature_configs", "tenant_smtp_configs", "tenant_storage_configs", "user_files", "users", ] # The predicate, in words: # * `docqube.bypass = 'on'` — an explicit system operation (migrations, # background sweeps). Only the application can set this; it is not reachable # from user input. # * the row has no tenant — shared system records such as global roles. # * the row's tenant matches the session variable. # # With no variable set, `current_setting(..., true)` returns NULL, `NULLIF` # keeps it NULL, and `tenant_id = NULL` is NULL — so only shared rows are # visible. Losing the tenant denies rather than reveals. USING = """ coalesce(current_setting('docqube.bypass', true), '') = 'on' OR tenant_id IS NULL OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid """ def upgrade() -> None: for table in TENANT_TABLES: op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") op.execute( f"CREATE POLICY {POLICY} ON {table} " f"USING ({USING}) WITH CHECK ({USING})" ) def downgrade() -> None: for table in TENANT_TABLES: op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")