162 lines
6.3 KiB
Python
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")
|