Files
docqube_backend/alembic/versions/b1_3_row_level_security.py
T
2026-09-08 11:00:05 +05:30

88 lines
3.1 KiB
Python

"""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")