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, 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()) @router.post("/templates") def create_template(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 not payload.get("name"): raise fail(400, "name 必填") 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, 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: raise fail(404, "template not found") @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: 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") @router.delete("/templates/{template_id}") 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: 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, 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("") def create_instance(payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user)) -> dict[str, Any]: payload["applicant_id"] = current_user.get("id") 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: raise fail(404, "template not found") @router.get("/{instance_id}") 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") and not is_tenant_admin(current_user, item.get("tenant_id")): raise fail(403, "no permission to access approval") return ok(item) except KeyError: raise fail(404, "instance not found") @router.post("/{instance_id}/steps/{step_index}/decision") def decide( instance_id: str, step_index: int, payload: dict[str, Any] = Body(...), current_user: dict = Depends(get_current_user), ) -> dict[str, Any]: try: 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=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)