73 lines
2.6 KiB
Python
73 lines
2.6 KiB
Python
"""Enforce workspace isolation in the database rather than in every query.
|
|
|
|
Until now isolation was a convention: each query had to remember to filter by
|
|
`tenant_id`. Finding S-5 was one that forgot, and forgetting was possible because
|
|
nothing outside the developer's memory was checking.
|
|
|
|
**This migration is inert until the application connects as a non-owner role.**
|
|
A PostgreSQL superuser bypasses row-level security unconditionally, and the table
|
|
owner does too — which is why every table here is FORCEd, and why
|
|
`scripts/create_app_role.py` exists. Applying this while still connecting as the
|
|
owner turns the policies on for that connection as well, so the code that sets
|
|
`app.tenant_id` has to be in place first. It ships in the same change.
|
|
|
|
Two settings drive the policies:
|
|
|
|
app.tenant_id the workspace, or '' when none is set
|
|
app.bypass_rls 'on' for deliberate cross-workspace work
|
|
|
|
Unset means '' means no rows. A code path that forgets to establish context
|
|
therefore fails loudly rather than quietly reading everyone's data.
|
|
|
|
Revision ID: c3d5e7f9a801
|
|
Revises: b2e1d4f5a602
|
|
"""
|
|
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "c3d5e7f9a801"
|
|
down_revision: Union[str, Sequence[str], None] = "b2e1d4f5a602"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
BYPASS = "current_setting('app.bypass_rls', true) = 'on'"
|
|
CURRENT = "NULLIF(current_setting('app.tenant_id', true), '')::uuid"
|
|
|
|
STRICT = ("users", "tenant_modules")
|
|
|
|
SHARED_NULLS = ("roles", "sso_grants")
|
|
|
|
|
|
def upgrade() -> None:
|
|
for table in STRICT:
|
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
|
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
|
op.execute(
|
|
f"""
|
|
CREATE POLICY tenant_isolation ON {table}
|
|
USING ({BYPASS} OR tenant_id = {CURRENT})
|
|
WITH CHECK ({BYPASS} OR tenant_id = {CURRENT})
|
|
"""
|
|
)
|
|
|
|
for table in SHARED_NULLS:
|
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
|
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
|
op.execute(
|
|
f"""
|
|
CREATE POLICY tenant_isolation ON {table}
|
|
USING ({BYPASS} OR tenant_id IS NULL OR tenant_id = {CURRENT})
|
|
WITH CHECK ({BYPASS} OR tenant_id = {CURRENT})
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in (*STRICT, *SHARED_NULLS):
|
|
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
|
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
|
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|