feat: 平台治理与权限体系完善,存储进度/GPU预留/审批中心与日志整合
- 平台治理: 租户用户权限层次、资源ACL、审批中心与审批模板、访问申请 - 存储: MinIO 存储进度迁移、对象存储安全加固与测试 - 计算: GPU 资源预留、compute 轮询与同步增强 - 权限: permission v2 迁移、权限安全验收测试 - 日志: 后端运行日志中文说明、操作日志整合 - 数据处理/评测: 数据转换与模型评测优化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.db.platform_store import get_platform_store
|
||||
from app.core.auth import get_current_user, is_admin
|
||||
from app.core.auth import (
|
||||
get_current_user,
|
||||
has_resource_access,
|
||||
is_tenant_admin,
|
||||
is_admin,
|
||||
resource_in_user_tenant,
|
||||
resource_record,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/approvals", tags=["approval"])
|
||||
|
||||
# Approval actions are deliberately finite. A caller must not be able to
|
||||
# create an arbitrary approval that no executor or audit policy understands.
|
||||
ALLOWED_APPROVAL_ACTIONS = {
|
||||
"resource.access",
|
||||
"gpu.assign",
|
||||
"tenant.quota.update",
|
||||
"tenant.member.add",
|
||||
"tenant.member.remove",
|
||||
"model.use",
|
||||
"dataset.use",
|
||||
"dataset.delete",
|
||||
"trained_model.merge",
|
||||
"trained_model.export",
|
||||
"trained_model.delete",
|
||||
"fine_tune.stop",
|
||||
"fine_tune.delete",
|
||||
"eval.delete",
|
||||
"inference.delete",
|
||||
"project.archive",
|
||||
"project.delete",
|
||||
}
|
||||
|
||||
|
||||
def _available_gpu_options() -> list[dict[str, Any]]:
|
||||
"""Return only online nodes and currently unassigned, idle GPUs."""
|
||||
store = get_platform_store()
|
||||
nodes = store.compute_nodes()
|
||||
gpus = store.gpus()
|
||||
assigned = {(str(item.get("node_id")), int(item.get("gpu_index"))) for item in store.gpu_assignments()}
|
||||
by_node: dict[str, list[dict[str, Any]]] = {}
|
||||
for gpu in gpus:
|
||||
node_id = str(gpu.get("node_id") or "")
|
||||
index = int(gpu.get("id") or 0)
|
||||
if gpu.get("status") != "idle" or (node_id, index) in assigned:
|
||||
continue
|
||||
by_node.setdefault(node_id, []).append({
|
||||
"index": index,
|
||||
"name": gpu.get("name") or "GPU",
|
||||
"memory_total_gb": float(gpu.get("memory_total_gb") or 0),
|
||||
})
|
||||
result = []
|
||||
for node in nodes:
|
||||
node_id = str(node.get("id") or "")
|
||||
if not node.get("enabled") or node.get("scheduler_status") != "online" or not by_node.get(node_id):
|
||||
continue
|
||||
result.append({
|
||||
"id": node_id,
|
||||
"code": node.get("code") or node_id,
|
||||
"name": node.get("name") or node.get("code") or node_id,
|
||||
"gpus": sorted(by_node[node_id], key=lambda item: item["index"]),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _validate_gpu_request(assignments: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(assignments, list) or not assignments:
|
||||
raise fail(400, "assignments 不能为空")
|
||||
available = {
|
||||
(node["id"], gpu["index"])
|
||||
for node in _available_gpu_options()
|
||||
for gpu in node["gpus"]
|
||||
}
|
||||
normalized = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
for item in assignments:
|
||||
if not isinstance(item, dict) or not item.get("node_id") or item.get("gpu_index") is None:
|
||||
raise fail(400, "每项必须包含 node_id 和 gpu_index")
|
||||
try:
|
||||
key = (str(item["node_id"]), int(item["gpu_index"]))
|
||||
except (TypeError, ValueError):
|
||||
raise fail(400, "gpu_index 必须是整数")
|
||||
if key not in available:
|
||||
raise fail(409, f"GPU {key[0]}:{key[1]} 当前不可申请")
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
normalized.append({"node_id": key[0], "gpu_index": key[1]})
|
||||
return normalized
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_templates(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not is_admin(current_user):
|
||||
raise fail(403, "admin permission required")
|
||||
return ok(get_platform_store().approval_templates())
|
||||
|
||||
|
||||
@@ -20,11 +108,31 @@ def create_template(payload: dict[str, Any] = Body(...), current_user: dict = De
|
||||
if not is_admin(current_user): raise fail(403, "admin permission required")
|
||||
if not payload.get("name"):
|
||||
raise fail(400, "name 必填")
|
||||
return ok(get_platform_store().create_approval_template(payload))
|
||||
steps = payload.get("steps") or []
|
||||
if not isinstance(steps, list) or any(not isinstance(step, dict) for step in steps):
|
||||
raise fail(400, "steps 格式无效")
|
||||
if payload.get("action") and payload["action"] not in ALLOWED_APPROVAL_ACTIONS:
|
||||
raise fail(400, "不支持的审批动作")
|
||||
payload = {
|
||||
**payload,
|
||||
"created_by": current_user.get("id"),
|
||||
"tenant_id": payload.get("tenant_id") or current_user.get("tenant_id") or "default",
|
||||
"scope": payload.get("scope") or "tenant",
|
||||
"status": payload.get("status") or "active",
|
||||
}
|
||||
template = get_platform_store().create_approval_template(payload)
|
||||
get_platform_store().record_audit(
|
||||
action="approval.template.create", actor_id=current_user.get("id"),
|
||||
target_type="approval_template", target_id=template["id"],
|
||||
tenant_id=template.get("tenant_id"),
|
||||
)
|
||||
return ok(template)
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
def get_template(template_id: str) -> dict[str, Any]:
|
||||
def get_template(template_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not is_admin(current_user):
|
||||
raise fail(403, "admin permission required")
|
||||
try:
|
||||
return ok(get_platform_store().approval_template(template_id))
|
||||
except KeyError:
|
||||
@@ -34,8 +142,16 @@ def get_template(template_id: str) -> dict[str, Any]:
|
||||
@router.put("/templates/{template_id}")
|
||||
def update_template(template_id: str, payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not is_admin(current_user): raise fail(403, "admin permission required")
|
||||
if payload.get("action") and payload["action"] not in ALLOWED_APPROVAL_ACTIONS:
|
||||
raise fail(400, "不支持的审批动作")
|
||||
try:
|
||||
return ok(get_platform_store().update_approval_template(template_id, payload))
|
||||
template = get_platform_store().update_approval_template(template_id, payload)
|
||||
get_platform_store().record_audit(
|
||||
action="approval.template.update", actor_id=current_user.get("id"),
|
||||
target_type="approval_template", target_id=template_id,
|
||||
tenant_id=template.get("tenant_id"), detail=f"fields={','.join(payload.keys())}",
|
||||
)
|
||||
return ok(template)
|
||||
except KeyError:
|
||||
raise fail(404, "template not found")
|
||||
|
||||
@@ -44,15 +160,77 @@ def update_template(template_id: str, payload: dict[str, Any] = Body(...), curre
|
||||
def delete_template(template_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not is_admin(current_user): raise fail(403, "admin permission required")
|
||||
try:
|
||||
return ok(get_platform_store().delete_approval_template(template_id))
|
||||
template = get_platform_store().delete_approval_template(template_id)
|
||||
get_platform_store().record_audit(
|
||||
action="approval.template.delete", actor_id=current_user.get("id"),
|
||||
target_type="approval_template", target_id=template_id,
|
||||
tenant_id=template.get("tenant_id"),
|
||||
)
|
||||
return ok(template)
|
||||
except KeyError:
|
||||
raise fail(404, "template not found")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_instances(status: str | None = None, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
items = get_platform_store().approval_instances(status=status)
|
||||
return ok(items if is_admin(current_user) else [item for item in items if item.get("applicant_id") == current_user.get("id")])
|
||||
def list_instances(
|
||||
status: str | None = None,
|
||||
mine: bool = False,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
user_id = current_user.get("id")
|
||||
items = get_platform_store().approval_instances(
|
||||
status=status,
|
||||
applicant_id=user_id if mine and not is_admin(current_user) else None,
|
||||
)
|
||||
if is_admin(current_user):
|
||||
return ok(items)
|
||||
if mine:
|
||||
return ok(items)
|
||||
visible = []
|
||||
for item in items:
|
||||
if item.get("applicant_id") == user_id:
|
||||
visible.append(item)
|
||||
continue
|
||||
if any(step.get("approver_id") == user_id and step.get("status") == "pending" for step in item.get("steps", [])):
|
||||
visible.append(item)
|
||||
continue
|
||||
if is_tenant_admin(current_user, item.get("tenant_id")) and item.get("status") == "pending":
|
||||
visible.append(item)
|
||||
return ok(visible)
|
||||
|
||||
|
||||
@router.get("/gpu-options")
|
||||
def gpu_request_options(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""Self-service GPU options; does not expose the admin compute page."""
|
||||
return ok({"nodes": _available_gpu_options()})
|
||||
|
||||
|
||||
@router.post("/gpu-requests")
|
||||
def create_gpu_request(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
assignments = _validate_gpu_request(payload.get("assignments"))
|
||||
user_id = str(current_user.get("id") or "")
|
||||
if is_admin(current_user):
|
||||
try:
|
||||
return ok({"approval_required": False, "assignments": get_platform_store().assign_gpus(
|
||||
[{**item, "user_id": user_id} for item in assignments], assigned_by=user_id,
|
||||
)})
|
||||
except ValueError as exc:
|
||||
raise fail(409, str(exc))
|
||||
normalized = [{**item, "user_id": user_id} for item in assignments]
|
||||
instance = get_platform_store().create_approval_instance({
|
||||
"resource_type": "gpu",
|
||||
"resource_id": f"batch:{user_id}",
|
||||
"applicant_id": user_id,
|
||||
"action": "gpu.assign",
|
||||
"tenant_id": current_user.get("tenant_id") or "default",
|
||||
"reason": json.dumps({"assignments": normalized, "reason": payload.get("reason")}, ensure_ascii=False),
|
||||
})
|
||||
get_platform_store().record_audit(
|
||||
action="gpu.assign.request", actor_id=user_id, target_type="gpu",
|
||||
target_id=instance["id"], tenant_id=current_user.get("tenant_id") or "default",
|
||||
detail=f"count={len(normalized)}",
|
||||
)
|
||||
return ok({"approval_required": True, "approval_id": instance["id"], "approval": instance})
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -61,6 +239,24 @@ def create_instance(payload: dict[str, Any] = Body(...), current_user: dict = De
|
||||
for field in ("resource_type", "resource_id"):
|
||||
if not payload.get(field):
|
||||
raise fail(400, f"{field} 必填")
|
||||
resource = resource_record(str(payload["resource_type"]), str(payload["resource_id"]))
|
||||
if not resource and not is_admin(current_user):
|
||||
raise fail(404, "resource not found")
|
||||
action = str(payload.get("action") or "")
|
||||
if not action or action not in ALLOWED_APPROVAL_ACTIONS:
|
||||
raise fail(400, "不支持的审批动作")
|
||||
if action != "resource.access" and not is_admin(current_user) and not has_resource_access(
|
||||
str(payload["resource_type"]), str(payload["resource_id"]), current_user, "read"
|
||||
):
|
||||
raise fail(403, "no permission to request approval for this resource")
|
||||
if resource and not is_admin(current_user) and not resource_in_user_tenant(str(payload["resource_type"]), resource, current_user):
|
||||
raise fail(403, "resource belongs to another tenant")
|
||||
requested = payload.get("requested_permissions") or []
|
||||
allowed = {"read", "write", "execute", "download"}
|
||||
if any(permission not in allowed for permission in requested):
|
||||
raise fail(400, "invalid requested permission")
|
||||
payload["tenant_id"] = current_user.get("tenant_id") or "default"
|
||||
payload["requested_permissions"] = requested
|
||||
try:
|
||||
return ok(get_platform_store().create_approval_instance(payload))
|
||||
except KeyError:
|
||||
@@ -71,7 +267,7 @@ def create_instance(payload: dict[str, Any] = Body(...), current_user: dict = De
|
||||
def get_instance(instance_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
try:
|
||||
item = get_platform_store().approval_instance(instance_id)
|
||||
if not is_admin(current_user) and item.get("applicant_id") != current_user.get("id"):
|
||||
if not is_admin(current_user) and item.get("applicant_id") != current_user.get("id") and not is_tenant_admin(current_user, item.get("tenant_id")):
|
||||
raise fail(403, "no permission to access approval")
|
||||
return ok(item)
|
||||
except KeyError:
|
||||
@@ -83,18 +279,118 @@ def decide(
|
||||
instance_id: str,
|
||||
step_index: int,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if not payload.get("approver_id"):
|
||||
raise fail(400, "approver_id 必填")
|
||||
try:
|
||||
return ok(
|
||||
get_platform_store().decide_approval_step(
|
||||
instance = get_platform_store().approval_instance(instance_id)
|
||||
if instance.get("applicant_id") == current_user.get("id"):
|
||||
raise fail(403, "applicant cannot approve own request")
|
||||
step = next((item for item in instance.get("steps", []) if int(item.get("step_index", -1)) == step_index), None)
|
||||
if not step and is_admin(current_user) and not instance.get("steps"):
|
||||
step = {"approver_id": None, "status": "pending"}
|
||||
if not step:
|
||||
raise fail(404, "approval step not found")
|
||||
designated = step.get("approver_id")
|
||||
if not is_admin(current_user) and designated != current_user.get("id") and not (
|
||||
step.get("approver_type") == "admin" and is_tenant_admin(current_user, instance.get("tenant_id"))
|
||||
):
|
||||
raise fail(403, "current user is not the designated approver")
|
||||
if instance.get("action") == "tenant.quota.update" and not is_admin(current_user):
|
||||
raise fail(403, "only platform administrator can approve tenant quota changes")
|
||||
submitted_approver = payload.get("approver_id")
|
||||
if submitted_approver and submitted_approver != current_user.get("id"):
|
||||
raise fail(403, "approver_id must match current session")
|
||||
store = get_platform_store()
|
||||
result = store.decide_approval_step(
|
||||
instance_id,
|
||||
step_index,
|
||||
approver_id=payload["approver_id"],
|
||||
approver_id=str(current_user.get("id")),
|
||||
approved=bool(payload.get("approved", False)),
|
||||
comment=payload.get("comment"),
|
||||
)
|
||||
if result.get("status") == "approved" and result.get("execution_status") == "ready":
|
||||
try:
|
||||
effect = store.apply_approval_effect(result, str(current_user.get("id") or ""))
|
||||
if effect is not None:
|
||||
result = store.approval_instance(instance_id)
|
||||
except Exception as exc:
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE approval_instances SET execution_status='failed', execution_error=? WHERE id=?",
|
||||
(str(exc), instance_id),
|
||||
)
|
||||
raise fail(409, f"审批已通过,但执行失败:{exc}")
|
||||
store.record_audit(
|
||||
action="approval.decision", actor_id=current_user.get("id"),
|
||||
target_type="approval_instance", target_id=instance_id,
|
||||
tenant_id=result.get("tenant_id"), result="success" if payload.get("approved") else "rejected",
|
||||
detail=f"step={step_index}", reason=payload.get("comment"),
|
||||
)
|
||||
return ok(result)
|
||||
except (KeyError, ValueError) as e:
|
||||
raise fail(400, str(e))
|
||||
|
||||
|
||||
@router.post("/resource-access/requests")
|
||||
def create_resource_access_request(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
resource_type = str(payload.get("resource_type") or "")
|
||||
resource_id = str(payload.get("resource_id") or "")
|
||||
resource = resource_record(resource_type, resource_id)
|
||||
if not resource:
|
||||
raise fail(404, "resource not found")
|
||||
if not is_admin(current_user) and not resource_in_user_tenant(resource_type, resource, current_user):
|
||||
raise fail(403, "resource belongs to another tenant")
|
||||
permissions = payload.get("requested_permissions") or ["read"]
|
||||
allowed = {"read", "write", "execute", "download"}
|
||||
if not permissions or any(permission not in allowed for permission in permissions):
|
||||
raise fail(400, "invalid requested permissions")
|
||||
result = get_platform_store().create_resource_access_request({
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"applicant_id": current_user.get("id"),
|
||||
"principal_type": "user",
|
||||
"principal_id": current_user.get("id"),
|
||||
"requested_permissions": permissions,
|
||||
"reason": payload.get("reason"),
|
||||
"template_id": payload.get("template_id"),
|
||||
"tenant_id": current_user.get("tenant_id") or "default",
|
||||
"expires_at": payload.get("expires_at"),
|
||||
})
|
||||
get_platform_store().record_audit(
|
||||
action="resource.access.request", actor_id=current_user.get("id"),
|
||||
target_type=resource_type, target_id=resource_id,
|
||||
tenant_id=current_user.get("tenant_id") or "default",
|
||||
detail=f"permissions={','.join(permissions)}",
|
||||
)
|
||||
return ok(result)
|
||||
|
||||
|
||||
@router.get("/resource-access/requests")
|
||||
def list_resource_access_requests(status: str | None = None, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
items = get_platform_store().resource_access_requests(
|
||||
user_id=None if is_admin(current_user) else current_user.get("id"),
|
||||
status=status,
|
||||
)
|
||||
return ok(items)
|
||||
|
||||
|
||||
@router.post("/resource-access/requests/{request_id}/cancel")
|
||||
def cancel_resource_access_request(request_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
try:
|
||||
item = get_platform_store().cancel_resource_access_request(
|
||||
request_id,
|
||||
str(current_user.get("id") or ""),
|
||||
is_admin_actor=is_admin(current_user),
|
||||
)
|
||||
except KeyError:
|
||||
raise fail(404, "access request not found")
|
||||
except PermissionError as exc:
|
||||
raise fail(403, str(exc))
|
||||
except ValueError as exc:
|
||||
raise fail(409, str(exc))
|
||||
get_platform_store().record_audit(
|
||||
action="resource.access.cancel", actor_id=current_user.get("id"),
|
||||
target_type="resource_access_request", target_id=request_id,
|
||||
tenant_id=item.get("tenant_id"),
|
||||
)
|
||||
return ok(item)
|
||||
|
||||
Reference in New Issue
Block a user