Files
docqube_backend/alembic/versions/c5_0_access_groups.py
T
2026-09-08 11:00:05 +05:30

162 lines
6.3 KiB
Python

"""C5.1 — access groups, and grants that can name one
Two new tables, and one genuinely risky change: `user_roles.user_id` becomes
nullable, so a grant can name a **group** instead.
That weakens an invariant every existing query relied on. Four readers exist and
all four were reviewed before this migration was written:
* `app/core/scope.py` — resolution; gains a group branch
* `app/modules/auth/services/permission_service.py` — the coarse gate; likewise
* `app/modules/org/routes/org_routes.py` — lists a user's own grants; unchanged
behaviour, it simply will not list grants a user holds *through* a group
* `app/modules/org/services/access_maintenance.py` — the expiry sweeper; its
audit entry uses the principal, which may now be a group
The `CHECK` is what keeps the pair honest. "Exactly one of two nullable columns"
is the kind of invariant that survives in the schema and rots in code — one new
call site that sets both, and the resolver counts the grant twice.
Existing rows all have `user_id`, so the constraint holds the moment it is
added, and nobody's authority changes.
Revision ID: c5_0_access_groups
Revises: c4_1_access_log_retention
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "c5_0_access_groups"
down_revision = "c4_1_access_log_retention"
branch_labels = None
depends_on = None
TENANT_TABLES = ["access_groups", "user_access_groups"]
POLICY = "tenant_isolation"
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:
op.create_table(
"access_groups",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("tenant_id", "name", name="uq_access_groups_tenant_name"),
)
op.create_index("ix_access_groups_tenant_id", "access_groups", ["tenant_id"])
op.create_table(
"user_access_groups",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"group_id",
UUID(as_uuid=True),
sa.ForeignKey("access_groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "group_id", name="uq_user_access_groups"),
)
op.create_index("ix_user_access_groups_tenant_id", "user_access_groups", ["tenant_id"])
op.create_index("ix_user_access_groups_user_id", "user_access_groups", ["user_id"])
op.create_index("ix_user_access_groups_group_id", "user_access_groups", ["group_id"])
op.create_index(
"ix_user_access_groups_lookup", "user_access_groups", ["user_id", "group_id"]
)
# The risky half.
op.add_column(
"user_roles",
sa.Column(
"group_id",
UUID(as_uuid=True),
# CASCADE: deleting a group takes its grants with it. A grant whose
# principal no longer exists is a row nobody can see, revoke or
# audit — and it must not keep applying.
sa.ForeignKey("access_groups.id", ondelete="CASCADE"),
nullable=True,
),
)
op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=True)
op.create_index("ix_user_roles_group_id", "user_roles", ["group_id"])
op.create_check_constraint(
"ck_user_roles_exactly_one_principal",
"user_roles",
"(user_id IS NOT NULL) <> (group_id IS NOT NULL)",
)
# The tenant-wide uniqueness guard was written for user grants. Group grants
# need the same protection, and the original index cannot serve both
# because `user_id` is now null on half the rows.
op.create_index(
"uq_user_roles_group_tenant_wide",
"user_roles",
["group_id", "role_id"],
unique=True,
postgresql_where=sa.text("org_unit_id IS NULL AND group_id IS NOT NULL"),
)
op.create_index(
"uq_user_roles_group_scoped",
"user_roles",
["group_id", "role_id", "org_unit_id"],
unique=True,
postgresql_where=sa.text("group_id IS NOT NULL"),
)
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} USING ({USING}) WITH CHECK ({USING})")
def downgrade() -> None:
for table in TENANT_TABLES:
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
# Group grants cannot survive the column being dropped, and they cannot be
# converted into user grants either — the whole point is that they name no
# single user. Removing them is the only honest reverse.
op.execute("DELETE FROM user_roles WHERE group_id IS NOT NULL")
op.drop_index("uq_user_roles_group_scoped", table_name="user_roles")
op.drop_index("uq_user_roles_group_tenant_wide", table_name="user_roles")
op.drop_constraint("ck_user_roles_exactly_one_principal", "user_roles", type_="check")
op.drop_index("ix_user_roles_group_id", table_name="user_roles")
op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=False)
op.drop_column("user_roles", "group_id")
op.drop_table("user_access_groups")
op.drop_table("access_groups")