feat: updated saas platform
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""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"
|
||||
|
||||
# Tables whose rows belong strictly to one workspace. A NULL tenant_id here means
|
||||
# a platform-level row — a superadmin account — which a workspace must not see.
|
||||
STRICT = ("users", "tenant_modules")
|
||||
|
||||
# Tables where a NULL tenant_id means "shared across workspaces" and is meant to
|
||||
# be readable. `roles` is the real case: `UserService.update_user` deliberately
|
||||
# permits assigning a role with no tenant, so a workspace user can hold one, and
|
||||
# loading `user.role` has to be able to see it.
|
||||
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")
|
||||
# Asymmetric on purpose. Reading a shared row is fine; *creating* one is
|
||||
# not — without that asymmetry a workspace could insert a role with no
|
||||
# tenant and hand it to anybody, which is an escalation rather than a
|
||||
# convenience.
|
||||
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")
|
||||
Reference in New Issue
Block a user