Files
AgentLab/backend/tests/test_access_key_gate.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

174 lines
8.4 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.
"""访问密钥的门禁(docs/design/next-round/04-对外集成与触达.md A2、A7):KEY_ROUTES 一张表,挂在全站一次。
合约测试遍历 app.routes:不在表里的 /api 接口,用密钥请求一律 403(key_route_forbidden);表里的每一行都对得上一个真实的
路由(改了路由、漏改表会在这里报出来)。另守:缺 scope 的 403(key_scope_missing);限定了应用时别的应用 403
(key_app_forbidden);个人应用、个人应用的刷新、蓝图的试运行一律 403(key_app_personal);带密钥连 WebSocket 被拒;
页面和 FastAPI 自带的接口说明不对密钥开放;接口说明(/api/access-keys/reference)和放行的接口出自同一张表。
"""
from __future__ import annotations
import re
import pytest
from fastapi.routing import APIRoute, APIWebSocketRoute
from sqlalchemy import delete
from app.auth import keys
from app.db.base import SessionLocal
from app.db.models import App, AppGrant, AppVersion, Run
from tests.fakes import auth_env
@pytest.fixture(autouse=True)
async def clean():
await auth_env.reset()
await auth_env.turn_on_local()
try:
yield
finally:
await auth_env.reset()
def _filled(path: str) -> str:
return re.sub(r"\{[^}]+\}", "x", path)
def _contexts():
"""全站的路由(FastAPI 0.14x 起 include_router 不再把路由摊平进 app.routes,按它给的路由上下文遍历)。"""
from fastapi.routing import iter_route_contexts
from app.main import app
return list(iter_route_contexts(app.routes))
async def test_every_route_not_in_the_table_is_forbidden_for_keys(monkeypatch):
monkeypatch.setattr(keys, "RATE_PER_MINUTE", 10_000) # 三百多个接口一口气调完,不让限流挡在门禁前面
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc, scopes=keys.SCOPES)
client = auth_env.keyed(secret)
seen: set[tuple[str, str]] = set()
checked = 0
for ctx in _contexts():
route = ctx.original_route
if not isinstance(route, APIRoute) or not ctx.path.startswith("/api/"):
continue
# 门禁按 scope['route'].path(原路由的模板)比对:include_router 不能再加前缀,不然两边对不上
assert route.path == ctx.path, f"{ctx.path} 的原路由模板是 {route.path}"
for method in sorted(ctx.methods or ()):
seen.add((method, ctx.path))
if (method, ctx.path) in keys.KEY_ROUTES:
continue
r = await client.request(method, _filled(ctx.path), json={} if method in ("POST", "PUT", "PATCH") else None)
assert r.status_code == 403 and r.json().get("code") == "key_route_forbidden", \
f"{method} {ctx.path} 用密钥调回了 {r.status_code} {r.text[:120]}"
checked += 1
assert checked > 200, "真遍历到了全部接口"
stale = set(keys.KEY_ROUTES) - seen
assert not stale, f"KEY_ROUTES 里有对不上路由的行:{stale}"
async def test_routes_in_the_table_need_their_scope():
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc, scopes=("apps.read",))
c = auth_env.keyed(secret)
assert (await c.get("/api/apps")).status_code == 200
r = await c.get("/api/tasks")
assert r.status_code == 403 and r.json()["code"] == "key_scope_missing"
r = await c.post("/api/apps/x/entries/y", json={"values": {}}, headers={"Idempotency-Key": "k"})
assert r.json()["code"] == "key_scope_missing", "发起要 apps.run"
assert (await c.get("/api/auth/me")).status_code == 200, "me 不用申请"
assert (await c.get("/api/health")).status_code == 200
async def test_pages_docs_and_websockets_are_closed_to_keys():
from app.auth.middleware import AuthMiddleware
from app.main import app
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc, scopes=keys.SCOPES)
c = auth_env.keyed(secret)
for path in ("/docs", "/openapi.json", "/apps"):
r = await c.get(path)
assert r.status_code == 403 and r.json()["code"] == "key_route_forbidden", path
sent: list[dict] = []
async def receive():
return {"type": "websocket.connect"}
async def send(message):
sent.append(message)
scope = {"type": "websocket", "path": "/api/runs/x/stream", "headers": [(b"authorization", f"Bearer {secret}".encode())],
"query_string": b""}
await AuthMiddleware(app.router)(scope, receive, send)
assert sent == [{"type": "websocket.close", "code": 1008}], "带密钥的握手前就关"
async def _apps() -> dict[str, str]:
async with SessionLocal() as s:
shared = App(name="周报", published_version=1, created_by="root")
other = App(name="报销", published_version=1, created_by="root")
mine = App(name="我的看板", scope="personal", owner="pia")
s.add_all([shared, other, mine])
await s.flush()
for app in (shared, other):
s.add(AppVersion(app_id=app.id, version=1, spec={"format": "agentlab.app/1", "pages": []}, spec_hash="h"))
s.add(AppGrant(app_id=app.id, principal_kind="user", principal="pia", role="observe"))
await s.commit()
return {"shared": shared.id, "other": other.id, "mine": mine.id}
async def test_app_limits_personal_apps_and_trial_runs():
await auth_env.personal_keys(True)
pia = await auth_env.make_user("pia", "user")
ids = await _apps()
try:
_, limited = await auth_env.make_key(pia, app_ids=[ids["shared"]])
_, free = await auth_env.make_key(pia, name="不限应用")
c, f = auth_env.keyed(limited), auth_env.keyed(free)
assert [a["id"] for a in (await c.get("/api/apps")).json()["items"]] == [ids["shared"]], "只列限定的那几个"
assert {a["id"] for a in (await f.get("/api/apps")).json()["items"]} == {ids["shared"], ids["other"]}, \
"个人应用不列"
assert (await c.get(f"/api/apps/{ids['shared']}")).status_code == 200
r = await c.get(f"/api/apps/{ids['other']}")
assert r.status_code == 403 and r.json()["code"] == "key_app_forbidden"
r = await f.get(f"/api/apps/{ids['mine']}")
assert r.status_code == 403 and r.json()["code"] == "key_app_personal", "自己的个人应用也不行"
async with SessionLocal() as s:
trial = Run(workflow_name="试运行", run_class="exploratory", origin_kind="trial", started_by="pia")
refresh = Run(workflow_name="刷新", run_class="exploratory", origin_kind="personal_app",
app_id=ids["mine"], started_by="pia")
elsewhere = Run(workflow_name="别处", run_class="formal", origin_kind="app_entry", app_id=ids["other"],
started_by="pia")
s.add_all([trial, refresh, elsewhere])
await s.commit()
runs = (trial.id, refresh.id, elsewhere.id)
for run_id in runs[:2]:
r = await f.get(f"/api/runs/{run_id}/report.html")
assert r.status_code == 403 and r.json()["code"] == "key_app_personal"
r = await c.get(f"/api/runs/{runs[2]}/evidence")
assert r.status_code == 403 and r.json()["code"] == "key_app_forbidden", "限定了应用:别的应用的运行也不行"
finally:
async with SessionLocal() as s:
await s.execute(delete(AppGrant).where(AppGrant.app_id.in_(list(ids.values()))))
await s.execute(delete(AppVersion).where(AppVersion.app_id.in_(list(ids.values()))))
await s.execute(delete(App).where(App.id.in_(list(ids.values()))))
await s.commit()
async def test_the_reference_is_generated_from_the_same_table():
admin = await auth_env.login("root")
ref = (await admin.get("/api/access-keys/reference")).json()
assert {(r["method"], r["path"]) for r in ref["routes"]} == set(keys.KEY_ROUTES)
entry = next(r for r in ref["routes"] if r["path"] == "/api/apps/{app_id}/entries/{entry_id}")
assert entry["scope"] == "apps.run" and entry["idempotent"] is True
assert "key_idempotency_required" in entry["errors"] and "key_files_unsupported" in entry["errors"]
assert "key_route_forbidden" in ref["common_errors"] and ref["rate_per_minute"] == keys.RATE_PER_MINUTE
def test_websocket_routes_are_never_in_the_table():
sockets = {c.path for c in _contexts() if isinstance(c.original_route, APIWebSocketRoute)}
assert sockets and not {p for _, p in keys.KEY_ROUTES} & sockets