diff --git a/migrations/versions/f6a7b8c9d0e1_seed_role_permissions_and_fix_scope.py b/migrations/versions/f6a7b8c9d0e1_seed_role_permissions_and_fix_scope.py new file mode 100644 index 0000000..36d8118 --- /dev/null +++ b/migrations/versions/f6a7b8c9d0e1_seed_role_permissions_and_fix_scope.py @@ -0,0 +1,167 @@ +"""Seed platform permissions + role_permissions, fix admin/developer role_scope. + +The squashed baseline (d4e5f6a7b8c9) ships the Permissions and +RolePermissions tables empty, and seeds admin/developer with +role_scope='workspace' (an early mistake; the codebase elsewhere treats +both as platform-scoped — see backend/platform.py::system_admin_context +and common/auth/membership.py::resolve_is_system_admin). This migration: + + 1. UPDATE roles SET role_scope='platform' for admin/developer rows. + 2. INSERT 12 permission rows covering the menu groups the frontend + consumes (dashboard / script / schedule / experiment / resource / + system). + 3. INSERT role_permissions join rows: admin gets all 12, developer + gets the 6 `*.own` / personal-resource codes. + +permission_id values are deterministic (sha256 of code) so a +downgrade → upgrade cycle is idempotent on the uk_permissions_code +unique index. Downgrade soft-deletes (is_deleted=1, deleted_at=now) +the rows this migration inserted; it does NOT revert role_scope +because that fix is not safely reversible once app code has touched +the rows. + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 (ensure_demo_login) +Create Date: 2026-08-07 +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +# Hardcoded to match d4e5f6a7b8c9_squashed_baseline.py seed values, so the +# role_permissions join rows below resolve against the right role rows. +ADMIN_ROLE_ID = "0000000000000000000000000A" +DEVELOPER_ROLE_ID = "0000000000000000000000000B" + +PERMISSIONS: list[tuple[str, str, str]] = [ + # (permission_code, permission_name, module_code) + ("dashboard.view", "查看工作台", "dashboard"), + ("script.build", "构建脚本", "script"), + ("script.public.manage", "管理公共脚本", "script"), + ("schedule.own", "管理本人调度", "schedule"), + ("schedule.all", "管理全部调度", "schedule"), + ("experiment.own", "管理本人实验", "experiment"), + ("experiment.all", "管理全部实验", "experiment"), + ("resource.personal", "管理个人资源", "resource"), + ("resource.public.upload", "上传公共资源", "resource"), + ("resource.public.manage", "管理公共资源", "resource"), + ("system.view", "查看系统管理", "system"), + ("system.manage", "管理系统配置", "system"), +] + +# developer gets *.own + personal resource only; no system.*, no *.all. +DEVELOPER_PERMISSION_CODES: list[str] = [ + "dashboard.view", + "script.build", + "script.public.manage", + "schedule.own", + "experiment.own", + "resource.personal", +] + +ADMIN_PERMISSION_CODES: list[str] = [code for code, _, _ in PERMISSIONS] + + +def _deterministic_permission_id(code: str) -> str: + """Stable 26-char ULID-shaped id derived from permission_code. + + Mirrors ``migrations/data/migrate_system_json.py::deterministic_legacy_ulid`` + so re-running this migration (or running it after the legacy data + migrator) keeps identical IDs for the same code. + """ + digest = hashlib.sha256( + f"model-platform-permission-v1:{code}".encode("utf-8") + ).digest() + value = int.from_bytes(b"\x00" * 6 + digest[:10], byteorder="big") + encoded = ["0"] * 26 + for index in range(25, -1, -1): + encoded[index] = CROCKFORD_BASE32[value & 31] + value >>= 5 + return "".join(encoded) + + +# revision identifiers, used by Alembic. +revision: str = "f6a7b8c9d0e1" +down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0" + + +def upgrade() -> None: + # (1) role_scope fix: admin/developer were seeded as 'workspace' but + # platform.py::system_admin_context treats them as platform-scoped. + op.execute( + "UPDATE roles SET role_scope = 'platform' " + "WHERE role_code IN ('admin', 'developer') AND is_deleted = 0" + ) + + # (2) Permissions rows. + permissions_table = sa.table( + "permissions", + sa.column("permission_id", sa.CHAR(26)), + sa.column("permission_code", sa.String(128)), + sa.column("permission_name", sa.String(100)), + sa.column("module_code", sa.String(64)), + sa.column("description", sa.String(500)), + ) + perm_id_by_code: dict[str, str] = {} + rows: list[dict[str, str]] = [] + for code, name, module in PERMISSIONS: + pid = _deterministic_permission_id(code) + perm_id_by_code[code] = pid + rows.append( + { + "permission_id": pid, + "permission_code": code, + "permission_name": name, + "module_code": module, + "description": f"platform 菜单权限:{name}", + } + ) + op.bulk_insert(permissions_table, rows) + + # (3) role_permissions join rows. + role_permissions_table = sa.table( + "role_permissions", + sa.column("role_id", sa.CHAR(26)), + sa.column("permission_id", sa.CHAR(26)), + ) + rp_rows: list[dict[str, str]] = [] + for code in ADMIN_PERMISSION_CODES: + rp_rows.append( + { + "role_id": ADMIN_ROLE_ID, + "permission_id": perm_id_by_code[code], + } + ) + for code in DEVELOPER_PERMISSION_CODES: + rp_rows.append( + { + "role_id": DEVELOPER_ROLE_ID, + "permission_id": perm_id_by_code[code], + } + ) + op.bulk_insert(role_permissions_table, rp_rows) + + +def downgrade() -> None: + # Soft-delete what we inserted. The role_scope fix is intentionally + # NOT reverted — the only safe direction is platform, since + # application code already keys off it. + code_list_sql = "(" + ",".join(f"'{c}'" for c in ADMIN_PERMISSION_CODES) + ")" + op.execute( + "UPDATE role_permissions " + "SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP(3) " + f"WHERE permission_id IN (SELECT permission_id FROM permissions " + f"WHERE is_deleted = 0 AND permission_code IN {code_list_sql})" + ) + op.execute( + "UPDATE permissions SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP(3) " + f"WHERE is_deleted = 0 AND permission_code IN {code_list_sql}" + ) \ No newline at end of file