feat: workspace CRUD at platform scope + drop audit_logs
新增系统管理模块 /api/v1/platform/*: - workspace 实体 CRUD(创建/列表/详情/更新/软删除) - workspace 成员 CRUD(添加/列表/更新/移除) - SystemAdminContext 依赖,仅 platform_role_id 指向 admin 角色的用户可访问 - /api/v1/auth/me 与 /auth/login 增 is_system_admin 派生字段 - 不变量:每个 workspace 至少保留一个 admin;系统管理员无法自我移除成员 - 软删除 workspace 级联软删除其成员 清理 audit_logs(无运行时写入,纯死特性): - baseline 移除 audit_logs 建表与三索引(20 → 19 tables) - 删除 AuditLogs 模型定义与 __init__.py 导出 - 清理 migrate_system_json / migrate_legacy_workspaces 中的 audit 写入与回填代码 API.md 增 §七系统管理,§七/§八/§九 顺延为 §八/§九/§十,附录 A/B 同步更新。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c263ae6a5f
commit
45f0ff534f
@@ -55,7 +55,12 @@ def _clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
def _user_payload(user: Users, role_code: str | None = None) -> dict[str, Any]:
|
||||
def _user_payload(
|
||||
user: Users,
|
||||
role_code: str | None = None,
|
||||
*,
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"username": user.username,
|
||||
@@ -63,9 +68,30 @@ def _user_payload(user: Users, role_code: str | None = None) -> dict[str, Any]:
|
||||
"email": user.email,
|
||||
"status": user.status,
|
||||
"role_code": role_code,
|
||||
"is_system_admin": is_system_admin,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_is_system_admin(
|
||||
session: AsyncSession,
|
||||
user: Users,
|
||||
) -> bool:
|
||||
"""Return True iff the user holds a platform-scoped admin role.
|
||||
|
||||
The check is: ``Users.status == 'active'`` AND
|
||||
``Users.platform_role_id`` points to a ``Roles`` row whose
|
||||
``role_code == 'admin'``. Any other shape (no platform_role_id,
|
||||
disabled user, wrong role code) returns False — the frontend reads
|
||||
this to decide whether to show the system-admin entry point.
|
||||
"""
|
||||
if user.status != "active" or 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"
|
||||
|
||||
|
||||
def _workspace_payload(
|
||||
workspace: Workspaces,
|
||||
role: Roles,
|
||||
@@ -161,10 +187,12 @@ async def login(
|
||||
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
|
||||
_set_session_cookie(request, response, token)
|
||||
|
||||
is_system_admin = await _resolve_is_system_admin(session, user)
|
||||
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {
|
||||
"user": _user_payload(user, user_role_code),
|
||||
"user": _user_payload(user, user_role_code, is_system_admin=is_system_admin),
|
||||
"workspaces": workspaces,
|
||||
"default_workspace_id": default_workspace_id,
|
||||
},
|
||||
@@ -238,10 +266,12 @@ async def me(
|
||||
if user_role_code is None and rows:
|
||||
user_role_code = rows[0][1].role_code
|
||||
|
||||
is_system_admin = await _resolve_is_system_admin(session, user)
|
||||
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {
|
||||
"user": _user_payload(user, user_role_code),
|
||||
"user": _user_payload(user, user_role_code, is_system_admin=is_system_admin),
|
||||
"workspaces": workspaces,
|
||||
"default_workspace_id": default_workspace_id,
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ from common.service_app import create_service_app
|
||||
from common.storage import RustFSObjectStore
|
||||
from backend.admin import router as admin_router
|
||||
from backend.auth import router as auth_router
|
||||
from backend.platform import router as platform_router
|
||||
from backend.jupyter import router as jupyter_router
|
||||
from backend.resources import router as resources_router
|
||||
from backend.runtime_client import RuntimeClient
|
||||
@@ -86,6 +87,7 @@ app.include_router(schedule_runs_router)
|
||||
app.include_router(schedules_router)
|
||||
app.include_router(scripts_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(platform_router)
|
||||
|
||||
# Reuse the proven storage endpoints without running another FastAPI service.
|
||||
for route in storage_app.routes:
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
"""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.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
|
||||
|
||||
Invariants
|
||||
----------
|
||||
|
||||
* Every workspace must always retain at least one active ``admin`` member.
|
||||
* 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.dependencies import current_user, database_session
|
||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||
from common.ids import new_ulid
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_id: str = Field(min_length=26, max_length=26)
|
||||
role_code: Literal["admin", "developer"]
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role_code: Literal["admin", "developer"] | None = None
|
||||
member_status: Literal["active", "disabled", "locked"] | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-admin context dependency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemAdminContext:
|
||||
"""Resolved identity for a system-admin request.
|
||||
|
||||
Carries the request id, the authenticated user row, and the resolved
|
||||
``Roles`` row the user holds via ``Users.platform_role_id``. By
|
||||
construction the role's ``role_code`` is ``"admin"``.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
user: Users
|
||||
platform_role: Roles
|
||||
|
||||
|
||||
async def system_admin_context(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> SystemAdminContext:
|
||||
"""Resolve the requester as a system admin.
|
||||
|
||||
Steps:
|
||||
1. Reuse :func:`backend.dependencies.current_user` to validate the JWT
|
||||
cookie and fetch the active ``Users`` row (raises 401 on failure).
|
||||
2. Require ``Users.platform_role_id`` to point to a row whose
|
||||
``role_code == 'admin'`` — anything else is 403.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 {},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace membership CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List active and historical (non-soft-deleted) members of a workspace."""
|
||||
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()
|
||||
return _envelope(
|
||||
context.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'."""
|
||||
await _load_workspace(session, workspace_id)
|
||||
user = await session.get(Users, payload.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
|
||||
role = await _load_role_by_code(session, payload.role_code)
|
||||
duplicate = await session.scalar(
|
||||
select(WorkspaceMembers.user_id).where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == payload.user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"用户已是该 workspace 成员",
|
||||
)
|
||||
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 member's role and/or status. Last-admin guard applies."""
|
||||
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
|
||||
|
||||
next_role = role
|
||||
if payload.role_code is not None and payload.role_code != role.role_code:
|
||||
if (
|
||||
role.role_code == "admin"
|
||||
and payload.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",
|
||||
)
|
||||
next_role = await _load_role_by_code(session, payload.role_code)
|
||||
membership.role_id = next_role.role_id
|
||||
|
||||
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, next_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},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"SystemAdminContext",
|
||||
"system_admin_context",
|
||||
]
|
||||
Reference in New Issue
Block a user