mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:41:58 +00:00
545babc541
- 补测试:应用授权收服务账号、岗位绑定的检查把它算成错;改派给服务账号 422;调度循环停下时把攒着的请求记录写进库 (去掉对应的代码回跑,测试抓得住) - 门禁里只给拥有者的运行统一按 auth/access.PRIVATE_ORIGINS 认,不再各写一份字面量 - 幂等的 once 只把普通异常记成被拒:请求被取消时记录停在处理中,过了时限同一个键能重新认领(同钩子) - 新建密钥的弹窗等服务账号取回来再说「还不能建」,不先闪一下
373 lines
20 KiB
Python
373 lines
20 KiB
Python
"""访问密钥与服务账号:签发、认人、轮换、身份来源、限流、留痕(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]
|