mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:01:59 +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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
125 lines
5.1 KiB
Python
125 lines
5.1 KiB
Python
"""身份相关测试的公共部分:把登录设置、用户、组、会话、审计清回关登录的样子,建用户,登录拿一个带会话的客户端。
|
|
|
|
登录设置是进程内缓存 + settings 表的一行:一个测试开了登录不复原,同一个进程里后面的测试全会 401。fixture 一律在
|
|
finally 里调 reset()。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import delete
|
|
|
|
from app.db.base import SessionLocal
|
|
|
|
#: 开了登录时非 GET 请求必须带的头
|
|
CSRF = {"X-AgentLab-Request": "1"}
|
|
|
|
|
|
async def reset() -> None:
|
|
from app.auth import SETTING_KEY, keys, remember_settings, sessions
|
|
from app.db.models import AuditEvent, AuthSession, Group, GroupMember, Setting, User
|
|
from app.db.models_apps import ApiReceipt
|
|
from app.db.models_identity import AccessKey, AccessKeyRequest
|
|
|
|
async with SessionLocal() as session:
|
|
for model in (AccessKeyRequest, ApiReceipt, AccessKey, AuthSession, GroupMember, Group, User, AuditEvent):
|
|
await session.execute(delete(model))
|
|
await session.execute(delete(Setting).where(Setting.key == SETTING_KEY))
|
|
await session.commit()
|
|
remember_settings({})
|
|
sessions._failures.clear()
|
|
# 访问密钥的进程内状态:限流的桶、还没写库的请求记录
|
|
keys._BUCKETS.clear()
|
|
keys._PENDING.clear()
|
|
|
|
|
|
def client(**headers: str) -> AsyncClient:
|
|
from app.main import app
|
|
|
|
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test", headers={**CSRF, **headers})
|
|
|
|
|
|
async def make_user(username: str, role: str, password: str = "password-123", **extra: Any) -> Any:
|
|
from app.auth.passwords import hash_password
|
|
from app.db.models import User
|
|
|
|
async with SessionLocal() as session:
|
|
user = User(username=username, display_name=username, role=role, password_hash=hash_password(password),
|
|
**extra)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|
|
|
|
|
|
async def turn_on_local(admin: str = "root", password: str = "password-123") -> AsyncClient:
|
|
"""关登录 → 账号密码登录,同一个请求建出第一个管理员;交回已经以管理员登录的客户端。"""
|
|
c = client()
|
|
r = await c.put("/api/auth/settings", json={"mode": "local",
|
|
"bootstrap": {"username": admin, "password": password}})
|
|
assert r.status_code == 200, r.text
|
|
return c
|
|
|
|
|
|
async def login(username: str, password: str = "password-123") -> AsyncClient:
|
|
c = client()
|
|
r = await c.post("/api/auth/login", json={"username": username, "password": password})
|
|
assert r.status_code == 200, r.text
|
|
return c
|
|
|
|
|
|
def keyed(secret: str, **headers: str) -> AsyncClient:
|
|
"""带访问密钥的客户端:只有 Authorization 头,不带 CSRF 头、不带 cookie(外部系统就是这样调的)。"""
|
|
from app.main import app
|
|
|
|
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test",
|
|
headers={"Authorization": f"Bearer {secret}", **headers})
|
|
|
|
|
|
async def make_key(owner: Any, *, scopes: tuple[str, ...] = ("apps.read", "apps.run", "tasks.read", "tasks.respond",
|
|
"reports.read"),
|
|
app_ids: list[str] | None = None, days: float = 90, name: str = "测试密钥") -> tuple[Any, str]:
|
|
"""直接在库里签一把密钥(不经接口),交回 (行, 明文)。owner 是 User 行。"""
|
|
from datetime import timedelta
|
|
|
|
from app.auth import keys
|
|
from app.db.base import utcnow
|
|
from app.db.models_identity import AccessKey
|
|
|
|
secret = keys.new_secret()
|
|
async with SessionLocal() as session:
|
|
row = AccessKey(owner_user_id=owner.id, name=name, key_hash=keys.key_hash(secret), prefix=keys.prefix_of(secret),
|
|
scopes=list(scopes), app_ids=app_ids, expires_at=utcnow() + timedelta(days=days))
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row, secret
|
|
|
|
|
|
async def make_service(username: str = "svc-portal", **extra: Any) -> Any:
|
|
from app.db.models import User
|
|
|
|
async with SessionLocal() as session:
|
|
user = User(username=username, display_name=username, role="user", kind="service", **extra)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|
|
|
|
|
|
async def personal_keys(on: bool = True) -> None:
|
|
"""个人访问密钥的开关(登录设置里的 personal_keys),直接改缓存和库。"""
|
|
from app.auth import PERSONAL_KEYS, SETTING_KEY, load_settings, remember_settings
|
|
from app.db.models import Setting
|
|
|
|
value = {**await load_settings(force=True), PERSONAL_KEYS: on}
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Setting, SETTING_KEY)
|
|
if row is None:
|
|
session.add(Setting(key=SETTING_KEY, value=value))
|
|
else:
|
|
row.value = value
|
|
await session.commit()
|
|
remember_settings(value)
|