mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:11:59 +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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
93 lines
5.3 KiB
Python
93 lines
5.3 KiB
Python
"""按幂等键去重的收件记录(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()
|