update
This commit is contained in:
@@ -34,6 +34,7 @@ from fastapi import (
|
||||
from fastapi.responses import StreamingResponse
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.core.auth import filter_accessible_resource_ids, get_current_user, is_admin
|
||||
from app.modules.data_process.algorithms import (
|
||||
ParsedText,
|
||||
canonical_record_json,
|
||||
@@ -815,18 +816,33 @@ def list_tasks(
|
||||
keyword: str | None = Query(default=None),
|
||||
status: DataProcessStatus | None = Query(default=None),
|
||||
process_type: ProcessType | None = Query(default=None),
|
||||
tenant_id: str | None = Query(default=None),
|
||||
project_id: str | None = Query(default=None),
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
return ok(
|
||||
store.list_tasks(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
process_type=process_type,
|
||||
)
|
||||
tasks = store.list_tasks(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
process_type=process_type,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
# #4 资源 ACL 过滤:admin 放行,普通用户只看到自己被授权的数据处理任务
|
||||
items = tasks.get("items", [])
|
||||
if not is_admin(current_user) and items:
|
||||
accessible_ids = set(
|
||||
filter_accessible_resource_ids(
|
||||
"data-process", [t["id"] for t in items], current_user
|
||||
)
|
||||
)
|
||||
items = [t for t in items if t["id"] in accessible_ids]
|
||||
tasks["items"] = items
|
||||
tasks["total"] = len(items)
|
||||
return ok(tasks)
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -2282,13 +2298,11 @@ def regenerate_results_batch(
|
||||
max_keepalive_connections=RESULT_REGENERATION_CONCURRENCY,
|
||||
)
|
||||
# httpx.Client 支持跨线程复用,批次内共享连接池可减少重复建连开销。
|
||||
with (
|
||||
httpx.Client(timeout=model_timeout, limits=model_limits) as model_client,
|
||||
ThreadPoolExecutor(
|
||||
max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)),
|
||||
thread_name_prefix="data-result-regeneration",
|
||||
) as executor,
|
||||
):
|
||||
with httpx.Client(timeout=model_timeout, limits=model_limits) as model_client, \
|
||||
ThreadPoolExecutor(
|
||||
max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)),
|
||||
thread_name_prefix="data-result-regeneration",
|
||||
) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
_regenerate_result_in_place,
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import PlainTextResponse, StreamingResponse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin
|
||||
from app.core.config import get_settings
|
||||
from app.db.platform_store import get_platform_store
|
||||
from app.modules.compute_gateway.client import ComputeNodeClient
|
||||
@@ -35,6 +37,35 @@ def fail(status_code: int, message: str) -> HTTPException:
|
||||
return HTTPException(status_code=status_code, detail={"code": status_code, "message": message, "data": None})
|
||||
|
||||
|
||||
def _require_approval_or_admin(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
current_user: dict[str, Any],
|
||||
action_desc: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
高风险操作审批旁路:
|
||||
- admin 用户直接放行(返回 None)
|
||||
- 普通用户创建审批实例,返回审批待定响应(code=202,非 None)
|
||||
code=202 使前端响应拦截器走业务错误分支,弹提示并 reject,
|
||||
避免前端误认为删除成功。
|
||||
"""
|
||||
if is_admin(current_user):
|
||||
return None
|
||||
store = get_platform_store()
|
||||
instance = store.create_approval_instance({
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"applicant_id": current_user.get("id"),
|
||||
"template_id": None,
|
||||
})
|
||||
return {
|
||||
"code": 202,
|
||||
"message": f"操作已提交审批,等待管理员批准:{action_desc}",
|
||||
"data": {"approval_required": True, "approval_id": instance["id"]},
|
||||
}
|
||||
|
||||
|
||||
def _node_for_task(task: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return next((node for node in get_platform_store().compute_nodes() if node["id"] == task.get("compute_node_id")), None)
|
||||
|
||||
@@ -231,8 +262,18 @@ async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me() -> dict[str, Any]:
|
||||
return ok(get_platform_store().users()[0])
|
||||
async def me(request: Request) -> dict[str, Any]:
|
||||
"""根据 Authorization header 中的 token 返回当前登录用户信息"""
|
||||
store = get_platform_store()
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
# token 格式: platform-token-{user_id}
|
||||
if token.startswith("platform-token-"):
|
||||
user_id = token[len("platform-token-"):]
|
||||
for u in store.users():
|
||||
if u.get("id") == user_id:
|
||||
return ok(u)
|
||||
raise fail(401, "invalid or missing token")
|
||||
|
||||
|
||||
@router.get("/dashboard/overview")
|
||||
@@ -251,6 +292,183 @@ async def dashboard_overview() -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/stats")
|
||||
async def dashboard_stats() -> dict[str, Any]:
|
||||
"""看板聚合数据:基于平台真实数据;缺项做合理近似。"""
|
||||
store = get_platform_store()
|
||||
tasks = store.tasks()
|
||||
users = store.users()
|
||||
nodes = store.compute_nodes()
|
||||
datasets = store.datasets()
|
||||
eval_tasks = store.eval_tasks()
|
||||
# 数据处理任务总数(来自 data_process 模块)
|
||||
try:
|
||||
from app.modules.data_process.store import get_data_process_store
|
||||
|
||||
dp_store = get_data_process_store()
|
||||
dp_result = dp_store.list_tasks(page=1, page_size=1)
|
||||
dp_count = int(dp_result.get("total", 0))
|
||||
except Exception:
|
||||
dp_count = 0
|
||||
|
||||
running_statuses = {"syncing", "queued", "running"}
|
||||
running_ft = [t for t in tasks if t.get("status") in running_statuses]
|
||||
failed_ft = [t for t in tasks if t.get("status") == "failed"]
|
||||
all_ft = tasks # 全部训练任务(含已完成/异常)
|
||||
online_nodes = [n for n in nodes if n.get("scheduler_status") == "online"]
|
||||
|
||||
# 近 7 天训练统计(按创建日期分桶)
|
||||
now = datetime.now(timezone.utc)
|
||||
train_by_day: dict[str, int] = {}
|
||||
for t in tasks:
|
||||
ct = t.get("create_time")
|
||||
if ct:
|
||||
train_by_day[ct[:10]] = train_by_day.get(ct[:10], 0) + 1
|
||||
training_7d = []
|
||||
for i in range(6, -1, -1):
|
||||
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
training_7d.append(
|
||||
{
|
||||
"date": day[5:],
|
||||
"train": train_by_day.get(day, 0),
|
||||
"gpu": sum(len(t.get("gpus") or []) for t in running_ft),
|
||||
"accuracy": None,
|
||||
}
|
||||
)
|
||||
|
||||
# 服务状态 —— 与界面实际数据对齐
|
||||
service_status = [
|
||||
{
|
||||
"type": "模型推理",
|
||||
"status": "error" if (nodes and not online_nodes) else ("busy" if (nodes and len(online_nodes) < len(nodes)) else "normal"),
|
||||
"count": len(online_nodes),
|
||||
},
|
||||
{
|
||||
"type": "模型训练",
|
||||
"status": "error" if failed_ft else ("busy" if running_ft else "normal"),
|
||||
"count": len(all_ft),
|
||||
},
|
||||
{
|
||||
"type": "模型评测",
|
||||
"status": "normal",
|
||||
"count": len(eval_tasks),
|
||||
},
|
||||
{
|
||||
"type": "数据处理",
|
||||
"status": "normal" if not failed_ft else "busy",
|
||||
"count": dp_count,
|
||||
},
|
||||
]
|
||||
|
||||
# 训练任务状态归一化
|
||||
status_map = {
|
||||
"syncing": "running",
|
||||
"queued": "running",
|
||||
"running": "running",
|
||||
"pending": "pending",
|
||||
"paused": "pending",
|
||||
"completed": "completed",
|
||||
"failed": "failed",
|
||||
"error": "failed",
|
||||
"cancelled": "failed",
|
||||
"stopped": "failed",
|
||||
}
|
||||
training_tasks = [
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"name": t.get("name"),
|
||||
"status": status_map.get(t.get("status"), "pending"),
|
||||
"train_type": t.get("train_type") or t.get("trainType") or "",
|
||||
"train_method": t.get("train_method") or t.get("trainMethod") or "",
|
||||
"base_model": t.get("base_model") or t.get("baseModel") or "",
|
||||
"progress": t.get("progress", 0),
|
||||
"accuracy": t.get("accuracy"),
|
||||
"started_at": (t.get("create_time") or "")[:16],
|
||||
}
|
||||
for t in tasks[:8]
|
||||
]
|
||||
|
||||
# 用户操作分布:统计平台全部操作(含治理模块)
|
||||
MODULE_LABELS = [
|
||||
("data-process", "数据处理"),
|
||||
("data_process", "数据处理"),
|
||||
("dataset", "数据集管理"),
|
||||
("fine-tune", "模型训练"),
|
||||
("fine_tune", "模型训练"),
|
||||
("model-eval", "模型评测"),
|
||||
("eval", "模型评测"),
|
||||
("model-inference", "模型推理"),
|
||||
("inference", "模型推理"),
|
||||
("model-manage", "模型管理"),
|
||||
("model", "模型管理"),
|
||||
("trained", "模型管理"),
|
||||
# 治理模块操作
|
||||
("tenant", "租户与项目"),
|
||||
("project", "租户与项目"),
|
||||
("approval", "租户与项目"),
|
||||
("acl", "租户与项目"),
|
||||
("user", "用户管理"),
|
||||
("role", "用户管理"),
|
||||
]
|
||||
OP_ORDER = [
|
||||
"数据集管理",
|
||||
"数据处理",
|
||||
"模型训练",
|
||||
"模型评测",
|
||||
"模型推理",
|
||||
"模型管理",
|
||||
"租户与项目",
|
||||
"用户管理",
|
||||
]
|
||||
|
||||
def _op_module(action: str) -> str | None:
|
||||
a = (action or "").lower()
|
||||
for prefix, label in MODULE_LABELS:
|
||||
if a.startswith(prefix):
|
||||
return label
|
||||
return None
|
||||
|
||||
audit = store.audit_logs(limit=1000)
|
||||
op_counter: dict[str, int] = {label: 0 for label in OP_ORDER}
|
||||
for log in audit.get("items", []):
|
||||
label = _op_module(log.get("action") or "")
|
||||
if label:
|
||||
op_counter[label] += 1
|
||||
operation_distribution = [{"name": k, "value": v} for k, v in op_counter.items()]
|
||||
|
||||
# 最近登录用户
|
||||
recent = sorted(
|
||||
[u for u in users if u.get("last_login")],
|
||||
key=lambda u: u["last_login"],
|
||||
reverse=True,
|
||||
)[:5]
|
||||
recent_login_users = [
|
||||
{
|
||||
"user": u.get("display_name") or u.get("username"),
|
||||
"role": u.get("role"),
|
||||
"last_login": (u.get("last_login") or "")[:16],
|
||||
}
|
||||
for u in recent
|
||||
]
|
||||
|
||||
# 登录时长排行(本月)
|
||||
login_duration_rank = store.login_duration_rank()
|
||||
|
||||
return ok(
|
||||
{
|
||||
"online_services": sum(s["count"] for s in service_status),
|
||||
"running_tasks": len(running_ft),
|
||||
"pending_alerts": 0,
|
||||
"training_7d": training_7d,
|
||||
"service_status": service_status,
|
||||
"training_tasks": training_tasks,
|
||||
"operation_distribution": operation_distribution,
|
||||
"login_duration_rank": login_duration_rank,
|
||||
"recent_login_users": recent_login_users,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system-info")
|
||||
async def system_info() -> dict[str, Any]:
|
||||
return ok(get_platform_store().system_info())
|
||||
@@ -285,6 +503,21 @@ async def delete_user(user_id: str, current_username: str | None = Query(default
|
||||
raise fail(400, str(exc))
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: str,
|
||||
payload: dict[str, Any] = Body(default={}),
|
||||
) -> dict[str, Any]:
|
||||
new_password = payload.get("password") or "Platform@123"
|
||||
try:
|
||||
get_platform_store().reset_password(user_id, new_password)
|
||||
return ok({"reset": user_id})
|
||||
except KeyError:
|
||||
raise fail(404, "user not found")
|
||||
except ValueError as exc:
|
||||
raise fail(400, str(exc))
|
||||
|
||||
|
||||
@router.get("/model-manage/local-models")
|
||||
async def local_models() -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
@@ -348,8 +581,13 @@ async def model_by_name(name: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/model-manage")
|
||||
async def model_list() -> dict[str, Any]:
|
||||
return ok(get_platform_store().models())
|
||||
async def model_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
models = get_platform_store().models()
|
||||
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||
return ok(models)
|
||||
# 普通用户只返回有 ACL 授权的模型
|
||||
accessible = set(filter_accessible_resource_ids("model", [m["id"] for m in models], current_user))
|
||||
return ok([m for m in models if m["id"] in accessible])
|
||||
|
||||
|
||||
@router.post("/model-manage")
|
||||
@@ -365,11 +603,14 @@ async def create_model(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/model-manage/{model_id}")
|
||||
async def model_detail(model_id: str) -> dict[str, Any]:
|
||||
async def model_detail(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().model(model_id))
|
||||
model = get_platform_store().model(model_id)
|
||||
except KeyError:
|
||||
raise fail(404, "model not found")
|
||||
if not has_resource_access("model", model_id, current_user, "read"):
|
||||
raise fail(403, "no permission to access this model")
|
||||
return ok(model)
|
||||
|
||||
|
||||
@router.put("/model-manage/{model_id}")
|
||||
@@ -389,7 +630,12 @@ async def update_model_purpose(model_id: str, payload: dict[str, Any] = Body(...
|
||||
|
||||
|
||||
@router.delete("/model-manage/{model_id}")
|
||||
async def delete_model(model_id: str) -> dict[str, Any]:
|
||||
async def delete_model(model_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not has_resource_access("model", model_id, current_user, "delete"):
|
||||
raise fail(403, "no permission to delete this model")
|
||||
pending = _require_approval_or_admin("model", model_id, current_user, f"删除模型 {model_id}")
|
||||
if pending:
|
||||
return pending
|
||||
get_platform_store().delete_model(model_id)
|
||||
return ok({"deleted": model_id})
|
||||
|
||||
@@ -651,8 +897,12 @@ async def download_dataset_file(dataset_id: str, file_id: str, version_id: str |
|
||||
|
||||
|
||||
@router.get("/dataset-manage")
|
||||
async def dataset_list() -> dict[str, Any]:
|
||||
return ok(get_platform_store().datasets())
|
||||
async def dataset_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
datasets = get_platform_store().datasets()
|
||||
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||
return ok(datasets)
|
||||
accessible = set(filter_accessible_resource_ids("dataset", [d["id"] for d in datasets], current_user))
|
||||
return ok([d for d in datasets if d["id"] in accessible])
|
||||
|
||||
|
||||
@router.post("/dataset-manage")
|
||||
@@ -662,11 +912,14 @@ async def create_dataset(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/dataset-manage/{dataset_id}")
|
||||
async def dataset_detail(dataset_id: str) -> dict[str, Any]:
|
||||
async def dataset_detail(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().dataset(dataset_id))
|
||||
dataset = get_platform_store().dataset(dataset_id)
|
||||
except KeyError:
|
||||
raise fail(404, "dataset not found")
|
||||
if not has_resource_access("dataset", dataset_id, current_user, "read"):
|
||||
raise fail(403, "no permission to access this dataset")
|
||||
return ok(dataset)
|
||||
|
||||
|
||||
@router.put("/dataset-manage/{dataset_id}")
|
||||
@@ -678,7 +931,12 @@ async def update_dataset(dataset_id: str, payload: dict[str, Any] = Body(...)) -
|
||||
|
||||
|
||||
@router.delete("/dataset-manage/{dataset_id}")
|
||||
async def delete_dataset(dataset_id: str) -> dict[str, Any]:
|
||||
async def delete_dataset(dataset_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not has_resource_access("dataset", dataset_id, current_user, "delete"):
|
||||
raise fail(403, "no permission to delete this dataset")
|
||||
pending = _require_approval_or_admin("dataset", dataset_id, current_user, f"删除数据集 {dataset_id}")
|
||||
if pending:
|
||||
return pending
|
||||
get_platform_store().delete_dataset(dataset_id)
|
||||
return ok({"deleted": dataset_id})
|
||||
|
||||
@@ -703,8 +961,12 @@ async def tensorboard_start() -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/fine-tune")
|
||||
async def fine_tune_list() -> dict[str, Any]:
|
||||
return ok(get_platform_store().tasks())
|
||||
async def fine_tune_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
tasks = get_platform_store().tasks()
|
||||
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||
return ok(tasks)
|
||||
accessible = set(filter_accessible_resource_ids("fine-tune", [t["id"] for t in tasks], current_user))
|
||||
return ok([t for t in tasks if t["id"] in accessible])
|
||||
|
||||
|
||||
@router.post("/fine-tune")
|
||||
@@ -897,7 +1159,12 @@ async def retry_fine_tune(task_id: str, payload: dict[str, Any] | None = Body(de
|
||||
|
||||
|
||||
@router.delete("/fine-tune/{task_id}")
|
||||
async def delete_fine_tune(task_id: str) -> dict[str, Any]:
|
||||
async def delete_fine_tune(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not has_resource_access("fine-tune", task_id, current_user, "delete"):
|
||||
raise fail(403, "no permission to delete this task")
|
||||
pending = _require_approval_or_admin("fine-tune", task_id, current_user, f"删除训练任务 {task_id}")
|
||||
if pending:
|
||||
return pending
|
||||
get_platform_store().delete_task(task_id)
|
||||
return ok({"deleted": task_id})
|
||||
|
||||
@@ -937,16 +1204,23 @@ async def fine_tune_metrics(task_id: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/model-eval")
|
||||
async def model_eval_list() -> dict[str, Any]:
|
||||
return ok(get_platform_store().eval_tasks())
|
||||
async def model_eval_list(current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
tasks = get_platform_store().eval_tasks()
|
||||
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||
return ok(tasks)
|
||||
accessible = set(filter_accessible_resource_ids("eval", [t["id"] for t in tasks], current_user))
|
||||
return ok([t for t in tasks if t["id"] in accessible])
|
||||
|
||||
|
||||
@router.get("/model-eval/{task_id}")
|
||||
async def model_eval_detail(task_id: str) -> dict[str, Any]:
|
||||
async def model_eval_detail(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().eval_task(task_id))
|
||||
task = get_platform_store().eval_task(task_id)
|
||||
except KeyError:
|
||||
raise fail(404, "eval task not found")
|
||||
if not has_resource_access("eval", task_id, current_user, "read"):
|
||||
raise fail(403, "no permission to access this eval task")
|
||||
return ok(task)
|
||||
|
||||
|
||||
@router.post("/model-eval/start")
|
||||
@@ -956,7 +1230,12 @@ async def model_eval_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any
|
||||
|
||||
|
||||
@router.delete("/model-eval/{task_id}")
|
||||
async def model_eval_delete(task_id: str) -> dict[str, Any]:
|
||||
async def model_eval_delete(task_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not has_resource_access("eval", task_id, current_user, "delete"):
|
||||
raise fail(403, "no permission to delete this eval task")
|
||||
pending = _require_approval_or_admin("eval", task_id, current_user, f"删除评测任务 {task_id}")
|
||||
if pending:
|
||||
return pending
|
||||
get_platform_store().delete_eval_task(task_id)
|
||||
return ok({"deleted": task_id})
|
||||
|
||||
|
||||
@@ -3,8 +3,20 @@
|
||||
from app.api.v1.endpoints.data_process import router as data_process_router
|
||||
from app.api.v1.endpoints.platform import router as platform_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.modules.tenant.router import router as tenant_router
|
||||
from app.modules.project.router import router as project_router
|
||||
from app.modules.approval.router import router as approval_router
|
||||
from app.modules.system.router import router as system_router
|
||||
from app.modules.retention.router import router as retention_router
|
||||
from app.modules.resource.router import router as resource_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_router, tags=["health"])
|
||||
api_router.include_router(data_process_router, tags=["data-process"])
|
||||
api_router.include_router(platform_router, tags=["platform"])
|
||||
api_router.include_router(system_router, tags=["system"])
|
||||
api_router.include_router(tenant_router, tags=["tenant"])
|
||||
api_router.include_router(project_router, tags=["project"])
|
||||
api_router.include_router(approval_router, tags=["approval"])
|
||||
api_router.include_router(retention_router, tags=["retention"])
|
||||
api_router.include_router(resource_router, tags=["resource"])
|
||||
|
||||
138
backend/app/core/auth.py
Normal file
138
backend/app/core/auth.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""鉴权依赖:从 Authorization header 解析当前用户,提供权限校验。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request, status
|
||||
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
# 无需鉴权的路径前缀(健康检查、登录等)
|
||||
PUBLIC_PATHS = ("/health", "/login", "/system-info")
|
||||
|
||||
|
||||
def _extract_token(request: Request) -> str | None:
|
||||
"""从 Authorization header 提取 token(格式: Bearer platform-token-{user_id})。"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
if token.startswith("platform-token-"):
|
||||
return token[len("platform-token-"):]
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> dict[str, Any]:
|
||||
"""
|
||||
FastAPI 依赖:解析当前登录用户。
|
||||
- 公开路径(/health, /login 等)直接放行,返回匿名用户。
|
||||
- 无 token 或 token 无效时抛 401。
|
||||
- admin 用户标记为超级管理员,拥有全部权限。
|
||||
"""
|
||||
path = request.url.path
|
||||
# 去掉路由前缀后判断
|
||||
for prefix in PUBLIC_PATHS:
|
||||
if path.endswith(prefix):
|
||||
return {"id": None, "username": "anonymous", "role": "viewer", "permissions": [], "protected": False}
|
||||
|
||||
user_id = _extract_token(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing or invalid token")
|
||||
|
||||
store = get_platform_store()
|
||||
for u in store.users():
|
||||
if u.get("id") == user_id:
|
||||
return u
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
|
||||
|
||||
def require_admin(current_user: dict[str, Any] = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""FastAPI 依赖:要求当前用户是管理员(role=admin 或 protected)。"""
|
||||
if current_user.get("role") == "admin" or current_user.get("protected"):
|
||||
return current_user
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin permission required")
|
||||
|
||||
|
||||
def is_admin(user: dict[str, Any]) -> bool:
|
||||
"""判断用户是否为管理员(admin 角色或 protected 标记)。"""
|
||||
return user.get("role") == "admin" or user.get("protected", False)
|
||||
|
||||
|
||||
def has_resource_access(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
user: dict[str, Any],
|
||||
permission: str = "read",
|
||||
) -> bool:
|
||||
"""
|
||||
检查用户对某资源是否有指定权限。
|
||||
- admin/protected 用户直接放行(旁路)。
|
||||
- 其他用户检查 acls 表中是否有对应授权。
|
||||
"""
|
||||
if user.get("role") == "admin" or user.get("protected"):
|
||||
return True
|
||||
|
||||
store = get_platform_store()
|
||||
acls = store.get_acl(resource_type, resource_id)
|
||||
user_id = user.get("id")
|
||||
user_role = user.get("role")
|
||||
|
||||
for entry in acls:
|
||||
# 按 user 授权
|
||||
if entry.get("principal_type") == "user" and entry.get("principal_id") == user_id:
|
||||
if _permission_covers(entry.get("permission"), permission):
|
||||
return True
|
||||
# 按 role 授权
|
||||
if entry.get("principal_type") == "role" and entry.get("principal_id") == user_role:
|
||||
if _permission_covers(entry.get("permission"), permission):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _permission_covers(granted: str | None, required: str) -> bool:
|
||||
"""权限覆盖判断:write/execute 覆盖 read;admin 覆盖一切。"""
|
||||
if not granted:
|
||||
return False
|
||||
if granted == "admin":
|
||||
return True
|
||||
if granted == required:
|
||||
return True
|
||||
# write 覆盖 read
|
||||
if required == "read" and granted in ("write", "execute"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def filter_accessible_resource_ids(
|
||||
resource_type: str,
|
||||
all_ids: list[str],
|
||||
user: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""
|
||||
从全部资源 ID 中过滤出当前用户可访问的 ID 列表。
|
||||
- admin 直接返回全部。
|
||||
- 普通用户查 acls 表取交集。
|
||||
"""
|
||||
if user.get("role") == "admin" or user.get("protected"):
|
||||
return all_ids
|
||||
|
||||
if not all_ids:
|
||||
return []
|
||||
|
||||
store = get_platform_store()
|
||||
user_id = user.get("id")
|
||||
user_role = user.get("role")
|
||||
|
||||
# 查询该用户在该资源类型下有 read 权限的所有 resource_id
|
||||
with store.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT resource_id FROM acls
|
||||
WHERE resource_type=? AND (
|
||||
(principal_type='user' AND principal_id=?)
|
||||
OR (principal_type='role' AND principal_id=?)
|
||||
)
|
||||
""",
|
||||
(resource_type, user_id, user_role),
|
||||
).fetchall()
|
||||
|
||||
accessible = {r["resource_id"] for r in rows}
|
||||
return [rid for rid in all_ids if rid in accessible]
|
||||
@@ -2,6 +2,18 @@
|
||||
from functools import lru_cache
|
||||
import os
|
||||
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 显式指定 backend 目录下的 .env,并强制覆盖已有环境变量,
|
||||
# 确保远程数据库配置生效,不被本地默认值或残留环境变量影响。
|
||||
_env_path = _Path(__file__).resolve().parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=_env_path, override=True)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
import psycopg
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -291,21 +292,54 @@ class PlatformStore:
|
||||
def __init__(self, database_url: str | None = None) -> None:
|
||||
settings = get_settings()
|
||||
self.database_url = _psycopg_url(database_url or settings.database_url)
|
||||
# Reuse connections via a pool to avoid the TCP+auth handshake on every
|
||||
# request (notably expensive against the remote PostgreSQL instance).
|
||||
# TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。
|
||||
pool_kwargs = {
|
||||
"keepalives": 1,
|
||||
"keepalives_idle": 30,
|
||||
"keepalives_interval": 10,
|
||||
"keepalives_count": 5,
|
||||
}
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=self.database_url,
|
||||
kwargs=pool_kwargs,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
# 借出前校验连接可用性,避免执行 SQL 时才发现 [BAD] 再重建。
|
||||
check=ConnectionPool.check_connection,
|
||||
# 不主动回收空闲连接(远程库约 10s 断,由 keepalive 维持),
|
||||
# 减少无谓的重建握手。
|
||||
max_idle=0,
|
||||
# 请求最多排队等待 5s,避免雪崩时无限堆积。
|
||||
max_waiting=16,
|
||||
open=False,
|
||||
)
|
||||
# 注意:不要在此调用 pool.wait(),它会阻塞等待 min_size 个连接就绪,
|
||||
# 在远程库响应慢/超时时会卡死 uvicorn worker 进程,导致所有请求无响应。
|
||||
self._pool.open()
|
||||
self.ensure_schema()
|
||||
self.ensure_seed_data()
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator["PgConnection"]:
|
||||
raw_conn = psycopg.connect(self.database_url)
|
||||
conn = PgConnection(raw_conn)
|
||||
with self._pool.connection() as raw_conn:
|
||||
conn = PgConnection(raw_conn)
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def close_pool(self) -> None:
|
||||
"""Release pooled connections. Safe to call multiple times."""
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
self._pool.close()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
pass
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
schema_path = Path(__file__).with_name("sql") / "001_platform_runtime.sql"
|
||||
@@ -335,6 +369,11 @@ class PlatformStore:
|
||||
"last_error": "TEXT",
|
||||
},
|
||||
)
|
||||
schema_dir = Path(__file__).with_name("sql")
|
||||
for extra in ("002_governance.sql", "003_tenant_quota.sql"):
|
||||
extra_path = schema_dir / extra
|
||||
if extra_path.exists():
|
||||
conn.executescript(extra_path.read_text(encoding="utf-8"))
|
||||
|
||||
def _column_names(self, conn: PgConnection, table_name: str) -> set[str]:
|
||||
columns = conn.execute(
|
||||
@@ -1095,6 +1134,18 @@ class PlatformStore:
|
||||
raise ValueError("protected user cannot be deleted")
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> None:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT protected FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(user_id)
|
||||
if row["protected"]:
|
||||
raise ValueError("protected user cannot reset password")
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=? WHERE id=?",
|
||||
(hash_password(new_password), user_id),
|
||||
)
|
||||
|
||||
def _user(self, row: PgRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
@@ -2891,6 +2942,672 @@ class PlatformStore:
|
||||
]
|
||||
return {"file": file_name, "content": "\n".join(lines), "size": "1 KB"}
|
||||
|
||||
# ===================== 平台治理:角色 =====================
|
||||
|
||||
def roles(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM roles ORDER BY name").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ===================== 平台治理:审计日志 =====================
|
||||
|
||||
def audit_logs(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
action: str | None = None,
|
||||
target_type: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if tenant_id:
|
||||
clauses.append("tenant_id=?")
|
||||
params.append(tenant_id)
|
||||
if project_id:
|
||||
clauses.append("project_id=?")
|
||||
params.append(project_id)
|
||||
if actor_id:
|
||||
clauses.append("actor_id=?")
|
||||
params.append(actor_id)
|
||||
if action:
|
||||
clauses.append("action=?")
|
||||
params.append(action)
|
||||
if target_type:
|
||||
clauses.append("target_type=?")
|
||||
params.append(target_type)
|
||||
if start_time:
|
||||
clauses.append("time>=?")
|
||||
params.append(start_time)
|
||||
if end_time:
|
||||
clauses.append("time<=?")
|
||||
params.append(end_time)
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self.connect() as conn:
|
||||
total = conn.execute(f"SELECT COUNT(*) AS c FROM audit_logs{where}", tuple(params)).fetchone()["c"]
|
||||
params_paged = list(params) + [limit, offset]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM audit_logs{where} ORDER BY time DESC LIMIT ? OFFSET ?",
|
||||
tuple(params_paged),
|
||||
).fetchall()
|
||||
return {"total": total, "items": [dict(r) for r in rows]}
|
||||
|
||||
def record_audit(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
actor_id: str | None = None,
|
||||
target_type: str | None = None,
|
||||
target_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
detail: str | None = None,
|
||||
ip: str | None = None,
|
||||
) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs
|
||||
(id, tenant_id, project_id, actor_id, action, target_type, target_id, detail, client_ip, time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
new_id("log"),
|
||||
tenant_id,
|
||||
project_id,
|
||||
actor_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
detail,
|
||||
ip,
|
||||
utcnow(),
|
||||
),
|
||||
)
|
||||
|
||||
# ===================== 平台治理:会话 =====================
|
||||
|
||||
def create_session(self, user_id: str, *, ip: str | None = None) -> dict[str, Any]:
|
||||
sid = new_id("sess")
|
||||
login_at = utcnow()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, username, login_at, create_time) "
|
||||
"VALUES (%s, %s, (SELECT username FROM users WHERE id=%s), %s, %s)",
|
||||
(sid, user_id, user_id, login_at, login_at),
|
||||
)
|
||||
return {"session_id": sid, "user_id": user_id, "login_at": login_at}
|
||||
|
||||
def finish_session(self, session_id: str) -> None:
|
||||
"""登出时记录 logout_at 与时长(秒)。"""
|
||||
logout_at = utcnow()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET logout_at=%s, "
|
||||
"duration_seconds=EXTRACT(EPOCH FROM (%s::timestamptz - login_at::timestamptz))::int "
|
||||
"WHERE id=%s AND logout_at IS NULL",
|
||||
(logout_at, logout_at, session_id),
|
||||
)
|
||||
|
||||
def active_sessions(self, user_id: str) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sessions WHERE user_id=%s AND logout_at IS NULL "
|
||||
"ORDER BY login_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def destroy_session(self, session_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE id=%s", (session_id,))
|
||||
|
||||
def extend_session(self, session_id: str, *, expires_in_seconds: int = 3600 * 8) -> dict[str, Any] | None:
|
||||
# 兼容旧调用,仅更新 login_at 之后延长的含义在此简化为 no-op 返回现有记录。
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM sessions WHERE id=%s", (session_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": row["user_id"],
|
||||
"login_at": row["login_at"],
|
||||
}
|
||||
|
||||
def set_session_user(self, session_id: str, user_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute("UPDATE sessions SET user_id=%s WHERE id=%s", (user_id, session_id))
|
||||
|
||||
def login_duration_rank(self, limit: int = 8, days: int = 30) -> list[dict[str, Any]]:
|
||||
"""登录时长排行:按用户聚合近 N 天的会话时长(小时)。
|
||||
|
||||
sessions 表列:login_at(TEXT), logout_at(TEXT), duration_seconds(INT)。
|
||||
优先用 duration_seconds;为空时回退计算 now-login_at(未登出)或 logout_at-login_at。
|
||||
"""
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT s.user_id, s.login_at, s.logout_at, s.duration_seconds, "
|
||||
"u.username, u.display_name, u.role "
|
||||
"FROM sessions s LEFT JOIN users u ON s.user_id = u.id "
|
||||
"WHERE s.login_at::timestamptz >= NOW() - make_interval(days => %s)",
|
||||
(days,),
|
||||
).fetchall()
|
||||
now = datetime.now(timezone.utc)
|
||||
agg: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
uid = r["user_id"] or ""
|
||||
bucket = agg.setdefault(
|
||||
uid,
|
||||
{
|
||||
"user": r["display_name"] or r["username"] or uid,
|
||||
"role": r["role"] or "",
|
||||
"total": 0.0,
|
||||
},
|
||||
)
|
||||
dur = r["duration_seconds"]
|
||||
if dur is not None:
|
||||
bucket["total"] += float(dur)
|
||||
continue
|
||||
start = parse_time(r["login_at"])
|
||||
end = parse_time(r["logout_at"]) if r["logout_at"] else None
|
||||
if start and end:
|
||||
bucket["total"] += max(0, (end - start).total_seconds())
|
||||
elif start:
|
||||
bucket["total"] += max(0, (now - start).total_seconds())
|
||||
result = [
|
||||
{"user": b["user"], "role": b["role"], "duration": round(b["total"] / 3600, 1)}
|
||||
for b in agg.values()
|
||||
]
|
||||
result.sort(key=lambda x: x["duration"], reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
# ===================== 平台治理:审批 =====================
|
||||
|
||||
def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
tid = new_id("tpl")
|
||||
conn.execute(
|
||||
"INSERT INTO approval_templates (id, name, steps, create_time) VALUES (?, ?, ?, ?)",
|
||||
(tid, payload["name"], json_dumps(payload.get("steps", [])), utcnow()),
|
||||
)
|
||||
return self.approval_template(tid)
|
||||
|
||||
def approval_templates(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM approval_templates ORDER BY create_time DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def approval_template(self, template_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM approval_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(template_id)
|
||||
return dict(row)
|
||||
|
||||
def update_approval_template(self, template_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
fields = {k: v for k, v in payload.items() if k in ("name", "steps")}
|
||||
if "steps" in fields:
|
||||
fields["steps"] = json_dumps(fields["steps"])
|
||||
if not fields:
|
||||
return self.approval_template(template_id)
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
params = list(fields.values()) + [template_id]
|
||||
with self.connect() as conn:
|
||||
conn.execute(f"UPDATE approval_templates SET {set_clause} WHERE id=?", tuple(params))
|
||||
return self.approval_template(template_id)
|
||||
|
||||
def delete_approval_template(self, template_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM approval_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(template_id)
|
||||
conn.execute("DELETE FROM approval_templates WHERE id=?", (template_id,))
|
||||
return dict(row)
|
||||
|
||||
def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = payload.get("template_id")
|
||||
steps = []
|
||||
if template_id:
|
||||
tpl = self.approval_template(template_id)
|
||||
steps = json_loads(tpl["steps"]) if tpl.get("steps") else []
|
||||
with self.connect() as conn:
|
||||
iid = new_id("appr")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO approval_instances
|
||||
(id, template_id, resource_type, resource_id, applicant_id, status, current_step, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
iid,
|
||||
template_id,
|
||||
payload["resource_type"],
|
||||
payload["resource_id"],
|
||||
payload["applicant_id"],
|
||||
"pending",
|
||||
0,
|
||||
utcnow(),
|
||||
),
|
||||
)
|
||||
for idx, step in enumerate(steps):
|
||||
conn.execute(
|
||||
"INSERT INTO approval_steps (id, instance_id, step_index, approver_id, status, time) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(new_id("step"), iid, idx, step.get("approver_id"), "pending", None),
|
||||
)
|
||||
return self.approval_instance(iid)
|
||||
|
||||
def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM approval_instances WHERE status=? ORDER BY create_time DESC", (status,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM approval_instances ORDER BY create_time DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def approval_instance(self, instance_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM approval_instances WHERE id=?", (instance_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(instance_id)
|
||||
steps = conn.execute(
|
||||
"SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (instance_id,)
|
||||
).fetchall()
|
||||
result = dict(row)
|
||||
result["steps"] = [dict(s) for s in steps]
|
||||
return result
|
||||
|
||||
def decide_approval_step(self, instance_id: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
inst = conn.execute("SELECT * FROM approval_instances WHERE id=?", (instance_id,)).fetchone()
|
||||
if not inst:
|
||||
raise KeyError(instance_id)
|
||||
if inst["status"] != "pending":
|
||||
raise ValueError("instance not pending")
|
||||
step = conn.execute(
|
||||
"SELECT * FROM approval_steps WHERE instance_id=? AND step_index=?",
|
||||
(instance_id, step_index),
|
||||
).fetchone()
|
||||
if not step:
|
||||
raise KeyError("step not found")
|
||||
if step["status"] != "pending":
|
||||
raise ValueError("step already decided")
|
||||
new_status = "approved" if approved else "rejected"
|
||||
conn.execute(
|
||||
"UPDATE approval_steps SET status=?, comment=?, time=? WHERE id=?",
|
||||
(new_status, comment, utcnow(), step["id"]),
|
||||
)
|
||||
if approved:
|
||||
conn.execute(
|
||||
"UPDATE approval_instances SET current_step=? WHERE id=?",
|
||||
(step_index + 1, instance_id),
|
||||
)
|
||||
step_rows = conn.execute(
|
||||
"SELECT * FROM approval_steps WHERE instance_id=? ORDER BY step_index", (instance_id,)
|
||||
).fetchall()
|
||||
if all(s["status"] == "approved" for s in step_rows):
|
||||
conn.execute("UPDATE approval_instances SET status='approved' WHERE id=?", (instance_id,))
|
||||
else:
|
||||
conn.execute("UPDATE approval_instances SET status='rejected' WHERE id=?", (instance_id,))
|
||||
return self.approval_instance(instance_id)
|
||||
|
||||
# ===================== 平台治理:租户 =====================
|
||||
|
||||
def tenants(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM tenants ORDER BY create_time DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def tenant(self, tenant_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM tenants WHERE id=?", (tenant_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(tenant_id)
|
||||
return dict(row)
|
||||
|
||||
def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
tid = new_id("tnt")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tenants (id, name, code, status, owner_user_id, quota, retention_policy_id, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
tid,
|
||||
payload["name"],
|
||||
payload.get("code"),
|
||||
"active",
|
||||
payload.get("owner_user_id"),
|
||||
json_dumps(payload.get("quota", {})),
|
||||
payload.get("retention_policy_id"),
|
||||
utcnow(),
|
||||
),
|
||||
)
|
||||
return self.tenant(tid)
|
||||
|
||||
def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
fields = {k: v for k, v in payload.items() if k in ("name", "code", "status", "owner_user_id", "quota", "retention_policy_id")}
|
||||
if "quota" in fields:
|
||||
fields["quota"] = json_dumps(fields["quota"])
|
||||
if not fields:
|
||||
return self.tenant(tenant_id)
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
params = list(fields.values()) + [tenant_id]
|
||||
with self.connect() as conn:
|
||||
conn.execute(f"UPDATE tenants SET {set_clause} WHERE id=?", tuple(params))
|
||||
return self.tenant(tenant_id)
|
||||
|
||||
def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
conn.execute("UPDATE tenants SET quota=? WHERE id=?", (json_dumps(quota), tenant_id))
|
||||
return self.tenant(tenant_id)
|
||||
|
||||
def set_tenant_retention(self, tenant_id: str, retention_policy_id: str | None) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
conn.execute("UPDATE tenants SET retention_policy_id=? WHERE id=?", (retention_policy_id, tenant_id))
|
||||
return self.tenant(tenant_id)
|
||||
|
||||
def delete_tenant(self, tenant_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM tenants WHERE id=?", (tenant_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(tenant_id)
|
||||
conn.execute("DELETE FROM tenants WHERE id=?", (tenant_id,))
|
||||
return dict(row)
|
||||
|
||||
def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM acls WHERE resource_type=? AND resource_id=?",
|
||||
(resource_type, resource_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def set_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM acls WHERE resource_type=? AND resource_id=?",
|
||||
(resource_type, resource_id),
|
||||
)
|
||||
for e in entries:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO acls (id, resource_type, resource_id, principal_type, principal_id, permission, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
new_id("acl"),
|
||||
resource_type,
|
||||
resource_id,
|
||||
e.get("principal_type"),
|
||||
e.get("principal_id"),
|
||||
e.get("permission"),
|
||||
utcnow(),
|
||||
),
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM acls WHERE resource_type=? AND resource_id=?",
|
||||
(resource_type, resource_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ===================== 平台治理:项目空间 =====================
|
||||
|
||||
def projects(self, *, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]:
|
||||
clauses = ["tenant_id=?"]
|
||||
params: list[Any] = [tenant_id]
|
||||
if status:
|
||||
clauses.append("status=?")
|
||||
params.append(status)
|
||||
if keyword:
|
||||
clauses.append("(name LIKE ? OR code LIKE ?)")
|
||||
params.extend([f"%{keyword}%", f"%{keyword}%"])
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM projects WHERE {' AND '.join(clauses)} ORDER BY create_time DESC",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def project(self, project_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(project_id)
|
||||
return dict(row)
|
||||
|
||||
def create_project(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
pid = new_id("prj")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO projects (id, tenant_id, name, code, description, quota, status, create_time, create_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
pid,
|
||||
payload.get("tenant_id", "default"),
|
||||
payload["name"],
|
||||
payload["code"],
|
||||
payload.get("description"),
|
||||
json_dumps(payload.get("quota", {})),
|
||||
"active",
|
||||
utcnow(),
|
||||
payload.get("create_by"),
|
||||
utcnow(),
|
||||
),
|
||||
)
|
||||
return self.project(pid)
|
||||
|
||||
def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
fields = {k: v for k, v in payload.items() if k in ("name", "code", "description", "quota", "status")}
|
||||
if "quota" in fields:
|
||||
fields["quota"] = json_dumps(fields["quota"])
|
||||
if not fields:
|
||||
return self.project(project_id)
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
params = list(fields.values()) + [project_id]
|
||||
with self.connect() as conn:
|
||||
conn.execute(f"UPDATE projects SET {set_clause} WHERE id=?", tuple(params))
|
||||
return self.project(project_id)
|
||||
|
||||
def archive_project(self, project_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
conn.execute("UPDATE projects SET status='archived' WHERE id=?", (project_id,))
|
||||
return self.project(project_id)
|
||||
|
||||
def activate_project(self, project_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
conn.execute("UPDATE projects SET status='active' WHERE id=?", (project_id,))
|
||||
return self.project(project_id)
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM projects WHERE id=?", (project_id,))
|
||||
|
||||
def project_members(self, project_id: str) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError(project_id)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT pm.*, u.username, u.display_name
|
||||
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||
WHERE pm.project_id=?
|
||||
""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def add_project_member(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
user_id = payload["user_id"]
|
||||
role = payload.get("role", "member")
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role, create_time) VALUES (?, ?, ?, ?)",
|
||||
(project_id, user_id, role, utcnow()),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT pm.*, u.username, u.display_name
|
||||
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||
WHERE pm.project_id=? AND pm.user_id=?
|
||||
""",
|
||||
(project_id, user_id),
|
||||
).fetchone()
|
||||
return {
|
||||
"project_id": row["project_id"],
|
||||
"user_id": row["user_id"],
|
||||
"username": row["username"],
|
||||
"display_name": row["display_name"],
|
||||
"role": row["role"],
|
||||
"create_time": row["create_time"],
|
||||
}
|
||||
|
||||
def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE project_members SET role=? WHERE project_id=? AND user_id=?",
|
||||
(role, project_id, user_id),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT pm.*, u.username, u.display_name
|
||||
FROM project_members pm JOIN users u ON u.id = pm.user_id
|
||||
WHERE pm.project_id=? AND pm.user_id=?
|
||||
""",
|
||||
(project_id, user_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(user_id)
|
||||
return {
|
||||
"project_id": row["project_id"],
|
||||
"user_id": row["user_id"],
|
||||
"username": row["username"],
|
||||
"display_name": row["display_name"],
|
||||
"role": row["role"],
|
||||
"create_time": row["create_time"],
|
||||
}
|
||||
|
||||
def remove_project_member(self, project_id: str, user_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_members WHERE project_id=? AND user_id=?",
|
||||
(project_id, user_id),
|
||||
)
|
||||
|
||||
# ===================== 平台治理:资源 ACL =====================
|
||||
|
||||
def resource_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
|
||||
"""返回资源 ACL,按主体分组,permissions 为数组。"""
|
||||
rows = self.get_acl(resource_type, resource_id)
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
key = f"{r.get('principal_type')}:{r.get('principal_id')}"
|
||||
bucket = grouped.setdefault(
|
||||
key,
|
||||
{
|
||||
"subject_type": r.get("principal_type"),
|
||||
"subject_id": r.get("principal_id"),
|
||||
"permissions": [],
|
||||
},
|
||||
)
|
||||
perm = r.get("permission")
|
||||
if perm and perm not in bucket["permissions"]:
|
||||
bucket["permissions"].append(perm)
|
||||
return list(grouped.values())
|
||||
|
||||
def set_resource_acl(
|
||||
self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按前端格式设置资源 ACL:entries 为 [{subject_type, subject_id, permissions: []}]。"""
|
||||
flat: list[dict[str, Any]] = []
|
||||
for e in entries:
|
||||
for perm in e.get("permissions") or []:
|
||||
flat.append(
|
||||
{
|
||||
"principal_type": e.get("subject_type"),
|
||||
"principal_id": e.get("subject_id"),
|
||||
"permission": perm,
|
||||
}
|
||||
)
|
||||
self.set_acl(resource_type, resource_id, flat)
|
||||
return self.resource_acl(resource_type, resource_id)
|
||||
|
||||
# ===================== 平台治理:留存策略 =====================
|
||||
|
||||
def retention_policies(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM retention_policies ORDER BY create_time DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def retention_policy(self, policy_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM retention_policies WHERE id=?", (policy_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(policy_id)
|
||||
return dict(row)
|
||||
|
||||
def create_retention_policy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
pid = payload.get("id") or new_id("rpol")
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO retention_policies
|
||||
(id, name, scope, rule, status, create_time, create_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
pid,
|
||||
payload["name"],
|
||||
payload.get("scope"),
|
||||
payload.get("rule"),
|
||||
payload.get("status", "active"),
|
||||
utcnow(),
|
||||
payload.get("create_by"),
|
||||
utcnow(),
|
||||
),
|
||||
)
|
||||
return self.retention_policy(pid)
|
||||
|
||||
def update_retention_policy(
|
||||
self, policy_id: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
fields = {
|
||||
k: v
|
||||
for k, v in payload.items()
|
||||
if k in ("name", "scope", "rule", "status")
|
||||
}
|
||||
if not fields:
|
||||
return self.retention_policy(policy_id)
|
||||
fields["updated_at"] = utcnow()
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
params = list(fields.values()) + [policy_id]
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE retention_policies SET {set_clause} WHERE id=?",
|
||||
tuple(params),
|
||||
)
|
||||
return self.retention_policy(policy_id)
|
||||
|
||||
def delete_retention_policy(self, policy_id: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM retention_policies WHERE id=?", (policy_id,)
|
||||
)
|
||||
|
||||
|
||||
_store: PlatformStore | None = None
|
||||
|
||||
@@ -2900,3 +3617,16 @@ def get_platform_store() -> PlatformStore:
|
||||
if _store is None:
|
||||
_store = PlatformStore()
|
||||
return _store
|
||||
|
||||
|
||||
import atexit as _atexit
|
||||
|
||||
|
||||
def _close_store_pool() -> None:
|
||||
global _store
|
||||
if _store is not None:
|
||||
_store.close_pool()
|
||||
_store = None
|
||||
|
||||
|
||||
_atexit.register(_close_store_pool)
|
||||
|
||||
@@ -282,3 +282,51 @@ CREATE INDEX IF NOT EXISTS idx_sync_jobs_node_status ON resource_sync_jobs(targe
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_tasks_status ON eval_tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_compare_tasks_status ON compare_tasks(status);
|
||||
|
||||
-- ===================== Project / Tenant =====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
description TEXT,
|
||||
quota TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
create_time TEXT NOT NULL,
|
||||
create_by TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
create_time TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
create_time TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
issued_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acls (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
principal_type TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
create_time TEXT
|
||||
);
|
||||
|
||||
68
backend/app/db/sql/002_governance.sql
Normal file
68
backend/app/db/sql/002_governance.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- 平台治理:租户 / 审批 / 审计(字段以 platform_store 实际写入为准)
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
owner_user_id TEXT,
|
||||
quota TEXT,
|
||||
retention_policy_id TEXT,
|
||||
create_time TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
steps TEXT,
|
||||
create_time TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
template_id TEXT,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
applicant_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
current_step INTEGER DEFAULT 0,
|
||||
create_time TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT,
|
||||
step_index INTEGER,
|
||||
approver_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
comment TEXT,
|
||||
time TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT,
|
||||
project_id TEXT,
|
||||
actor_id TEXT,
|
||||
action TEXT,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
detail TEXT,
|
||||
client_ip TEXT,
|
||||
time TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit_logs(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_project ON audit_logs(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_logs(time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS retention_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT,
|
||||
rule TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
create_time TEXT,
|
||||
create_by TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
3
backend/app/db/sql/003_tenant_quota.sql
Normal file
3
backend/app/db/sql/003_tenant_quota.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 租户配额与保留策略扩展(如后续治理表需补列,可在此追加)
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS gpu_quota TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS storage_quota TEXT;
|
||||
91
backend/app/modules/approval/router.py
Normal file
91
backend/app/modules/approval/router.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter(prefix="/approvals", tags=["approval"])
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_templates() -> dict[str, Any]:
|
||||
return ok(get_platform_store().approval_templates())
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
def create_template(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
if not payload.get("name"):
|
||||
raise fail(400, "name 必填")
|
||||
return ok(get_platform_store().create_approval_template(payload))
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
def get_template(template_id: str) -> dict[str, Any]:
|
||||
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(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().update_approval_template(template_id, payload))
|
||||
except KeyError:
|
||||
raise fail(404, "template not found")
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template(template_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().delete_approval_template(template_id))
|
||||
except KeyError:
|
||||
raise fail(404, "template not found")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_instances(status: str | None = None) -> dict[str, Any]:
|
||||
return ok(get_platform_store().approval_instances(status=status))
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_instance(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
for field in ("resource_type", "resource_id", "applicant_id"):
|
||||
if not payload.get(field):
|
||||
raise fail(400, f"{field} 必填")
|
||||
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) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().approval_instance(instance_id))
|
||||
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(...),
|
||||
) -> dict[str, Any]:
|
||||
if not payload.get("approver_id"):
|
||||
raise fail(400, "approver_id 必填")
|
||||
try:
|
||||
return ok(
|
||||
get_platform_store().decide_approval_step(
|
||||
instance_id,
|
||||
step_index,
|
||||
approver_id=payload["approver_id"],
|
||||
approved=bool(payload.get("approved", False)),
|
||||
comment=payload.get("comment"),
|
||||
)
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
raise fail(400, str(e))
|
||||
244
backend/app/modules/project/router.py
Normal file
244
backend/app/modules/project/router.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.core.auth import filter_accessible_resource_ids, get_current_user, has_resource_access, is_admin
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["project"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> str | None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
return token or None
|
||||
|
||||
|
||||
def _require_no_pending_approval(resource_type: str, resource_id: str) -> None:
|
||||
"""第 4 周:写操作审批拦截——存在待审批实例时拒绝执行。"""
|
||||
store = get_platform_store()
|
||||
pending = [
|
||||
i for i in store.approval_instances(status="pending")
|
||||
if i["resource_type"] == resource_type and i["resource_id"] == resource_id
|
||||
]
|
||||
if pending:
|
||||
raise fail(409, "存在待审批的变更,请先完成审批")
|
||||
|
||||
|
||||
def _require_approval_or_admin(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
current_user: dict[str, Any],
|
||||
action_desc: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
"""高风险操作审批旁路:admin 直接放行,普通用户创建审批实例(code=202)。"""
|
||||
if is_admin(current_user):
|
||||
return None
|
||||
store = get_platform_store()
|
||||
instance = store.create_approval_instance({
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"applicant_id": current_user.get("id"),
|
||||
"template_id": None,
|
||||
})
|
||||
return {
|
||||
"code": 202,
|
||||
"message": f"操作已提交审批,等待管理员批准:{action_desc}",
|
||||
"data": {"approval_required": True, "approval_id": instance["id"]},
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_projects(
|
||||
tenant_id: str = "default",
|
||||
status: str | None = None,
|
||||
keyword: str | None = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
projects = store.projects(tenant_id=tenant_id, status=status, keyword=keyword)
|
||||
# #1 ACL 过滤:admin 直接放行,普通用户只能看到自己被授权的项目
|
||||
accessible_ids = set(
|
||||
filter_accessible_resource_ids("project", [p["id"] for p in projects], current_user)
|
||||
)
|
||||
filtered = [p for p in projects if p["id"] in accessible_ids]
|
||||
return ok(filtered)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_project(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
proj = store.create_project(payload)
|
||||
store.record_audit(
|
||||
action="project.create",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project",
|
||||
target_id=proj["id"],
|
||||
tenant_id=proj.get("tenant_id"),
|
||||
detail=f"name={proj.get('name')}",
|
||||
)
|
||||
return ok(proj)
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
def get_project(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
# #2 访问控制:普通用户无 read 权限则拒绝
|
||||
if not has_resource_access("project", project_id, current_user, "read"):
|
||||
raise fail(403, "no permission to access this project")
|
||||
try:
|
||||
return ok(get_platform_store().project(project_id))
|
||||
except KeyError:
|
||||
raise fail(404, "project not found")
|
||||
|
||||
|
||||
@router.put("/{project_id}")
|
||||
def update_project(
|
||||
project_id: str,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if not has_resource_access("project", project_id, current_user, "write"):
|
||||
raise fail(403, "no permission to update this project")
|
||||
store = get_platform_store()
|
||||
try:
|
||||
proj = store.update_project(project_id, payload)
|
||||
except KeyError:
|
||||
raise fail(404, "project not found")
|
||||
store.record_audit(
|
||||
action="project.update",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project",
|
||||
target_id=project_id,
|
||||
tenant_id=proj.get("tenant_id"),
|
||||
detail=f"fields={','.join(payload.keys())}",
|
||||
)
|
||||
return ok(proj)
|
||||
|
||||
|
||||
@router.post("/{project_id}/archive")
|
||||
def archive_project(
|
||||
project_id: str,
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
_require_no_pending_approval("project", project_id)
|
||||
pending = _require_approval_or_admin("project", project_id, current_user, f"归档项目 {project_id}")
|
||||
if pending:
|
||||
return pending
|
||||
store = get_platform_store()
|
||||
try:
|
||||
proj = store.archive_project(project_id)
|
||||
except KeyError:
|
||||
raise fail(404, "project not found")
|
||||
store.record_audit(
|
||||
action="project.archive",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project",
|
||||
target_id=project_id,
|
||||
tenant_id=proj.get("tenant_id"),
|
||||
)
|
||||
return ok(proj)
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
def delete_project(
|
||||
project_id: str,
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
_require_no_pending_approval("project", project_id)
|
||||
pending = _require_approval_or_admin("project", project_id, current_user, f"删除项目 {project_id}")
|
||||
if pending:
|
||||
return pending
|
||||
store = get_platform_store()
|
||||
store.delete_project(project_id)
|
||||
store.record_audit(
|
||||
action="project.delete",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project",
|
||||
target_id=project_id,
|
||||
)
|
||||
return ok(None)
|
||||
|
||||
|
||||
@router.get("/{project_id}/members")
|
||||
def list_members(project_id: str, current_user: dict = Depends(get_current_user)) -> dict[str, Any]:
|
||||
if not has_resource_access("project", project_id, current_user, "read"):
|
||||
raise fail(403, "no permission to access this project")
|
||||
try:
|
||||
return ok(get_platform_store().project_members(project_id))
|
||||
except KeyError:
|
||||
raise fail(404, "project not found")
|
||||
|
||||
|
||||
@router.post("/{project_id}/members")
|
||||
def add_member(
|
||||
project_id: str,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if not has_resource_access("project", project_id, current_user, "write"):
|
||||
raise fail(403, "no permission to manage members of this project")
|
||||
store = get_platform_store()
|
||||
try:
|
||||
member = store.add_project_member(project_id, payload)
|
||||
except KeyError:
|
||||
raise fail(404, "project not found")
|
||||
store.record_audit(
|
||||
action="project.member.add",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project.member",
|
||||
target_id=project_id,
|
||||
detail=f"user_id={payload.get('user_id')},role={payload.get('role')}",
|
||||
)
|
||||
return ok(member)
|
||||
|
||||
|
||||
@router.put("/{project_id}/members/{user_id}")
|
||||
def update_member(
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if not has_resource_access("project", project_id, current_user, "write"):
|
||||
raise fail(403, "no permission to manage members of this project")
|
||||
store = get_platform_store()
|
||||
try:
|
||||
member = store.update_project_member_role(project_id, user_id, payload)
|
||||
except KeyError:
|
||||
raise fail(404, "project or member not found")
|
||||
store.record_audit(
|
||||
action="project.member.update",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project.member",
|
||||
target_id=project_id,
|
||||
detail=f"user_id={user_id},role={payload.get('role')}",
|
||||
)
|
||||
return ok(member)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{user_id}")
|
||||
def remove_member(
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
request: Request = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
if not has_resource_access("project", project_id, current_user, "write"):
|
||||
raise fail(403, "no permission to manage members of this project")
|
||||
store = get_platform_store()
|
||||
store.remove_project_member(project_id, user_id)
|
||||
store.record_audit(
|
||||
action="project.member.remove",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="project.member",
|
||||
target_id=project_id,
|
||||
detail=f"user_id={user_id}",
|
||||
)
|
||||
return ok(None)
|
||||
1
backend/app/modules/resource/__init__.py
Normal file
1
backend/app/modules/resource/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Resource access control list (ACL) module."""
|
||||
41
backend/app/modules/resource/router.py
Normal file
41
backend/app/modules/resource/router.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter(prefix="/resources", tags=["resource"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> str | None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
return token or None
|
||||
|
||||
|
||||
@router.get("/{resource_type}/{resource_id}/acl")
|
||||
def get_acl(resource_type: str, resource_id: str) -> dict[str, Any]:
|
||||
"""查询资源 ACL,返回按主体分组的权限列表。"""
|
||||
return ok(get_platform_store().resource_acl(resource_type, resource_id))
|
||||
|
||||
|
||||
@router.put("/{resource_type}/{resource_id}/acl")
|
||||
def set_acl(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
request: Request = None,
|
||||
) -> dict[str, Any]:
|
||||
"""设置资源 ACL,body: { entries: [{ subject_type, subject_id, permissions: [] }] }"""
|
||||
entries = payload.get("entries") or []
|
||||
result = get_platform_store().set_resource_acl(resource_type, resource_id, entries)
|
||||
get_platform_store().record_audit(
|
||||
action="resource.acl.set",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type=resource_type,
|
||||
target_id=resource_id,
|
||||
detail=f"entries={len(entries)}",
|
||||
)
|
||||
return ok(result)
|
||||
75
backend/app/modules/retention/router.py
Normal file
75
backend/app/modules/retention/router.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter(prefix="/retention-policies", tags=["retention"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> str | None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
return token or None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_policies() -> dict[str, Any]:
|
||||
return ok(get_platform_store().retention_policies())
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_policy(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
if not payload.get("name"):
|
||||
raise fail(400, "name 必填")
|
||||
policy = get_platform_store().create_retention_policy(payload)
|
||||
get_platform_store().record_audit(
|
||||
action="retention.create",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="retention_policy",
|
||||
target_id=policy["id"],
|
||||
detail=f"name={policy.get('name')}",
|
||||
)
|
||||
return ok(policy)
|
||||
|
||||
|
||||
@router.get("/{policy_id}")
|
||||
def get_policy(policy_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().retention_policy(policy_id))
|
||||
except KeyError:
|
||||
raise fail(404, "retention policy not found")
|
||||
|
||||
|
||||
@router.put("/{policy_id}")
|
||||
def update_policy(
|
||||
policy_id: str, payload: dict[str, Any] = Body(...), request: Request = None
|
||||
) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
policy = store.update_retention_policy(policy_id, payload)
|
||||
except KeyError:
|
||||
raise fail(404, "retention policy not found")
|
||||
store.record_audit(
|
||||
action="retention.update",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="retention_policy",
|
||||
target_id=policy_id,
|
||||
detail=f"fields={','.join(payload.keys())}",
|
||||
)
|
||||
return ok(policy)
|
||||
|
||||
|
||||
@router.delete("/{policy_id}")
|
||||
def delete_policy(policy_id: str, request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
store.delete_retention_policy(policy_id)
|
||||
store.record_audit(
|
||||
action="retention.delete",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="retention_policy",
|
||||
target_id=policy_id,
|
||||
)
|
||||
return ok({"deleted": policy_id})
|
||||
93
backend/app/modules/system/router.py
Normal file
93
backend/app/modules/system/router.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.db.platform_store import ALL_PERMISSIONS, get_platform_store
|
||||
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/permissions/codes")
|
||||
def permission_codes() -> dict:
|
||||
"""返回平台权限码清单(权限码接口)。"""
|
||||
return {"code": 0, "message": "ok", "data": {"codes": ALL_PERMISSIONS}}
|
||||
|
||||
|
||||
@router.get("/permissions")
|
||||
def permissions_overview() -> dict:
|
||||
"""返回权限码清单与角色定义。"""
|
||||
store = get_platform_store()
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {"codes": ALL_PERMISSIONS, "roles": store.roles()},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit-logs")
|
||||
def audit_logs(
|
||||
tenant_id: str | None = Query(default=None, description="租户 ID"),
|
||||
project_id: str | None = Query(default=None, description="项目 ID"),
|
||||
actor_id: str | None = Query(default=None, description="操作人 ID"),
|
||||
action: str | None = Query(default=None, description="动作类型"),
|
||||
target_type: str | None = Query(default=None, description="目标类型"),
|
||||
start_time: str | None = Query(default=None, description="ISO8601 起始时间"),
|
||||
end_time: str | None = Query(default=None, description="ISO8601 结束时间"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
"""审计日志查询:按租户/项目/操作人/动作/目标类型/时间范围分页过滤。"""
|
||||
store = get_platform_store()
|
||||
result = store.audit_logs(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {"code": 0, "message": "ok", "data": result}
|
||||
|
||||
|
||||
@router.get("/audit-logs/export")
|
||||
def audit_logs_export(
|
||||
tenant_id: str | None = Query(default=None, description="租户 ID"),
|
||||
project_id: str | None = Query(default=None, description="项目 ID"),
|
||||
actor_id: str | None = Query(default=None, description="操作人 ID"),
|
||||
action: str | None = Query(default=None, description="动作类型"),
|
||||
target_type: str | None = Query(default=None, description="目标类型"),
|
||||
start_time: str | None = Query(default=None, description="ISO8601 起始时间"),
|
||||
end_time: str | None = Query(default=None, description="ISO8601 结束时间"),
|
||||
) -> StreamingResponse:
|
||||
"""审计日志导出:返回 CSV 流,与应用查询相同的过滤条件。"""
|
||||
store = get_platform_store()
|
||||
result = store.audit_logs(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=10000,
|
||||
offset=0,
|
||||
)
|
||||
items = result["items"]
|
||||
columns = ["time", "tenant_id", "project_id", "actor_id", "action", "target_type", "target_id", "detail", "client_ip"]
|
||||
header = ",".join(columns) + "\n"
|
||||
|
||||
def iter_rows():
|
||||
yield header
|
||||
for row in items:
|
||||
yield ",".join(f'"{str(row.get(c, "") or "")}"' for c in columns) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
iter_rows(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=audit_logs.csv"},
|
||||
)
|
||||
116
backend/app/modules/tenant/router.py
Normal file
116
backend/app/modules/tenant/router.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.endpoints.platform import ok, fail
|
||||
from app.db.platform_store import get_platform_store
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["tenant"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> str | None:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.replace("Bearer ", "").strip()
|
||||
return token or None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tenants() -> dict[str, Any]:
|
||||
return ok(get_platform_store().tenants())
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_tenant(payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
tenant = store.create_tenant(payload)
|
||||
except KeyError as e:
|
||||
raise fail(400, f"missing field: {e}")
|
||||
store.record_audit(
|
||||
action="tenant.create",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="tenant",
|
||||
target_id=tenant["id"],
|
||||
tenant_id=tenant["id"],
|
||||
detail=f"name={tenant.get('name')}",
|
||||
)
|
||||
return ok(tenant)
|
||||
|
||||
|
||||
@router.get("/{tenant_id}")
|
||||
def get_tenant(tenant_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return ok(get_platform_store().tenant(tenant_id))
|
||||
except KeyError:
|
||||
raise fail(404, "tenant not found")
|
||||
|
||||
|
||||
@router.put("/{tenant_id}")
|
||||
def update_tenant(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
tenant = store.update_tenant(tenant_id, payload)
|
||||
except KeyError:
|
||||
raise fail(404, "tenant not found")
|
||||
store.record_audit(
|
||||
action="tenant.update",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="tenant",
|
||||
target_id=tenant_id,
|
||||
tenant_id=tenant_id,
|
||||
detail=f"fields={','.join(payload.keys())}",
|
||||
)
|
||||
return ok(tenant)
|
||||
|
||||
|
||||
@router.put("/{tenant_id}/quota")
|
||||
def set_quota(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
tenant = store.set_tenant_quota(tenant_id, payload.get("quota", {}))
|
||||
except KeyError:
|
||||
raise fail(404, "tenant not found")
|
||||
store.record_audit(
|
||||
action="tenant.quota.set",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="tenant",
|
||||
target_id=tenant_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return ok(tenant)
|
||||
|
||||
|
||||
@router.put("/{tenant_id}/retention-policy")
|
||||
def set_retention(tenant_id: str, payload: dict[str, Any] = Body(...), request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
tenant = store.set_tenant_retention(tenant_id, payload.get("retention_policy_id"))
|
||||
except KeyError:
|
||||
raise fail(404, "tenant not found")
|
||||
store.record_audit(
|
||||
action="tenant.retention.set",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="tenant",
|
||||
target_id=tenant_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return ok(tenant)
|
||||
|
||||
|
||||
@router.delete("/{tenant_id}")
|
||||
def delete_tenant(tenant_id: str, request: Request = None) -> dict[str, Any]:
|
||||
store = get_platform_store()
|
||||
try:
|
||||
tenant = store.delete_tenant(tenant_id)
|
||||
except KeyError:
|
||||
raise fail(404, "tenant not found")
|
||||
store.record_audit(
|
||||
action="tenant.delete",
|
||||
actor_id=_actor(request) if request else None,
|
||||
target_type="tenant",
|
||||
target_id=tenant_id,
|
||||
tenant_id=tenant_id,
|
||||
detail=f"name={tenant.get('name')}",
|
||||
)
|
||||
return ok(tenant)
|
||||
Reference in New Issue
Block a user