Files
AgentLab/backend/app/api/tasks.py
T
ThearchyHelios 19329c873e 访问密钥与服务账号的后端(下一轮第 2 波 W2-d,细案 04 A1–A6、总方案 X8)
外部系统(门户、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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
2026-10-16 13:48:09 +08:00

160 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""待办(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)