feat: role CRUD

This commit is contained in:
tao.chen
2026-08-25 22:33:25 +08:00
parent 38e763d08e
commit 90955a2c1d
8 changed files with 1717 additions and 1315 deletions
+13 -1
View File
@@ -123,4 +123,16 @@ Lessons from splitting `frontend/app/features/schedules/state/schedulesStore.ts`
### Frontend coupling
- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated.
- **`UserManagementPage.tsx` and `api.ts` still call `/api/v1/admin/employees`.** Don't migrate them in the same change as a `/api/v1/platform/employees` addition — the contract surface is intentionally duplicated.
### File size: 500 lines hard cap
- **每个文件最多 500 行** — 超过时主动拆分,不要等 review 才动。
- 常见拆分维度:
- **后端 — 按资源 / 端点分组**:例如 `api/platform.py` (1600 行 / 20 endpoint) → `api/platform/{__init__,_deps,employees,roles,workspaces}.py`。原文件改为 thin shim,只 re-export 公共符号,保持 `from backend.api.platform import router` 等既有 import path 不变。
- **后端 — 按层级**:参考 `schedule/` 已有的 `domain/` / `application/` / `infrastructure/` 分层。
- **前端 — 按职责**:`types.ts` / `helpers.ts` / `slices/` / `useXStore.ts`(参考 `useSchedulesStore` 拆 8-slice 的纪律)。
- **前端 — 页面 vs 路由 wrapper**:Page 组件持有 useState,route 文件只做 `<Page key={...} />` 重挂载,二者不要混在一起。
- **拆分前先列调用面**(`grep "from <old_path>"`),任何外部 import 路径必须仍然可用 — 用 re-export 或 shim 兜底,不要让调用方被迫改。
- **拆分后**每个新文件 ≤ 500 行是硬约束,验证方式:`wc -l <file>` 或 CI 脚本。
- 拆分本身是**纯结构调整**,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
"""系统级管理接口。
中文导读:本模块管理全平台的用户、工作区、成员关系和角色权限。它使用
``system_admin_context`` 进行平台管理员校验,因此不要求请求者先加入某个具体
工作区;普通工作区内的业务接口则使用 ``request_context``。
System-admin (platform-scope) endpoints for workspace & membership management.
All routes under ``/api/v1/platform/*`` are gated by
:func:`system_admin_context`, which requires the requester to hold a
``Users.platform_role_id`` pointing to a ``Roles`` row whose
``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``,
this dependency does NOT require an active workspace membership — system
admins can manage workspaces before/without being a member of any.
Endpoints
---------
Workspace CRUD::
GET /workspaces — list non-deleted workspaces
POST /workspaces — create a new workspace
GET /workspaces/{workspace_id} — single workspace (incl. disabled)
PATCH /workspaces/{workspace_id} — update editable fields
DELETE /workspaces/{workspace_id} — soft delete (cascades memberships)
Workspace membership CRUD::
GET /workspaces/{workspace_id}/members — list active members
POST /workspaces/{workspace_id}/members — add a member
PATCH /workspaces/{workspace_id}/members/{user_id} — update role/status
DELETE /workspaces/{workspace_id}/members/{user_id} — remove a member
Platform employee roster::
GET /employees — list all non-deleted users
POST /employees — create a user without workspace membership
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``.
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
from fastapi import APIRouter
from backend.api.platform._deps import (
SystemAdminContext,
system_admin_context,
)
from backend.api.platform.employees import router as employees_router
from backend.api.platform.roles import router as roles_router
from backend.api.platform.workspaces import router as workspaces_router
# 聚合 router,所有 endpoint 都挂在 /api/v1/platform 下。每个子 router 自带
# prefix="/api/v1/platform",这里 include_router 不加 prefix,路径保持不变。
router = APIRouter()
router.include_router(employees_router)
router.include_router(workspaces_router)
router.include_router(roles_router)
# 保留 system_admin_context 的 re-export,供其他文件使用
# (之前从 backend.api.platform 导出)。
__all__ = [
"SystemAdminContext",
"router",
"system_admin_context",
]
+148
View File
@@ -0,0 +1,148 @@
"""Shared dependencies & helpers for the platform admin API.
Centralizes the ``system_admin_context`` gate plus cross-resource helpers so the
resource modules (employees / workspaces / roles) stay focused on their endpoints.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from common.db.models import Roles, Users, WorkspaceMembers
from common.ids import new_ulid
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import current_user, database_session
# ---------------------------------------------------------------------------
# System-admin context dependency
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SystemAdminContext:
"""通过系统管理员校验后的上下文,只包含当前用户和请求追踪 ID。"""
request_id: str
user: Users
platform_role: Roles
async def system_admin_context(
request: Request,
session: AsyncSession = Depends(database_session),
) -> SystemAdminContext:
"""验证当前用户是否为平台管理员,供 /api/v1/platform 下的路由依赖。"""
user = await current_user(request, session)
if user.platform_role_id is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"需要系统管理员权限",
)
platform_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role is None or platform_role.role_code != "admin":
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"需要系统管理员权限",
)
request_id = request.headers.get("X-Request-ID") or new_ulid()
return SystemAdminContext(
request_id=request_id,
user=user,
platform_role=platform_role,
)
async def _is_system_admin(session: AsyncSession, user: Users) -> bool:
"""True if ``user`` holds the platform-scoped admin role.
Mirrors the check inside :func:`system_admin_context` so member-listing
endpoints can admit workspace members *or* system admins without pulling
in the full :class:`SystemAdminContext` (which 403s non-admins outright).
"""
if user.platform_role_id is None:
return False
platform_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return platform_role is not None and platform_role.role_code == "admin"
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _load_role_by_code(session: AsyncSession, role_code: str) -> Roles:
role = await session.scalar(select(Roles).where(Roles.role_code == role_code))
if role is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"角色 {role_code} 不存在",
)
return role
async def _count_active_admins(
session: AsyncSession,
workspace_id: str,
exclude_user_id: str | None = None,
) -> int:
"""Count active admin members of ``workspace_id``.
Pass ``exclude_user_id`` when checking "would X be the last admin?"
before mutating X.
"""
admin_role = await _load_role_by_code(session, "admin")
stmt = (
select(func.count())
.select_from(WorkspaceMembers)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.role_id == admin_role.role_id,
WorkspaceMembers.member_status == "active",
WorkspaceMembers.is_deleted == 0,
)
)
if exclude_user_id is not None:
stmt = stmt.where(WorkspaceMembers.user_id != exclude_user_id)
return int(await session.scalar(stmt) or 0)
async def _count_active_system_admins(
session: AsyncSession,
exclude_user_id: str | None = None,
) -> int:
"""Count active system admins across the platform.
Pass ``exclude_user_id`` when checking "would X be the last admin?"
before mutating X.
"""
admin_role = await _load_role_by_code(session, "admin")
stmt = (
select(func.count())
.select_from(Users)
.where(
Users.status == "active",
Users.is_deleted == 0,
Users.platform_role_id == admin_role.role_id,
)
)
if exclude_user_id is not None:
stmt = stmt.where(Users.user_id != exclude_user_id)
return int(await session.scalar(stmt) or 0)
def _envelope(request_id: str, data: Any, meta: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"request_id": request_id,
"data": data,
"meta": meta or {},
}
@@ -0,0 +1,343 @@
"""Platform employee roster endpoints.
CRUD for platform users (``/employees``). Role-code changes cascade to every active
``workspace_members`` row because workspace role is inherited from the platform role.
"""
from __future__ import annotations
import datetime
from typing import Any, Literal
from common.auth.passwords import hash_password
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import database_session
from backend.api.platform._deps import (
SystemAdminContext,
_count_active_admins,
_count_active_system_admins,
_envelope,
_load_role_by_code,
system_admin_context,
)
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
# 新建平台用户的请求体;创建用户不等同于把用户加入某个工作区。
class PlatformEmployeeCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(min_length=2, max_length=64)
display_name: str = Field(min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=255)
password: str = Field(min_length=8, max_length=72)
role_code: Literal["admin", "developer"] | None = None
# 修改平台用户资料、状态或平台角色的请求体。
class PlatformEmployeeUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=255)
status: Literal["active", "disabled", "locked"] | None = None
role_code: Literal["admin", "developer"] | None = None
# ---------------------------------------------------------------------------
# Payload helpers
# ---------------------------------------------------------------------------
def platform_employee_payload(
user: Users,
role: Roles | None,
) -> dict[str, Any]:
return {
"user_id": user.user_id,
"username": user.username,
"display_name": user.display_name,
"email": user.email,
"status": user.status,
"role_code": role.role_code if role is not None else None,
"role_name": role.role_name if role is not None else None,
"created_at": user.created_at.isoformat(),
}
# ---------------------------------------------------------------------------
# Platform employee roster
# ---------------------------------------------------------------------------
# 列出整个平台的非删除用户;不局限于某一个工作区。
@router.get("/employees")
async def list_platform_employees(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List every non-soft-deleted platform user."""
rows = (
await session.execute(
select(Users, Roles)
.outerjoin(Roles, Roles.role_id == Users.platform_role_id)
.where(Users.is_deleted == 0)
.order_by(Users.created_at, Users.user_id)
)
).all()
return _envelope(
context.request_id,
[platform_employee_payload(user, role) for user, role in rows],
{"count": len(rows)},
)
# 创建平台用户;后续可再通过成员接口把该用户加入工作区。
@router.post("/employees", status_code=status.HTTP_201_CREATED)
async def create_platform_employee(
payload: PlatformEmployeeCreate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Create a platform user without assigning workspace membership."""
username = payload.username.strip()
display_name = payload.display_name.strip()
duplicate_conditions = [Users.username == username]
if payload.email:
duplicate_conditions.append(Users.email == payload.email.strip())
duplicate = await session.scalar(
select(Users.user_id).where(or_(*duplicate_conditions))
)
if duplicate is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在")
new_role: Roles | None = None
if payload.role_code is not None:
new_role = await _load_role_by_code(session, payload.role_code)
user = Users(
user_id=new_ulid(),
username=username,
display_name=display_name,
email=payload.email.strip() if payload.email else None,
password_hash=hash_password(payload.password),
status="active",
platform_role_id=new_role.role_id if new_role is not None else None,
)
session.add(user)
await session.flush()
await session.refresh(user)
return _envelope(
context.request_id,
platform_employee_payload(user, new_role),
)
# 更新平台用户资料、账号状态或平台角色,同时保护最少管理员等约束。
@router.patch("/employees/{user_id}")
async def update_platform_employee(
user_id: str,
payload: PlatformEmployeeUpdate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""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, "用户不存在")
is_self = user_id == context.user.user_id
if (
is_self
and payload.status is not None
and payload.status != "active"
):
raise HTTPException(status.HTTP_409_CONFLICT, "不能停用当前登录账号")
current_role: Roles | None = None
if user.platform_role_id is not None:
current_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
is_current_system_admin = (
user.status == "active"
and current_role is not None
and current_role.role_code == "admin"
)
new_role: Roles | None = None
if payload.role_code is not None:
new_role = await _load_role_by_code(session, payload.role_code)
next_status = payload.status if payload.status is not None else user.status
leaves_admin_pool = (
is_current_system_admin
and (
next_status != "active"
or (new_role is not None and new_role.role_code != "admin")
)
)
if leaves_admin_pool:
remaining = await _count_active_system_admins(
session, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"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, "不能降级自身管理员角色")
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.email is not None:
user.email = payload.email.strip() or None
if payload.status is not None:
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)
response_role: Roles | None = None
if user.platform_role_id is not None:
response_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return _envelope(
context.request_id, platform_employee_payload(user, response_role),
)
# 软删除平台用户,并级联标记其工作区成员关系为删除。
@router.delete("/employees/{user_id}")
async def delete_platform_employee(
user_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft delete a platform user and cascade-soft-delete workspace memberships."""
user = await session.get(Users, user_id)
if user is None or user.is_deleted != 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
if user_id == context.user.user_id:
raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录账号")
if user.status == "active" and user.platform_role_id is not None:
current_admin_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if current_admin_role is not None and current_admin_role.role_code == "admin":
remaining = await _count_active_system_admins(
session, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"platform 必须保留至少一个 active 系统管理员",
)
now = datetime.datetime.utcnow()
user.status = "disabled"
user.is_deleted = 1
user.deleted_at = now
await session.execute(
update(WorkspaceMembers)
.where(
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
.values(is_deleted=1, deleted_at=now)
)
await session.flush()
return _envelope(
context.request_id,
{"user_id": user_id, "deleted": True},
)
+499
View File
@@ -0,0 +1,499 @@
"""Platform role & permission management endpoints.
Seven endpoints: role list/CRUD, per-role permission get/patch, and the permission
catalog; ``_apply_role_permission_set`` is shared by PATCH /roles/{code}/permissions and POST /roles.
"""
from __future__ import annotations
import datetime
import re
from typing import Any
from common.db.models import (
Permissions,
RolePermissions,
Roles,
Users,
WorkspaceMembers,
)
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import database_session
from backend.api.platform._deps import (
SystemAdminContext,
_envelope,
system_admin_context,
)
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
RESERVED_PLATFORM_ROLE_CODES = frozenset({"admin", "developer"})
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{1,63}$")
class RolePermissionsPatch(BaseModel):
"""Replace a platform role's permission set wholesale.
Empty list is allowed (revokes all permissions) for non-`admin`
roles. The PATCH endpoint rejects emptying an `admin` role of its
system.* permissions; see ``patch_role_permissions`` for the
load-bearing guard order.
"""
model_config = ConfigDict(extra="forbid")
permission_codes: list[str] = Field(default_factory=list, max_length=64)
class RoleCreate(BaseModel):
"""Create a platform role.
``role_code`` must not collide with the reserved built-in codes
(``admin`` / ``developer``); the validator rejects those with 422.
``permission_codes`` is optional — an empty list means the role
starts with no menu permissions.
"""
model_config = ConfigDict(extra="forbid")
role_code: str = Field(min_length=2, max_length=64)
role_name: str = Field(min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
permission_codes: list[str] = Field(default_factory=list, max_length=64)
@field_validator("role_code")
@classmethod
def _validate_role_code(cls, value: str) -> str:
if value in RESERVED_PLATFORM_ROLE_CODES:
raise ValueError(f"role_code {value!r} 是预留字")
if not ROLE_CODE_PATTERN.match(value):
raise ValueError("role_code 必须以小写字母开头,仅含小写字母/数字/下划线/连字符")
return value
class RoleUpdate(BaseModel):
"""Update a platform role's ``role_name`` / ``description`` only.
``role_code`` is intentionally absent — it is the URL key and is
backed by a unique index; ``extra="forbid"`` rejects any attempt to
send it. ``description`` accepts an explicit ``null`` (clear the
value); omitting it leaves it unchanged.
"""
model_config = ConfigDict(extra="forbid")
role_name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
async def _load_platform_role_by_code(
session: AsyncSession, role_code: str
) -> Roles:
"""Load a platform-scoped role by code; 404 if missing or not platform-scope."""
role = await session.scalar(
select(Roles).where(
Roles.role_code == role_code, Roles.is_deleted == 0,
)
)
if role is None or role.role_scope != "platform":
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"platform 角色 {role_code} 不存在",
)
return role
async def _load_role_permission_codes(
session: AsyncSession, role_id: str
) -> list[str]:
"""Return the active permission_codes for a role, ordered by code."""
rows = (
await session.execute(
select(Permissions.permission_code)
.join(
RolePermissions,
RolePermissions.permission_id == Permissions.permission_id,
)
.where(
RolePermissions.role_id == role_id,
RolePermissions.is_deleted == 0,
Permissions.is_deleted == 0,
)
.order_by(Permissions.permission_code)
)
).all()
return [row[0] for row in rows]
def _role_payload(role: Roles, permission_codes: list[str]) -> dict[str, Any]:
return {
"role_id": role.role_id,
"role_code": role.role_code,
"role_name": role.role_name,
"is_builtin": bool(role.is_builtin),
"permission_codes": permission_codes,
# 加法字段:POST/PATCH/GET 都自动带上 description,便于前端展示。
"description": role.description,
}
@router.get("/roles")
async def list_platform_roles(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List every platform-scoped role with its current permission_codes."""
roles = (
await session.scalars(
select(Roles)
.where(Roles.role_scope == "platform", Roles.is_deleted == 0)
.order_by(Roles.role_code)
)
).all()
payload = []
for role in roles:
codes = await _load_role_permission_codes(session, role.role_id)
payload.append(_role_payload(role, codes))
return _envelope(
context.request_id, payload, {"count": len(payload)},
)
@router.get("/roles/{role_code}/permissions")
async def get_role_permissions(
role_code: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Return one platform role's permission_codes."""
role = await _load_platform_role_by_code(session, role_code)
codes = await _load_role_permission_codes(session, role.role_id)
return _envelope(
context.request_id, _role_payload(role, codes),
)
async def _apply_role_permission_set(
session: AsyncSession, role: Roles, codes: list[str]
) -> list[str]:
"""Validate ``codes`` then replace the role's permission set wholesale.
Shared by PATCH /roles/{role_code}/permissions and POST /roles so the
guard order is identical no matter the entry point:
1. Admin: must keep ``system:view`` → 409 (menu perms never gate
API access; auth keys off ``role_code == 'admin'``).
2. Non-admin: ``system.*`` codes → 422.
3. Unknown codes → 422.
4. Diff-based soft-delete + insert — the ``(role_id, permission_id)``
PK keeps soft-deleted rows, so delete-all/insert-all would
IntegrityError. Repeat-with-same-set is a no-op.
Returns the role's final permission_codes (after flush).
"""
new_codes = list(dict.fromkeys(codes))
if role.role_code == "admin":
keeps_admin_entry = "system:view" in new_codes
if not keeps_admin_entry:
raise HTTPException(
status.HTTP_409_CONFLICT,
"admin 角色必须保留 system:view 权限",
)
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}",
)
# 3. Validate every requested permission_code exists and is live.
if new_codes:
rows = (
await session.execute(
select(Permissions.permission_code).where(
Permissions.permission_code.in_(new_codes),
Permissions.is_deleted == 0,
)
)
).all()
found = {row[0] for row in rows}
missing = [code for code in new_codes if code not in found]
if missing:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"未知的 permission_code: {missing}",
)
# 4. Write: diff-based soft-delete + insert.
# The (role_id, permission_id) PRIMARY KEY still occupies the slot
# of soft-deleted rows, so a "delete-all then insert-all" approach
# would IntegrityError on any code that was already linked.
# Instead: only soft-delete codes NOT in the new set, only INSERT
# codes NOT already active. Repeat-with-same-payload is a no-op.
now = datetime.datetime.utcnow()
current_codes = set(
await _load_role_permission_codes(session, role.role_id)
)
new_set = set(new_codes)
codes_to_drop = current_codes - new_set
codes_to_add = new_set - current_codes
if codes_to_drop:
# Resolve to permission_ids then soft-delete by id pair.
drop_ids = (
await session.execute(
select(Permissions.permission_id).where(
Permissions.permission_code.in_(codes_to_drop),
Permissions.is_deleted == 0,
)
)
).all()
drop_id_values = [row[0] for row in drop_ids]
await session.execute(
update(RolePermissions)
.where(
RolePermissions.role_id == role.role_id,
RolePermissions.permission_id.in_(drop_id_values),
RolePermissions.is_deleted == 0,
)
.values(is_deleted=1, deleted_at=now)
)
if codes_to_add:
add_ids = (
await session.execute(
select(Permissions.permission_id).where(
Permissions.permission_code.in_(codes_to_add),
Permissions.is_deleted == 0,
)
)
).all()
if add_ids:
await session.execute(
insert(RolePermissions),
[
{"role_id": role.role_id, "permission_id": pid}
for pid, in add_ids
],
)
await session.flush()
return await _load_role_permission_codes(session, role.role_id)
@router.patch("/roles/{role_code}/permissions")
async def patch_role_permissions(
role_code: str,
payload: RolePermissionsPatch,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Replace a platform role's permission set wholesale.
The guard order and the diff-based write live in
:func:`_apply_role_permission_set`, shared with POST /roles so both
entry points validate identically.
"""
role = await _load_platform_role_by_code(session, role_code)
final_codes = await _apply_role_permission_set(
session, role, payload.permission_codes,
)
return _envelope(
context.request_id, _role_payload(role, final_codes),
)
@router.post("/roles", status_code=status.HTTP_201_CREATED)
async def create_platform_role(
payload: RoleCreate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Create a platform role.
- ``role_code`` validated by the schema (reserved codes + pattern →
422) and must not collide with ``uk_roles_code`` → 409 (incl.
soft-deleted rows that still occupy the index slot).
- Always ``role_scope='platform'``, ``is_builtin=0``.
- Optional ``permission_codes``: empty → no menu permissions; non-empty
applies the same guard order as PATCH via
:func:`_apply_role_permission_set`.
"""
role_code = payload.role_code
duplicate = await session.scalar(
select(Roles.role_id).where(Roles.role_code == role_code)
)
if duplicate is not None:
raise HTTPException(
status.HTTP_409_CONFLICT, f"role_code {role_code!r} 已存在",
)
role = Roles(
role_id=new_ulid(),
role_code=role_code,
role_name=payload.role_name.strip(),
role_scope="platform",
is_builtin=0,
description=payload.description,
)
session.add(role)
await session.flush()
final_codes: list[str] = []
if payload.permission_codes:
final_codes = await _apply_role_permission_set(
session, role, payload.permission_codes,
)
await session.refresh(role)
return _envelope(
context.request_id, _role_payload(role, final_codes),
)
@router.patch("/roles/{role_code}")
async def update_platform_role(
role_code: str,
payload: RoleUpdate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update a platform role's ``role_name`` / ``description``.
- ``role_code`` cannot change: it is the URL key (unique
``uk_roles_code`` index) and the schema forbids sending it → 422.
- Built-in roles (``is_builtin=1``) keep ``role_name`` locked → 409;
``description`` may still be edited.
- ``description`` accepts an explicit ``null`` (clear); omission
leaves it unchanged.
- ``updated_at`` refreshed by the DB's ``ON UPDATE
CURRENT_TIMESTAMP(3)`` server default.
"""
role = await _load_platform_role_by_code(session, role_code)
if (
role.is_builtin == 1
and payload.role_name is not None
and payload.role_name != role.role_name
):
raise HTTPException(
status.HTTP_409_CONFLICT,
"内置角色 role_name 不可改",
)
if payload.role_name is not None:
role.role_name = payload.role_name.strip()
# 显式传 null → 清空 description;字段省略 → 不改。
# 用 model_fields_set 区分 "省略" 与 "显式 null",因为两者在 Pydantic
# 里都解析为 None。
if "description" in payload.model_fields_set:
role.description = payload.description
await session.flush()
await session.refresh(role)
codes = await _load_role_permission_codes(session, role.role_id)
return _envelope(
context.request_id, _role_payload(role, codes),
)
@router.delete("/roles/{role_code}")
async def delete_platform_role(
role_code: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft-delete a platform role.
Guards:
- Built-in roles (``is_builtin=1``) → 409.
- Active ``users.platform_role_id`` reference → 409 (migrate users first).
- Active ``workspace_members.role_id`` reference → 409 (same ``Roles``
row — a hard orphan would leave it dangling).
"""
role = await _load_platform_role_by_code(session, role_code)
if role.is_builtin == 1:
raise HTTPException(
status.HTTP_409_CONFLICT, "内置角色不可删除",
)
user_refs = int(
await session.scalar(
select(func.count())
.select_from(Users)
.where(
Users.platform_role_id == role.role_id,
Users.is_deleted == 0,
)
)
or 0
)
if user_refs > 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"仍有 {user_refs} 个用户引用此角色",
)
member_refs = int(
await session.scalar(
select(func.count())
.select_from(WorkspaceMembers)
.where(
WorkspaceMembers.role_id == role.role_id,
WorkspaceMembers.is_deleted == 0,
)
)
or 0
)
if member_refs > 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"仍有 {member_refs} 个 workspace 成员引用此角色",
)
role.is_deleted = 1
role.deleted_at = datetime.datetime.utcnow()
await session.flush()
return _envelope(
context.request_id,
{"role_code": role_code, "deleted": True},
)
@router.get("/permissions")
async def list_platform_permissions(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List every active permission, ordered by module_code + permission_code.
Returns ``[{permission_code, permission_name, module_code,
description}, ...]`` for the role-management UI. Includes
``system:role:view`` which the admin role holds by default.
"""
rows = (
await session.execute(
select(
Permissions.permission_code,
Permissions.permission_name,
Permissions.module_code,
Permissions.description,
)
.where(Permissions.is_deleted == 0)
.order_by(Permissions.module_code, Permissions.permission_code)
)
).all()
payload = [
{
"permission_code": code,
"permission_name": name,
"module_code": module,
"description": description,
}
for code, name, module, description in rows
]
return _envelope(
context.request_id, payload, {"count": len(payload)},
)
@@ -0,0 +1,497 @@
"""Workspace & membership CRUD endpoints.
Five workspace endpoints plus five membership endpoints, gated by
``system_admin_context`` (except ``GET .../members``, which also admits workspace
members). Last-admin guards and soft-delete cascades live here.
"""
from __future__ import annotations
import datetime
import re
from typing import Any, Literal
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import current_user, database_session
from backend.api.platform._deps import (
SystemAdminContext,
_count_active_admins,
_envelope,
_is_system_admin,
_load_role_by_code,
system_admin_context,
)
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$")
LIST_PAGE_SIZE = 100
WORKSPACE_EDITABLE_STATUS = ("active", "archived")
MEMBER_ROLE_CODES = ("admin", "developer")
MEMBER_STATUS_VALUES = ("active", "disabled", "locked")
# 创建工作区时前端提交的请求体;禁止未声明字段。
class WorkspaceCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
workspace_code: str = Field(min_length=3, max_length=32)
workspace_name: str = Field(min_length=1, max_length=150)
quota_bytes: int = Field(default=0, ge=0)
description: str | None = Field(default=None, max_length=1000)
# 编辑工作区时允许修改的字段;禁用操作必须走删除接口而不是直接传状态。
class WorkspaceUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
workspace_name: str | None = Field(default=None, min_length=1, max_length=150)
quota_bytes: int | None = Field(default=None, ge=0)
description: str | None = Field(default=None, max_length=1000)
# 'disabled' is rejected here on purpose — soft delete must go through DELETE.
status: Literal["active", "archived"] | None = None
class MemberCreate(BaseModel):
"""Add a user to a workspace. Role is inherited from the user's
platform role (Users.platform_role_id) — not set here."""
model_config = ConfigDict(extra="forbid")
user_id: str = Field(min_length=26, max_length=26)
class MemberUpdate(BaseModel):
"""Update a workspace membership's status. Role cannot be changed
via this endpoint — workspace role is always inherited from the
user's platform role. To change a member's role, PATCH
/platform/employees/{user_id} instead."""
model_config = ConfigDict(extra="forbid")
member_status: Literal["active", "disabled", "locked"] | None = None
def workspace_payload(workspace: Workspaces) -> dict[str, Any]:
return {
"workspace_id": workspace.workspace_id,
"workspace_code": workspace.workspace_code,
"workspace_name": workspace.workspace_name,
"active_root_uri": workspace.active_root_uri,
"quota_bytes": workspace.quota_bytes,
"status": workspace.status,
"description": workspace.description,
"created_by": workspace.created_by,
"created_at": workspace.created_at.isoformat(),
"updated_at": (
workspace.updated_at.isoformat() if workspace.updated_at else None
),
}
def member_payload(
user: Users,
role: Roles,
membership: WorkspaceMembers,
) -> dict[str, Any]:
return {
"user_id": user.user_id,
"username": user.username,
"display_name": user.display_name,
"email": user.email,
"user_status": user.status,
"role_code": role.role_code,
"role_name": role.role_name,
"member_status": membership.member_status,
"joined_at": membership.joined_at.isoformat(),
}
async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspaces:
workspace = await session.get(Workspaces, workspace_id)
if workspace is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace 不存在")
return workspace
@router.get("/workspaces")
async def list_workspaces(
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List active/archived workspaces. Soft-deleted rows are filtered out.
Silent ``pageSize=100`` cap — YAGNI on real pagination until needed.
"""
rows = (
await session.execute(
select(Workspaces)
.where(
Workspaces.status != "disabled",
Workspaces.is_deleted == 0,
)
.order_by(Workspaces.created_at, Workspaces.workspace_id)
.limit(LIST_PAGE_SIZE)
)
).scalars().all()
return _envelope(
context.request_id,
[workspace_payload(w) for w in rows],
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
)
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
async def create_workspace(
payload: WorkspaceCreate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Create a workspace and auto-join the creator as an admin member."""
if not WORKSPACE_CODE_PATTERN.fullmatch(payload.workspace_code):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"workspace_code 必须匹配 ^[a-z0-9-]{3,32}$",
)
duplicate = await session.scalar(
select(Workspaces.workspace_id).where(
Workspaces.workspace_code == payload.workspace_code,
)
)
if duplicate is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "workspace_code 已存在")
admin_role = await _load_role_by_code(session, "admin")
workspace_id = new_ulid()
workspace = Workspaces(
workspace_id=workspace_id,
workspace_code=payload.workspace_code,
workspace_name=payload.workspace_name,
active_root_uri=f"s3://workspaces/{workspace_id}/",
quota_bytes=payload.quota_bytes,
status="active",
created_by=context.user.user_id,
description=payload.description,
)
session.add(workspace)
session.add(
WorkspaceMembers(
workspace_id=workspace_id,
user_id=context.user.user_id,
role_id=admin_role.role_id,
member_status="active",
)
)
await session.flush()
await session.refresh(workspace)
return _envelope(context.request_id, workspace_payload(workspace))
@router.get("/workspaces/{workspace_id}")
async def get_workspace(
workspace_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Fetch a single workspace — even soft-deleted ones are reachable."""
workspace = await _load_workspace(session, workspace_id)
return _envelope(context.request_id, workspace_payload(workspace))
@router.patch("/workspaces/{workspace_id}")
async def update_workspace(
workspace_id: str,
payload: WorkspaceUpdate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Patch editable workspace fields. ``status='disabled'`` is rejected."""
workspace = await _load_workspace(session, workspace_id)
if workspace.status == "disabled":
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 已删除,无法修改",
)
if payload.workspace_name is not None:
workspace.workspace_name = payload.workspace_name.strip()
if payload.quota_bytes is not None:
workspace.quota_bytes = payload.quota_bytes
if payload.description is not None:
workspace.description = payload.description
if payload.status is not None:
workspace.status = payload.status
await session.flush()
await session.refresh(workspace)
return _envelope(context.request_id, workspace_payload(workspace))
@router.delete("/workspaces/{workspace_id}")
async def delete_workspace(
workspace_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft-delete a workspace and cascade-soft-delete its memberships.
Allowed from any non-disabled status (active or archived). The
membership cascade is what lets system admins leave a workspace —
there is no per-member DELETE escape for self-removal.
"""
workspace = await _load_workspace(session, workspace_id)
if workspace.status == "disabled":
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 已被删除",
)
now = datetime.datetime.utcnow()
workspace.status = "disabled"
workspace.is_deleted = 1
workspace.deleted_at = now
await session.execute(
update(WorkspaceMembers)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.is_deleted == 0,
)
.values(is_deleted=1, deleted_at=now)
)
await session.flush()
await session.refresh(workspace)
return _envelope(context.request_id, workspace_payload(workspace))
@router.get("/workspaces/{workspace_id}/members")
async def list_members(
workspace_id: str,
request: Request,
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""List active and historical (non-soft-deleted) members of a workspace.
Accessible to system admins (any workspace) and to active members of the
workspace itself. The script explorer calls this to seed the per-owner
directory-tree groups for non-admin users; visibility filters on the
scripts/data-resources endpoints still keep each peer's private content
hidden, so this only exposes membership (names), not private files.
"""
user = await current_user(request, session)
is_system_admin = await _is_system_admin(session, user)
if not is_system_admin:
membership = await session.scalar(
select(WorkspaceMembers).where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user.user_id,
WorkspaceMembers.is_deleted == 0,
WorkspaceMembers.member_status == "active",
)
)
if membership is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"需要系统管理员或该工作区成员权限",
)
await _load_workspace(session, workspace_id)
rows = (
await session.execute(
select(Users, Roles, WorkspaceMembers)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.is_deleted == 0,
)
.order_by(WorkspaceMembers.joined_at, Users.user_id)
.limit(LIST_PAGE_SIZE)
)
).all()
request_id = request.headers.get("X-Request-ID") or new_ulid()
return _envelope(
request_id,
[member_payload(u, r, m) for u, r, m in rows],
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
)
@router.post(
"/workspaces/{workspace_id}/members",
status_code=status.HTTP_201_CREATED,
)
async def add_member(
workspace_id: str,
payload: MemberCreate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Add a user to a workspace. The new row starts with member_status='active'.
The role is inherited from the target user's ``platform_role_id``;
the request body does NOT take a ``role_code``. To change a member's
role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
"""
await _load_workspace(session, workspace_id)
user = await session.get(Users, payload.user_id)
if user is None or user.is_deleted != 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
if user.status != "active":
raise HTTPException(
status.HTTP_409_CONFLICT,
f"用户状态为 {user.status},无法加入 workspace",
)
if user.platform_role_id is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"目标用户尚未分配平台角色,无法加入 workspace;"
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
)
role = await session.scalar(
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,
"用户的平台角色行不存在或已被删除",
)
# ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
# 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
# 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
# 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
existing = await session.scalar(
select(WorkspaceMembers).where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == payload.user_id,
)
)
if existing is not None:
if existing.is_deleted == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"用户已是该 workspace 成员;workspace 角色继承自平台角色,"
"要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
)
# 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
# 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
# ``deleted_at`` 标记本轮已不在软删状态.
existing.is_deleted = 0
existing.deleted_at = None
existing.role_id = role.role_id
existing.member_status = "active"
await session.flush()
await session.refresh(existing)
return _envelope(
context.request_id, member_payload(user, role, existing),
)
membership = WorkspaceMembers(
workspace_id=workspace_id,
user_id=payload.user_id,
role_id=role.role_id,
member_status="active",
)
session.add(membership)
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, role, membership))
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
async def update_member(
workspace_id: str,
user_id: str,
payload: MemberUpdate,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update a workspace membership's status. Role is not editable here.
Workspace role is always inherited from the user's platform role
(``Users.platform_role_id``). To change role, PATCH
``/api/v1/platform/employees/{user_id}`` instead.
Last-admin guard still applies to ``member_status`` changes: setting
the only active admin to ``disabled``/``locked`` would leave the
workspace without admin coverage.
"""
await _load_workspace(session, workspace_id)
row = (
await session.execute(
select(Users, Roles, WorkspaceMembers)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
)
).first()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
user, role, membership = row
if payload.member_status is not None and payload.member_status != membership.member_status:
if (
role.role_code == "admin"
and payload.member_status != "active"
):
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
membership.member_status = payload.member_status
await session.flush()
await session.refresh(membership)
return _envelope(context.request_id, member_payload(user, role, membership))
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
async def remove_member(
workspace_id: str,
user_id: str,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft-delete a workspace membership.
System admins cannot remove themselves — the only escape is to delete
the entire workspace, which cascades membership soft-deletion.
"""
await _load_workspace(session, workspace_id)
if user_id == context.user.user_id:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
)
row = (
await session.execute(
select(Roles, WorkspaceMembers)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.user_id == user_id,
WorkspaceMembers.is_deleted == 0,
)
)
).first()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
role, membership = row
if role.role_code == "admin" and membership.member_status == "active":
remaining = await _count_active_admins(
session, workspace_id, exclude_user_id=user_id,
)
if remaining == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"workspace 必须保留至少一个 admin",
)
membership.is_deleted = 1
membership.deleted_at = datetime.datetime.utcnow()
await session.flush()
return _envelope(
context.request_id,
{"workspace_id": workspace_id, "user_id": user_id, "removed": True},
)
@@ -0,0 +1,119 @@
"""add system:role:view permission + grant it to admin
Revision ID: j4k5l6m7n8o9
Revises: e1f2a3b4c5d6
Create Date: 2026-08-25
"""
import hashlib
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "j4k5l6m7n8o9"
down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# ── seed constants ───────────────────────────────────────────────
ADMIN_ROLE_ID = "0000000000000000000000000A"
PERMISSION_CODE = "system:role:view"
PERMISSION_NAME = "查看角色管理"
MODULE_CODE = "system"
PERMISSION_DESCRIPTION = "查看角色管理与权限配置"
CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
def _deterministic_permission_id(code: str) -> str:
"""Stable 26-char ULID-shaped id derived from permission_code.
Mirrors ``e1f2a3b4c5d6_rebuild_baseline._deterministic_permission_id``
so the id for a given code matches what the baseline would derive.
"""
digest = hashlib.sha256(
f"model-platform-permission-v1:{code}".encode()
).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)
def upgrade() -> None:
"""Add system:role:view permission and grant it to admin only."""
permission_id = _deterministic_permission_id(PERMISSION_CODE)
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)),
)
op.bulk_insert(
permissions_table,
[
{
"permission_id": permission_id,
"permission_code": PERMISSION_CODE,
"permission_name": PERMISSION_NAME,
"module_code": MODULE_CODE,
"description": PERMISSION_DESCRIPTION,
}
],
)
# admin 持有全部 system:*(沿用基线约定);developer 不授予 system:*.
role_permissions_table = sa.table(
"role_permissions",
sa.column("role_id", sa.CHAR(26)),
sa.column("permission_id", sa.CHAR(26)),
)
op.bulk_insert(
role_permissions_table,
[
{
"role_id": ADMIN_ROLE_ID,
"permission_id": permission_id,
}
],
)
def downgrade() -> None:
"""Remove admin's system:role:view grant and soft-delete the permission."""
permission_id = _deterministic_permission_id(PERMISSION_CODE)
# 软删 role_permissions 关联行(与后端 soft-delete 约定一致).
role_permissions_table = sa.table(
"role_permissions",
sa.column("role_id", sa.CHAR(26)),
sa.column("permission_id", sa.CHAR(26)),
sa.column("is_deleted", sa.Integer),
sa.column("deleted_at", sa.DateTime),
)
op.execute(
role_permissions_table.update()
.where(role_permissions_table.c.role_id == ADMIN_ROLE_ID)
.where(role_permissions_table.c.permission_id == permission_id)
.where(role_permissions_table.c.is_deleted == 0)
.values(is_deleted=1, deleted_at=sa.text("CURRENT_TIMESTAMP(3)"))
)
# 软删 permissions 行(基线表含 is_deleted,优先软删除).
permissions_table = sa.table(
"permissions",
sa.column("permission_id", sa.CHAR(26)),
sa.column("is_deleted", sa.Integer),
sa.column("deleted_at", sa.DateTime),
)
op.execute(
permissions_table.update()
.where(permissions_table.c.permission_id == permission_id)
.where(permissions_table.c.is_deleted == 0)
.values(is_deleted=1, deleted_at=sa.text("CURRENT_TIMESTAMP(3)"))
)