fix: auth error
This commit is contained in:
+121
-33
@@ -32,16 +32,30 @@ Platform employee roster::
|
||||
PATCH /employees/{user_id} — update a user's profile, status or platform role
|
||||
DELETE /employees/{user_id} — soft delete a user (cascades to workspace memberships)
|
||||
|
||||
Role menu-permission management::
|
||||
|
||||
GET /roles — list platform roles with their permission_codes
|
||||
GET /roles/{role_code}/permissions — one role's permission_codes
|
||||
PATCH /roles/{role_code}/permissions — replace a role's permission set (diff-based)
|
||||
|
||||
Invariants
|
||||
----------
|
||||
|
||||
* Every workspace must always retain at least one active ``admin`` member.
|
||||
This is enforced on member PATCH/DELETE AND on
|
||||
``PATCH /employees/{user_id}`` demotions, because workspace role is
|
||||
inherited from ``users.platform_role_id`` and demoting a platform
|
||||
admin cascades to all of their active memberships.
|
||||
* A system admin cannot remove their own workspace membership via
|
||||
``DELETE .../members/{self}``; the only escape is to delete the entire
|
||||
workspace, which cascades membership soft-deletion.
|
||||
* ``DELETE /workspaces/{id}`` is allowed from any non-disabled status and
|
||||
sets ``status='disabled'`` + ``is_deleted=1`` + ``deleted_at`` on the
|
||||
workspace and every one of its active memberships.
|
||||
* The ``admin`` role must always keep ``system.view`` + ``system.manage``
|
||||
menu permissions; non-admin roles may never hold ``system.*``
|
||||
permissions. Menu permissions gate frontend rendering only — API
|
||||
authorization always keys off ``role_code == 'admin'``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -423,7 +437,15 @@ async def update_platform_employee(
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update a platform user's profile, status, or platform role."""
|
||||
"""Update a platform user's profile, status, or platform role.
|
||||
|
||||
Changing ``role_code`` cascades: every active ``workspace_members``
|
||||
row of the user is rewritten to the new role (workspace role is
|
||||
inherited from the platform role). Demoting admin → developer is
|
||||
rejected with 409 when it would leave any workspace without an
|
||||
active admin member, or the platform without an active system
|
||||
admin. Self-demotion is always rejected.
|
||||
"""
|
||||
user = await session.get(Users, user_id)
|
||||
if user is None or user.is_deleted != 0:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
|
||||
@@ -469,6 +491,53 @@ async def update_platform_employee(
|
||||
"platform 必须保留至少一个 active 系统管理员",
|
||||
)
|
||||
|
||||
# Workspace-level last-admin guard for the demote path. The role_code
|
||||
# sync below rewrites ``workspace_members.role_id`` for every active
|
||||
# membership of this user, so demoting admin → developer would
|
||||
# silently strip workspace admin coverage anywhere this user is the
|
||||
# sole active admin member. ``update_member`` / ``remove_member``
|
||||
# guard the same invariant via ``_count_active_admins``; this
|
||||
# endpoint must too, now that it can change workspace roles.
|
||||
demotes_admin = (
|
||||
is_current_system_admin
|
||||
and new_role is not None
|
||||
and new_role.role_code != "admin"
|
||||
)
|
||||
if demotes_admin:
|
||||
assert current_role is not None # implied by is_current_system_admin
|
||||
admin_memberships = (
|
||||
await session.execute(
|
||||
select(WorkspaceMembers.workspace_id)
|
||||
.where(
|
||||
WorkspaceMembers.user_id == user_id,
|
||||
WorkspaceMembers.role_id == current_role.role_id,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
orphaned: list[str] = []
|
||||
for (ws_id,) in admin_memberships:
|
||||
remaining_ws = await _count_active_admins(
|
||||
session, ws_id, exclude_user_id=user_id,
|
||||
)
|
||||
if remaining_ws == 0:
|
||||
orphaned.append(ws_id)
|
||||
if orphaned:
|
||||
codes = (
|
||||
await session.execute(
|
||||
select(Workspaces.workspace_code).where(
|
||||
Workspaces.workspace_id.in_(orphaned)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
names = sorted(row[0] for row in codes)
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"以下 workspace 将失去唯一 active admin: {names};"
|
||||
"请先在这些 workspace 中指定其他 admin,再降级该用户",
|
||||
)
|
||||
|
||||
if is_self and new_role is not None and new_role.role_code != "admin":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "不能降级自身管理员角色")
|
||||
|
||||
@@ -480,6 +549,21 @@ async def update_platform_employee(
|
||||
user.status = payload.status
|
||||
if new_role is not None:
|
||||
user.platform_role_id = new_role.role_id
|
||||
# Workspace role is always inherited from the platform role
|
||||
# (§7.5/§7.6 cannot change it). Keep workspace_members.role_id
|
||||
# in sync so downstream reads — `/me` workspaces[].role_code,
|
||||
# load_active_membership, §7.7 DELETE last-admin guard —
|
||||
# see the up-to-date role. Without this sync, a user demoted
|
||||
# from admin → developer would still appear as admin in every
|
||||
# workspace they belong to until they leave and re-join.
|
||||
await session.execute(
|
||||
update(WorkspaceMembers)
|
||||
.where(
|
||||
WorkspaceMembers.user_id == user.user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
.values(role_id=new_role.role_id)
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
@@ -760,12 +844,15 @@ async def add_member(
|
||||
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
|
||||
)
|
||||
role = await session.scalar(
|
||||
select(Roles).where(Roles.role_id == user.platform_role_id)
|
||||
select(Roles).where(
|
||||
Roles.role_id == user.platform_role_id,
|
||||
Roles.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"用户的平台角色行不存在",
|
||||
"用户的平台角色行不存在或已被删除",
|
||||
)
|
||||
duplicate = await session.scalar(
|
||||
select(WorkspaceMembers.user_id).where(
|
||||
@@ -777,7 +864,8 @@ async def add_member(
|
||||
if duplicate is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"用户已是该 workspace 成员",
|
||||
"用户已是该 workspace 成员;workspace 角色继承自平台角色,"
|
||||
"要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
|
||||
)
|
||||
membership = WorkspaceMembers(
|
||||
workspace_id=workspace_id,
|
||||
@@ -1006,29 +1094,29 @@ async def patch_role_permissions(
|
||||
) -> dict[str, Any]:
|
||||
"""Replace a platform role's permission set wholesale.
|
||||
|
||||
Guard order is load-bearing (mirrors ``update_platform_employee``):
|
||||
Guard order:
|
||||
|
||||
1. Load the role. Reject 404 if it is missing or not
|
||||
platform-scoped.
|
||||
2. Self-protection: when the caller is modifying the role they
|
||||
themselves hold via ``platform_role_id``, evaluate the
|
||||
post-patch permission set against the last-admin rule below.
|
||||
Putting this check before the last-admin count keeps the
|
||||
test surface stable (see CLAUDE.md line 78).
|
||||
3. Last-admin guard (admin role only): the patched
|
||||
permission_codes MUST still include both ``system.view`` and
|
||||
``system.manage``. Otherwise every active admin loses the
|
||||
entry point to this very endpoint and the platform locks
|
||||
itself out. Reject with 409.
|
||||
2. Admin role: the patched ``permission_codes`` MUST still include
|
||||
both ``system.view`` and ``system.manage``. Otherwise every
|
||||
active admin loses the menu entry to this very endpoint and
|
||||
the platform locks itself out. Reject with 409. (No last-admin
|
||||
count is needed here — menu permissions never gate API access;
|
||||
``system_admin_context`` keys off ``role_code == 'admin'``.)
|
||||
3. Non-admin role: ``system.*`` codes are rejected with 422 —
|
||||
they would render a system-admin menu entry whose API calls
|
||||
all 403.
|
||||
4. Validate every code resolves to a non-deleted ``Permissions``
|
||||
row; unknown codes → 422.
|
||||
5. Write: soft-delete existing ``RolePermissions`` rows for this
|
||||
role, then bulk_insert the new set. Repeat-with-same-payload
|
||||
is idempotent (inserts after the soft-delete).
|
||||
5. Write: diff-based. Only soft-delete codes leaving the set,
|
||||
only insert codes entering it. The ``(role_id, permission_id)``
|
||||
PRIMARY KEY still occupies soft-deleted rows, so a blanket
|
||||
"delete-all then insert-all" would IntegrityError.
|
||||
Repeat-with-same-payload is a no-op.
|
||||
"""
|
||||
role = await _load_platform_role_by_code(session, role_code)
|
||||
|
||||
is_self = context.user.platform_role_id == role.role_id
|
||||
new_codes = list(dict.fromkeys(payload.permission_codes))
|
||||
|
||||
if role.role_code == "admin":
|
||||
@@ -1040,21 +1128,21 @@ async def patch_role_permissions(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"admin 角色必须保留 system.view 与 system.manage 权限",
|
||||
)
|
||||
# Last-admin safety: if removing any current system.* permission
|
||||
# would leave zero active admins able to reach this endpoint,
|
||||
# reject. In practice the "keeps_admin_entry" check above
|
||||
# already covers this for the admin role; the redundant guard
|
||||
# is kept for clarity and as a safety net if a future schema
|
||||
# change adds new admin-only permissions.
|
||||
if is_self:
|
||||
remaining = await _count_active_system_admins(
|
||||
session, exclude_user_id=context.user.user_id,
|
||||
else:
|
||||
# Menu permissions are a frontend-display signal only — backend
|
||||
# authorization keeps keying off role_code == "admin". Letting a
|
||||
# non-admin role hold system.* permissions would render the
|
||||
# system-admin entry in the developer's UI while every
|
||||
# /api/v1/platform/* call still returns 403. Reject with 422 so
|
||||
# the failure is unambiguous about *what* the input violated.
|
||||
leaked_system = [
|
||||
code for code in new_codes if code.startswith("system.")
|
||||
]
|
||||
if leaked_system:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
f"非 admin 角色不能拥有 system.* 权限: {leaked_system}",
|
||||
)
|
||||
if remaining == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"platform 必须保留至少一个 active 系统管理员",
|
||||
)
|
||||
|
||||
# 4. Validate every requested permission_code exists and is live.
|
||||
if new_codes:
|
||||
|
||||
Reference in New Issue
Block a user