Files
AgentLab/backend/tests/test_auth_identity.py
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

328 lines
16 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.
"""身份三种模式(应用平台 A7):off 默认、local、oidc。
要守住的:
- 关登录(off)时一切照旧:X-Actor 自报署名,所有接口放行,非 GET 不用带任何新请求头;
- 从 off 切到 local 必须同一个请求建出第一个管理员,建好就以他登录;
- 开登录后:没登录 401、角色不够 403;非 GET 要带 X-AgentLab-Request 头、Origin 不能是别的站点;
伪造的 X-Actor 一律不看,审计记的是认证过的用户名;登录失败退避;会话闲置过期、登出、停用都立刻失效;
- 环境变量指定的登录方式不能在设置里改;只有 local 模式下、还没有管理员时能 bootstrap;
- oidc:授权码 + PKCE,id_token 按 JWKS 核验,state 只能用一次,groups 同步成只读的组、按映射定角色;
- python -m app.auth reset-admin 能找回管理员;
- WebSocket 只给 builder 及以上。
"""
from __future__ import annotations
from datetime import timedelta
import httpx
import pytest
from sqlalchemy import select, update
from app.api.deps import Principal, actor_of, reset_principal, set_principal, signer_of
from app.db.base import SessionLocal, utcnow
from app.db.models import AuditEvent, AuthSession, Group, User
from tests.fakes import auth_env
from tests.fakes.fake_oidc import FakeOidc
@pytest.fixture(autouse=True)
async def clean():
await auth_env.reset()
try:
yield
finally:
await auth_env.reset()
# ---------------------------------------------------------------- off
async def test_off_mode_behaves_as_before():
c = auth_env.client()
c.headers.pop("X-AgentLab-Request")
me = (await c.get("/api/auth/me", headers={"X-Actor": "%E5%BC%A0%E4%B8%89"})).json()
assert me == {**me, "mode": "off", "role": "admin", "username": "张三", "verified": False}
# 管理员接口、非 GET 都不用登录、不用新请求头
assert (await c.get("/api/users")).status_code == 200
r = await c.post("/api/groups", json={"name": "ops"}, headers={"X-Actor": "lee"})
assert r.status_code == 201, r.text
async with SessionLocal() as s:
audit = (await s.execute(select(AuditEvent).where(AuditEvent.action == "group.create"))).scalar_one()
assert (audit.actor, audit.verified) == ("lee", False), "关登录时审计照实记自报的署名、未认证"
async def test_actor_of_ignores_header_once_logged_in():
token = set_principal(Principal(username="alice", role="user", verified=True, mode="local"))
try:
assert actor_of("mallory") == "alice"
assert signer_of("请求体署名", "mallory") == "alice", "开了登录:数据导入这类请求体里自带的署名也不看"
finally:
reset_principal(token)
assert actor_of("mallory") == "mallory"
assert signer_of(" 请求体署名 ", "mallory") == "请求体署名" and signer_of("", "mallory") == "mallory", \
"关登录:请求体里填的优先,其次 X-Actor"
# ---------------------------------------------------------------- local
async def test_switching_to_local_needs_the_first_admin_in_the_same_request():
c = auth_env.client()
r = await c.put("/api/auth/settings", json={"mode": "local"})
assert r.status_code == 422 and "第一个管理员" in r.json()["detail"]
r = await c.put("/api/auth/settings", json={"mode": "local", "bootstrap": {"username": "root", "password": "short"}})
assert r.status_code == 422, "密码太短也不能切过去"
r = await c.put("/api/auth/settings", json={"mode": "local",
"bootstrap": {"username": "root", "password": "password-123"}})
assert r.status_code == 200, r.text
assert r.json()["principal"]["role"] == "admin"
me = (await c.get("/api/auth/me")).json()
assert (me["mode"], me["username"], me["verified"]) == ("local", "root", True), "建出来就是登录状态"
async with SessionLocal() as s:
row = (await s.execute(select(AuthSession))).scalar_one()
assert len(row.token_hash) == 64 and c.cookies.get("agentlab_session") not in (None, row.token_hash), \
"库里只存 token 的 sha256"
async def test_roles_401_403_and_forged_actor():
admin = await auth_env.turn_on_local()
await auth_env.make_user("bob", "builder")
await auth_env.make_user("uma", "user")
anon = auth_env.client()
r = await anon.get("/api/workflows")
assert r.status_code == 401
me = await anon.get("/api/auth/me")
assert me.status_code == 401 and me.json()["mode"] == "local", "登录页据此显示账号密码"
assert me.json()["can_bootstrap"] is False, "已经有管理员:登录页不给建管理员"
uma = await auth_env.login("uma")
assert (await uma.get("/api/workflows")).status_code == 403
assert (await uma.get("/api/users")).status_code == 403
bob = await auth_env.login("bob")
assert (await bob.get("/api/workflows")).status_code == 200
assert (await bob.get("/api/users")).status_code == 403, "用户、组、审计只给管理员"
r = await admin.post("/api/groups", json={"name": "fin"}, headers={"X-Actor": "mallory"})
assert r.status_code == 201
async with SessionLocal() as s:
audit = (await s.execute(select(AuditEvent).where(AuditEvent.action == "group.create"))).scalar_one()
assert (audit.actor, audit.verified) == ("root", True), "伪造的 X-Actor 被忽略,审计照实记"
async def test_directory_is_read_only_for_builders_and_leaves_out_the_rest():
admin = await auth_env.turn_on_local()
await auth_env.make_user("bob", "builder", email="bob@example.com")
await auth_env.make_user("uma", "user", disabled=True)
assert (await admin.post("/api/groups", json={"name": "fin", "label": "财务"})).status_code == 201
bob = await auth_env.login("bob")
r = await bob.get("/api/directory")
assert r.status_code == 200, "搭建者绑岗位、授权要选人:名录给搭建者"
body = r.json()
assert {u["username"]: u["disabled"] for u in body["users"]} == {"root": False, "bob": False, "uma": True}, \
"停用的人也列出来,标明停用"
assert set(body["users"][0]) == {"username", "display_name", "disabled"}, "不含邮箱、角色这些"
assert body["groups"] == [{"name": "fin", "label": "财务"}]
assert (await bob.post("/api/directory", json={})).status_code == 405, "只读"
async with SessionLocal() as s:
await s.execute(update(User).where(User.username == "uma").values(disabled=False))
await s.commit()
uma = await auth_env.login("uma")
assert (await uma.get("/api/directory")).status_code == 403, "使用者不给"
async def test_csrf_header_and_origin():
admin = await auth_env.turn_on_local()
r = await admin.post("/api/groups", json={"name": "a"}, headers={"X-AgentLab-Request": ""})
# 头带了但为空也算带了;去掉才算没带
del admin.headers["X-AgentLab-Request"]
r = await admin.post("/api/groups", json={"name": "b"})
assert r.status_code == 403 and r.json()["code"] == "csrf_header"
admin.headers["X-AgentLab-Request"] = "1"
r = await admin.post("/api/groups", json={"name": "c"}, headers={"Origin": "https://evil.example"})
assert r.status_code == 403 and r.json()["code"] == "csrf_origin"
r = await admin.post("/api/groups", json={"name": "d"}, headers={"Origin": "http://test"})
assert r.status_code == 201
# GET 不查
del admin.headers["X-AgentLab-Request"]
assert (await admin.get("/api/groups")).status_code == 200
async def test_login_backoff_logout_idle_and_disabled():
await auth_env.turn_on_local()
await auth_env.make_user("uma", "user")
c = auth_env.client()
for _ in range(5):
assert (await c.post("/api/auth/login", json={"username": "uma", "password": "wrong-pass"})).status_code == 401
r = await c.post("/api/auth/login", json={"username": "uma", "password": "password-123"})
assert r.status_code == 429, "连错之后要等一会儿,密码对了也一样"
from app.auth import sessions
sessions._failures.clear()
uma = await auth_env.login("uma")
assert (await uma.get("/api/auth/me")).status_code == 200
assert (await uma.post("/api/auth/logout")).status_code == 204
assert (await uma.get("/api/auth/me")).status_code == 401, "登出之后同一个 cookie 认不出来"
uma = await auth_env.login("uma")
async with SessionLocal() as s:
await s.execute(update(AuthSession).values(last_seen_at=utcnow() - timedelta(hours=13)))
await s.commit()
assert (await uma.get("/api/auth/me")).status_code == 401, "闲置 12 小时过期"
uma = await auth_env.login("uma")
async with SessionLocal() as s:
await s.execute(update(User).where(User.username == "uma").values(disabled=True))
await s.commit()
assert (await uma.get("/api/auth/me")).status_code == 401, "停用立刻生效"
async def test_last_admin_cannot_be_demoted():
admin = await auth_env.turn_on_local()
users = (await admin.get("/api/users")).json()["items"]
root = next(u for u in users if u["username"] == "root")
r = await admin.patch(f"/api/users/{root['id']}", json={"role": "builder"})
assert r.status_code == 409
async def test_env_mode_wins_and_bootstrap(monkeypatch):
monkeypatch.setenv("AGENTLAB_AUTH_MODE", "local")
c = auth_env.client()
assert (await c.get("/api/workflows")).status_code == 401
me = await c.get("/api/auth/me")
assert me.status_code == 401 and me.json()["can_bootstrap"] is True, "新部署:登录页据此给「建第一个管理员」"
r = await c.post("/api/auth/bootstrap", json={"username": "first", "password": "password-123"})
assert r.status_code == 200 and r.json()["role"] == "admin"
me = await auth_env.client().get("/api/auth/me")
assert me.status_code == 401 and me.json()["can_bootstrap"] is False, "有了管理员就不再给"
assert (await c.post("/api/auth/bootstrap", json={"username": "second", "password": "password-123"})
).status_code == 409
r = await c.put("/api/auth/settings", json={"mode": "off"})
assert r.status_code == 409, "环境变量指定的登录方式不能在设置里改"
r = await c.put("/api/auth/settings", json={"user_can_chat": True})
assert r.status_code == 200 and r.json()["user_can_chat"] is True and r.json()["env_locked"] is True
async def test_reset_admin_recovers_access():
from app.auth.__main__ import reset_admin
await auth_env.turn_on_local()
await auth_env.make_user("ops", "user", disabled=True)
assert await reset_admin("ops", "brand-new-pass") == "已重置"
ops = await auth_env.login("ops", "brand-new-pass")
me = (await ops.get("/api/auth/me")).json()
assert me["role"] == "admin"
assert await reset_admin("fresh", "brand-new-pass") == "已新建"
async with SessionLocal() as s:
actions = list((await s.execute(select(AuditEvent.actor).where(AuditEvent.action == "user.reset_admin"))
).scalars())
assert actions == ["cli", "cli"]
async def test_websocket_needs_a_login_and_the_route_judges_the_run():
"""中间件只挡没登录的 WebSocket;登录了的交给路由按运行判(api/deps.py 的 run_scope,见 test_user_chat_access.py)。"""
from app.auth.middleware import AuthMiddleware
await auth_env.turn_on_local()
await auth_env.make_user("uma", "user")
uma = await auth_env.login("uma")
reached: list[str] = []
async def inner(scope, receive, send):
reached.append(scope["path"])
sent: list[dict] = []
async def receive():
return {"type": "websocket.connect"}
async def send(message):
sent.append(message)
scope = {"type": "websocket", "path": "/api/runs/r1/stream", "headers": []}
await AuthMiddleware(inner)(scope, receive, send)
assert not reached and sent == [{"type": "websocket.close", "code": 1008}], "没登录的握手前就关掉"
scope["headers"] = [(b"cookie", f"agentlab_session={uma.cookies.get('agentlab_session')}".encode())]
await AuthMiddleware(inner)(scope, receive, send)
assert reached == ["/api/runs/r1/stream"], "登录了的使用者交给路由按运行判,不再一刀切"
await auth_env.make_user("bob", "builder")
bob = await auth_env.login("bob")
scope["headers"] = [(b"cookie", f"agentlab_session={bob.cookies.get('agentlab_session')}".encode())]
await AuthMiddleware(inner)(scope, receive, send)
assert reached == ["/api/runs/r1/stream"] * 2
# ---------------------------------------------------------------- oidc
async def _oidc_login(c: httpx.AsyncClient, fake: FakeOidc, next_path: str = "/apps") -> httpx.Response:
start = await c.get("/api/auth/oidc/start", params={"next": next_path})
assert start.status_code == 302, start.text
async with httpx.AsyncClient(trust_env=False) as browser:
granted = await browser.get(start.headers["location"])
assert granted.status_code == 302
callback = httpx.URL(granted.headers["location"])
return await c.get(callback.path, params=dict(callback.params))
async def test_oidc_login_maps_roles_and_syncs_groups():
with FakeOidc() as fake:
c = auth_env.client()
r = await c.put("/api/auth/settings", json={"mode": "oidc", "oidc": {
"issuer": fake.issuer, "client_id": "agentlab", "client_secret": "s3cret"}})
assert r.status_code == 422, "没有管理员映射不能切到单点登录"
r = await c.put("/api/auth/settings", json={"mode": "oidc", "oidc": {
"issuer": fake.issuer, "client_id": "agentlab", "client_secret": "s3cret",
"admin_groups": ["ops"], "builder_groups": ["analysts"]}})
assert r.status_code == 200, r.text
assert r.json()["oidc"]["client_secret"] != "s3cret", "客户端密钥只回掩码"
fake.next_claims = {"sub": "u-1", "preferred_username": "alice", "name": "Alice",
"groups": ["ops", "finance"]}
done = await _oidc_login(c, fake)
assert done.status_code == 302 and done.headers["location"] == "/apps", done.headers.get("location")
me = (await c.get("/api/auth/me")).json()
assert (me["username"], me["role"], me["verified"]) == ("alice", "admin", True)
assert set(me["groups"]) == {"ops", "finance"}
groups = {g["name"]: g for g in (await c.get("/api/groups")).json()["items"]}
assert groups["finance"]["source"] == "oidc" and groups["finance"]["members"] == ["alice"]
r = await c.put(f"/api/groups/{groups['finance']['id']}/members", json={"members": []})
assert r.status_code == 409, "同步来的组只读"
# 换一个人登录:只在 analysts 组,是搭建者;alice 不再在 finance 组时,下次登录成员关系跟着撤
other = auth_env.client()
fake.next_claims = {"sub": "u-2", "preferred_username": "bert", "groups": ["analysts"]}
await _oidc_login(other, fake)
assert (await other.get("/api/auth/me")).json()["role"] == "builder"
fake.next_claims = {"sub": "u-1", "preferred_username": "alice", "groups": ["ops"]}
await _oidc_login(c, fake)
async with SessionLocal() as s:
finance = (await s.execute(select(Group).where(Group.name == "finance"))).scalar_one()
assert (await c.get("/api/groups")).json()["items"] and \
next(g for g in (await c.get("/api/groups")).json()["items"] if g["id"] == finance.id)["members"] == []
async def test_oidc_state_cannot_be_replayed():
with FakeOidc() as fake:
c = auth_env.client()
r = await c.put("/api/auth/settings", json={"mode": "oidc", "oidc": {
"issuer": fake.issuer, "client_id": "agentlab", "client_secret": "s3cret", "admin_users": ["alice"]}})
assert r.status_code == 200, r.text
start = await c.get("/api/auth/oidc/start")
async with httpx.AsyncClient(trust_env=False) as browser:
granted = await browser.get(start.headers["location"])
callback = httpx.URL(granted.headers["location"])
params = dict(callback.params)
first = await c.get(callback.path, params=params)
assert first.headers["location"] == "/"
c.cookies.clear()
again = await c.get(callback.path, params=params)
assert again.headers["location"].startswith("/login?error="), "state cookie 已作废,回放被拒"
wrong = auth_env.client()
await wrong.get("/api/auth/oidc/start")
bad = await wrong.get(callback.path, params={**params, "state": "forged"})
assert bad.headers["location"].startswith("/login?error=")
assert fake.token_calls == 1, "对不上的请求不去换 token"