feat(backend): add USER_MANAGE permission + delta migration (D-015)
Adds `Permission.USER_MANAGE = "user.manage"` to the F11 matrix. rt_lead already holds ALL_PERMISSIONS so GROUP_PERMISSIONS is unchanged — rt_lead gets the new permission automatically, rt_operator and soc_analyst get 403. Alembic migration `202605230001_add_user_manage_permission`: - inserts the `user.manage` row into `permission`, - inserts the `(rt_lead, user.manage)` link into `group_permission`, - exposes `_DELTA_PERMISSIONS` / `_DELTA_GROUP_PERMISSIONS` for parity tests. The previous `test_frozen_*_matches_runtime` invariant (MA3) is generalised to "runtime = initial frozen ∪ deltas of every migration in `_DELTAS`". New migrations register themselves there without editing the historical one. Verbatim wording from spec-analyst is recorded as D-015 in `tasks/spec-decisions.md` (separate commit).
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""add `user.manage` permission + link to rt_lead (D-015)
|
||||
|
||||
Revision ID: 202605230001
|
||||
Revises: 202605210001
|
||||
Create Date: 2026-05-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import NAMESPACE_DNS, uuid5
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "202605230001"
|
||||
down_revision: str | None = "202605210001"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
_PERMISSION_CODE = "user.manage"
|
||||
_GROUP_NAME = "rt_lead"
|
||||
|
||||
# Frozen delta exposed for the migration-seed parity test
|
||||
# (see tests/unit/test_migration_seed.py). The runtime matrix must equal the
|
||||
# union of the initial migration freeze + every subsequent migration delta.
|
||||
_DELTA_PERMISSIONS: tuple[str, ...] = (_PERMISSION_CODE,)
|
||||
_DELTA_GROUP_PERMISSIONS: dict[str, frozenset[str]] = {
|
||||
_GROUP_NAME: frozenset({_PERMISSION_CODE}),
|
||||
}
|
||||
|
||||
|
||||
def _pid(code: str) -> str:
|
||||
"""Same hashing scheme as the initial-schema seed (frozen scheme)."""
|
||||
return str(uuid5(NAMESPACE_DNS, f"mimic.permission.{code}"))
|
||||
|
||||
|
||||
def _gid(name: str) -> str:
|
||||
return str(uuid5(NAMESPACE_DNS, f"mimic.group.{name}"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
permission_table = sa.table(
|
||||
"permission",
|
||||
sa.column("id", UUID(as_uuid=True)),
|
||||
sa.column("code", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
)
|
||||
op.bulk_insert(
|
||||
permission_table,
|
||||
[{"id": _pid(_PERMISSION_CODE), "code": _PERMISSION_CODE, "description": None}],
|
||||
)
|
||||
|
||||
group_permission_table = sa.table(
|
||||
"group_permission",
|
||||
sa.column("group_id", UUID(as_uuid=True)),
|
||||
sa.column("permission_id", UUID(as_uuid=True)),
|
||||
)
|
||||
op.bulk_insert(
|
||||
group_permission_table,
|
||||
[{"group_id": _gid(_GROUP_NAME), "permission_id": _pid(_PERMISSION_CODE)}],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.exec_driver_sql(
|
||||
sa.text("DELETE FROM group_permission WHERE permission_id = :pid").bindparams(
|
||||
pid=_pid(_PERMISSION_CODE)
|
||||
)
|
||||
)
|
||||
bind.exec_driver_sql(
|
||||
sa.text("DELETE FROM permission WHERE code = :code").bindparams(code=_PERMISSION_CODE)
|
||||
)
|
||||
@@ -54,6 +54,9 @@ class Permission(enum.StrEnum):
|
||||
# Audit
|
||||
AUDIT_READ = "audit.read"
|
||||
|
||||
# User management (D-015): gates all /api/v1/users CRUD. rt_lead only.
|
||||
USER_MANAGE = "user.manage"
|
||||
|
||||
|
||||
ALL_PERMISSIONS: tuple[Permission, ...] = tuple(Permission)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user