Files
AgentLab/backend/app/process/receipts.py
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

93 lines
5.3 KiB
Python
Raw Permalink 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.
"""按幂等键去重的收件记录(docs/design/next-round/04-对外集成与触达.md A4):入站钩子(hook_receipts)和访问密钥
(api_receipts)共用这一套状态机,各存各的表(Book 按表参数化)。
一条记录按(谁, 幂等键)唯一:钩子按钩子,访问密钥按拥有者(轮换了密钥,同一个键照样认得出是同一件事)。
- claim:先插一行 processing。插得进去就是头一回,交回 (None, False);插不进去(撞了唯一约束)看已有的那一行:
- accepted:交回上次的结果(duplicate: true),不再处理;
- rejected:重新认领。被拒的(表单不对、没有在等的待办)可以用同一个键重试——外部系统多半会原样重发,拒过一次就永远拒,
它只能换键,去重也就落空了;
- processing 超过 STALE_S:处理一次请求用不了这么久,多半是中途服务停了,重新认领,交回 reclaimed=True。调用方先找这个键
已经办成的事(已经发起的实例、已经解决的待办),找到就交回它,不重复办;
- processing 没超时:正在处理,抛 ReceiptBusy(调用方回 409)。
重新认领用条件 UPDATE(状态、收到时间都对得上才改):并发的两次只有一次认领得到。
- finish:处理完写结果(accepted 带运行或待办,rejected 带原因)。
有指纹的表(api_receipts.fingerprint)另核对同一个键办的是不是同一件事:办成过的、还在办的,指纹对不上抛 ReceiptMismatch,
不把上一件事的结果当成这一件的交回去;被拒过的不算办过,换了内容重试照常认领(指纹换成这一次的)。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from app.db.base import SessionLocal, utcnow
#: 回执停在 processing 超过这么久(秒),同一个幂等键可以重新认领
STALE_S = 600
#: 幂等键最长存多少字
KEY_MAX = 200
class ReceiptBusy(Exception):
"""同一个幂等键的请求正在处理(调用方回 409)。"""
class ReceiptMismatch(Exception):
"""同一个幂等键已经用来办过另一件事(指纹对不上,调用方回 422)。"""
@dataclass(frozen=True)
class Book:
"""一张收件记录表:model 是表,owner 是「谁」那一列的名字(hook_id / owner_user_id)。表里要有 idem_key、status、
detail、run_id、task_id、received_at;fingerprint 列可有可无。"""
model: Any
owner: str
stale_s: int = STALE_S
def _key(self, owner: str, idem_key: str) -> list[Any]:
return [getattr(self.model, self.owner) == owner, self.model.idem_key == idem_key]
async def claim(self, owner: str, idem_key: str, *, fingerprint: str | None = None,
extra: dict[str, Any] | None = None) -> tuple[dict[str, Any] | None, bool]:
"""按(谁, 幂等键)认领这一次,交回 (上次的结果, 是不是重新认领了一条停在处理中的)。规则见模块说明。extra 是
认领时一并记下的列(访问密钥记哪一把密钥、哪个接口)。"""
fields = {**(extra or {}), **({"fingerprint": fingerprint} if fingerprint is not None else {})}
async with SessionLocal() as session:
session.add(self.model(**{self.owner: owner}, idem_key=idem_key, status="processing", **fields))
try:
await session.commit()
return None, False
except IntegrityError:
await session.rollback()
row = await session.scalar(select(self.model).where(*self._key(owner, idem_key)))
if row is None:
# 撞了约束、回头又查不到:那一行刚被删掉(钩子被删除时连同记录一起删)。当成正在处理,让调用方稍后重发
raise ReceiptBusy
known = getattr(row, "fingerprint", None)
if fingerprint is not None and known and known != fingerprint and row.status != "rejected":
raise ReceiptMismatch
if row.status == "accepted":
return {"status": "accepted", "duplicate": True, "run_id": row.run_id, "task_id": row.task_id}, False
stale = row.status == "processing" and row.received_at < utcnow() - timedelta(seconds=self.stale_s)
if row.status == "rejected" or stale:
again = await session.execute(update(self.model).where(
self.model.id == row.id, self.model.status == row.status,
self.model.received_at == row.received_at)
.values(status="processing", detail=None, received_at=utcnow(), **fields))
await session.commit()
if again.rowcount == 1:
return None, stale
raise ReceiptBusy
async def finish(self, owner: str, idem_key: str, *, status: str, detail: str | None = None,
run_id: str | None = None, task_id: str | None = None) -> None:
async with SessionLocal() as session:
await session.execute(update(self.model).where(*self._key(owner, idem_key))
.values(status=status, detail=detail, run_id=run_id, task_id=task_id))
await session.commit()