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:
tao.chen
2026-08-04 17:20:42 +08:00
co-authored by Claude Fable 5
parent c263ae6a5f
commit 45f0ff534f
9 changed files with 806 additions and 243 deletions
+33 -3
View File
@@ -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,
},
+2
View File
@@ -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:
+607
View File
@@ -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",
]