Files
AgentLab/backend/tests/test_access_keys.py
T
ThearchyHelios 545babc541 访问密钥收尾:服务账号进授权不进岗位的测试、关停时写请求记录的测试,几处整理
- 补测试:应用授权收服务账号、岗位绑定的检查把它算成错;改派给服务账号 422;调度循环停下时把攒着的请求记录写进库
  (去掉对应的代码回跑,测试抓得住)
- 门禁里只给拥有者的运行统一按 auth/access.PRIVATE_ORIGINS 认,不再各写一份字面量
- 幂等的 once 只把普通异常记成被拒:请求被取消时记录停在处理中,过了时限同一个键能重新认领(同钩子)
- 新建密钥的弹窗等服务账号取回来再说「还不能建」,不先闪一下
2026-10-16 14:15:51 +08:00

373 lines
20 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
async def test_grants_take_service_accounts_but_slot_bindings_do_not():
"""服务账号能拿应用授权(外部系统按授权读应用),岗位绑定里算错:派给它的待办没人能处理。"""
from app.api.apps import grants_problem
from app.apps.lint import _Issues, _lint_slot_principals
await auth_env.turn_on_local()
await auth_env.make_service("svc-bi")
async with SessionLocal() as s:
wanted, problem = await grants_problem(s, [("user", "svc-bi", "observe")])
assert problem is None and wanted == [("user", "svc-bi", "observe")]
out = _Issues()
await _lint_slot_principals(s, {"approver": {"label": "审批人"}},
{"approver": [{"kind": "user", "name": "svc-bi"}]}, out)
assert [(i["level"], i["code"], i["problem"]) for i in out.items] == [
("error", "app.slot_principal_unknown", "service")]
assert "服务账号" in out.items[0]["message"]
async def test_scheduler_stop_flushes_the_request_buffer():
"""关停时还攒在进程里的请求记录写进库(没起调度循环时也写)。"""
from app.scheduler.loop import runner
await auth_env.turn_on_local()
svc = await auth_env.make_service()
row, secret = await auth_env.make_key(svc)
await auth_env.keyed(secret).get("/api/auth/me")
await runner.stop()
async with SessionLocal() as s:
assert [r.key_id for r in (await s.execute(select(AccessKeyRequest))).scalars()] == [row.id]