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