88 lines
3.1 KiB
Python
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")
|