Files
saas_backend/alembic/versions/a4d7f2c9e63b_org_units.py
T

140 lines
6.4 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""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")