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

342 lines
19 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 A1、A2、A6)。
要守住的:
- 密钥只在建、轮换时显示一次,库里只有 sha256 和开头一截;到期必填、最长 365 天;
- 认不出的密钥各回 401 带各自的 code(无效、到期、撤销、拥有者停用、拥有者被删、个人密钥开关关了),绝不退回匿名或会话;
- 轮换后旧密钥 24 小时内还能用,过了就失效;关登录时带密钥回 409(keys_need_login);
- 带密钥的请求只按密钥认:不查 CSRF、不看 cookie、不看 X-Actor;带 cookie、不带来源标记的 POST 照旧 403;
- 每把密钥每分钟 120 次,超了 429 带 Retry-After;
- 建、撤销、轮换密钥,建、停用服务账号,开关个人密钥都写审计;请求记录批量写之后查得到、超过保留期的清掉;
- 服务账号 svc- 开头、不能登录、角色固定;名录里标出,岗位绑定、选人候选里排除,授权照收。
"""
from __future__ import annotations
from datetime import timedelta
import pytest
from sqlalchemy import select, update
from app.auth import keys
from app.db.base import SessionLocal, utcnow
from app.db.models import AuditEvent, User
from app.db.models_identity import AccessKey, AccessKeyRequest
from tests.fakes import auth_env
@pytest.fixture(autouse=True)
async def clean():
await auth_env.reset()
try:
yield
finally:
await auth_env.reset()
async def _audits(action: str) -> list[AuditEvent]:
async with SessionLocal() as s:
return list((await s.execute(select(AuditEvent).where(AuditEvent.action == action))).scalars())
# ---------------------------------------------------------------- 签发
async def test_a_personal_key_is_shown_once_and_only_its_hash_is_stored():
await auth_env.turn_on_local()
await auth_env.make_user("ana", "builder")
ana = await auth_env.login("ana")
r = await ana.post("/api/access-keys", json={"name": "日报脚本"})
assert r.status_code == 409 and r.json()["code"] == "personal_keys_off", "开关缺省关:建不了个人密钥"
await auth_env.personal_keys(True)
r = await ana.post("/api/access-keys", json={"name": "日报脚本"})
assert r.status_code == 201, r.text
body = r.json()
secret = body["key"]
assert secret.startswith("agl_") and len(secret) >= 4 + 43
assert body["scopes"] == list(keys.READ_SCOPES), "新建时缺省只勾读的"
assert body["prefix"] == secret[:12] and body["state"] == "active"
async with SessionLocal() as s:
row = await s.get(AccessKey, body["id"])
assert row.key_hash == keys.key_hash(secret) and secret not in (row.key_hash, row.prefix), "库里没有明文"
listed = (await ana.get("/api/access-keys")).json()["items"]
assert [i["id"] for i in listed] == [body["id"]] and "key" not in listed[0], "列表里不再有明文"
days = (keys.aware(row.expires_at) - utcnow()).days
assert days in (89, 90), "缺省 90 天"
too_long = await ana.post("/api/access-keys", json={"name": "x", "expires_in_days": 366})
assert too_long.status_code == 422, "最长 365 天"
bad_scope = await ana.post("/api/access-keys", json={"name": "x", "scopes": ["runs.read"]})
assert bad_scope.status_code == 422 and bad_scope.json()["field"] == "scopes"
assert [a.data["name"] for a in await _audits("access_key.create")] == ["日报脚本"]
async def test_each_unusable_key_gets_its_own_401_and_never_falls_back():
admin = await auth_env.turn_on_local()
await auth_env.personal_keys(True)
uma = await auth_env.make_user("uma", "user")
ok_row, ok = await auth_env.make_key(uma)
_, expired = await auth_env.make_key(uma, days=-1)
revoked_row, revoked = await auth_env.make_key(uma)
assert (await admin.post(f"/api/access-keys/{revoked_row.id}/revoke")).status_code == 200
async def code(secret: str, client=None) -> tuple[int, str | None]:
r = await (client or auth_env.keyed(secret)).get("/api/auth/me")
return r.status_code, r.json().get("code")
assert (await code(ok)) == (200, None)
assert (await code("agl_not-a-real-key")) == (401, "key_invalid")
assert (await code(expired)) == (401, "key_expired")
assert (await code(revoked)) == (401, "key_revoked")
# 带着管理员的会话 cookie 也不退回会话:认不出的密钥就是 401
admin.headers["Authorization"] = f"Bearer {revoked}"
assert (await code(revoked, admin)) == (401, "key_revoked")
del admin.headers["Authorization"]
async with SessionLocal() as s:
await s.execute(update(User).where(User.id == uma.id).values(disabled=True))
await s.commit()
assert (await code(ok)) == (401, "key_owner_disabled")
async with SessionLocal() as s:
await s.execute(update(User).where(User.id == uma.id).values(disabled=False))
await s.commit()
await auth_env.personal_keys(False)
assert (await code(ok)) == (401, "key_personal_off"), "管理员关了开关:已有的个人密钥一并不认"
await auth_env.personal_keys(True)
assert (await code(ok)) == (200, None)
async with SessionLocal() as s:
await s.delete(await s.get(User, uma.id))
await s.commit()
assert await s.get(AccessKey, ok_row.id) is None, "拥有者删了,密钥随外键一起删"
assert (await code(ok)) == (401, "key_invalid")
async def test_me_tells_who_the_key_is_and_its_scope_and_expiry():
await auth_env.turn_on_local()
svc = await auth_env.make_service()
row, secret = await auth_env.make_key(svc, scopes=("apps.read", "tasks.respond"), app_ids=["app-a"])
me = (await auth_env.keyed(secret).get("/api/auth/me")).json()
assert (me["username"], me["role"], me["verified"]) == ("svc-portal", "user", True)
assert me["key"]["id"] == row.id and me["key"]["service"] is True
assert me["key"]["scopes"] == ["apps.read", "me"], "服务账号的密钥去掉只给个人密钥的回复待办"
assert me["key"]["app_ids"] == ["app-a"] and me["key"]["expires_at"]
async def test_rotation_keeps_the_old_key_for_24_hours():
admin = await auth_env.turn_on_local()
svc = await auth_env.make_service()
created = (await admin.post("/api/access-keys", json={"name": "门户", "service_account_id": svc.id,
"scopes": ["apps.read"], "expires_in_days": 30})).json()
old = created["key"]
rotated = await admin.post(f"/api/access-keys/{created['id']}/rotate")
assert rotated.status_code == 200, rotated.text
body = rotated.json()
new = body["key"]
assert new != old and body["rotated_from"] == created["id"] and body["scopes"] == ["apps.read"]
assert (await auth_env.keyed(old).get("/api/auth/me")).status_code == 200, "旧密钥 24 小时内还能用"
assert (await auth_env.keyed(new).get("/api/auth/me")).status_code == 200
async with SessionLocal() as s:
row = await s.get(AccessKey, created["id"])
grace = keys.aware(row.expires_at) - utcnow()
assert timedelta(hours=23) < grace <= timedelta(hours=24)
fresh = await s.get(AccessKey, body["id"])
assert 29 <= (keys.aware(fresh.expires_at) - utcnow()).days <= 30, "新密钥的有效期长短照旧"
row.expires_at = utcnow() - timedelta(seconds=1) # 24 小时过去了
await s.commit()
assert (await auth_env.keyed(old).get("/api/auth/me")).json()["code"] == "key_expired"
again = await admin.post(f"/api/access-keys/{created['id']}/rotate")
assert again.status_code == 409, "过期的不能再轮换"
assert [a.subject_id for a in await _audits("access_key.rotate")] == [created["id"]]
async def test_keys_do_nothing_with_login_off():
c = auth_env.keyed("agl_whatever")
r = await c.get("/api/apps")
assert r.status_code == 409 and r.json()["code"] == "keys_need_login", "关登录时不能让调用方以为自己被收窄了"
off = auth_env.client()
assert (await off.post("/api/access-keys", json={"name": "x"})).json()["code"] == "keys_need_login"
assert (await off.get("/api/access-keys")).json() == {"items": []}
assert (await off.get("/api/access-keys/settings")).json()["login_on"] is False
# ---------------------------------------------------------------- 身份来源与 CSRF
async def test_bearer_skips_csrf_but_cookie_requests_still_need_it():
admin = await auth_env.turn_on_local()
await auth_env.personal_keys(True)
uma = await auth_env.make_user("uma", "user")
_, secret = await auth_env.make_key(uma)
# 带密钥的 POST 不带来源标记也能过 CSRF(到了接口:应用不存在 404,不是 csrf_header)
r = await auth_env.keyed(secret).post("/api/apps/nope/entries/x", json={"values": {}},
headers={"Idempotency-Key": "k1", "Origin": "https://evil.example"})
assert r.status_code == 404 and r.json()["code"] == "app_not_found", r.text
# 带 cookie、不带来源标记的 POST 照旧拒
del admin.headers["X-AgentLab-Request"]
assert (await admin.post("/api/groups", json={"name": "g"})).json()["code"] == "csrf_header"
# 同时带 cookie 和密钥:按密钥认,cookie 不起作用;X-Actor 一律不看
admin.headers["Authorization"] = f"Bearer {secret}"
me = (await admin.get("/api/auth/me", headers={"X-Actor": "mallory"})).json()
assert (me["username"], me["role"]) == ("uma", "user")
assert (await admin.get("/api/users")).json()["code"] == "key_route_forbidden", "管理员的 cookie 不起作用"
async def test_the_principal_from_a_key_is_capped_at_user():
await auth_env.turn_on_local()
await auth_env.personal_keys(True)
root = await _user("root")
_, secret = await auth_env.make_key(root)
me = (await auth_env.keyed(secret).get("/api/auth/me")).json()
assert (me["username"], me["role"]) == ("root", "user"), "管理员的密钥也按使用者算"
async def _user(username: str) -> User:
async with SessionLocal() as s:
return (await s.execute(select(User).where(User.username == username))).scalar_one()
# ---------------------------------------------------------------- 限流
async def test_each_key_is_rate_limited_per_minute(monkeypatch):
await auth_env.turn_on_local()
svc = await auth_env.make_service()
row, secret = await auth_env.make_key(svc)
monkeypatch.setattr(keys, "RATE_PER_MINUTE", 3)
c = auth_env.keyed(secret)
assert [(await c.get("/api/auth/me")).status_code for _ in range(3)] == [200, 200, 200]
r = await c.get("/api/auth/me")
assert r.status_code == 429 and r.json()["code"] == "key_rate_limited"
assert int(r.headers["retry-after"]) >= 1
_, other = await auth_env.make_key(svc, name="另一把")
assert (await auth_env.keyed(other).get("/api/auth/me")).status_code == 200, "按密钥各算各的"
assert keys.take(row.id, now=10**9) == 0, "过一会儿补满了就又能用"
# ---------------------------------------------------------------- 留痕
async def test_requests_are_buffered_written_in_batches_and_purged():
admin = await auth_env.turn_on_local()
svc = await auth_env.make_service()
row, secret = await auth_env.make_key(svc, scopes=("apps.read",))
c = auth_env.keyed(secret)
await c.get("/api/apps")
await c.get("/api/tasks") # 缺 scope:被拒的也记
async with SessionLocal() as s:
assert (await s.execute(select(AccessKeyRequest))).first() is None, "先攒在进程里,不是每个请求同步写一行"
from app.scheduler.loop import process_due
await process_due() # 调度循环的一拍
async with SessionLocal() as s:
rows = list((await s.execute(select(AccessKeyRequest).order_by(AccessKeyRequest.at))).scalars())
assert [(r.method, r.route, r.status) for r in rows] == [("GET", "/api/apps", 200), ("GET", "/api/tasks", 403)]
assert all(r.key_id == row.id for r in rows)
listed = (await admin.get(f"/api/access-keys/{row.id}/requests")).json()
assert [i["route"] for i in listed["items"]] == ["/api/tasks", "/api/apps"] and listed["retain_days"] == 90
async with SessionLocal() as s:
await s.execute(update(AccessKeyRequest).where(AccessKeyRequest.route == "/api/apps")
.values(at=utcnow() - timedelta(days=91)))
await s.commit()
await keys.tick(utcnow())
async with SessionLocal() as s:
left = [r.route for r in (await s.execute(select(AccessKeyRequest))).scalars()]
assert left == ["/api/tasks"], "超过 90 天的清掉"
async def test_last_used_is_written_at_most_once_a_minute():
await auth_env.turn_on_local()
svc = await auth_env.make_service()
row, secret = await auth_env.make_key(svc)
c = auth_env.keyed(secret)
await c.get("/api/auth/me")
async with SessionLocal() as s:
first = (await s.get(AccessKey, row.id)).last_used_at
await c.get("/api/auth/me")
async with SessionLocal() as s:
assert (await s.get(AccessKey, row.id)).last_used_at == first, "一分钟内不再写"
await s.execute(update(AccessKey).where(AccessKey.id == row.id)
.values(last_used_at=utcnow() - timedelta(minutes=2)))
await s.commit()
await c.get("/api/auth/me")
async with SessionLocal() as s:
assert keys.aware((await s.get(AccessKey, row.id)).last_used_at) > utcnow() - timedelta(seconds=30)
async def test_only_owners_and_admins_manage_keys_and_everything_is_audited():
admin = await auth_env.turn_on_local()
r = await admin.put("/api/access-keys/settings", json={"personal_keys": True})
assert r.status_code == 200 and r.json()["personal_keys"] is True
assert len(await _audits("auth.personal_keys")) == 1
await auth_env.make_user("ana", "builder")
await auth_env.make_user("bo", "user")
ana, bo = await auth_env.login("ana"), await auth_env.login("bo")
assert (await ana.put("/api/access-keys/settings", json={"personal_keys": False})).status_code == 403
mine = (await ana.post("/api/access-keys", json={"name": "a"})).json()
assert (await bo.post(f"/api/access-keys/{mine['id']}/revoke")).status_code == 404, "别人的密钥当不存在"
assert (await bo.get(f"/api/access-keys/{mine['id']}/requests")).status_code == 404
assert (await bo.get("/api/access-keys")).json()["items"] == []
assert len((await admin.get("/api/access-keys")).json()["items"]) == 1, "管理员看得到全部"
assert (await ana.post(f"/api/access-keys/{mine['id']}/revoke")).json()["state"] == "revoked"
assert len(await _audits("access_key.revoke")) == 1
# 服务账号:只给管理员建
assert (await ana.post("/api/access-keys/service-accounts", json={"username": "svc-x"})).status_code == 403
bad = await admin.post("/api/access-keys/service-accounts", json={"username": "portal"})
assert bad.status_code == 422 and bad.json()["code"] == "service_account_invalid", "统一 svc- 开头"
svc = (await admin.post("/api/access-keys/service-accounts", json={"username": "svc-portal",
"display_name": "门户"})).json()
assert svc["username"] == "svc-portal" and len(await _audits("service_account.create")) == 1
no_respond = await admin.post("/api/access-keys", json={"name": "k", "service_account_id": svc["id"],
"scopes": ["apps.read", "tasks.respond"]})
assert no_respond.status_code == 422, "服务账号不能被派待办,它的密钥不能勾回复待办"
key = (await admin.post("/api/access-keys", json={"name": "k", "service_account_id": svc["id"]})).json()
assert (await auth_env.keyed(key["key"]).get("/api/auth/me")).status_code == 200
out = await admin.patch(f"/api/access-keys/service-accounts/{svc['id']}", json={"disabled": True})
assert out.json()["disabled"] is True and len(await _audits("service_account.disable")) == 1
assert (await auth_env.keyed(key["key"]).get("/api/auth/me")).json()["code"] == "key_owner_disabled", \
"停用之后密钥失效"
ref = (await bo.get("/api/access-keys/reference")).json()
assert {"method": "POST", "path": "/api/apps/{app_id}/entries/{entry_id}"}.items() <= next(
r for r in ref["routes"] if r["path"].endswith("/entries/{entry_id}")).items()
assert (await auth_env.client().get("/api/access-keys/reference")).status_code == 401, "接口说明要登录"
# ---------------------------------------------------------------- 服务账号
async def test_service_accounts_cannot_log_in_or_get_a_password_or_role():
admin = await auth_env.turn_on_local()
svc = (await admin.post("/api/access-keys/service-accounts", json={"username": "svc-bi"})).json()
r = await auth_env.client().post("/api/auth/login", json={"username": "svc-bi", "password": "password-123"})
assert r.status_code == 401
assert (await admin.patch(f"/api/users/{svc['id']}", json={"password": "password-123"})).status_code == 409
assert (await admin.patch(f"/api/users/{svc['id']}", json={"role": "admin"})).status_code == 409
users = {u["username"]: u["kind"] for u in (await admin.get("/api/users")).json()["items"]}
assert users["svc-bi"] == "service" and users["root"] == "person"
taken = await admin.post("/api/users", json={"username": "svc-me", "role": "user"})
assert taken.status_code == 422, "svc- 开头留给服务账号"
# 万一有会话指向服务账号也认不出来
from app.auth import sessions
async with SessionLocal() as s:
user = await s.get(User, svc["id"])
token = await sessions.create_session(s, user, ip=None, user_agent=None)
await s.commit()
assert await sessions.user_of(s, token) is None
async def test_service_accounts_are_marked_in_the_directory_and_kept_out_of_assignments():
from app.auth.directory import load_directory, mismatch
admin = await auth_env.turn_on_local()
await auth_env.make_service("svc-bi")
await auth_env.make_user("ana", "builder")
r = await admin.post("/api/groups", json={"name": "ops"})
await admin.put(f"/api/groups/{r.json()['id']}/members", json={"members": ["ana", "svc-bi"]})
users = {u["username"]: u for u in (await admin.get("/api/directory")).json()["users"]}
assert users["svc-bi"]["service"] is True and "service" not in users["ana"]
async with SessionLocal() as s:
known = await load_directory(s)
assert mismatch(known, "local", "user", "svc-bi").problem == "service", "岗位绑定:服务账号算错"
assert mismatch(known, "local", "user", "svc-bi", allow_service=True) is None, "授权:照收"
from app.auth.directory import _members, review_pool
assert set(await _members(s, "ops")) == {"ana"}, "候选池里不列服务账号"
assert "svc-bi" not in (await review_pool(s)).users