140 lines
6.4 KiB
Python
140 lines
6.4 KiB
Python
"""Departments, branches, teams — and administration scoped to one.
|
|
|
|
A workspace is currently flat: everybody who can manage users can manage all of
|
|
them. That is fine for ten people and wrong for a thousand, where the practical
|
|
requirement is "the Lahore branch manager administers the Lahore branch".
|
|
|
|
## What this deliberately is and is not
|
|
|
|
**It is** structure and membership, plus one scoping rule: a person's user
|
|
administration can be confined to a unit and everything under it.
|
|
|
|
**It is not** a scoping dimension on every record in the product. Adding a column
|
|
to every domain table and a filter to every query, in advance of anything needing
|
|
it, is how a platform ends up with a permission model nobody can reason about and
|
|
half the queries quietly ignoring it. The place that needed it is user
|
|
administration, and that is what it covers. When a domain table needs the same,
|
|
the tables here are what it hangs off.
|
|
|
|
## The path column
|
|
|
|
`path` holds the ancestry as a materialised path (`/root-id/child-id/`). A
|
|
recursive CTE would answer "everything under this unit" without it, and would
|
|
cost a recursive scan on every permission check — which is every request an
|
|
administrator makes. A prefix match on an indexed text column is one index scan.
|
|
|
|
The cost is that moving a unit rewrites the paths of its descendants. That is a
|
|
rare, deliberate act, and it is far better to pay there than on every request.
|
|
|
|
Revision ID: a4d7f2c9e63b
|
|
Revises: f3c6e1b8d52a
|
|
"""
|
|
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
|
|
revision: str = "a4d7f2c9e63b"
|
|
down_revision: Union[str, Sequence[str], None] = "f3c6e1b8d52a"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
SCOPED = ("org_units", "user_org_units", "user_admin_scopes")
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"org_units",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()")),
|
|
sa.Column("tenant_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("name", sa.String(160), nullable=False),
|
|
sa.Column("code", sa.String(60), nullable=True),
|
|
sa.Column("parent_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("org_units.id", ondelete="RESTRICT"), nullable=True),
|
|
sa.Column("path", sa.Text(), nullable=False, server_default="/"),
|
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.func.now()),
|
|
sa.UniqueConstraint("tenant_id", "code", name="uq_org_units_code"),
|
|
)
|
|
op.create_index("ix_org_units_tenant", "org_units", ["tenant_id"])
|
|
op.create_index("ix_org_units_parent", "org_units", ["parent_id"])
|
|
op.execute(
|
|
"CREATE INDEX ix_org_units_path ON org_units (path text_pattern_ops)"
|
|
)
|
|
|
|
op.create_table(
|
|
"user_org_units",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()")),
|
|
sa.Column("tenant_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("user_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("org_unit_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
|
|
sa.Column("is_lead", sa.Boolean(), nullable=False, server_default=sa.false()),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.func.now()),
|
|
sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_org_unit"),
|
|
)
|
|
op.create_index("ix_user_org_units_user", "user_org_units", ["user_id"])
|
|
op.create_index("ix_user_org_units_unit", "user_org_units", ["org_unit_id"])
|
|
op.execute(
|
|
"CREATE UNIQUE INDEX uq_user_primary_org_unit ON user_org_units (user_id) "
|
|
"WHERE is_primary"
|
|
)
|
|
|
|
op.create_table(
|
|
"user_admin_scopes",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()")),
|
|
sa.Column("tenant_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("user_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("org_unit_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.func.now()),
|
|
sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_admin_scope"),
|
|
)
|
|
op.create_index("ix_user_admin_scopes_user", "user_admin_scopes", ["user_id"])
|
|
|
|
for table in SCOPED:
|
|
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 (
|
|
current_setting('app.bypass_rls', true) = 'on'
|
|
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
|
)
|
|
WITH CHECK (
|
|
current_setting('app.bypass_rls', true) = 'on'
|
|
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
for table in SCOPED:
|
|
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
|
op.drop_index("ix_user_admin_scopes_user", table_name="user_admin_scopes")
|
|
op.drop_table("user_admin_scopes")
|
|
op.execute("DROP INDEX IF EXISTS uq_user_primary_org_unit")
|
|
op.drop_index("ix_user_org_units_unit", table_name="user_org_units")
|
|
op.drop_index("ix_user_org_units_user", table_name="user_org_units")
|
|
op.drop_table("user_org_units")
|
|
op.execute("DROP INDEX IF EXISTS ix_org_units_path")
|
|
op.drop_index("ix_org_units_parent", table_name="org_units")
|
|
op.drop_index("ix_org_units_tenant", table_name="org_units")
|
|
op.drop_table("org_units")
|