mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
19329c873e
外部系统(门户、OA、BI)和个人脚本要用一个身份调接口,以前只能拿个人会话 cookie 去模拟。这次加访问密钥:
- 签发(A1):users.kind 区分人和服务账号(svc- 开头、没有口令、不能登录、角色固定使用者,管理员建);access_keys 只存
sha256 和开头一截,明文 agl_ 加 32 字节随机串只显示一次;到期必填(最长 365 天、缺省 90 天)、可撤销、可轮换(新钥 + 旧钥
24 小时后到期);个人访问密钥由管理员开关 auth.personal_keys 管,缺省关,关掉时已有的个人密钥一并不认
- 认人与门禁(A2):Principal 加 via / key_id / key_name / scopes / app_ids(全部带默认值);中间件认 Bearer agl_…,不看
cookie、不查 CSRF、不看 X-Actor,认不出的各回 401 带各自的 code、绝不退回匿名;WebSocket 带密钥一律拒;关登录时 409
keys_need_login。KEY_ROUTES(方法 + 路由模板 → scope)一张表,FastAPI(dependencies=[Depends(key_gate)]) 挂一次;不在表里的
403 key_route_forbidden,缺 scope、限定应用、个人应用和试运行各有各的 403;每把密钥每分钟 120 次(429 + Retry-After)
- 封顶(A3):按密钥认出来的一律是使用者(拥有者现在的组),现有的使用者规则和遮罩全部自动生效,管理员的密钥也一样
- 写操作(A4):发起入口、刷新视图必须带 Idempotency-Key,api_receipts 按(拥有者, 键)去重,轮换后照样认得出;状态机从
process/hooks.py 抽到 process/receipts.py,钩子和密钥共用(钩子现有测试原样通过),另按请求指纹拒掉同一个键办另一件事;
有文件控件的入口 422;回复待办只给个人密钥、只限审批 / 补充输入 / 编辑稿件;run.origin、task.responded 记 via=key、哪把密钥
- 视图数据(A5):GET /api/apps/{id}/views/{view}/data(agentlab.viewdata/1),印章、清单哈希、口径卡指标原值和渲染文字,
遮罩的 value 为 null(和页面同一个判断),按需视图只读缓存、不发起运行,只给正式运行
- 留痕(A6):建 / 撤销 / 轮换密钥、建 / 停用服务账号、开关个人密钥写审计;请求记录进程内缓冲、调度循环一拍里批量写、
保留 90 天;last_used_at 至少隔 1 分钟写一次;钩子收件记录接口(manage),验签失败只在进程内按钩子计数
- 名录与派单:服务账号在名录里标出,岗位绑定、选人候选、提交审核、改派里排除,应用授权照收;服务账号发起的实例里派给发起人
的待办交给任何人
- 接口说明(A7):GET /api/access-keys/reference 从 KEY_ROUTES 生成(要登录)
测试:签发与各种 401、门禁合约(遍历全站路由)、CSRF 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
160 lines
7.1 KiB
Python
160 lines
7.1 KiB
Python
"""待办(BE-2):列表、计数、详情、回复、改派、催办。设计文档 §3.7、§3.10。
|
||
|
||
身份经 api/deps.current_principal 取;关登录时它没有名字,用自报的署名(X-Actor)补上,回复人一栏才写得出人。
|
||
回复被拒回 403 / 404 / 409 / 422,{detail, code},一律带 code(外部系统按它认);表单不通过时另带 errors(同应用入口的
|
||
422)。服务层没带码的拒绝按状态补上同一类的码(api/coded.py 的 TASK_*)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Literal
|
||
|
||
from fastapi import APIRouter, Header, Query
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.api.coded import (
|
||
ROLE_REQUIRED, TASK_CLOSED, TASK_FORBIDDEN, TASK_INVALID, TASK_NOT_FOUND, CodedHTTPException,
|
||
)
|
||
from app.api.deps import current_principal, decode_actor
|
||
from app.process import tasks as task_service
|
||
from app.process.tasks import FormInvalid, TaskRefused
|
||
|
||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||
|
||
|
||
def principal_of(x_actor: str | None) -> Any:
|
||
return task_service.principal_with_actor(current_principal(), decode_actor(x_actor))
|
||
|
||
|
||
#: 服务层(process/tasks.py、process/actions.py、blueprints/personal.py)没带码的拒绝按状态归到哪一类
|
||
_CODE_BY_STATUS = {404: TASK_NOT_FOUND, 403: TASK_FORBIDDEN, 409: TASK_CLOSED, 422: TASK_INVALID}
|
||
|
||
|
||
def refused(e: TaskRefused) -> CodedHTTPException | JSONResponse:
|
||
if isinstance(e, FormInvalid):
|
||
return JSONResponse({"detail": str(e), "code": e.code, "errors": e.errors}, status_code=422)
|
||
return CodedHTTPException(e.status_code, str(e), e.code or _CODE_BY_STATUS.get(e.status_code, TASK_CLOSED))
|
||
|
||
|
||
def _raise(e: TaskRefused) -> Any:
|
||
out = refused(e)
|
||
if isinstance(out, JSONResponse):
|
||
return out
|
||
raise out from e
|
||
|
||
|
||
def _csv(value: str | None) -> list[str]:
|
||
return [v.strip() for v in (value or "").split(",") if v.strip()]
|
||
|
||
|
||
@router.get("")
|
||
async def list_tasks(
|
||
assigned: Literal["me", "all"] = "me",
|
||
status: str = Query(default="pending", description="pending / resolved / all,可逗号分隔"),
|
||
app_id: str | None = None,
|
||
run_id: str | None = None,
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
x_actor: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
principal = principal_of(x_actor)
|
||
if assigned == "all" and principal.mode != "off" and not principal.at_least("builder"):
|
||
raise CodedHTTPException(403, "只能查看派给自己的待办", ROLE_REQUIRED)
|
||
statuses = None if status == "all" else _csv(status) or ["pending"]
|
||
items = await task_service.list_tasks(principal, assigned=assigned, statuses=statuses, app_id=app_id,
|
||
run_id=run_id, limit=limit)
|
||
return {"items": await _for_key(principal, items)}
|
||
|
||
|
||
async def _for_key(principal: Any, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""访问密钥(04 细案 A2):个人应用、只给拥有者的运行(个人应用的刷新、蓝图试运行)里的待办不列;限定了应用的只列那几个
|
||
应用里的(不属于应用的待办也不列)。不是密钥认出来的原样交回。"""
|
||
if getattr(principal, "via", None) != "key":
|
||
return items
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import App, Run
|
||
|
||
app_ids = {i.get("app_id") for i in items if i.get("app_id")}
|
||
run_ids = {i.get("run_id") for i in items if i.get("run_id")}
|
||
async with SessionLocal() as session:
|
||
personal = set((await session.execute(select(App.id).where(App.id.in_(app_ids or {""}),
|
||
App.scope == "personal"))).scalars())
|
||
private = set((await session.execute(select(Run.id).where(
|
||
Run.id.in_(run_ids or {""}), Run.origin_kind.in_(("personal_app", "trial"))))).scalars())
|
||
return [i for i in items if i.get("app_id") not in personal and i.get("run_id") not in private
|
||
and (principal.app_ids is None or i.get("app_id") in principal.app_ids)]
|
||
|
||
|
||
@router.get("/count")
|
||
async def count_tasks(mine: int = 1, x_actor: str | None = Header(default=None)) -> dict[str, Any]:
|
||
"""导航徽标:派给我的、待处理的待办数,按来源分开(apps:应用里的;other:画布、定时任务的审批);latest 是最新一条的开出时间。
|
||
|
||
访问密钥按它碰得到的那几条数(同列表的 _for_key),不另写一份计数的口径。"""
|
||
principal = principal_of(x_actor)
|
||
if getattr(principal, "via", None) != "key":
|
||
return await task_service.count(principal)
|
||
items = await _for_key(principal, await task_service.list_tasks(principal, statuses=["pending"], limit=500))
|
||
stamps = [i.get("created_at") for i in items if i.get("created_at")]
|
||
apps = sum(1 for i in items if i.get("app_id"))
|
||
return {"mine": len(items), "apps": apps, "other": len(items) - apps, "latest": max(stamps) if stamps else None}
|
||
|
||
|
||
@router.get("/{task_id}")
|
||
async def get_task(task_id: str, x_actor: str | None = Header(default=None)) -> dict[str, Any]:
|
||
principal = principal_of(x_actor)
|
||
out = await task_service.detail(task_id, principal)
|
||
if out is None or not task_service.visible(out, principal):
|
||
raise CodedHTTPException(404, "待办不存在,所属的运行可能已被删除", TASK_NOT_FOUND)
|
||
return out
|
||
|
||
|
||
class RespondIn(BaseModel):
|
||
decision: str
|
||
values: dict[str, Any] | None = None
|
||
note: str | None = None
|
||
#: 动作确认:确认卡上显示的内容指纹,原样带回
|
||
payload_hash: str | None = None
|
||
#: 核实「已生效」时填的外部单号
|
||
external_id: str | None = None
|
||
|
||
|
||
@router.post("/{task_id}/respond", response_model=None)
|
||
async def respond(task_id: str, payload: RespondIn, x_actor: str | None = Header(default=None)) -> Any:
|
||
values = dict(payload.values or {})
|
||
if payload.external_id:
|
||
values["external_id"] = payload.external_id
|
||
try:
|
||
out = await task_service.respond(task_id, principal_of(x_actor), decision=payload.decision, values=values,
|
||
note=payload.note, payload_hash=payload.payload_hash)
|
||
except TaskRefused as e:
|
||
return _raise(e)
|
||
return {"task": out["task"], "resolved": out["resolved"]}
|
||
|
||
|
||
class Target(BaseModel):
|
||
kind: Literal["user", "group"]
|
||
name: str
|
||
|
||
|
||
class ReassignIn(BaseModel):
|
||
to: list[Target] = Field(default_factory=list)
|
||
note: str | None = None
|
||
|
||
|
||
@router.post("/{task_id}/reassign", response_model=None)
|
||
async def reassign(task_id: str, payload: ReassignIn, x_actor: str | None = Header(default=None)) -> Any:
|
||
try:
|
||
return await task_service.reassign(task_id, principal_of(x_actor),
|
||
to=[t.model_dump() for t in payload.to], note=payload.note)
|
||
except TaskRefused as e:
|
||
return _raise(e)
|
||
|
||
|
||
@router.post("/{task_id}/remind", response_model=None)
|
||
async def remind(task_id: str, x_actor: str | None = Header(default=None)) -> Any:
|
||
try:
|
||
return await task_service.remind(task_id, principal_of(x_actor))
|
||
except TaskRefused as e:
|
||
return _raise(e)
|