合入 W2-d 访问密钥与服务账号:服务账号(管理员建、不能登录)与个人访问密钥(管理员开关,缺省关);只存哈希、必设到期、轮换与撤销;按路由白名单放行、封顶在使用者边界(看到的都遮过);发起入口和刷新视图必须带幂等键;视图数据接口 agentlab.viewdata/1;审计、请求记录、钩子收件记录;接口说明;设置里的访问密钥页

This commit is contained in:
ThearchyHelios
2026-10-16 15:38:33 +08:00
41 changed files with 4221 additions and 124 deletions
+374 -3
View File
@@ -1,9 +1,380 @@
"""访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7)。
"""访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7):建、撤销、轮换密钥,建、停用服务账号,个人
访问密钥的开关,每把密钥最近的请求,接口说明。签发、认人、门禁的规则在 app/auth/keys.py。
main.py 挂登录即可(require_role("user")):本人的个人密钥本人管,服务账号和全部密钥在接口里要管理员。第 2 波 W2-d 填。
main.py 挂登录即可(require_role("user")),接口里再按本人 / 管理员判:
- 管理员看得到服务账号和全部密钥,能给服务账号建密钥、撤销和轮换任何一把;
- 每个人看得到自己的个人访问密钥;管理员打开开关(auth.personal_keys,缺省关)之后才能建;
- 关登录时访问密钥不生效:列表照给(空的,带 login_on=false),建密钥、建服务账号回 409(keys_need_login)。
建、撤销、轮换密钥,建、停用服务账号,开关个人密钥都写审计(同一个事务)。密钥明文只在建、轮换的回包里出现一次。
"""
from __future__ import annotations
from fastapi import APIRouter
import re
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import current_principal
from app.auth import keys
from app.auth.audit import record
from app.db.base import get_session, utcnow
from app.db.models_identity import AccessKey, User
router = APIRouter(prefix="/api/access-keys", tags=["access-keys"])
#: 错误码(403 / 404 / 409 / 422)
KEY_NOT_FOUND = "access_key_not_found"
KEY_FORBIDDEN = "access_key_forbidden"
KEY_INVALID_INPUT = "access_key_invalid"
KEY_NOT_ACTIVE = "access_key_not_active"
PERSONAL_KEYS_OFF = "personal_keys_off"
SERVICE_NOT_FOUND = "service_account_not_found"
SERVICE_INVALID = "service_account_invalid"
SERVICE_TAKEN = "service_account_taken"
_SERVICE_NAME = re.compile(r"^svc-[A-Za-z0-9._-]{1,96}$")
def _iso(value: Any) -> str | None:
return value.isoformat() if value else None
def _need_login() -> None:
if current_principal().mode == "off":
raise CodedHTTPException(409, keys.KEYS_NEED_LOGIN_TEXT, keys.KEYS_NEED_LOGIN)
def _is_admin() -> bool:
principal = current_principal()
return principal.mode != "off" and principal.at_least("admin") and principal.via != "key"
def _require_admin() -> None:
from app.api.deps import ensure_role
ensure_role("admin")
def key_out(row: AccessKey, owner: User | None) -> dict[str, Any]:
"""接口里的一把密钥:没有明文、没有哈希,只有开头一截(prefix)给人认。"""
return {"id": row.id, "name": row.name, "prefix": row.prefix, "scopes": [s for s in row.scopes or []],
"app_ids": list(row.app_ids) if row.app_ids else None,
"owner": {"id": row.owner_user_id, "username": owner.username if owner else None,
"display_name": (owner.display_name or owner.username) if owner else None,
"kind": owner.kind if owner else None},
"personal": owner is None or owner.kind != keys.SERVICE_KIND,
"state": keys.state_of(row, owner),
"expires_at": _iso(row.expires_at), "revoked_at": _iso(row.revoked_at), "revoked_by": row.revoked_by,
"last_used_at": _iso(row.last_used_at), "last_used_ip": row.last_used_ip,
"rotated_from": row.rotated_from, "created_by": row.created_by, "created_at": _iso(row.created_at)}
def service_out(row: User, key_count: int = 0) -> dict[str, Any]:
return {"id": row.id, "username": row.username, "display_name": row.display_name or row.username,
"disabled": row.disabled, "created_by": row.created_by, "created_at": _iso(row.created_at),
"keys": key_count}
async def _owners(session: AsyncSession, rows: list[AccessKey]) -> dict[str, User]:
ids = {r.owner_user_id for r in rows}
if not ids:
return {}
return {u.id: u for u in (await session.execute(select(User).where(User.id.in_(ids)))).scalars()}
# ---------------------------------------------------------------- 开关与概况
@router.get("/settings")
async def get_settings() -> dict[str, Any]:
"""这一页要的几样:开没开登录(关登录时密钥不生效)、个人访问密钥的开关、我能不能建个人密钥、是不是管理员。"""
from app.auth import personal_keys_enabled
principal = current_principal()
login_on = principal.mode != "off"
return {"login_on": login_on, "personal_keys": personal_keys_enabled(), "admin": _is_admin(),
"can_create_personal": login_on and personal_keys_enabled() and bool(principal.user_id)
and principal.via != "key",
"scopes": [*keys.SCOPES], "read_scopes": [*keys.READ_SCOPES], "personal_scopes": [*keys.PERSONAL_SCOPES],
"expires_default_days": keys.EXPIRES_DEFAULT_DAYS, "expires_max_days": keys.EXPIRES_MAX_DAYS}
class SettingsIn(BaseModel):
personal_keys: bool
@router.put("/settings")
async def put_settings(payload: SettingsIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""个人访问密钥的开关(只给管理员)。存在登录设置那一行里(auth.personal_keys),和「使用者可以问数据」同一处缓存。
关掉之后建不了个人密钥,已有的个人密钥也不认(401 key_personal_off),重新打开就又能用。"""
from app.auth import PERSONAL_KEYS, SETTING_KEY, load_settings, remember_settings
from app.db.models import Setting
_need_login()
_require_admin()
before = await load_settings(force=True)
value = {**before, PERSONAL_KEYS: payload.personal_keys}
row = await session.get(Setting, SETTING_KEY)
if row is None:
session.add(Setting(key=SETTING_KEY, value=value))
else:
row.value = value
if bool(before.get(PERSONAL_KEYS)) != payload.personal_keys:
await record(session, "auth.personal_keys", "auth", None,
data={"before": bool(before.get(PERSONAL_KEYS)), "after": payload.personal_keys})
await session.commit()
remember_settings(value)
return await get_settings()
@router.get("/reference")
async def reference() -> dict[str, Any]:
"""接口说明(A7):从门禁那张表(auth/keys.KEY_ROUTES)生成,方法、路径、scope、说明、可能的错误码。要登录(main.py 挂的
require_role("user")),不对匿名的人敞开。"""
return keys.reference()
# ---------------------------------------------------------------- 服务账号(只给管理员)
@router.get("/service-accounts")
async def list_service_accounts(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
_require_admin()
rows = list((await session.execute(select(User).where(User.kind == keys.SERVICE_KIND)
.order_by(User.created_at))).scalars())
counts: dict[str, int] = {}
for owner_id in (await session.execute(select(AccessKey.owner_user_id).where(
AccessKey.owner_user_id.in_([r.id for r in rows] or [""]), AccessKey.revoked_at.is_(None)))).scalars():
counts[owner_id] = counts.get(owner_id, 0) + 1
return {"items": [service_out(r, counts.get(r.id, 0)) for r in rows]}
class ServiceIn(BaseModel):
username: str = Field(min_length=5, max_length=100)
display_name: str | None = Field(default=None, max_length=100)
@router.post("/service-accounts", status_code=201)
async def create_service_account(payload: ServiceIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""建一个服务账号:用户名统一 svc- 开头,没有口令、不能登录,角色固定使用者。它就是一个用户——授权、组、名录全部沿用
现有的机制(应用授权里能选它;岗位绑定、选人候选里排除它:服务账号不能被派待办)。"""
_need_login()
_require_admin()
username = payload.username.strip()
if not _SERVICE_NAME.match(username):
raise CodedHTTPException(422, "服务账号的用户名要以 svc- 开头,后面只用字母、数字、点、下划线和短横线",
SERVICE_INVALID, field="username")
if (await session.execute(select(User.id).where(User.username == username))).first():
raise CodedHTTPException(409, f"用户名「{username}」已存在", SERVICE_TAKEN, field="username")
row = User(username=username, display_name=(payload.display_name or "").strip() or username, role="user",
kind=keys.SERVICE_KIND, password_hash=None, created_by=current_principal().username)
session.add(row)
try:
await session.flush()
except IntegrityError as e:
await session.rollback()
raise CodedHTTPException(409, f"用户名「{username}」已存在", SERVICE_TAKEN, field="username") from e
await record(session, "service_account.create", "user", row.id, data={"username": row.username})
await session.commit()
return service_out(row)
class ServicePatch(BaseModel):
display_name: str | None = Field(default=None, max_length=100)
disabled: bool | None = None
async def _service(session: AsyncSession, user_id: str) -> User:
row = await session.get(User, user_id)
if row is None or row.kind != keys.SERVICE_KIND:
raise CodedHTTPException(404, "服务账号不存在", SERVICE_NOT_FOUND)
return row
@router.patch("/service-accounts/{user_id}")
async def update_service_account(user_id: str, payload: ServicePatch,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""改显示名、停用 / 启用。停用之后它的密钥全部失效(401 key_owner_disabled),重新启用就又能用(没到期、没撤销的)。"""
_need_login()
_require_admin()
row = await _service(session, user_id)
changes: dict[str, Any] = {}
if payload.display_name is not None and payload.display_name.strip() != row.display_name:
changes["display_name"] = [row.display_name, payload.display_name.strip() or row.username]
row.display_name = payload.display_name.strip() or row.username
if payload.disabled is not None and payload.disabled != row.disabled:
changes["disabled"] = [row.disabled, payload.disabled]
row.disabled = payload.disabled
if changes:
action = ("service_account.disable" if payload.disabled else "service_account.enable") \
if "disabled" in changes else "service_account.update"
await record(session, action, "user", row.id, data={"username": row.username, **changes})
await session.commit()
return service_out(row)
# ---------------------------------------------------------------- 密钥
@router.get("")
async def list_keys(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""管理员:全部密钥(服务账号的和每个人的个人密钥);别人:自己的个人密钥。关登录时是空的。"""
principal = current_principal()
if principal.mode == "off" or not principal.user_id:
return {"items": []}
query = select(AccessKey).order_by(AccessKey.created_at.desc())
if not _is_admin():
query = query.where(AccessKey.owner_user_id == principal.user_id)
rows = list((await session.execute(query)).scalars())
owners = await _owners(session, rows)
return {"items": [key_out(r, owners.get(r.owner_user_id)) for r in rows]}
class KeyIn(BaseModel):
name: str = Field(min_length=1, max_length=keys.NAME_MAX)
scopes: list[str] = Field(default_factory=lambda: list(keys.READ_SCOPES))
#: 不给(或空)是不限应用
app_ids: list[str] | None = None
expires_in_days: int = Field(default=keys.EXPIRES_DEFAULT_DAYS, ge=1, le=keys.EXPIRES_MAX_DAYS)
#: 给服务账号建(只给管理员);不给是建自己的个人访问密钥
service_account_id: str | None = None
async def _check_input(session: AsyncSession, scopes: list[str], app_ids: list[str] | None, *,
service: bool) -> tuple[list[str], list[str] | None]:
"""scope 只能是认得的那几个(至少一个);服务账号不能勾只给个人密钥的(回复待办)。限定的应用要是在的、共享的应用。"""
from app.db.models_apps import App
picked = list(dict.fromkeys(s for s in scopes))
if not picked or any(s not in keys.SCOPES for s in picked):
raise CodedHTTPException(422, f"权限只能从 {'、'.join(keys.SCOPES)} 里选,至少选一项", KEY_INVALID_INPUT,
field="scopes")
if service and (bad := [s for s in picked if s in keys.PERSONAL_SCOPES]):
raise CodedHTTPException(422, f"服务账号不能被派待办,它的密钥不能有「{'、'.join(bad)}」:外部系统回调请用应用的 signal 钩子",
KEY_INVALID_INPUT, field="scopes")
apps = list(dict.fromkeys(str(a).strip() for a in app_ids or [] if str(a).strip())) or None
if apps is not None:
if len(apps) > keys.APPS_MAX:
raise CodedHTTPException(422, f"一把密钥最多限定 {keys.APPS_MAX} 个应用", KEY_INVALID_INPUT, field="app_ids")
found = {a.id: a for a in (await session.execute(select(App).where(App.id.in_(apps)))).scalars()}
missing = [a for a in apps if a not in found]
personal = [found[a].name for a in apps if a in found and found[a].scope == "personal"]
if missing:
raise CodedHTTPException(422, f"限定的应用不存在:{'、'.join(missing)}", KEY_INVALID_INPUT, field="app_ids")
if personal:
raise CodedHTTPException(422, f"访问密钥不能限定到个人应用:{'、'.join(personal)}", KEY_INVALID_INPUT,
field="app_ids")
return picked, apps
async def _issue(session: AsyncSession, *, owner: User, name: str, scopes: list[str], app_ids: list[str] | None,
days: float, rotated_from: str | None = None) -> tuple[AccessKey, str]:
secret = keys.new_secret()
row = AccessKey(owner_user_id=owner.id, name=name, key_hash=keys.key_hash(secret), prefix=keys.prefix_of(secret),
scopes=scopes, app_ids=app_ids, expires_at=utcnow() + timedelta(days=days),
rotated_from=rotated_from, created_by=current_principal().username)
session.add(row)
await session.flush()
return row, secret
@router.post("", status_code=201)
async def create_key(payload: KeyIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""建一把密钥。明文(agl_…)只在这一次的回包里(key);库里只存哈希和开头一截。"""
from app.auth import personal_keys_enabled
_need_login()
principal = current_principal()
if principal.via == "key":
raise CodedHTTPException(403, "不能用访问密钥建访问密钥", KEY_FORBIDDEN)
if payload.service_account_id:
_require_admin()
owner = await _service(session, payload.service_account_id)
if owner.disabled:
raise CodedHTTPException(409, f"服务账号「{owner.username}」已停用,先启用再建密钥", KEY_NOT_ACTIVE)
else:
if not personal_keys_enabled():
raise CodedHTTPException(409, "管理员还没有打开个人访问密钥:系统间集成请让管理员建服务账号的密钥",
PERSONAL_KEYS_OFF)
owner = await session.get(User, principal.user_id) if principal.user_id else None
if owner is None:
raise CodedHTTPException(409, "找不到你的账号,请重新登录", KEY_FORBIDDEN)
scopes, apps = await _check_input(session, payload.scopes, payload.app_ids,
service=owner.kind == keys.SERVICE_KIND)
row, secret = await _issue(session, owner=owner, name=payload.name.strip() or "访问密钥", scopes=scopes,
app_ids=apps, days=payload.expires_in_days)
await record(session, "access_key.create", "access_key", row.id,
data={"name": row.name, "owner": owner.username, "owner_kind": owner.kind, "prefix": row.prefix,
"scopes": scopes, "app_ids": apps, "expires_at": _iso(row.expires_at)})
await session.commit()
return {**key_out(row, owner), "key": secret}
async def _own_or_admin(session: AsyncSession, key_id: str) -> tuple[AccessKey, User | None]:
"""这把密钥能不能由这个人管:管理员能管任何一把;别人只能管自己的。看不到的一律当不存在(404)。"""
_need_login()
principal = current_principal()
if principal.via == "key":
raise CodedHTTPException(403, "不能用访问密钥管理访问密钥", KEY_FORBIDDEN)
row = await session.get(AccessKey, key_id)
if row is None or (not _is_admin() and row.owner_user_id != principal.user_id):
raise CodedHTTPException(404, "访问密钥不存在", KEY_NOT_FOUND)
return row, await session.get(User, row.owner_user_id)
@router.post("/{key_id}/revoke")
async def revoke_key(key_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""撤销:立刻失效(401 key_revoked),不能恢复。撤销过的再撤销一次照样回成功(不再改时间)。"""
row, owner = await _own_or_admin(session, key_id)
if row.revoked_at is None:
row.revoked_at = utcnow()
row.revoked_by = current_principal().username
await record(session, "access_key.revoke", "access_key", row.id,
data={"name": row.name, "owner": owner.username if owner else None, "prefix": row.prefix})
await session.commit()
return key_out(row, owner)
@router.post("/{key_id}/rotate")
async def rotate_key(key_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""轮换:签一把新的(名字、权限、限定的应用、有效期长短照旧),旧的 24 小时后到期——换钥匙不用停机。新密钥的明文只在
这一次的回包里(key)。撤销了的、过期了的、已经轮换过的不能再轮换(新建一把)。"""
from app.auth import personal_keys_enabled
row, owner = await _own_or_admin(session, key_id)
state = keys.state_of(row, owner)
if state != "active":
raise CodedHTTPException(409, "这把密钥已撤销、过期或拥有者已停用,不能轮换:请新建一把", KEY_NOT_ACTIVE)
if await session.scalar(select(AccessKey.id).where(AccessKey.rotated_from == row.id).limit(1)):
raise CodedHTTPException(409, "这把密钥已经轮换过:请用轮换出来的那一把", KEY_NOT_ACTIVE)
if owner is None:
raise CodedHTTPException(404, "访问密钥不存在", KEY_NOT_FOUND)
if owner.kind != keys.SERVICE_KIND and not personal_keys_enabled():
raise CodedHTTPException(409, "管理员已关闭个人访问密钥,不能轮换", PERSONAL_KEYS_OFF)
span = keys.aware(row.expires_at) - keys.aware(row.created_at)
days = min(max(span.total_seconds() / 86400, 1.0), float(keys.EXPIRES_MAX_DAYS))
fresh, secret = await _issue(session, owner=owner, name=row.name, scopes=list(row.scopes or []),
app_ids=list(row.app_ids) if row.app_ids else None, days=days, rotated_from=row.id)
grace = utcnow() + keys.ROTATE_GRACE
if keys.aware(row.expires_at) > grace:
row.expires_at = grace
await record(session, "access_key.rotate", "access_key", row.id,
data={"name": row.name, "owner": owner.username, "prefix": row.prefix, "new_key": fresh.id,
"new_prefix": fresh.prefix, "old_expires_at": _iso(row.expires_at)})
await session.commit()
return {**key_out(fresh, owner), "key": secret, "previous": key_out(row, owner)}
@router.get("/{key_id}/requests")
async def key_requests(key_id: str, limit: int = 100, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""这把密钥最近的请求(新的在前,最多 100 条):时间、方法、接口(路由模板)、状态码、涉及的运行、来源地址。"""
row, _ = await _own_or_admin(session, key_id)
return {"items": await keys.recent_requests(session, row.id, min(max(limit, 1), 100)),
"retain_days": keys.RETAIN.days}
+98 -9
View File
@@ -256,8 +256,13 @@ async def list_apps(include_archived: bool = False, scope: str | None = None,
query = query.where(App.scope == "personal")
elif scope == "shared":
query = query.where(or_(App.scope == "shared", App.scope.is_(None)))
from app.auth.keys import app_allowed
items = []
for app in (await session.execute(query)).scalars():
if not app_allowed(principal, app):
# 访问密钥(04 细案 A2):个人应用不列,限定了应用的只列那几个
continue
role = await app_role(session, principal, app)
if role is None:
if _sees_stub(principal, app):
@@ -639,8 +644,9 @@ async def grants_problem(session: AsyncSession, items: list[tuple[str, str, str]
wanted.append(key)
if (mode := auth_mode()) != "off":
known = await load_directory(session)
# 服务账号能拿授权(外部系统按授权读应用,docs/design/next-round/04-对外集成与触达.md A1),这里不算对不上
refused = [f"「{ROLE_TEXT[role]}」里的{m.text}" for kind, who, role in wanted
if (m := mismatch(known, mode, kind, who)) is not None and m.blocking]
if (m := mismatch(known, mode, kind, who, allow_service=True)) is not None and m.blocking]
if refused:
return wanted, f"{';'.join(refused)}:授权只能给名录里在用的人和组,请移除或从候选中重新选择"
return wanted, None
@@ -772,6 +778,17 @@ async def delete_hook(app_id: str, name: str, session: AsyncSession = Depends(ge
await session.commit()
@router.get("/{app_id}/hooks/{name}/receipts")
async def hook_receipts(app_id: str, name: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""这个钩子最近收到的请求(04 细案 A6,要管理权限):最近 100 条的幂等键、状态、时间、发起的实例或解决的待办、被拒的
原因,另给近 1 小时验签失败几次(不落库,只在进程里按钩子数)。还没生成密钥的是空的。"""
from app.process import hooks
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
return await hooks.recent_receipts(session, app.id, name)
# --------------------------------------------------------------------------
# 使用:页面、入口、实例、视图刷新
# --------------------------------------------------------------------------
@@ -830,21 +847,59 @@ class StartIn(BaseModel):
@router.post("/{app_id}/entries/{entry_id}", status_code=201)
async def start(app_id: str, entry_id: str, payload: StartIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""发起一个实例。表单没通过校验回 422,带 {errors: [{path, code, message}]}。"""
x_actor: str | None = Header(default=None),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key")) -> Any:
"""发起一个实例。表单没通过校验回 422,带 {errors: [{path, code, message}]}。
用访问密钥发起(docs/design/next-round/04-对外集成与触达.md A4):必须带 Idempotency-Key,同一个键重发交回同一个实例
(duplicate: true),处理到一半服务停了的同一个键能认领回已发起的实例;入口表单里有文件控件的 422
(key_files_unsupported:文件要先在应用里上传,密钥不开上传接口)。"""
from app.apps.entries import EntryRefused, FormRejected, start_entry
from app.apps.resolve import instance_title
from app.auth import keys
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "use")
idem = keys.idempotency_of(idempotency_key) if principal.via == "key" else None
async def launch(reclaimed: bool = False) -> dict[str, Any]:
if reclaimed and idem and (run_id := await keys.started_before(principal, idem)):
# 上一次处理到一半服务停了:实例其实已经发起,交回它,不再发起一个
return await replay({"run_id": run_id})
run = await start_entry(app.id, entry_id, payload.values, principal, idempotency_key=idem)
return {"run_id": run.id, "title": instance_title(run)}
async def replay(previous: dict[str, Any]) -> dict[str, Any]:
run = await session.get(Run, previous.get("run_id")) if previous.get("run_id") else None
return {"run_id": previous.get("run_id"), "title": instance_title(run) if run is not None else "",
**({"duplicate": True} if previous.get("duplicate") else {})}
try:
run = await start_entry(app.id, entry_id, payload.values, principal)
if principal.via != "key":
return await launch()
await _refuse_key_files(session, app, entry_id)
return await keys.once(principal, idem, route=f"POST /api/apps/{app.id}/entries/{entry_id}",
request=payload.values, run=launch, replay=replay)
except FormRejected as e:
return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "code": FORM_INVALID, "errors": e.errors})
except (EntryRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "title": instance_title(run)}
async def _refuse_key_files(session: AsyncSession, app: App, entry_id: str) -> None:
"""用访问密钥发起时,入口表单(当前发布版)里有文件控件的拒(422 key_files_unsupported)。发布版、入口不存在的放过去,
由发起照旧报。"""
from app.apps.tree import entry_of, resolve_form
from app.auth import keys
from app.files.store import file_fields
version = await _previous(session, app)
spec = (version.spec or {}) if version is not None else {}
entry = entry_of(spec, entry_id)
if entry is not None and file_fields(resolve_form(spec, entry.get("form"))):
raise CodedHTTPException(422, "这个入口的表单里要传文件,不能用访问密钥发起:请在应用里发起",
keys.KEY_FILES_UNSUPPORTED)
@router.get("/{app_id}/instances")
@@ -899,23 +954,57 @@ class RefreshIn(BaseModel):
@router.post("/{app_id}/views/{view_id}/refresh")
async def refresh_view(app_id: str, view_id: str, payload: RefreshIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""按需视图:发起一次运行,或者命中缓存、接上同一个参数正在运行的那次。"""
x_actor: str | None = Header(default=None),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key")) -> Any:
"""按需视图:发起一次运行,或者命中缓存、接上同一个参数正在运行的那次。
用访问密钥刷新(04 细案 A4,集成-I3):同发起实例,必须带 Idempotency-Key,同一个键重发交回同一次运行。刷新本来就按
参数去重(缓存、同一组参数只跑一份),幂等键再管住「上一次失败、缓存被清掉之后重发又发起一次」这种情形。"""
from app.apps.entries import FormRejected
from app.apps.resolve import ViewRefused, run_view
from app.auth import keys
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "use")
version = await _current_version(session, app)
try:
async def refresh(_reclaimed: bool = False) -> dict[str, Any]:
# 重新认领时照样走 run_view:上一次已经发起的那次在缓存里,命中它或者接上它,不会再发起一次
run, cached = await run_view(session, app=app, version=version, view_id=view_id, params=payload.params,
principal=principal, actor=actor_of(x_actor))
return {"run_id": run.id, "cached": cached}
async def replay(previous: dict[str, Any]) -> dict[str, Any]:
return {"run_id": previous.get("run_id"), "cached": False, "duplicate": True}
try:
if principal.via != "key":
return await refresh()
return await keys.once(principal, idempotency_key, route=f"POST /api/apps/{app.id}/views/{view_id}/refresh",
request=payload.params, run=refresh, replay=replay)
except FormRejected as e:
return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "code": FORM_INVALID, "errors": e.errors})
except (ViewRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "cached": cached}
@router.get("/{app_id}/views/{view_id}/data")
async def view_data(app_id: str, view_id: str, params: str | None = None, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""视图数据(agentlab.viewdata/1,04 细案 A5):按当前发布版解析这个视图取的是哪一次封存的正式运行,交出印章、清单哈希、
口径卡指标的原值和渲染文字(遮罩的 value 为 null)、证据指针。按需视图读 ?params= 这组参数上次算的结果,不发起运行,
没算过的 state=empty。界面和访问密钥都能用;个人应用没有发布版(409)。"""
from app.apps.resolve import ViewRefused, view_data as data_of
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "use")
version = await _current_version(session, app)
try:
return await data_of(session, app, version=version, view_id=view_id, params=_params(params),
principal=principal, role=role)
except ViewRefused as e:
raise _view_refused(e) from e
# --------------------------------------------------------------------------
+9 -1
View File
@@ -36,9 +36,12 @@ _OIDC_FIELDS = ("issuer", "client_id", "client_secret", "scopes", "username_clai
def principal_out(p: Principal) -> dict[str, Any]:
from app.auth import personal_keys_enabled
# personal_keys:管理员开没开个人访问密钥(next-round 04 A1),不是管理员的人在设置里据此给不给「访问密钥」一页
return {"mode": p.mode, "username": p.username, "display_name": p.display_name or p.username,
"role": p.role or "user", "groups": list(p.groups), "verified": p.verified,
"user_can_chat": user_can_chat()}
"user_can_chat": user_can_chat(), "personal_keys": personal_keys_enabled()}
async def principal_full(session: AsyncSession, p: Principal) -> dict[str, Any]:
@@ -62,6 +65,11 @@ async def me(x_actor: str | None = Header(default=None), session: AsyncSession =
can_bootstrap = principal.mode == "local" and not await _local_admin_exists(session)
return JSONResponse(status_code=401, content={"detail": "请先登录", "code": AUTH_REQUIRED,
"mode": principal.mode, "can_bootstrap": can_bootstrap})
if principal.via == "key":
# 用访问密钥自省(next-round 04 A4 的 me):另给这把密钥的名字、开头、scope、限定的应用、到期时间
from app.auth.keys import introspect
return {**await principal_full(session, principal), "key": await introspect(session, principal)}
return await principal_full(session, principal)
+12 -2
View File
@@ -3,7 +3,8 @@
登录方式三种:off(默认,今天的行为)、local、oidc。各接口不直接碰请求头或会话,只问这里:
- actor_of(header):记在各「人」列上的名字。关登录时照旧解码 X-Actor(自报署名);开了登录返回认证过的
用户名,请求头一律不看——这样各接口的签名一个都不用改;
- current_principal():完整身份(用户名、角色、组、是否认证过、登录方式),中间件按请求写进 contextvar;
- current_principal():完整身份(用户名、角色、组、是否认证过、登录方式、经什么认出来的),中间件按请求写进 contextvar。
访问密钥认出来的身份(via=key)一律按使用者算,另带这把密钥的 scope 和限定的应用(auth/keys.py);
- require_role(role):挂在 main.py 的 include_router(..., dependencies=[...]) 上的路由级依赖;
- can_read_run / can_read_artifact:运行、工件的读权限(user 角色只能读有授权的应用视图运行、自己参与的实例);
- ensure_role / ensure_can_publish:接口内部按请求内容判定的权限(MCP 的 stdio、受管级发布、版本退役,Q8);
@@ -52,6 +53,15 @@ class Principal:
groups: tuple[str, ...] = field(default_factory=tuple)
verified: bool = False
mode: str = "off"
#: 经什么认出来的(docs/design/next-round/04-对外集成与触达.md A2):off 关登录的自报署名;session 会话 cookie(开登录时
#: 匿名也是这一种:没认出会话);key 访问密钥;hook 入站钩子的签名;share 分享链接(第 3 波)
via: str = "session"
#: 按访问密钥认出来的(via=key):哪一把、叫什么、带哪些 scope、限定在哪些应用(None 是不限应用)。别的认法都是空的。
#: 权限封顶在使用者(A3,auth/keys.principal_for_key):role 一律是 user,这几项只在门禁(auth/keys.key_gate)里再收窄
key_id: str | None = None
key_name: str | None = None
scopes: frozenset[str] = field(default_factory=frozenset)
app_ids: tuple[str, ...] | None = None
def at_least(self, role: str) -> bool:
return ROLE_RANK.get(self.role, 0) >= ROLE_RANK.get(role, 99)
@@ -122,7 +132,7 @@ def current_principal() -> Principal:
principal = _principal.get()
if principal is not None:
return principal
return Principal(username=None, role="admin", verified=False, mode="off")
return Principal(username=None, role="admin", verified=False, mode="off", via="off")
def actor_verified() -> bool:
+13 -2
View File
@@ -24,6 +24,8 @@ from app.db.models import AuditEvent, Group, GroupMember, User
from app.db.models_identity import ROLES
router = APIRouter(prefix="/api", tags=["identity"])
#: 服务账号的用户名开头(auth/keys.SERVICE_PREFIX):这里建的人不能用它,免得和服务账号混在一起
_SERVICE_PREFIX = "svc-"
#: 人和组的名录(GET /api/directory):只读、给搭建者及以上(main.py 挂 require_role("builder")),岗位绑定、应用授权的
#: 选人控件据此给候选、核对填的名字在不在。只有用户名、显示名、是否停用和组名,不含邮箱、角色、登录时间
directory_router = APIRouter(prefix="/api", tags=["identity"])
@@ -40,7 +42,9 @@ def user_out(row: User) -> dict[str, Any]:
return {"id": row.id, "username": row.username, "display_name": row.display_name or row.username,
"email": row.email, "role": row.role, "disabled": row.disabled,
"source": "oidc" if row.oidc_subject else "local", "last_login_at": _iso(row.last_login_at),
"created_at": _iso(row.created_at)}
"created_at": _iso(row.created_at),
# 服务账号(next-round 04 A1)在「设置 → 访问密钥」里建和停用,这里只标出来
"kind": row.kind or "person"}
@router.get("/users")
@@ -65,6 +69,8 @@ def _check_role(role: str) -> None:
@router.post("/users", status_code=201)
async def create_user(payload: UserIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
_check_role(payload.role)
if payload.username.lower().startswith(_SERVICE_PREFIX):
raise HTTPException(422, f"{_SERVICE_PREFIX} 开头的用户名留给服务账号:服务账号请在「设置 → 访问密钥」里建")
if payload.password and (problem := password_problem(payload.password)):
raise HTTPException(422, problem)
if (await session.execute(select(User.id).where(User.username == payload.username))).first():
@@ -101,6 +107,9 @@ async def update_user(user_id: str, payload: UserPatch, session: AsyncSession =
row = await session.get(User, user_id)
if row is None:
raise HTTPException(404, "用户不存在")
if row.kind == "service" and ((payload.role is not None and payload.role != row.role) or payload.password):
# 服务账号角色固定使用者、没有口令、不能登录(访问密钥一律按使用者算,next-round 04 A1、A3)
raise HTTPException(409, "服务账号的角色固定是使用者,也不能设密码:它只持有访问密钥,在「设置 → 访问密钥」里管理")
changes: dict[str, Any] = {}
if payload.role is not None and payload.role != row.role:
_check_role(payload.role)
@@ -222,7 +231,9 @@ async def directory(session: AsyncSession = Depends(get_session)) -> dict[str, A
from app.auth.directory import load_directory
known = await load_directory(session)
return {"users": [{"username": u.username, "display_name": u.display_name, "disabled": u.disabled}
# 服务账号另标 service:岗位绑定、改派不能选它(不能被派待办),应用授权能选
return {"users": [{"username": u.username, "display_name": u.display_name, "disabled": u.disabled,
**({"service": True} if u.service else {})}
for u in known.users.values()],
"groups": [{"name": g.name, "label": g.label} for g in known.groups.values()]}
+33 -3
View File
@@ -62,13 +62,43 @@ async def list_tasks(
statuses = None if status == "all" else _csv(status) or ["pending"]
items = await task_service.list_tasks(principal, assigned=assigned, statuses=statuses, app_id=app_id,
run_id=run_id, limit=limit)
return {"items": items}
return {"items": await _for_key(principal, items)}
async def _for_key(principal: Any, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""访问密钥(04 细案 A2):个人应用、只给拥有者的运行(个人应用的刷新、蓝图试运行)里的待办不列;限定了应用的只列那几个
应用里的(不属于应用的待办也不列)。不是密钥认出来的原样交回。"""
if getattr(principal, "via", None) != "key":
return items
from sqlalchemy import select
from app.auth.access import PRIVATE_ORIGINS
from app.db.base import SessionLocal
from app.db.models import App, Run
app_ids = {i.get("app_id") for i in items if i.get("app_id")}
run_ids = {i.get("run_id") for i in items if i.get("run_id")}
async with SessionLocal() as session:
personal = set((await session.execute(select(App.id).where(App.id.in_(app_ids or {""}),
App.scope == "personal"))).scalars())
private = set((await session.execute(select(Run.id).where(
Run.id.in_(run_ids or {""}), Run.origin_kind.in_(PRIVATE_ORIGINS)))).scalars())
return [i for i in items if i.get("app_id") not in personal and i.get("run_id") not in private
and (principal.app_ids is None or i.get("app_id") in principal.app_ids)]
@router.get("/count")
async def count_tasks(mine: int = 1, x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""导航徽标:派给我的、待处理的待办数,按来源分开(apps:应用里的;other:画布、定时任务的审批);latest 是最新一条的开出时间。"""
return await task_service.count(principal_of(x_actor))
"""导航徽标:派给我的、待处理的待办数,按来源分开(apps:应用里的;other:画布、定时任务的审批);latest 是最新一条的开出时间。
访问密钥按它碰得到的那几条数(同列表的 _for_key),不另写一份计数的口径。"""
principal = principal_of(x_actor)
if getattr(principal, "via", None) != "key":
return await task_service.count(principal)
items = await _for_key(principal, await task_service.list_tasks(principal, statuses=["pending"], limit=500))
stamps = [i.get("created_at") for i in items if i.get("created_at")]
apps = sum(1 for i in items if i.get("app_id"))
return {"mine": len(items), "apps": apps, "other": len(items) - apps, "latest": max(stamps) if stamps else None}
@router.get("/{task_id}")
+12 -2
View File
@@ -78,11 +78,14 @@ def render_title(entry: dict[str, Any], inputs: dict[str, Any]) -> str | None:
async def start_entry(app_id: str, entry_id: str, values: dict[str, Any], principal: Any, *,
origin_kind: str = "app_entry", hook: str | None = None, hook_key: str | None = None) -> Any:
origin_kind: str = "app_entry", hook: str | None = None, hook_key: str | None = None,
idempotency_key: str | None = None) -> Any:
"""发起一个实例,返回 Run。校验失败抛 FormRejected(errors);应用需升级(钉住的版本已退役)时拒绝。
钩子发起的(origin_kind=hook)来源里另记钩子名和这次请求的幂等键(hook_key):处理到一半服务停了、同一个键重新
认领时,按它找已经发起的实例(process/hooks.py),不重复发起。
用访问密钥发起的(docs/design/next-round/04-对外集成与触达.md A4)来源仍是 app_entry,另记 via=key、哪一把密钥、叫什么
(auth/keys.trace)和这次的幂等键(idempotency_key),同样在封存范围内:查得到是哪把密钥发起的,重新认领时按它找。
principal.username 记成发起人(关登录时是自报的署名,调用方按 X-Actor 填好)。发起被 launch_run 拒绝时抛
LaunchRefused,原样交给调用方。"""
@@ -132,7 +135,8 @@ async def start_entry(app_id: str, entry_id: str, values: dict[str, Any], princi
origin_kind=origin_kind, app_id=app.id, app_version=version.version,
origin={"entry": entry_id, "spec_hash": version.spec_hash, "title": title, "hook": hook,
"hook_key": hook_key, "actor_verified": bool(getattr(principal, "verified", False)),
"auth_mode": getattr(principal, "mode", "off")})
"auth_mode": getattr(principal, "mode", "off"), **_key_trace(principal),
**({"idempotency_key": idempotency_key} if idempotency_key else {})})
except BaseException:
await files_store.release(claimed)
raise
@@ -140,6 +144,12 @@ async def start_entry(app_id: str, entry_id: str, values: dict[str, Any], princi
return run
def _key_trace(principal: Any) -> dict[str, Any]:
from app.auth.keys import trace
return trace(principal)
async def _claim_files(form: dict[str, Any], values: dict[str, Any], principal: Any, app_id: str,
entry_id: str) -> tuple[dict[str, Any], Any]:
"""占住表单里的文件;引用不对(不是自己传的、在别处传的、已经用过)按表单错误交回,落到那个字段上。"""
+2 -1
View File
@@ -762,7 +762,8 @@ async def acl_issues(session: AsyncSession, app_id: str) -> list[dict[str, Any]]
out = _Issues()
known = await load_directory(session)
for g in grants:
if (m := mismatch(known, mode, g.principal_kind, g.principal)) is None:
# 服务账号能拿授权(只是不能被派待办,岗位绑定那边才算错)
if (m := mismatch(known, mode, g.principal_kind, g.principal, allow_service=True)) is None:
continue
what = f"「{ROLE_TEXT.get(g.role, g.role)}」授权给的{m.text}"
if m.blocking:
+109 -4
View File
@@ -433,7 +433,7 @@ async def run_view(session: AsyncSession, *, app: Any, version: AppVersion, view
expect_hash=state.pin.get("graph_hash"), input_payload=inputs, actor=actor, origin_kind="app_view",
app_id=app.id, app_version=version.version,
origin={"view": view_id, "spec_hash": version.spec_hash, "actor_verified": principal.verified,
"auth_mode": principal.mode})
"auth_mode": principal.mode, **_key_trace(principal)})
ttl = source.get("ttl_s")
if ttl is None and await reads_live(session, state.graph):
ttl = TTL_DEFAULT
@@ -444,6 +444,109 @@ async def run_view(session: AsyncSession, *, app: Any, version: AppVersion, view
return run, False
def _key_trace(principal: Any) -> dict[str, Any]:
"""用访问密钥刷新时 run.origin 另记的:via=key、哪一把、叫什么(auth/keys.trace)。"""
from app.auth.keys import trace
return trace(principal)
# --------------------------------------------------------------------------
# 视图数据 agentlab.viewdata/1(docs/design/next-round/04-对外集成与触达.md A5)
#
# 给外部系统(门户、BI)拉封存的数:按应用当前发布版解析这个视图取的是哪一次封存运行,交出印章、清单哈希和这次运行封存的
# 所有口径卡指标——原值 value、渲染好的 text、遮罩状态、证据指针。不让集成方解析页面视图模型:组件 id 和布局一改集成就坏,
# 而且那里只有渲染好的文字。数值直接取自口径卡封存的指标(叙述层无算术),口径名和版本来自口径卡;遮罩判断和页面用的是同一个
# 函数(_metric_value),遮住的 value 为 null、state 为 masked。查询表格不放进来:要的话按证据指针走证据接口或工件接口,
# 那两处已经按使用者遮过。只给正式运行(最近封存、按需两种视图);按需视图只读这组参数缓存的那次结果,不发起运行
# --------------------------------------------------------------------------
VIEWDATA_FORMAT = "agentlab.viewdata/1"
VIEW_NOT_FOUND = "app_view_not_found"
VIEWDATA_ON_DEMAND_EMPTY = "这组参数还没有算过:读视图数据不发起运行,请先刷新这个按需视图(访问密钥要有 apps.run)"
VIEWDATA_KINDS = ("latest_sealed", "on_demand")
def _ran(vid: str, kind: str, run: Run, state: PinState | None, origin: Any = None) -> Source:
"""一次已经挑出来的运行 → 来源(同 _source 的末尾:在跑、成功、没成功)。"""
if run.status in _ACTIVE:
return Source(id=vid, kind=kind, state="running", run=run, pin=state, message=RUNNING_TEXT)
source = Source(id=vid, kind=kind, state="ok" if run.status == "succeeded" else "error", run=run, pin=state,
origin=origin)
if run.status != "succeeded":
source.message = f"这次运行{STATUS_TEXT.get(run.status, run.status)}"
return source
async def view_data(session: AsyncSession, app: Any, *, version: AppVersion, view_id: str, params: Any,
principal: Principal, role: str | None) -> dict[str, Any]:
"""一个视图的数据(agentlab.viewdata/1)。视图不存在 404;实例视图、个人应用的探索视图 409(只给正式运行)。"""
from app.api.evidence import _sealed_cards
spec = version.spec or {}
view = (spec.get("views") or {}).get(view_id)
if not isinstance(view, dict):
raise ViewRefused(404, f"应用里没有视图「{view_id}」", VIEW_NOT_FOUND)
src = view.get("source") or {}
kind = src.get("kind")
if kind not in VIEWDATA_KINDS:
raise ViewRefused(409, "视图数据只给取正式运行的视图(最近封存、按需运行):实例视图请按实例读", VIEW_REFUSED)
ctx = _Page(app=app, spec=spec, version=version, principal=principal, role=role, instance=None,
params=params if isinstance(params, dict) else {}, draft=False, actor=None)
ctx.pins = await load_pins(session, spec)
inputs: dict[str, Any] | None = None
if kind == "on_demand":
state = ctx.pins.get(src.get("pin"))
if state is None:
source = Source(id=view_id, kind=kind, state="error", message=f"视图「{view_id}」引用的工作流版本不在应用里")
else:
form = resolve_form(spec, src.get("params")) if src.get("params") is not None else None
inputs = normalize_params(form, ctx.params)
run = await _cached(session, await cache_key(session, state, inputs))
source = _ran(view_id, kind, run, state) if run is not None else Source(
id=view_id, kind=kind, state="empty", pin=state, message=VIEWDATA_ON_DEMAND_EMPTY)
else:
source = await _source(session, ctx, view_id)
if source.run is not None and source.run.run_class != "formal":
source = Source(id=view_id, kind=kind, state="error", message="这个视图取到的不是正式运行,视图数据只给正式运行")
await _load(session, source)
metrics: list[dict[str, Any]] = []
if source.state == "ok" and source.sealed is not None:
latest: dict[str, tuple[str, dict[str, Any]]] = {}
for node_id, artifact, card in _sealed_cards(source.sealed):
latest[node_id] = (artifact, card) # 同一张卡执行过几次(循环里)取最后一次,同页面上的绑定
labels = {str(n.get("id")): ((n.get("data") or {}).get("label") or n.get("id"))
for n in source.sealed.graph.get("nodes") or [] if isinstance(n, dict)}
for node_id, (artifact, card) in latest.items():
for item in card.get("metrics") or []:
if not isinstance(item, dict) or not item.get("id"):
continue
metric_id = str(item["id"])
metric, text, eid, held = await _metric_value(source, node_id, metric_id, None, (artifact, card))
if metric is None:
continue
value = None if held else metric.get("value")
metrics.append({
"node": node_id, "node_label": labels.get(node_id, node_id),
"caliber": str(card.get("caliber") or "") or None,
"caliber_version": str(card.get("caliber_version") or "") or None,
"id": metric_id, "name": metric.get("name") or metric_id, "unit": metric.get("unit") or "",
"value": value, "text": text,
"state": "masked" if held else ("ok" if value is not None else "missing"),
"evidence": {"run_id": source.run.id, "eid": eid, "node": node_id, "metric": metric_id}})
run = source.run
return {"format": VIEWDATA_FORMAT,
"app": {"id": app.id, "version": version.version},
"view": {"id": view_id, "kind": kind},
"state": source.state,
**({"params": inputs} if inputs is not None else {}),
"run_id": run.id if run is not None else None,
"manifest_hash": (source.sealed.manifest_hash if source.sealed is not None else None),
"stamp": source.stamp,
"metrics": metrics,
**({"message": source.message} if source.message else {})}
# --------------------------------------------------------------------------
# 解析
# --------------------------------------------------------------------------
@@ -907,13 +1010,15 @@ def _card(source: Source, node_id: str) -> tuple[str, dict[str, Any]] | None:
return found[-1] if found else None
async def _metric_value(source: Source, node_id: str, metric_id: str, conv: Any
async def _metric_value(source: Source, node_id: str, metric_id: str, conv: Any,
found: tuple[str, dict[str, Any]] | None = None
) -> tuple[dict[str, Any] | None, str, str | None, str | None]:
"""(指标, 渲染出来的字, 证据 eid, 不给值时写的字)。"""
"""(指标, 渲染出来的字, 证据 eid, 不给值时写的字)。found 是已经取出来的 (工件, 指标集),不给就按节点取最后一次的。"""
from app.api.evidence import MASKED, _metric_in, _metric_masks
from app.engine.evidence import MISSING, RenderError, make_eid, render_metric
found = _card(source, node_id)
if found is None:
found = _card(source, node_id)
if found is None:
return None, MISSING, None, None
artifact, card = found
+11 -1
View File
@@ -5,7 +5,8 @@ api/deps.py 只定接口,这里给实现:
- middleware.py:按请求认出是谁(会话 cookie),写进 api/deps 的 contextvar;非 GET 请求核对 X-AgentLab-Request 头和 Origin;
- sessions.py / passwords.py:会话(库里只存 token 的 sha256)、口令(hashlib.scrypt)、登录失败退避;
- oidc.py:授权码 + PKCE;access.py:运行、工件的读权限;audit.py:管理类操作的留痕;signing.py:身份断言;
- chat_policy.py:开了问数据的使用者能运行什么样的图(节点、工具的白名单)。
- chat_policy.py:开了问数据的使用者能运行什么样的图(节点、工具的白名单);
- keys.py:访问密钥(服务账号和个人访问密钥)的签发、认人、门禁、限流、幂等、请求记录、接口说明。
关登录(off,默认)时:中间件什么都不做,所有依赖放行,行为和加这一层之前一模一样。
"""
@@ -55,6 +56,15 @@ def user_can_chat() -> bool:
return bool(_stored.get("user_can_chat"))
#: 登录设置里「个人访问密钥」开关的键(docs/design/next-round/04-对外集成与触达.md A1,界面上在「设置 → 访问密钥」)
PERSONAL_KEYS = "personal_keys"
def personal_keys_enabled() -> bool:
"""个人访问密钥开没开(集成-I2,默认关,管理员开关)。关着时建不了个人密钥,已有的个人密钥也不认(auth/keys.py)。"""
return bool(_stored.get(PERSONAL_KEYS))
async def load_settings(force: bool = False) -> dict[str, Any]:
"""从库里读登录设置进缓存。"""
global _loaded
+39 -14
View File
@@ -9,6 +9,10 @@
- 账号密码登录:人和组都在本系统里建,名录里没有就是填错了(或人被删了),停用的人处理不了待办——都算错;
- 单点登录:人和组第一次登录后才进名录,名录里没有的照实提醒、不拒收;停用的人名录里认得出,照样算错;
- 关登录:没有用户表,签批记的是各自填的署名,不核对。
服务账号(users.kind=service,docs/design/next-round/04-对外集成与触达.md A1)也在名录里、标 service:它能拿应用授权(外部系统
按授权读应用),但不能被派待办——它只持有访问密钥,密钥回复不了待办。所以岗位绑定、个人应用提交审核核对时它算错
(mismatch 的 allow_service 为假时),候选池(表单选人、提交审核)里一律不列;授权核对放过它(allow_service=True)。
"""
from __future__ import annotations
@@ -19,8 +23,10 @@ from typing import Any, Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
#: 名录里对不上的两种:没有这个人或组、人停用了
Problem = Literal["missing", "disabled"]
#: 名录里对不上的几种:没有这个人或组、人停用了、是服务账号(只在不收服务账号的地方算错)
Problem = Literal["missing", "disabled", "service"]
#: users.kind 里服务账号的那个值(同 auth/keys.SERVICE_KIND)
SERVICE_KIND = "service"
_KIND = {"user": "用户", "group": "组"}
@@ -29,6 +35,8 @@ class DirectoryUser:
username: str
display_name: str
disabled: bool
#: 服务账号(只持有访问密钥、不能被派待办)
service: bool = False
@dataclass(frozen=True)
@@ -42,13 +50,17 @@ class Directory:
users: dict[str, DirectoryUser] = field(default_factory=dict)
groups: dict[str, DirectoryGroup] = field(default_factory=dict)
def problem(self, kind: str, name: str) -> Problem | None:
"""这一项在名录里对不上的地方;所有人(everyone)不判。"""
def problem(self, kind: str, name: str, *, allow_service: bool = False) -> Problem | None:
"""这一项在名录里对不上的地方;所有人(everyone)不判。服务账号只在 allow_service 时算对得上(应用授权)。"""
if kind == "group":
return None if name in self.groups else "missing"
if kind == "user":
user = self.users.get(name)
return "missing" if user is None else "disabled" if user.disabled else None
if user is None:
return "missing"
if user.disabled:
return "disabled"
return "service" if user.service and not allow_service else None
return None
@@ -70,7 +82,8 @@ async def load_directory(session: AsyncSession) -> Directory:
users = (await session.execute(select(User).order_by(User.username))).scalars()
groups = (await session.execute(select(Group).order_by(Group.name))).scalars()
return Directory(
users={u.username: DirectoryUser(u.username, u.display_name or u.username, u.disabled) for u in users},
users={u.username: DirectoryUser(u.username, u.display_name or u.username, u.disabled,
u.kind == SERVICE_KIND) for u in users},
groups={g.name: DirectoryGroup(g.name, g.label or g.name) for g in groups})
@@ -85,16 +98,19 @@ class Mismatch:
text: str
def mismatch(directory: Directory, mode: str, kind: str, name: str) -> Mismatch | None:
"""按登录方式判这一项:关登录、所有人、对得上的都是 None。"""
def mismatch(directory: Directory, mode: str, kind: str, name: str, *, allow_service: bool = False) -> Mismatch | None:
"""按登录方式判这一项:关登录、所有人、对得上的都是 None。allow_service:服务账号算不算对得上(应用授权算;岗位绑定、
提交审核这些要派待办的不算)。"""
if mode == "off":
return None
problem = directory.problem(kind, name)
problem = directory.problem(kind, name, allow_service=allow_service)
if problem is None:
return None
what = f"{_KIND.get(kind, kind)}「{name}」"
if problem == "disabled":
return Mismatch(kind, name, problem, True, f"{what}已停用")
if problem == "service":
return Mismatch(kind, name, problem, True, f"{what}是服务账号,不能被派待办")
if mode == "oidc":
return Mismatch(kind, name, problem, False,
f"{what}名录里还没有:单点登录的人和组第一次登录后才进名录,名字要和身份提供方里的一致才生效")
@@ -137,6 +153,15 @@ def pool_of(field: Any) -> str:
return POOL_APP
def _person() -> Any:
"""SQL 条件:是人,不是服务账号(kind 为空的老行按人算)。候选池里一律不列服务账号。"""
from sqlalchemy import func
from app.db.models import User
return func.coalesce(User.kind, "person") != SERVICE_KIND
@dataclass
class Pool:
"""一个候选池。open:池就是全部启用的人(应用授权给了所有人)——不带前缀不列,免得等于发通讯录。"""
@@ -171,7 +196,7 @@ async def _members(session: AsyncSession, group: str) -> dict[str, DirectoryUser
rows = (await session.execute(
select(User).join(GroupMember, GroupMember.user_id == User.id).join(Group, Group.id == GroupMember.group_id)
.where(Group.name == group, User.disabled.is_(False)))).scalars()
.where(Group.name == group, User.disabled.is_(False), _person()))).scalars()
return {u.username: DirectoryUser(u.username, u.display_name or u.username, False) for u in rows}
@@ -183,8 +208,8 @@ async def _people(session: AsyncSession, items: Iterable[tuple[str, str]]) -> Po
items = list(items)
names = sorted({n for k, n in items if k == "user" and n})
if names:
for u in (await session.execute(select(User).where(User.username.in_(names), User.disabled.is_(False))))\
.scalars():
for u in (await session.execute(select(User).where(User.username.in_(names), User.disabled.is_(False),
_person()))).scalars():
pool.users[u.username] = DirectoryUser(u.username, u.display_name or u.username, False)
groups = sorted({n for k, n in items if k == "group" and n})
if groups:
@@ -198,7 +223,7 @@ async def _everyone(session: AsyncSession) -> Pool:
from app.db.models import Group, User
pool = Pool(open=True)
for u in (await session.execute(select(User).where(User.disabled.is_(False)))).scalars():
for u in (await session.execute(select(User).where(User.disabled.is_(False), _person()))).scalars():
pool.users[u.username] = DirectoryUser(u.username, u.display_name or u.username, False)
for g in (await session.execute(select(Group))).scalars():
pool.groups[g.name] = DirectoryGroup(g.name, g.label or g.name)
@@ -241,7 +266,7 @@ async def review_pool(session: AsyncSession) -> Pool:
from app.db.models import User
rows = (await session.execute(select(User).where(User.role.in_(("builder", "admin")),
User.disabled.is_(False)))).scalars()
User.disabled.is_(False), _person()))).scalars()
return Pool(users={u.username: DirectoryUser(u.username, u.display_name or u.username, False) for u in rows})
+670
View File
@@ -0,0 +1,670 @@
"""访问密钥(docs/design/next-round/04-对外集成与触达.md A1–A7):签发、认人、门禁、限流、幂等、请求记录、接口说明。
外部系统(门户、OA、BI)和个人脚本用一把访问密钥调接口:Authorization: Bearer agl_…。密钥是不记名的,泄露了就能被冒用,
所以这一层的每个决定都往窄处取:
- 签发(A1):密钥格式 agl_ 加 32 字节随机串,只在创建、轮换时交回一次;库里只存 sha256 和开头一截(prefix,认是哪一把)。
到期必填(最长 365 天,缺省 90 天);轮换是签一把新的、旧的 24 小时后到期,换钥匙不用停机;撤销立刻失效。拥有者是人
(个人访问密钥,管理员开关 auth.personal_keys 缺省关)或服务账号(users.kind=service,svc- 开头、没有口令、不能登录、
角色固定使用者,管理员建)。集成依附在某个员工身上,人一离职集成就断,所以有服务账号;开发自己写脚本拉数时不用找管理员,
所以有个人密钥——它暴露面更大,缺省关,管理员关掉开关时已有的个人密钥一并不认。
- 认人(A2,authenticate):中间件按哈希找密钥。找不到、到期、撤销、拥有者停用(或被删)、个人密钥开关关了,各回 401 带
各自的 code,绝不退回匿名、也不退回会话 cookie。认出来的身份一律封顶在使用者(A3,principal_for_key):role=user、组是
拥有者现在的组、verified;不论拥有者是管理员还是搭建者——现有的使用者规则(只看得到授权的应用、最高到查看全部实例、工件
按白名单遮过、运行事件和实时流碰不到)全部自动生效,不另写一套「密钥能读什么」。
- 门禁(A2,key_gate):KEY_ROUTES 一张(方法, 路由模板)→ scope 的表,在 FastAPI(dependencies=[Depends(key_gate)]) 上挂一次,
按 request.scope['route'].path 比对路由模板(不在中间件里写正则:会和路由定义重复一遍,改路由时漏改)。不在表里的 403
key_route_forbidden;缺 scope 的 403 key_scope_missing;路径里的应用不在密钥限定的应用里 403 key_app_forbidden;个人应用、
只给拥有者的运行(个人应用的刷新、个人蓝图的试运行)一律 403 key_app_personal。同一张表生成接口说明(A7,reference),
说明和真实放行的接口出自一处,不会对不上。
- 限流(take):每把密钥每分钟 120 次,进程内令牌桶,超了 429 带 Retry-After。
- 幂等(A4,once):用密钥发起实例、刷新视图必须带 Idempotency-Key(外部系统超时重试一次就多出一个实例),按(拥有者, 键)
去重——轮换了密钥同一个键照样认得出;状态机和钩子共用(process/receipts.py)。
- 请求记录(A6,note / flush):哪一把、什么时候、哪个接口、回了什么、涉及哪次运行、从哪来。先在进程内攒着,调度循环每一拍
(或攒满 FLUSH_AT 条)批量写一次,保留 RETAIN 天;last_used_at 至少隔 1 分钟写一次——每个请求同步写一行会和运行抢 SQLite 的
写锁(同 sessions 的节流)。
不限定来源 IP:服务端认的客户端地址是 request.client.host,在反向代理后面认不准,靠到期、撤销和请求记录兜底。限流、请求缓冲
都在进程内,以单进程部署为前提(同登录失败退避)。
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import math
import secrets
import time
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable
from sqlalchemy import delete, func, select
from starlette.requests import HTTPConnection
from app.api.coded import CodedHTTPException
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------- 常量与错误码
#: 密钥的固定开头:密钥扫描工具据此认出泄露的 AgentLab 密钥
KEY_PREFIX = "agl_"
#: 随机部分的字节数
KEY_BYTES = 32
#: 库里存的明文开头:agl_ 加随机串的前 8 位
PREFIX_LEN = len(KEY_PREFIX) + 8
#: 服务账号的用户名开头
SERVICE_PREFIX = "svc-"
SERVICE_KIND = "service"
#: 能申请的 scope(A4):读应用(列表、详情、页面、视图数据、实例)、发起入口和刷新视图、读待办、回复待办(只给个人密钥)、
#: 读报告和证据。另有一个不用申请就有的 me(GET /api/auth/me 自省、/api/health)
SCOPES = ("apps.read", "apps.run", "tasks.read", "tasks.respond", "reports.read")
ME = "me"
#: 新建时缺省只勾读的(集成-I3)
READ_SCOPES = ("apps.read", "tasks.read", "reports.read")
#: 只给个人密钥的 scope:服务账号不能被派待办,外部系统回调仍走 signal 钩子
PERSONAL_SCOPES = ("tasks.respond",)
#: 用密钥能回复的待办种类:审批、补充输入、编辑稿件。确认写入、核实写入结果、双盲复算裁决、工具和代码审批要人在界面上
#: 看过确认卡再按
TASK_KINDS = ("approve", "input", "edit")
EXPIRES_DEFAULT_DAYS = 90
EXPIRES_MAX_DAYS = 365
#: 轮换后旧密钥还能用多久
ROTATE_GRACE = timedelta(hours=24)
#: last_used_at 至少隔这么久才写一次(同 sessions._TOUCH_EVERY)
TOUCH_EVERY = timedelta(minutes=1)
#: 每把密钥每分钟最多几次请求
RATE_PER_MINUTE = 120
#: 请求记录攒满多少条就不等调度循环、立刻写一次
FLUSH_AT = 200
#: 请求记录保留多少天
RETAIN = timedelta(days=90)
#: 缓冲里最多攒多少条:库一直写不进去时丢最早的(记一条日志),不让进程内存无限涨
PENDING_MAX = 10_000
#: 过期的请求记录多久清一次
_PURGE_EVERY_S = 3600
IDEMPOTENCY_HEADER = "Idempotency-Key"
#: 名字、限定应用的上限
NAME_MAX = 100
APPS_MAX = 50
#: 认不出密钥(401):不存在(填错了或拥有者被删了)、到期、撤销、拥有者停用、个人密钥的开关关了
KEY_INVALID = "key_invalid"
KEY_EXPIRED = "key_expired"
KEY_REVOKED = "key_revoked"
KEY_OWNER_DISABLED = "key_owner_disabled"
KEY_PERSONAL_OFF = "key_personal_off"
#: 关登录时带了密钥(409):免得调用方以为自己的权限被收窄了,其实服务端把他当成了管理员
KEYS_NEED_LOGIN = "keys_need_login"
#: 门禁(403):接口不在表里、缺 scope、应用不在限定的应用里、个人应用和只给拥有者的运行
KEY_ROUTE_FORBIDDEN = "key_route_forbidden"
KEY_SCOPE_MISSING = "key_scope_missing"
KEY_APP_FORBIDDEN = "key_app_forbidden"
KEY_APP_PERSONAL = "key_app_personal"
#: 限流(429,带 Retry-After)
KEY_RATE_LIMITED = "key_rate_limited"
#: 幂等:没带键(400)、同一个键正在处理(409)、同一个键已经用来办过另一件事(422)
KEY_IDEMPOTENCY_REQUIRED = "key_idempotency_required"
KEY_IDEMPOTENCY_BUSY = "key_idempotency_busy"
KEY_IDEMPOTENCY_REUSED = "key_idempotency_reused"
#: 入口表单里有文件控件(422):文件要先在应用里上传,密钥不开上传接口
KEY_FILES_UNSUPPORTED = "key_files_unsupported"
#: 这种待办不能用密钥回复(403);服务账号的密钥不能回复待办(403)
KEY_TASK_KIND = "key_task_kind"
KEYS_NEED_LOGIN_TEXT = "未开启登录,访问密钥不生效:关登录时所有请求都等同管理员,带着密钥也不会按密钥的权限收窄。请先开启登录"
ROUTE_FORBIDDEN_TEXT = "访问密钥不能调这个接口:能调的接口见「设置 → 访问密钥 → 接口说明」"
APP_PERSONAL_TEXT = "访问密钥不能读个人应用、个人应用的刷新和蓝图的试运行:只给正式发布的应用"
APP_FORBIDDEN_TEXT = "这把访问密钥限定了应用,这个应用不在其中"
def _forbidden(detail: str, code: str) -> CodedHTTPException:
return CodedHTTPException(403, detail, code)
# ---------------------------------------------------------------- 密钥本身
def new_secret() -> str:
return KEY_PREFIX + secrets.token_urlsafe(KEY_BYTES)
def prefix_of(secret: str) -> str:
return secret[:PREFIX_LEN]
def key_hash(secret: str) -> str:
from app.auth.sessions import token_hash
return token_hash(secret)
def bearer(headers: dict[str, str]) -> str | None:
"""请求头里的访问密钥:Authorization: Bearer agl_…。别的 Authorization(Basic、别的 Bearer)不归这里管,照旧按会话认。"""
raw = (headers.get("authorization") or "").strip()
scheme, _, value = raw.partition(" ")
value = value.strip()
if scheme.lower() != "bearer" or not value.startswith(KEY_PREFIX):
return None
return value
def aware(moment: datetime | None) -> datetime | None:
if moment is None:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def state_of(row: Any, owner: Any, now: datetime | None = None) -> str:
"""一把密钥现在的状态:revoked 已撤销 / expired 已过期 / owner_disabled 拥有者停用了 / active 能用。"""
from app.db.base import utcnow
now = now or utcnow()
if row.revoked_at is not None:
return "revoked"
if aware(row.expires_at) <= now:
return "expired"
if owner is None or owner.disabled:
return "owner_disabled"
return "active"
def scopes_of(row: Any, owner: Any) -> frozenset[str]:
"""这把密钥实际带的 scope:存着的里头认得的,加上 me;服务账号的另去掉只给个人密钥的(建的时候就不让勾,这里再守一道)。"""
wanted = {s for s in (row.scopes or []) if s in SCOPES}
if getattr(owner, "kind", None) == SERVICE_KIND:
wanted -= set(PERSONAL_SCOPES)
return frozenset(wanted | {ME})
# ---------------------------------------------------------------- 认人(中间件调)
@dataclass(frozen=True)
class Refusal:
"""认不出这把密钥:回 status 和这一句、这个码。key_id 是认得出是哪一把、只是不能用的(到期、撤销、停用),请求记录记它。"""
status: int
detail: str
code: str
key_id: str | None = None
def principal_for_key(row: Any, owner: Any, groups: tuple[str, ...], mode: str) -> Any:
"""按密钥认出来的身份(A3):一律是使用者。拥有者现在的组、显示名照用;scope、限定的应用另带着,门禁里再收窄。"""
from app.api.deps import Principal
apps = tuple(str(a) for a in row.app_ids or [] if a) or None
return Principal(username=owner.username, role="user", display_name=owner.display_name or owner.username,
user_id=owner.id, groups=groups, verified=True, mode=mode, via="key", key_id=row.id,
key_name=row.name, scopes=scopes_of(row, owner), app_ids=apps)
async def authenticate(secret: str, mode: str, ip: str | None) -> Any:
"""密钥 → Principal;认不出交回 Refusal。顺带按节流写 last_used_at / last_used_ip。"""
from app.auth import personal_keys_enabled
from app.auth.sessions import groups_of
from app.db.base import SessionLocal, utcnow
from app.db.models_identity import AccessKey, User
async with SessionLocal() as session:
row = await session.scalar(select(AccessKey).where(AccessKey.key_hash == key_hash(secret)))
if row is None:
return Refusal(401, "访问密钥无效:可能填错了,或者已被删除", KEY_INVALID)
owner = await session.get(User, row.owner_user_id)
now = utcnow()
state = state_of(row, owner, now)
if state == "revoked":
return Refusal(401, "这把访问密钥已撤销,请换一把新的", KEY_REVOKED, row.id)
if state == "expired":
return Refusal(401, f"这把访问密钥已于 {aware(row.expires_at).astimezone():%Y-%m-%d %H:%M} 过期,请换一把新的",
KEY_EXPIRED, row.id)
if state == "owner_disabled":
return Refusal(401, "这把访问密钥的拥有者已停用,密钥随之失效", KEY_OWNER_DISABLED, row.id)
if owner.kind != SERVICE_KIND and not personal_keys_enabled():
return Refusal(401, "管理员已关闭个人访问密钥,这把密钥暂不生效;系统间集成请用服务账号的密钥", KEY_PERSONAL_OFF,
row.id)
groups = await groups_of(session, owner.id)
last = aware(row.last_used_at)
if last is None or now - last > TOUCH_EVERY:
row.last_used_at = now
row.last_used_ip = (ip or "")[:64] or None
await session.commit()
return principal_for_key(row, owner, groups, mode)
async def introspect(session: Any, principal: Any) -> dict[str, Any]:
"""GET /api/auth/me 用密钥调时另给的那一块:哪一把(名字、开头)、scope、限定的应用、到期时间、拥有者是不是服务账号。"""
from app.db.models_identity import AccessKey, User
row = await session.get(AccessKey, principal.key_id)
owner = await session.get(User, row.owner_user_id) if row is not None else None
return {"id": principal.key_id, "name": principal.key_name, "prefix": row.prefix if row is not None else None,
"scopes": sorted(principal.scopes), "app_ids": list(principal.app_ids) if principal.app_ids else None,
"expires_at": aware(row.expires_at).isoformat() if row is not None else None,
"service": getattr(owner, "kind", None) == SERVICE_KIND}
# ---------------------------------------------------------------- 限流
#: 密钥 id → (还剩几个令牌, 上次补的时刻)
_BUCKETS: dict[str, tuple[float, float]] = {}
def take(key_id: str, *, now: float | None = None) -> int:
"""这把密钥再发一个请求:放行交回 0,超了交回还要等几秒(Retry-After)。令牌桶:满桶 RATE_PER_MINUTE 个,每秒补
RATE_PER_MINUTE / 60 个。"""
rate = RATE_PER_MINUTE / 60.0
moment = time.monotonic() if now is None else now
tokens, at = _BUCKETS.get(key_id, (float(RATE_PER_MINUTE), moment))
tokens = min(float(RATE_PER_MINUTE), tokens + (moment - at) * rate)
if tokens >= 1.0:
_BUCKETS[key_id] = (tokens - 1.0, moment)
return 0
_BUCKETS[key_id] = (tokens, moment)
return max(1, math.ceil((1.0 - tokens) / rate))
# ---------------------------------------------------------------- 门禁
@dataclass(frozen=True)
class KeyRoute:
"""KEY_ROUTES 里的一行:要什么 scope、给人看的一句说明、可能回的错误码(通用的几种另列,见 COMMON_ERRORS)、要不要带幂等键。"""
scope: str
summary: str
errors: tuple[str, ...] = ()
idempotent: bool = False
_APP = ("app_not_found", "app_forbidden", KEY_APP_FORBIDDEN, KEY_APP_PERSONAL)
_PUBLISHED = ("app_unpublished",)
_RUN = ("run_not_found", "run_forbidden", KEY_APP_FORBIDDEN, KEY_APP_PERSONAL)
_IDEM = (KEY_IDEMPOTENCY_REQUIRED, KEY_IDEMPOTENCY_BUSY, KEY_IDEMPOTENCY_REUSED)
#: 访问密钥能调的接口:(方法, 路由模板) → KeyRoute。不在这里的一律 403(key_route_forbidden)。门禁和接口说明都只认这一张
KEY_ROUTES: dict[tuple[str, str], KeyRoute] = {
("GET", "/api/auth/me"): KeyRoute(ME, "这把密钥认出来是谁:拥有者、组、scope、限定的应用、到期时间"),
("GET", "/api/health"): KeyRoute(ME, "服务在不在"),
("GET", "/api/apps"): KeyRoute("apps.read", "能用的应用:授权给拥有者、所在组或所有人的;限定了应用的只列那几个"),
("GET", "/api/apps/{app_id}"): KeyRoute("apps.read", "应用概况和当前发布版的页面、入口目录", _APP),
("GET", "/api/apps/{app_id}/pages/{page_id}"): KeyRoute(
"apps.read", "页面的视图模型(agentlab.view/1):服务端渲染好的文字和证据指针,遮罩值不出服务端",
(*_APP, *_PUBLISHED, "app_page_not_found", "app_params_invalid")),
("GET", "/api/apps/{app_id}/views/{view_id}/data"): KeyRoute(
"apps.read", "视图数据(agentlab.viewdata/1):印章、清单哈希、口径卡指标的原值和渲染文字;遮罩的指标 value 为 null。"
"按需视图读这组参数(?params=)上次算的结果,不发起运行",
(*_APP, *_PUBLISHED, "app_view_not_found", "app_view_refused", "app_params_invalid")),
("GET", "/api/apps/{app_id}/instances"): KeyRoute(
"apps.read", "实例列表:只有「使用」授权的只列拥有者自己发起的,?who=all 要「查看全部实例」授权", _APP),
("GET", "/api/apps/{app_id}/instances/{run_id}"): KeyRoute(
"apps.read", "一个实例的状态和实例页的视图模型", (*_APP, "app_instance_not_found")),
("POST", "/api/apps/{app_id}/entries/{entry_id}"): KeyRoute(
"apps.run", "按入口发起一个实例(当前发布版)。必须带 Idempotency-Key:同一个键重发交回同一个 run_id",
(*_APP, *_PUBLISHED, *_IDEM, "app_entry_not_found", "app_needs_upgrade", "form_invalid", KEY_FILES_UNSUPPORTED,
"run_launch_refused"), idempotent=True),
("POST", "/api/apps/{app_id}/views/{view_id}/refresh"): KeyRoute(
"apps.run", "按需视图按参数运行一次(命中缓存或接上同一组参数正在运行的那次就不再发起)。必须带 Idempotency-Key;"
"沿用每个应用每天的次数上限",
(*_APP, *_PUBLISHED, *_IDEM, "app_view_refused", "app_needs_upgrade", "app_view_daily_limit", "form_invalid",
"run_launch_refused"), idempotent=True),
("GET", "/api/tasks"): KeyRoute("tasks.read", "派给拥有者(或所在组)的待办;限定了应用的只列那几个应用里的"),
("GET", "/api/tasks/count"): KeyRoute("tasks.read", "派给拥有者的、待处理的待办数"),
("GET", "/api/tasks/{task_id}"): KeyRoute("tasks.read", "一条待办的详情", ("task_not_found", KEY_APP_FORBIDDEN,
KEY_APP_PERSONAL)),
("POST", "/api/tasks/{task_id}/respond"): KeyRoute(
"tasks.respond", "回复一条待办:只限审批、补充输入、编辑稿件三种,只给个人访问密钥",
("task_not_found", "task_not_assigned", "task_closed", "task_invalid", "form_invalid", KEY_TASK_KIND,
KEY_FILES_UNSUPPORTED, KEY_APP_FORBIDDEN, KEY_APP_PERSONAL)),
("GET", "/api/runs/{run_id}/report.html"): KeyRoute(
"reports.read", "运行的报告导出成一个自包含的 HTML 文件;遮罩的值写「已遮罩」",
(*_RUN, "evidence_report_not_found")),
("GET", "/api/runs/{run_id}/evidence"): KeyRoute("reports.read", "整次运行的证据图(遮过的)", _RUN),
("GET", "/api/runs/{run_id}/evidence/metrics/{node}/{metric}"): KeyRoute(
"reports.read", "视图数据里一个指标的证据(指针里的 node、metric):指标 → 输入 → 查询快照,遮过的",
(*_RUN, "evidence_metric_not_found")),
("GET", "/api/artifacts/{artifact_id}"): KeyRoute(
"reports.read", "按内容哈希取一件工件:只给报告文档、查询快照、指标集、动作回执几种,按使用者的遮罩处理",
("artifact_forbidden", "artifact_kind_forbidden", "artifact_not_found", KEY_APP_FORBIDDEN, KEY_APP_PERSONAL)),
}
#: 每个接口都可能回的几种(认人、门禁、限流)
COMMON_ERRORS = (KEY_INVALID, KEY_EXPIRED, KEY_REVOKED, KEY_OWNER_DISABLED, KEY_PERSONAL_OFF, KEYS_NEED_LOGIN,
KEY_ROUTE_FORBIDDEN, KEY_SCOPE_MISSING, KEY_RATE_LIMITED)
def app_allowed(principal: Any, app: Any) -> bool:
"""这把密钥碰不碰得到这个应用:个人应用一律不碰;限定了应用的只认那几个。不是密钥认出来的一律为真(不归这里管)。"""
if getattr(principal, "via", None) != "key":
return True
if getattr(app, "scope", "shared") == "personal":
return False
return principal.app_ids is None or app.id in principal.app_ids
async def _check_app(session: Any, principal: Any, app_id: str) -> None:
from app.db.models_apps import App
app = await session.get(App, app_id)
if app is None:
return # 接口照旧回 404
if getattr(app, "scope", "shared") == "personal":
raise _forbidden(APP_PERSONAL_TEXT, KEY_APP_PERSONAL)
if principal.app_ids is not None and app.id not in principal.app_ids:
raise _forbidden(APP_FORBIDDEN_TEXT, KEY_APP_FORBIDDEN)
async def _run_refusal(session: Any, principal: Any, run: Any) -> CodedHTTPException | None:
"""这次运行这把密钥碰不碰得到:只给拥有者的运行(个人应用的刷新、个人蓝图的试运行)不碰;限定了应用的,运行要属于那几个
应用,或者是那几个应用当前发布版作为视图固定的工作流版本的正式运行(视图读的是定时任务那次运行,它不带应用 id)。"""
from app.auth.access import PRIVATE_ORIGINS, _viewed_by_granted_app
from app.db.models_apps import App
if (getattr(run, "origin_kind", None) or "") in PRIVATE_ORIGINS:
return _forbidden(APP_PERSONAL_TEXT, KEY_APP_PERSONAL)
if run.app_id:
scope = await session.scalar(select(App.scope).where(App.id == run.app_id))
if scope == "personal":
return _forbidden(APP_PERSONAL_TEXT, KEY_APP_PERSONAL)
if principal.app_ids is None:
return None
if run.app_id in principal.app_ids or await _viewed_by_granted_app(session, run, set(principal.app_ids)):
return None
return _forbidden(APP_FORBIDDEN_TEXT, KEY_APP_FORBIDDEN)
async def _check_run(session: Any, principal: Any, run_id: str) -> None:
from app.db.models import Run
run = await session.get(Run, run_id)
if run is not None and (refused := await _run_refusal(session, principal, run)) is not None:
raise refused
async def _check_task(session: Any, principal: Any, task_id: str) -> None:
from app.db.models import Run, Task
task = await session.get(Task, task_id)
if task is None:
return
if task.app_id:
await _check_app(session, principal, task.app_id)
elif principal.app_ids is not None:
# 不属于应用的待办(画布、定时任务里的审批):限定了应用的密钥碰不到
raise _forbidden(APP_FORBIDDEN_TEXT, KEY_APP_FORBIDDEN)
from app.auth.access import PRIVATE_ORIGINS
run = await session.get(Run, task.run_id) if task.run_id else None
if run is not None and (getattr(run, "origin_kind", None) or "") in PRIVATE_ORIGINS:
raise _forbidden(APP_PERSONAL_TEXT, KEY_APP_PERSONAL)
async def _check_artifact(session: Any, principal: Any, artifact_id: str) -> None:
"""工件按内容寻址,可能被好几次运行引用:只要有一次是这把密钥碰得到的就放过(能不能读、怎么遮由工件接口照使用者判)。
一次都没有的拒;不挂在任何运行上的放过去,由工件接口照使用者拒。"""
from app.db.models import Artifact, Run
run_ids = [r for r in (await session.execute(select(Artifact.run_id).where(Artifact.id == artifact_id)))
.scalars() if r]
refused: CodedHTTPException | None = None
for run_id in dict.fromkeys(run_ids):
run = await session.get(Run, run_id)
if run is None:
continue
refused = await _run_refusal(session, principal, run)
if refused is None:
return
if refused is not None:
raise refused
async def check_targets(principal: Any, params: dict[str, Any]) -> None:
"""路径参数里的应用、运行、待办、工件这把密钥碰不碰得到(不存在的放过去,接口照旧回 404)。"""
from app.db.base import SessionLocal
async with SessionLocal() as session:
if app_id := params.get("app_id"):
await _check_app(session, principal, str(app_id))
if run_id := params.get("run_id"):
await _check_run(session, principal, str(run_id))
if task_id := params.get("task_id"):
await _check_task(session, principal, str(task_id))
if artifact_id := params.get("artifact_id"):
await _check_artifact(session, principal, str(artifact_id))
def route_of(conn: HTTPConnection) -> tuple[str, str | None]:
route = conn.scope.get("route")
method = "WS" if conn.scope["type"] == "websocket" else str(conn.scope.get("method", "GET")).upper()
return method, getattr(route, "path", None)
async def key_gate(conn: HTTPConnection) -> None:
"""全站的路由级依赖(main.py 的 FastAPI(dependencies=…),在各组路由自己的依赖之前跑):只管按访问密钥认出来的请求,
别的认法一律放行。按 (方法, 路由模板) 查 KEY_ROUTES,再核 scope 和路径里的应用、运行、待办、工件。"""
from app.api.deps import current_principal
principal = current_principal()
if principal.via != "key":
return
entry = KEY_ROUTES.get(route_of(conn))
if entry is None:
raise _forbidden(ROUTE_FORBIDDEN_TEXT, KEY_ROUTE_FORBIDDEN)
if entry.scope not in principal.scopes:
raise _forbidden(f"这把访问密钥没有「{entry.scope}」权限,调不了这个接口:请换一把带这项权限的密钥",
KEY_SCOPE_MISSING)
await check_targets(principal, dict(conn.path_params or {}))
# ---------------------------------------------------------------- 写进运行来源、待办事件的那几项
def trace(principal: Any) -> dict[str, Any]:
"""按密钥发起、回复时记进 run.origin、task.responded 的:经访问密钥、哪一把、叫什么。别的认法交回空的(不改原来记的样子)。"""
if getattr(principal, "via", None) != "key":
return {}
return {"via": "key", "key_id": principal.key_id, "key_name": principal.key_name}
# ---------------------------------------------------------------- 幂等(A4)
def _book() -> Any:
from app.db.models_apps import ApiReceipt
from app.process import receipts
return receipts.Book(ApiReceipt, "owner_user_id")
def _fingerprint(route: str, request: Any) -> str:
from app.core.artifact_store import canonical_json
return hashlib.sha256(canonical_json({"route": route, "request": request}).encode()).hexdigest()
def idempotency_of(raw: str | None) -> str | None:
"""请求头里的幂等键规整成存的样子(去首尾空白、截到 KEY_MAX 字);空的是 None。"""
from app.process import receipts
return (raw or "").strip()[:receipts.KEY_MAX] or None
async def once(principal: Any, idem_key: str | None, *, route: str, request: Any,
run: Callable[[bool], Awaitable[dict[str, Any]]],
replay: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]) -> dict[str, Any]:
"""用密钥办一件要去重的事(发起实例、刷新视图)。没带键 400;同一个键办成过的交回上次的结果(replay,duplicate: true);
正在办的 409;用来办过另一件事的 422。run(reclaimed) 真正去办,交回带 run_id 的结果;reclaimed 为真是上一次办到一半
服务停了,run 要先找已经办成的那一件。run 抛出的错原样往上抛,记录记成被拒(同一个键可以重试)。"""
from app.process import receipts
key = idempotency_of(idem_key)
if not key:
raise CodedHTTPException(400, f"用访问密钥发起时要带 {IDEMPOTENCY_HEADER} 请求头:同一件事重发时要带同一个键,"
"才不会多发起一次", KEY_IDEMPOTENCY_REQUIRED)
book = _book()
owner = str(principal.user_id)
try:
previous, reclaimed = await book.claim(owner, key, fingerprint=_fingerprint(route, request),
extra={"key_id": principal.key_id, "route": route[:300]})
except receipts.ReceiptBusy:
raise CodedHTTPException(409, "同一个幂等键的请求正在处理,请稍后再查", KEY_IDEMPOTENCY_BUSY) from None
except receipts.ReceiptMismatch:
raise CodedHTTPException(422, "这个幂等键已经用来办过另一件事(别的接口或别的内容):新的一件事请换一个键",
KEY_IDEMPOTENCY_REUSED) from None
if previous is not None:
note_run(previous.get("run_id"))
return await replay(previous)
try:
out = await run(reclaimed)
except Exception as e:
# 被拒(表单不对、发起不了):记成 rejected,同一个键可以重试。请求被取消(客户端断开)不在这里收:记录停在
# processing,过了 STALE_S 同一个键能重新认领(同钩子)
await book.finish(owner, key, status="rejected", detail=(str(e) or type(e).__name__)[:500])
raise
note_run(out.get("run_id"))
await book.finish(owner, key, status="accepted", run_id=out.get("run_id"), task_id=out.get("task_id"))
return out
async def started_before(principal: Any, idem_key: str) -> str | None:
"""拥有者按这个幂等键用密钥发起过的实例(来源里记着 idempotency_key):处理到一半服务停了、同一个键重新认领时交回它。"""
from app.db.base import SessionLocal
from app.db.models import Run
async with SessionLocal() as session:
return await session.scalar(select(Run.id).where(
Run.origin_kind == "app_entry", Run.started_by == principal.username,
func.json_extract(Run.origin, "$.idempotency_key") == idem_key,
func.json_extract(Run.origin, "$.via") == "key").order_by(Run.created_at.desc()).limit(1))
# ---------------------------------------------------------------- 请求记录(A6)
#: 这一次请求的记录(中间件按请求放一份,接口里 note_run 往里写涉及的运行)
_CURRENT: ContextVar[dict[str, Any] | None] = ContextVar("agentlab_key_request", default=None)
#: 还没写进库的请求记录
_PENDING: list[dict[str, Any]] = []
_flushing: asyncio.Task[Any] | None = None
_last_purge = 0.0
def begin_request() -> tuple[dict[str, Any], Any]:
record: dict[str, Any] = {}
return record, _CURRENT.set(record)
def end_request(token: Any) -> None:
_CURRENT.reset(token)
def note_run(run_id: Any) -> None:
"""接口里交出运行 id 时调(发起实例、刷新视图):请求记录记下涉及哪次运行。不在密钥请求里时什么都不做。"""
record = _CURRENT.get()
if record is not None and run_id:
record["run_id"] = str(run_id)
def note(key_id: str, *, method: str, route: str, status: int, run_id: str | None, ip: str | None) -> None:
"""记一次请求(先进缓冲)。攒满 FLUSH_AT 条就起一个后台任务写库,不等调度循环。"""
global _flushing
from app.db.base import utcnow
_PENDING.append({"key_id": key_id, "at": utcnow(), "method": method[:10], "route": (route or "")[:300],
"status": int(status or 0), "run_id": (run_id or None) and str(run_id)[:32],
"ip": (ip or "")[:64] or None})
if len(_PENDING) > PENDING_MAX:
dropped = len(_PENDING) - PENDING_MAX
del _PENDING[:dropped]
logger.warning("访问密钥的请求记录一直写不进库,丢掉了最早的 %d 条", dropped)
if len(_PENDING) >= FLUSH_AT and (_flushing is None or _flushing.done()):
try:
_flushing = asyncio.get_running_loop().create_task(_flush_quietly(), name="agentlab-key-requests")
except RuntimeError:
pass # 没有事件循环(不会发生在请求里):等下一拍
async def _flush_quietly() -> None:
try:
await flush()
except Exception: # noqa: BLE001 - 没人 await 这个任务,异常只记日志
logger.exception("访问密钥的请求记录写库失败,下一拍再试")
async def flush() -> int:
"""把缓冲里的请求记录批量写进库,交回写了几条。写失败的放回缓冲(下一拍再试),不丢。"""
from app.db.base import SessionLocal
from app.db.models_identity import AccessKeyRequest
if not _PENDING:
return 0
batch = list(_PENDING)
del _PENDING[:len(batch)]
try:
async with SessionLocal() as session:
session.add_all(AccessKeyRequest(**item) for item in batch)
await session.commit()
except BaseException:
_PENDING[:0] = batch
raise
return len(batch)
async def purge(now: datetime | None = None) -> int:
"""清掉超过保留期的请求记录,交回清了几条。"""
from app.db.base import SessionLocal, utcnow
from app.db.models_identity import AccessKeyRequest
floor = (now or utcnow()) - RETAIN
async with SessionLocal() as session:
done = await session.execute(delete(AccessKeyRequest).where(AccessKeyRequest.at < floor))
await session.commit()
return int(done.rowcount or 0)
async def tick(now: datetime | None = None) -> list[str]:
"""调度循环每一拍(scheduler/loop.process_due):写缓冲里的请求记录(交回写了的那几条的密钥 id,日志按条数记);每小时
清一次过了保留期的(给了 now 的测试每次都清)。"""
global _last_purge
written = list(_PENDING)
count = await flush()
moment = time.monotonic()
if now is not None or moment - _last_purge >= _PURGE_EVERY_S:
_last_purge = moment
if purged := await purge(now):
logger.info("清掉 %d 条超过 %d 天的访问密钥请求记录", purged, RETAIN.days)
return [item["key_id"] for item in written[:count]]
async def recent_requests(session: Any, key_id: str, limit: int = 100) -> list[dict[str, Any]]:
"""这把密钥最近的请求(新的在前)。先把缓冲写进库:刚发的请求在界面上马上查得到。"""
from app.db.models_identity import AccessKeyRequest
try:
await flush()
except Exception: # noqa: BLE001 - 写不进去只是少几条最新的,照常给库里有的
logger.exception("访问密钥的请求记录写库失败")
rows = (await session.execute(select(AccessKeyRequest).where(AccessKeyRequest.key_id == key_id)
.order_by(AccessKeyRequest.at.desc()).limit(max(1, min(limit, 200))))).scalars()
return [{"at": r.at.isoformat() if r.at else None, "method": r.method, "route": r.route, "status": r.status,
"run_id": r.run_id, "ip": r.ip} for r in rows]
# ---------------------------------------------------------------- 接口说明(A7)
def reference() -> dict[str, Any]:
"""从 KEY_ROUTES 生成的接口说明:方法、路径、scope、说明、可能的错误码、要不要带幂等键。通用的错误码另列。"""
from app.core.public_url import public_base_url
routes = [{"method": method, "path": path, "scope": entry.scope, "summary": entry.summary,
"errors": list(dict.fromkeys(entry.errors)), "idempotent": entry.idempotent}
for (method, path), entry in KEY_ROUTES.items()]
return {"base_url": public_base_url(), "header": "Authorization: Bearer agl_…",
"idempotency_header": IDEMPOTENCY_HEADER, "scopes": [*SCOPES], "implicit_scope": ME,
"personal_scopes": [*PERSONAL_SCOPES], "rate_per_minute": RATE_PER_MINUTE,
"common_errors": [*COMMON_ERRORS], "routes": routes}
+70 -4
View File
@@ -15,6 +15,14 @@
(要过 CORS 预检,只放行配置里的前端地址),SameSite=Lax 的 cookie 也不随跨站的表单提交带过去;
- WebSocket:没登录的握手前就关掉;登录了的交给路由,按运行判(api/deps.py 的 run_scope:user 角色只能连自己能读、
查询结果在工具边界遮过的运行,也就是自己的问数据运行;应用实例页照旧轮询)。
访问密钥(Authorization: Bearer agl_…,auth/keys.py,docs/design/next-round/04-对外集成与触达.md A2):
- 带了密钥的请求只按密钥认:不看 cookie、不看 X-Actor,也不查 CSRF——Authorization 头跨站发不出来(要过 CORS 预检,只放行
登记的前端地址),同一个请求带了会话 cookie 也不起作用;
- 认不出(无效、到期、撤销、拥有者停用、个人密钥开关关了)回 401,各带各的 code,绝不退回匿名或会话;
- 只能调 /api 下的接口(哪些能调由路由级的门禁 keys.key_gate 按 KEY_ROUTES 判),WebSocket 一律拒;
- 关登录时回 409(keys_need_login):关登录时所有请求都等同管理员,不能让调用方以为自己的权限被收窄了;
- 每把密钥每分钟 120 次,超了 429 带 Retry-After;每次请求(含被拒的)进请求记录。
"""
from __future__ import annotations
@@ -71,10 +79,12 @@ def _origin_ok(origin: str, headers: dict[str, str], scope: dict[str, Any]) -> b
return origin.rstrip("/") in allowed
async def _reply(send: Any, status: int, detail: str, code: str) -> None:
async def _reply(send: Any, status: int, detail: str, code: str,
headers: list[tuple[bytes, bytes]] | None = None) -> None:
body = json.dumps({"detail": detail, "code": code}, ensure_ascii=False).encode()
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())]})
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()),
*(headers or [])]})
await send({"type": "http.response.body", "body": body})
@@ -102,18 +112,22 @@ class AuthMiddleware:
await self.app(scope, receive, send)
return
from app.api.deps import reset_principal, set_principal
from app.auth import current_mode, refusal
from app.auth import current_mode, keys, refusal
# 登录设置在启动时读进缓存(main.py 的 _platform_startup),改设置的接口改完就刷新;这里不查库
mode = current_mode()
headers = _headers(scope)
# 带访问密钥的请求另走一条路:只按密钥认,不看 cookie、不查 CSRF(见模块说明)
if (secret := keys.bearer(headers)) is not None:
await self._with_key(scope, receive, send, secret, mode)
return
if mode == "off":
# 关登录:所有人等同管理员,署名是自报的 X-Actor。actor_of 照旧自己解码请求头,这里写进来只为让审计、
# current_principal() 也拿得到这个署名
from app.api.deps import Principal, decode_actor
actor = decode_actor(headers.get("x-actor"))
token = set_principal(Principal(username=actor, display_name=actor, role="admin", mode="off"))
token = set_principal(Principal(username=actor, display_name=actor, role="admin", mode="off", via="off"))
try:
await self.app(scope, receive, send)
finally:
@@ -152,3 +166,55 @@ class AuthMiddleware:
await self.app(scope, receive, send)
finally:
reset_principal(token)
async def _with_key(self, scope: dict[str, Any], receive: Any, send: Any, secret: str, mode: str) -> None:
"""带访问密钥的请求(见模块说明):只按密钥认,认出来的身份交给路由,门禁(keys.key_gate)按路由模板再判。"""
from app.api.deps import reset_principal, set_principal
from app.auth import keys
if scope["type"] == "websocket":
# 实时流只给界面:带密钥的握手前就关
await receive()
await send({"type": "websocket.close", "code": 1008})
return
if mode == "off":
await _reply(send, 409, keys.KEYS_NEED_LOGIN_TEXT, keys.KEYS_NEED_LOGIN)
return
path = scope.get("path") or ""
method = str(scope.get("method", "GET")).upper()
if not path.startswith("/api/"):
# 页面、FastAPI 自带的接口说明都不对密钥开放
await _reply(send, 403, keys.ROUTE_FORBIDDEN_TEXT, keys.KEY_ROUTE_FORBIDDEN)
return
client = scope.get("client")
ip = str(client[0]) if client else None
principal = await keys.authenticate(secret, mode, ip)
if isinstance(principal, keys.Refusal):
if principal.key_id:
keys.note(principal.key_id, method=method, route=path, status=principal.status, run_id=None, ip=ip)
await _reply(send, principal.status, principal.detail, principal.code)
return
if wait := keys.take(principal.key_id):
keys.note(principal.key_id, method=method, route=path, status=429, run_id=None, ip=ip)
await _reply(send, 429, f"这把访问密钥的请求太频繁(每分钟最多 {keys.RATE_PER_MINUTE} 次),请 {wait} 秒后再试",
keys.KEY_RATE_LIMITED, [(b"retry-after", str(wait).encode())])
return
status = {"code": 0}
async def watch(message: dict[str, Any]) -> None:
if message.get("type") == "http.response.start":
status["code"] = int(message.get("status") or 0)
await send(message)
record, record_token = keys.begin_request()
token = set_principal(principal)
try:
await self.app(scope, receive, watch)
finally:
reset_principal(token)
keys.end_request(record_token)
# 路由模板(请求走完时 FastAPI 记在 scope['route'] 上);没匹配上任何路由的记原样的路径
route = getattr(scope.get("route"), "path", None) or path
run_id = record.get("run_id") or (scope.get("path_params") or {}).get("run_id")
keys.note(principal.key_id, method=method, route=route, status=status["code"] or 500, run_id=run_id, ip=ip)
+2 -1
View File
@@ -70,7 +70,8 @@ async def user_of(session: AsyncSession, token: str) -> Any | None:
if now - row.last_seen_at > timedelta(hours=settings.auth_session_idle_hours):
return None
user = await session.get(User, row.user_id)
if user is None or user.disabled:
# 服务账号不能登录(只持有访问密钥,auth/keys.py):它没有口令,这里再守一道——万一有会话指向它也认不出来
if user is None or user.disabled or user.kind == "service":
return None
if now - row.last_seen_at > _TOUCH_EVERY:
row.last_seen_at = now
+28
View File
@@ -118,3 +118,31 @@ class HookReceipt(Base):
status: Mapped[str] = mapped_column(String(20))
detail: Mapped[str | None] = mapped_column(Text, default=None)
received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class ApiReceipt(Base):
"""用访问密钥发起实例、刷新视图时按 Idempotency-Key 去重的记录(docs/design/next-round/04-对外集成与触达.md A4)。
按 (拥有者, 幂等键) 唯一,不按密钥:轮换了密钥,同一个键照样认得出是同一件事。状态机和钩子的收件记录是同一套
(process/receipts.py):processing 认领、处理中 → accepted / rejected;停在 processing 太久的可以重新认领。钩子那张表
(hook_receipts.hook_id 非空外键,SQLite 改不了可空性)不动,两张表各存各的、共用状态机。
fingerprint 是这次请求的指纹(方法、路由模板、路径参数、请求体):同一个键拿去办另一件事(换了入口、换了表单值)不交回
上一件的结果,照实拒掉——钩子的键只在一个钩子里用,这里一个键跨所有应用和入口,撞键的可能大得多。"""
__tablename__ = "api_receipts"
__table_args__ = (UniqueConstraint("owner_user_id", "idem_key"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
owner_user_id: Mapped[str] = mapped_column(String(32), index=True)
idem_key: Mapped[str] = mapped_column(String(200))
#: 最近一次认领它的那把密钥(轮换前后可能不同)
key_id: Mapped[str | None] = mapped_column(String(32), default=None)
route: Mapped[str] = mapped_column(String(300), default="")
fingerprint: Mapped[str | None] = mapped_column(String(64), default=None)
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
task_id: Mapped[str | None] = mapped_column(String(32), default=None)
# processing | accepted | rejected
status: Mapped[str] = mapped_column(String(20))
detail: Mapped[str | None] = mapped_column(Text, default=None)
received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
+48
View File
@@ -97,3 +97,51 @@ class AuditEvent(Base):
subject_kind: Mapped[str] = mapped_column(String(40))
subject_id: Mapped[str | None] = mapped_column(String(64), default=None)
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
class AccessKey(Base):
"""访问密钥(docs/design/next-round/04-对外集成与触达.md A1):外部系统、个人脚本调接口用的不记名凭据。
库里只存密钥的 sha256(key_hash)和明文开头那一截(prefix,「agl_」加随机串的前 8 位,界面上认是哪一把):库被拷走
也拿不到能用的密钥。到期必填(最长 365 天);撤销、到期、拥有者停用之后,下一个请求就认不出来。拥有者可以是人(个人
访问密钥,管理员开关 auth.personal_keys)或服务账号(users.kind=service,管理员建)。
scopes 是这把密钥能调的几类接口(auth/keys.SCOPES);app_ids 为空表示不限应用,否则只认这几个应用。不论拥有者是谁,
按密钥认出来的身份一律封顶在使用者的权限和遮罩边界(A3)。轮换时签一把新的、旧的 24 小时后到期(rotated_from 记新钥
从哪一把换来)。"""
__tablename__ = "access_keys"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(100))
key_hash: Mapped[str] = mapped_column(String(64), unique=True)
prefix: Mapped[str] = mapped_column(String(16))
scopes: Mapped[list[Any]] = mapped_column(default=list)
app_ids: Mapped[list[Any] | None] = mapped_column(default=None)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime)
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
revoked_by: Mapped[str | None] = mapped_column(String(100), default=None)
last_used_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
last_used_ip: Mapped[str | None] = mapped_column(String(64), default=None)
rotated_from: Mapped[str | None] = mapped_column(String(32), default=None)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class AccessKeyRequest(Base):
"""用访问密钥发来的请求(A6):哪一把、什么时候、哪个接口(路由模板,不是带参数的路径)、回了什么状态、涉及哪次运行、
从哪个地址来。先在进程内攒着,调度循环每一拍(或攒满一批)批量写一次:每个请求同步写一行会和运行抢 SQLite 的写锁。
保留 90 天,过期的在调度循环里清掉。"""
__tablename__ = "access_key_requests"
__table_args__ = (Index("ix_access_key_requests_key_at", "key_id", "at"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
key_id: Mapped[str] = mapped_column(String(32))
at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, index=True)
method: Mapped[str] = mapped_column(String(10))
route: Mapped[str] = mapped_column(String(300))
status: Mapped[int] = mapped_column(default=0)
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
ip: Mapped[str | None] = mapped_column(String(64), default=None)
+5 -1
View File
@@ -4,7 +4,7 @@ import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
@@ -27,6 +27,7 @@ from app.api import (
)
from app.api.coded import CodedHTTPException, coded_handler
from app.api.deps import chat_scope, evidence_scope, private_run_scope, require_role, role_by_method, run_scope
from app.auth.keys import key_gate
from app.auth.middleware import AuthMiddleware
from app.core.config import settings
from app.db.base import init_db
@@ -190,11 +191,14 @@ async def _platform_startup() -> None:
logger.exception("能力登记同步失败,服务照常启动")
# 访问密钥的门禁(app/auth/keys.py 的 key_gate,docs/design/next-round/04-对外集成与触达.md A2)挂在全站一次:每个路由都先过它,
# 按访问密钥认出来的请求只放行 KEY_ROUTES 里的接口;别的认法(会话、关登录、钩子)一律放过,由各组路由自己的依赖判
app = FastAPI(
title="AgentLab",
description="可视化 Agent 编排实验台",
version="0.1.0",
lifespan=lifespan,
dependencies=[Depends(key_gate)],
)
# 身份(app/auth/middleware.py):按请求认出是谁、核对 CSRF。加在 CORS 之前,CORS 就在它外面一层:
+68 -33
View File
@@ -35,6 +35,7 @@ import json
import logging
import secrets
import time
from collections import deque
from dataclasses import dataclass
from typing import Any
@@ -44,13 +45,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import SessionLocal, utcnow
from app.db.models import AppHook, HookReceipt, Task
from app.process import receipts
logger = logging.getLogger(__name__)
#: 签名时间窗(秒)
WINDOW_S = 300
#: 回执停在 processing 超过这么久(秒),同一个幂等键可以重新认领:处理一次请求用不了这么久,多半是中途服务停了
STALE_S = 600
STALE_S = receipts.STALE_S
#: 钩子的收件记录:按 (钩子, 幂等键) 去重的状态机(process/receipts.py,访问密钥的发起也用它)
_BOOK = receipts.Book(HookReceipt, "hook_id", stale_s=STALE_S)
#: 「这个签名认了哪个幂等键」的记录也放在回执表里(idem_key 是这个前缀加时间戳和签名,detail 是认的幂等键):同一个
#: 唯一约束管两件事,不另起表;过了两个时间窗就删
SIGNATURE_PREFIX = "sig:"
@@ -116,7 +120,11 @@ async def receive(hook_id: str, headers: Any, body: bytes) -> dict[str, Any]:
declared = await published_declaration(session, hook.app_id, hook.name)
if not secret:
raise HookRejected("钩子的密钥无法解密(加密密钥可能已更换),请在应用里重新生成钩子", status_code=409)
verify(secret, headers, body)
try:
verify(secret, headers, body)
except HookRejected:
_signature_failed(hook.id)
raise
# 验过签名才说「发布版里没有它」:没验签的请求不该探出应用里声明了哪些钩子
if declared is None:
raise HookRejected("应用当前的发布版本里没有声明这个钩子,不接收请求", status_code=404)
@@ -203,40 +211,17 @@ async def _bind_signature(hook_id: str, ts: str, signature: str, idem_key: str)
async def _claim(hook_id: str, idem_key: str) -> tuple[dict[str, Any] | None, bool]:
"""按 (钩子, 幂等键) 认领这一次,交回 (上次的结果, 是不是重新认领了一条停在处理中的)。处理成功过的交回上次的结果
(duplicate: true);被拒过的重新认领;正在处理的 409,停在处理中超过 STALE_S 的重新认领(条件 UPDATE,并发的两次
只有一次认领得到)。"""
from datetime import timedelta
async with SessionLocal() as session:
session.add(HookReceipt(hook_id=hook_id, idem_key=idem_key, status="processing"))
try:
await session.commit()
return None, False
except IntegrityError:
await session.rollback()
row = await session.scalar(select(HookReceipt).where(HookReceipt.hook_id == hook_id,
HookReceipt.idem_key == idem_key))
if row.status == "accepted":
return {"status": "accepted", "duplicate": True, "run_id": row.run_id, "task_id": row.task_id}, False
stale = row.status == "processing" and row.received_at < utcnow() - timedelta(seconds=STALE_S)
if row.status == "rejected" or stale:
again = await session.execute(update(HookReceipt).where(
HookReceipt.id == row.id, HookReceipt.status == row.status,
HookReceipt.received_at == row.received_at)
.values(status="processing", detail=None, received_at=utcnow()))
await session.commit()
if again.rowcount == 1:
return None, stale
raise HookRejected("同一个幂等键的请求正在处理,请稍后再查", status_code=409)
(duplicate: true);被拒过的重新认领;正在处理的 409,停在处理中超过 STALE_S 的重新认领。状态机和访问密钥的发起
共用(process/receipts.py)。"""
try:
return await _BOOK.claim(hook_id, idem_key)
except receipts.ReceiptBusy:
raise HookRejected("同一个幂等键的请求正在处理,请稍后再查", status_code=409) from None
async def _finish(hook_id: str, idem_key: str, *, status: str, detail: str | None = None,
run_id: str | None = None, task_id: str | None = None) -> None:
async with SessionLocal() as session:
await session.execute(update(HookReceipt).where(HookReceipt.hook_id == hook_id,
HookReceipt.idem_key == idem_key)
.values(status=status, detail=detail, run_id=run_id, task_id=task_id))
await session.commit()
await _BOOK.finish(hook_id, idem_key, status=status, detail=detail, run_id=run_id, task_id=task_id)
def _caller(hook: AppHook) -> Any:
@@ -244,7 +229,7 @@ def _caller(hook: AppHook) -> Any:
from app.api.deps import Principal, auth_mode
return Principal(username=None, role="admin", display_name=f"外部系统({hook.name})", verified=True,
mode=auth_mode())
mode=auth_mode(), via="hook")
async def _start(hook: AppHook, request: HookRequest, entry: Any, *, reclaimed: bool = False) -> dict[str, Any]:
@@ -379,3 +364,53 @@ async def delete_hook(session: AsyncSession, app_id: str, name: str) -> bool:
for row in rows:
await session.delete(row)
return bool(rows)
# --------------------------------------------------------------------------
# 收件记录(给 GET /api/apps/{id}/hooks/{name}/receipts 用,docs/design/next-round/04-对外集成与触达.md A6)
#
# 外部系统说「我发过了」,管理应用的人要查得到收到没有、为什么被拒。验过签名的请求在 hook_receipts 里本来就有一行;
# 验签失败的不落库(匿名的人能拿它把库刷满),只在进程里按钩子记最近一小时的次数,界面上写「近 1 小时验签失败 N 次」。
# 单进程部署(同登录失败退避),重启清零
# --------------------------------------------------------------------------
#: 验签失败按多长时间算(秒)
SIGNATURE_WINDOW_S = 3600
#: 一个钩子最多记多少次失败的时间:被刷的时候只要知道「很多」,不用无限长
_FAILURES_MAX = 10_000
#: 钩子 id → 验签失败的时刻(time.monotonic)
_SIGNATURE_FAILURES: dict[str, deque[float]] = {}
#: 一页收件记录最多几条
RECEIPTS_MAX = 100
def _signature_failed(hook_id: str) -> None:
times = _SIGNATURE_FAILURES.setdefault(hook_id, deque(maxlen=_FAILURES_MAX))
times.append(time.monotonic())
def signature_failures(hook_ids: list[str]) -> int:
"""这几个钩子(同名多出来的行一起算)最近一小时验签失败了几次。顺带丢掉窗口外的。"""
floor = time.monotonic() - SIGNATURE_WINDOW_S
total = 0
for hook_id in hook_ids:
times = _SIGNATURE_FAILURES.get(hook_id)
while times and times[0] < floor:
times.popleft()
total += len(times or ())
return total
async def recent_receipts(session: AsyncSession, app_id: str, name: str,
limit: int = RECEIPTS_MAX) -> dict[str, Any]:
"""这个名字的钩子最近收到的请求(新的在前):幂等键、状态(处理中 / 已接受 / 被拒)、时间、发起的实例或解决的待办、
被拒的原因;另给近 1 小时验签失败的次数。防换键重放的签名记录(status=signature)不是一次请求,不列。"""
ids = list((await session.execute(select(AppHook.id).where(AppHook.app_id == app_id, AppHook.name == name)))
.scalars())
rows = list((await session.execute(
select(HookReceipt).where(HookReceipt.hook_id.in_(ids or [""]), HookReceipt.status != "signature")
.order_by(HookReceipt.received_at.desc()).limit(max(1, min(limit, RECEIPTS_MAX))))).scalars())
return {"items": [{"idem_key": r.idem_key, "status": r.status,
"received_at": r.received_at.isoformat() if r.received_at else None,
"run_id": r.run_id, "task_id": r.task_id, "detail": r.detail} for r in rows],
"signature_failures": signature_failures(ids), "window_s": SIGNATURE_WINDOW_S}
+92
View File
@@ -0,0 +1,92 @@
"""按幂等键去重的收件记录(docs/design/next-round/04-对外集成与触达.md A4):入站钩子(hook_receipts)和访问密钥
(api_receipts)共用这一套状态机,各存各的表(Book 按表参数化)。
一条记录按(谁, 幂等键)唯一:钩子按钩子,访问密钥按拥有者(轮换了密钥,同一个键照样认得出是同一件事)。
- claim:先插一行 processing。插得进去就是头一回,交回 (None, False);插不进去(撞了唯一约束)看已有的那一行:
- accepted:交回上次的结果(duplicate: true),不再处理;
- rejected:重新认领。被拒的(表单不对、没有在等的待办)可以用同一个键重试——外部系统多半会原样重发,拒过一次就永远拒,
它只能换键,去重也就落空了;
- processing 超过 STALE_S:处理一次请求用不了这么久,多半是中途服务停了,重新认领,交回 reclaimed=True。调用方先找这个键
已经办成的事(已经发起的实例、已经解决的待办),找到就交回它,不重复办;
- processing 没超时:正在处理,抛 ReceiptBusy(调用方回 409)。
重新认领用条件 UPDATE(状态、收到时间都对得上才改):并发的两次只有一次认领得到。
- finish:处理完写结果(accepted 带运行或待办,rejected 带原因)。
有指纹的表(api_receipts.fingerprint)另核对同一个键办的是不是同一件事:办成过的、还在办的,指纹对不上抛 ReceiptMismatch,
不把上一件事的结果当成这一件的交回去;被拒过的不算办过,换了内容重试照常认领(指纹换成这一次的)。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from app.db.base import SessionLocal, utcnow
#: 回执停在 processing 超过这么久(秒),同一个幂等键可以重新认领
STALE_S = 600
#: 幂等键最长存多少字
KEY_MAX = 200
class ReceiptBusy(Exception):
"""同一个幂等键的请求正在处理(调用方回 409)。"""
class ReceiptMismatch(Exception):
"""同一个幂等键已经用来办过另一件事(指纹对不上,调用方回 422)。"""
@dataclass(frozen=True)
class Book:
"""一张收件记录表:model 是表,owner 是「谁」那一列的名字(hook_id / owner_user_id)。表里要有 idem_key、status、
detail、run_id、task_id、received_at;fingerprint 列可有可无。"""
model: Any
owner: str
stale_s: int = STALE_S
def _key(self, owner: str, idem_key: str) -> list[Any]:
return [getattr(self.model, self.owner) == owner, self.model.idem_key == idem_key]
async def claim(self, owner: str, idem_key: str, *, fingerprint: str | None = None,
extra: dict[str, Any] | None = None) -> tuple[dict[str, Any] | None, bool]:
"""按(谁, 幂等键)认领这一次,交回 (上次的结果, 是不是重新认领了一条停在处理中的)。规则见模块说明。extra 是
认领时一并记下的列(访问密钥记哪一把密钥、哪个接口)。"""
fields = {**(extra or {}), **({"fingerprint": fingerprint} if fingerprint is not None else {})}
async with SessionLocal() as session:
session.add(self.model(**{self.owner: owner}, idem_key=idem_key, status="processing", **fields))
try:
await session.commit()
return None, False
except IntegrityError:
await session.rollback()
row = await session.scalar(select(self.model).where(*self._key(owner, idem_key)))
if row is None:
# 撞了约束、回头又查不到:那一行刚被删掉(钩子被删除时连同记录一起删)。当成正在处理,让调用方稍后重发
raise ReceiptBusy
known = getattr(row, "fingerprint", None)
if fingerprint is not None and known and known != fingerprint and row.status != "rejected":
raise ReceiptMismatch
if row.status == "accepted":
return {"status": "accepted", "duplicate": True, "run_id": row.run_id, "task_id": row.task_id}, False
stale = row.status == "processing" and row.received_at < utcnow() - timedelta(seconds=self.stale_s)
if row.status == "rejected" or stale:
again = await session.execute(update(self.model).where(
self.model.id == row.id, self.model.status == row.status,
self.model.received_at == row.received_at)
.values(status="processing", detail=None, received_at=utcnow(), **fields))
await session.commit()
if again.rowcount == 1:
return None, stale
raise ReceiptBusy
async def finish(self, owner: str, idem_key: str, *, status: str, detail: str | None = None,
run_id: str | None = None, task_id: str | None = None) -> None:
async with SessionLocal() as session:
await session.execute(update(self.model).where(*self._key(owner, idem_key))
.values(status=status, detail=detail, run_id=run_id, task_id=task_id))
await session.commit()
+51 -4
View File
@@ -212,6 +212,42 @@ async def _assign(session: AsyncSession, task: Task, run: Run | None, assignee:
#: 蓝图试运行的运行来源(engine/origin.py):待办派给发起人自己、调度不处理时限
TRIAL_ORIGIN = "trial"
#: 服务账号(users.kind,docs/design/next-round/04-对外集成与触达.md A1):只持有访问密钥、回复不了待办,所以不派给它——
#: 组展开时跳过、发起人是它时交给任何人、表达式里点了它的名字跳过、改派给它拒收
SERVICE_KIND = "service"
async def _service_accounts(session: AsyncSession, names: list[str]) -> set[str]:
"""这几个用户名里哪些是服务账号。"""
wanted = sorted({n for n in names if n})
if not wanted:
return set()
return set((await session.execute(select(User.username).where(
User.username.in_(wanted), User.kind == SERVICE_KIND))).scalars())
def key_trace(principal: Any) -> dict[str, Any]:
"""用访问密钥回复时 task.responded 另记的:via=key、哪一把、叫什么(auth/keys.trace)。别的认法什么都不加。"""
from app.auth.keys import trace
return trace(principal)
def _refuse_key(task: Task, principal: Any) -> None:
"""用访问密钥回复待办(04 细案 A4,集成-I3):只给个人访问密钥,只限审批、补充输入、编辑稿件三种。确认写入、核实写入
结果、双盲复算裁决、工具和代码审批要人在界面上看过确认卡再按,一律 403(key_task_kind)。表单里有文件控件的也不行
(文件要先在应用里上传,422 key_files_unsupported)。不是访问密钥认出来的不管。"""
if getattr(principal, "via", None) != "key":
return
from app.auth import keys
from app.files import store as files_store
if task.kind not in keys.TASK_KINDS:
raise TaskRefused("这种待办不能用访问密钥回复:确认写入、核实写入结果、双盲复算裁决、工具和代码审批要在界面上看过"
"确认卡再处理", status_code=403, code=keys.KEY_TASK_KIND)
if files_store.file_fields(task.form):
raise TaskRefused("这条待办的表单里要传文件,不能用访问密钥回复:请在应用里处理", status_code=422,
code=keys.KEY_FILES_UNSUPPORTED)
async def _targets(session: AsyncSession, task: Task, run: Run | None, assignee: Any) -> list[tuple[str, str, str]]:
@@ -232,10 +268,15 @@ async def _targets(session: AsyncSession, task: Task, run: Run | None, assignee:
else:
out.append(("anyone", "", f"slot:{slot}"))
elif kind == "initiator":
out.append(("user", run.started_by, "initiator") if run is not None and run.started_by
else ("anyone", "", "initiator"))
# 用服务账号的访问密钥发起的实例:服务账号不能被派待办(它回复不了),交给「任何人」(搭建者及以上能处理)
starter = run.started_by if run is not None and run.started_by \
and not await _service_accounts(session, [run.started_by]) else None
out.append(("user", starter, "initiator") if starter else ("anyone", "", "initiator"))
elif kind == "expression":
service = await _service_accounts(session, [str(n) for n in spec.get("names") or []])
for name in spec.get("names") or []:
if str(name) in service:
continue
is_group = await session.scalar(select(Group.id).where(Group.name == str(name)))
out += await _expand(session, task, "group" if is_group else "user", str(name), "expression")
elif kind == "hook":
@@ -261,7 +302,8 @@ async def _expand(session: AsyncSession, task: Task, kind: str, name: str, sourc
members = list((await session.execute(
select(User.username).join(GroupMember, GroupMember.user_id == User.id)
.join(Group, Group.id == GroupMember.group_id)
.where(Group.name == name, User.disabled.is_(False)).order_by(User.username))).scalars())
.where(Group.name == name, User.disabled.is_(False), func.coalesce(User.kind, "person") != SERVICE_KIND)
.order_by(User.username))).scalars())
if not members:
return [("group", name, source)]
session.add(AuditEvent(actor=None, verified=False, action="task.group_expanded", subject_kind="task",
@@ -495,6 +537,7 @@ async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[s
task = await session.get(Task, task_id)
if task is None:
raise TaskRefused("待办不存在,所属的运行可能已被删除", status_code=404)
_refuse_key(task, principal)
if task.kind == "reconcile":
return await _respond_reconcile(session, task, principal, decision, values, note)
if task.kind == REVIEW_KIND:
@@ -545,7 +588,8 @@ async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[s
run_id, approval_id, node_id = task.run_id, task.approval_id, task.node_id
await record(run_id, EventType.TASK_RESPONDED, node_id=node_id, task_id=task_id, actor=principal.username,
verified=bool(principal.verified), decision=verdict, index=responded, of=len(voters))
verified=bool(principal.verified), decision=verdict, index=responded, of=len(voters),
**key_trace(principal))
if decided is None:
return {"task": await detail(task_id, principal), "resolved": False, "run": await _run(run_id)}
@@ -728,6 +772,9 @@ async def reassign(task_id: str, principal: Any, *, to: list[dict[str, str]], no
raise TaskRefused(_closed(task.status))
if not await _can_manage(session, task, principal):
raise TaskRefused("只有管理员或这个应用的管理者可以改派待办", status_code=403)
if service := await _service_accounts(session, [n for k, n in targets if k == "user"]):
raise TaskRefused(f"「{'、'.join(sorted(service))}」是服务账号,不能被派待办:请改派给人或组", status_code=422,
code="reassign_service")
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task_id,
TaskAssignment.status == "pending")
.values(status="withdrawn"))
+11 -2
View File
@@ -122,13 +122,15 @@ async def tick(now: datetime | None = None) -> None:
async def process_due(now: datetime | None = None) -> None:
"""流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查;
上传了却一直没提交的文件过了时限清掉(app/files,每小时一次)。
上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉
(auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写)。
各自出错只记日志:这一拍出错不该让调度循环停下,下一拍照常再查。"""
from app.auth import keys
from app.files import store as files_store
from app.process import actions, tasks
for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick),
("没有提交的上传文件清理", files_store.cleanup_tick)):
("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick)):
try:
done = await step(now)
if isinstance(done, list) and done:
@@ -194,6 +196,13 @@ class _Runner:
# 兜底:万一还是落下了一份(以后改循环时漏掉上面的条件),不让它在关停期间接着推
await _settle(self.notify, 0)
self.task = self.notify = None
# 关停前把还攒在进程里的访问密钥请求记录写进库(没起调度循环时也写:请求记录不该因为关停丢掉最后一拍)
from app.auth import keys
try:
await keys.flush()
except Exception: # noqa: BLE001 - 写不进去只记日志,不拦关停
logger.exception("关停前写访问密钥的请求记录失败")
if tick is not None or notify is not None:
logger.info("定时任务调度已停止")
+62 -2
View File
@@ -17,16 +17,21 @@ CSRF = {"X-AgentLab-Request": "1"}
async def reset() -> None:
from app.auth import SETTING_KEY, remember_settings, sessions
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 (AuthSession, GroupMember, Group, User, AuditEvent):
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:
@@ -62,3 +67,58 @@ async def login(username: str, password: str = "password-123") -> AsyncClient:
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)
+173
View File
@@ -0,0 +1,173 @@
"""访问密钥的门禁(docs/design/next-round/04-对外集成与触达.md A2、A7):KEY_ROUTES 一张表,挂在全站一次。
合约测试遍历 app.routes:不在表里的 /api 接口,用密钥请求一律 403(key_route_forbidden);表里的每一行都对得上一个真实的
路由(改了路由、漏改表会在这里报出来)。另守:缺 scope 的 403(key_scope_missing);限定了应用时别的应用 403
(key_app_forbidden);个人应用、个人应用的刷新、蓝图的试运行一律 403(key_app_personal);带密钥连 WebSocket 被拒;
页面和 FastAPI 自带的接口说明不对密钥开放;接口说明(/api/access-keys/reference)和放行的接口出自同一张表。
"""
from __future__ import annotations
import re
import pytest
from fastapi.routing import APIRoute, APIWebSocketRoute
from sqlalchemy import delete
from app.auth import keys
from app.db.base import SessionLocal
from app.db.models import App, AppGrant, AppVersion, Run
from tests.fakes import auth_env
@pytest.fixture(autouse=True)
async def clean():
await auth_env.reset()
await auth_env.turn_on_local()
try:
yield
finally:
await auth_env.reset()
def _filled(path: str) -> str:
return re.sub(r"\{[^}]+\}", "x", path)
def _contexts():
"""全站的路由(FastAPI 0.14x 起 include_router 不再把路由摊平进 app.routes,按它给的路由上下文遍历)。"""
from fastapi.routing import iter_route_contexts
from app.main import app
return list(iter_route_contexts(app.routes))
async def test_every_route_not_in_the_table_is_forbidden_for_keys(monkeypatch):
monkeypatch.setattr(keys, "RATE_PER_MINUTE", 10_000) # 三百多个接口一口气调完,不让限流挡在门禁前面
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc, scopes=keys.SCOPES)
client = auth_env.keyed(secret)
seen: set[tuple[str, str]] = set()
checked = 0
for ctx in _contexts():
route = ctx.original_route
if not isinstance(route, APIRoute) or not ctx.path.startswith("/api/"):
continue
# 门禁按 scope['route'].path(原路由的模板)比对:include_router 不能再加前缀,不然两边对不上
assert route.path == ctx.path, f"{ctx.path} 的原路由模板是 {route.path}"
for method in sorted(ctx.methods or ()):
seen.add((method, ctx.path))
if (method, ctx.path) in keys.KEY_ROUTES:
continue
r = await client.request(method, _filled(ctx.path), json={} if method in ("POST", "PUT", "PATCH") else None)
assert r.status_code == 403 and r.json().get("code") == "key_route_forbidden", \
f"{method} {ctx.path} 用密钥调回了 {r.status_code} {r.text[:120]}"
checked += 1
assert checked > 200, "真遍历到了全部接口"
stale = set(keys.KEY_ROUTES) - seen
assert not stale, f"KEY_ROUTES 里有对不上路由的行:{stale}"
async def test_routes_in_the_table_need_their_scope():
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc, scopes=("apps.read",))
c = auth_env.keyed(secret)
assert (await c.get("/api/apps")).status_code == 200
r = await c.get("/api/tasks")
assert r.status_code == 403 and r.json()["code"] == "key_scope_missing"
r = await c.post("/api/apps/x/entries/y", json={"values": {}}, headers={"Idempotency-Key": "k"})
assert r.json()["code"] == "key_scope_missing", "发起要 apps.run"
assert (await c.get("/api/auth/me")).status_code == 200, "me 不用申请"
assert (await c.get("/api/health")).status_code == 200
async def test_pages_docs_and_websockets_are_closed_to_keys():
from app.auth.middleware import AuthMiddleware
from app.main import app
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc, scopes=keys.SCOPES)
c = auth_env.keyed(secret)
for path in ("/docs", "/openapi.json", "/apps"):
r = await c.get(path)
assert r.status_code == 403 and r.json()["code"] == "key_route_forbidden", path
sent: list[dict] = []
async def receive():
return {"type": "websocket.connect"}
async def send(message):
sent.append(message)
scope = {"type": "websocket", "path": "/api/runs/x/stream", "headers": [(b"authorization", f"Bearer {secret}".encode())],
"query_string": b""}
await AuthMiddleware(app.router)(scope, receive, send)
assert sent == [{"type": "websocket.close", "code": 1008}], "带密钥的握手前就关"
async def _apps() -> dict[str, str]:
async with SessionLocal() as s:
shared = App(name="周报", published_version=1, created_by="root")
other = App(name="报销", published_version=1, created_by="root")
mine = App(name="我的看板", scope="personal", owner="pia")
s.add_all([shared, other, mine])
await s.flush()
for app in (shared, other):
s.add(AppVersion(app_id=app.id, version=1, spec={"format": "agentlab.app/1", "pages": []}, spec_hash="h"))
s.add(AppGrant(app_id=app.id, principal_kind="user", principal="pia", role="observe"))
await s.commit()
return {"shared": shared.id, "other": other.id, "mine": mine.id}
async def test_app_limits_personal_apps_and_trial_runs():
await auth_env.personal_keys(True)
pia = await auth_env.make_user("pia", "user")
ids = await _apps()
try:
_, limited = await auth_env.make_key(pia, app_ids=[ids["shared"]])
_, free = await auth_env.make_key(pia, name="不限应用")
c, f = auth_env.keyed(limited), auth_env.keyed(free)
assert [a["id"] for a in (await c.get("/api/apps")).json()["items"]] == [ids["shared"]], "只列限定的那几个"
assert {a["id"] for a in (await f.get("/api/apps")).json()["items"]} == {ids["shared"], ids["other"]}, \
"个人应用不列"
assert (await c.get(f"/api/apps/{ids['shared']}")).status_code == 200
r = await c.get(f"/api/apps/{ids['other']}")
assert r.status_code == 403 and r.json()["code"] == "key_app_forbidden"
r = await f.get(f"/api/apps/{ids['mine']}")
assert r.status_code == 403 and r.json()["code"] == "key_app_personal", "自己的个人应用也不行"
async with SessionLocal() as s:
trial = Run(workflow_name="试运行", run_class="exploratory", origin_kind="trial", started_by="pia")
refresh = Run(workflow_name="刷新", run_class="exploratory", origin_kind="personal_app",
app_id=ids["mine"], started_by="pia")
elsewhere = Run(workflow_name="别处", run_class="formal", origin_kind="app_entry", app_id=ids["other"],
started_by="pia")
s.add_all([trial, refresh, elsewhere])
await s.commit()
runs = (trial.id, refresh.id, elsewhere.id)
for run_id in runs[:2]:
r = await f.get(f"/api/runs/{run_id}/report.html")
assert r.status_code == 403 and r.json()["code"] == "key_app_personal"
r = await c.get(f"/api/runs/{runs[2]}/evidence")
assert r.status_code == 403 and r.json()["code"] == "key_app_forbidden", "限定了应用:别的应用的运行也不行"
finally:
async with SessionLocal() as s:
await s.execute(delete(AppGrant).where(AppGrant.app_id.in_(list(ids.values()))))
await s.execute(delete(AppVersion).where(AppVersion.app_id.in_(list(ids.values()))))
await s.execute(delete(App).where(App.id.in_(list(ids.values()))))
await s.commit()
async def test_the_reference_is_generated_from_the_same_table():
admin = await auth_env.login("root")
ref = (await admin.get("/api/access-keys/reference")).json()
assert {(r["method"], r["path"]) for r in ref["routes"]} == set(keys.KEY_ROUTES)
entry = next(r for r in ref["routes"] if r["path"] == "/api/apps/{app_id}/entries/{entry_id}")
assert entry["scope"] == "apps.run" and entry["idempotent"] is True
assert "key_idempotency_required" in entry["errors"] and "key_files_unsupported" in entry["errors"]
assert "key_route_forbidden" in ref["common_errors"] and ref["rate_per_minute"] == keys.RATE_PER_MINUTE
def test_websocket_routes_are_never_in_the_table():
sockets = {c.path for c in _contexts() if isinstance(c.original_route, APIWebSocketRoute)}
assert sockets and not {p for _, p in keys.KEY_ROUTES} & sockets
+473
View File
@@ -0,0 +1,473 @@
"""用访问密钥用应用(docs/design/next-round/04-对外集成与触达.md A3–A6):封顶在使用者、幂等、留痕、回复待办、视图数据、
钩子的收件记录。
- 封顶:管理员的密钥也按使用者算——取工件时查询快照是遮过的、节点产出 403;有「查看全部实例」授权的服务账号能列全部实例、
能下载报告,报告里遮罩的值写「已遮罩」;
- 幂等:同一个 Idempotency-Key 发起两次只有一个实例;轮换密钥后同一个键照样去重;处理到一半服务停了,同一个键认领回已
发起的实例;不带键 400;同一个键办另一件事 422;有文件控件的入口 422;刷新视图同样要带键;
- 留痕:run.origin 里有 via=key、key_id、密钥名(在清单哈希的封存范围内);task.responded 记了 via;
- 待办:个人密钥能回复审批、补充输入、编辑稿件;确认写入、双盲裁决、核实写入结果、工具审批 403(key_task_kind);
- 视图数据(agentlab.viewdata/1):印章、清单哈希、指标原值;遮罩的 value 为 null、state 为 masked,和页面同一个判断;
按需视图没算过时 empty、不发起运行;改了页面布局形状不变;
- 钩子收件记录:只有管理权限能读,列出状态和原因、近 1 小时验签失败次数。
"""
from __future__ import annotations
import asyncio
import json
import uuid
import pytest
from sqlalchemy import delete, select
from app.core.artifact_store import graph_hash, put_json
from app.db.base import SessionLocal, utcnow
from app.db.models import (
App, AppGrant, AppVersion, DataSource, Run, RunEvent, Task, TaskAssignment, Workflow, WorkflowVersion,
)
from app.db.models_apps import ApiReceipt
from app.engine.runner import run_manager
from tests.fakes import auth_env
@pytest.fixture(autouse=True)
async def world():
await auth_env.reset()
await run_manager.setup()
await auth_env.turn_on_local()
await auth_env.personal_keys(True)
made: dict[str, list[str]] = {"apps": [], "workflows": [], "sources": []}
try:
yield made
finally:
await run_manager.shutdown()
async with SessionLocal() as s:
await s.execute(delete(AppGrant).where(AppGrant.app_id.in_(made["apps"] or [""])))
await s.execute(delete(App).where(App.id.in_(made["apps"] or [""])))
await s.execute(delete(DataSource).where(DataSource.name.in_(made["sources"] or [""])))
await s.commit()
await auth_env.reset()
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
ECHO = chain(node("start", "input", "开始", fields=[{"name": "amount", "type": "number"}, {"name": "who"}]),
node("out", "output", "成果", fields=[{"name": "r", "value": "{{ input.who }}"}]))
KPI = chain(
node("start", "input", "开始", fields=[{"name": "week"}]),
node("fetch", "transform", "取数", mode="expression", expression="{'gmv': 4321.5, 'n': 7}", assign_to="kpi"),
node("card", "metrics", "口径卡", caliber="周报口径", caliber_version="v3",
metrics=[{"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.kpi.gmv"},
{"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.n"}]),
node("out", "output", "成果", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
async def workflow(graph: dict) -> tuple[str, str]:
async with SessionLocal() as s:
wf = Workflow(name=f"密钥测试-{uuid.uuid4().hex[:6]}", graph=graph, status="published", published_version=1,
version=1)
s.add(wf)
await s.flush()
s.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph, graph_hash=graph_hash(graph),
level="published", published_at=utcnow()))
await s.commit()
return wf.id, graph_hash(graph)
async def published(made: dict, spec: dict, grants: list[tuple[str, str, str]]) -> str:
admin = await auth_env.login("root")
r = await admin.post("/api/apps", json={"name": spec.get("name", "应用"), "spec": spec})
assert r.status_code == 201, r.text
app_id = r.json()["id"]
made["apps"].append(app_id)
items = [{"principal_kind": k, "principal": p, "role": role} for k, p, role in grants]
assert (await admin.put(f"/api/apps/{app_id}/grants", json={"items": items})).status_code == 200
out = (await admin.post(f"/api/apps/{app_id}/publish", json={})).json()
assert out["ok"], out
return app_id
def entry_spec(wf: str, hash_: str, *, files: bool = False) -> dict:
props = {"amount": {"type": "number", "title": "金额"}}
if files:
props["receipt"] = {"type": "object", "title": "发票", "x-widget": "file"}
return {"format": "agentlab.app/1", "name": "报销",
"pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": hash_, "use": "entry"}],
"entries": [{"id": "apply", "label": "发起", "pin": "main", "form": {"type": "object", "properties": props},
"map": {"amount": "form.amount", "who": "user.username"}}],
"pages": [{"id": "home", "title": "首页", "body": [{"id": "new", "type": "entry_button",
"props": {"entry": "apply"}}]}]}
def view_spec(wf: str, hash_: str, *, on_demand: bool = False, extra: list | None = None) -> dict:
source = ({"kind": "on_demand", "pin": "main", "params": {"type": "object", "properties": {"week": {"type": "string"}}}}
if on_demand else {"kind": "latest_sealed", "pin": "main", "from": "formal"})
return {"format": "agentlab.app/1", "name": "周报看板",
"pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": hash_, "use": "view"}],
"views": {"latest": {"source": source}},
"pages": [{"id": "home", "title": "首页", "body": [
{"id": "gmv", "type": "metric", "props": {"label": "销售额"}, "bind": {"view": "latest", "metric": "card.gmv"}},
*(extra or [])]}]}
async def wait(run_id: str, statuses=("succeeded", "failed", "cancelled")) -> Run:
for _ in range(600):
async with SessionLocal() as s:
row = await s.get(Run, run_id)
if row is not None and row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 没有到 {statuses}")
async def formal(wf: str, week: str = "W1") -> Run:
from app.engine.launch import launch_run
async with SessionLocal() as s:
run = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={"week": week},
actor="编辑", origin_kind="launcher")
row = await wait(run.id)
assert row.status == "succeeded", row.error
return row
# ---------------------------------------------------------------- 幂等与留痕
async def test_starting_with_a_key_is_idempotent_and_leaves_a_trace(world):
pia = await auth_env.make_user("pia", "user")
wf, h = await workflow(ECHO)
app_id = await published(world, entry_spec(wf, h), [("user", "pia", "use")])
row, secret = await auth_env.make_key(pia, name="报销脚本")
c = auth_env.keyed(secret)
url = f"/api/apps/{app_id}/entries/apply"
missing = await c.post(url, json={"values": {"amount": 3}})
assert missing.status_code == 400 and missing.json()["code"] == "key_idempotency_required"
first = await c.post(url, json={"values": {"amount": 3}}, headers={"Idempotency-Key": "order-1"})
assert first.status_code == 201, first.text
again = await c.post(url, json={"values": {"amount": 3}}, headers={"Idempotency-Key": "order-1"})
assert again.json()["run_id"] == first.json()["run_id"] and again.json()["duplicate"] is True
other = await c.post(url, json={"values": {"amount": 4}}, headers={"Idempotency-Key": "order-1"})
assert other.status_code == 422 and other.json()["code"] == "key_idempotency_reused", "同一个键办另一件事"
run = await wait(first.json()["run_id"])
assert run.started_by == "pia"
assert {k: run.origin.get(k) for k in ("via", "key_id", "key_name", "idempotency_key")} == {
"via": "key", "key_id": row.id, "key_name": "报销脚本", "idempotency_key": "order-1"}
async with SessionLocal() as s:
note = (await s.execute(select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "run.origin")))\
.scalar_one()
assert (note.data["via"], note.data["key_id"]) == ("key", row.id) and note.seq <= run.manifest_seq, \
"来源在封存范围内"
async with SessionLocal() as s:
n = len(list((await s.execute(select(Run).where(Run.app_id == app_id))).scalars()))
assert n == 1, "同一个键只发起一个实例"
# 轮换之后:同一个键照样认得出(按拥有者去重,不按密钥)
admin = await auth_env.login("root")
rotated = (await admin.post(f"/api/access-keys/{row.id}/rotate")).json()["key"]
again = await auth_env.keyed(rotated).post(url, json={"values": {"amount": 3}}, headers={"Idempotency-Key": "order-1"})
assert again.json()["run_id"] == first.json()["run_id"]
async def test_a_half_done_start_is_reclaimed_after_a_crash(world):
from datetime import timedelta
from app.process import receipts
pia = await auth_env.make_user("pia", "user")
wf, h = await workflow(ECHO)
app_id = await published(world, entry_spec(wf, h), [("user", "pia", "use")])
row, secret = await auth_env.make_key(pia)
# 上一次处理到一半服务停了:回执停在 processing,实例其实已经发起
async with SessionLocal() as s:
s.add(ApiReceipt(owner_user_id=pia.id, idem_key="crash-1", status="processing", route="x",
received_at=utcnow() - timedelta(seconds=receipts.STALE_S + 5)))
s.add(ApiReceipt(owner_user_id=pia.id, idem_key="busy-1", status="processing", route="x"))
s.add(Run(id=f"run-{uuid.uuid4().hex[:8]}", workflow_name="实例", run_class="formal", app_id=app_id,
origin_kind="app_entry", started_by="pia", origin={"via": "key", "idempotency_key": "crash-1"}))
await s.commit()
before = (await s.execute(select(Run.id).where(Run.app_id == app_id))).scalar_one()
c = auth_env.keyed(secret)
url = f"/api/apps/{app_id}/entries/apply"
busy = await c.post(url, json={"values": {}}, headers={"Idempotency-Key": "busy-1"})
assert busy.status_code == 409 and busy.json()["code"] == "key_idempotency_busy"
back = await c.post(url, json={"values": {}}, headers={"Idempotency-Key": "crash-1"})
assert back.status_code == 201 and back.json()["run_id"] == before, back.text
async with SessionLocal() as s:
assert len(list((await s.execute(select(Run.id).where(Run.app_id == app_id))).scalars())) == 1
receipt = (await s.execute(select(ApiReceipt).where(ApiReceipt.idem_key == "crash-1"))).scalar_one()
assert (receipt.status, receipt.run_id, receipt.key_id) == ("accepted", before, row.id)
async def test_entries_with_file_controls_cannot_be_started_with_a_key(world):
pia = await auth_env.make_user("pia", "user")
wf, h = await workflow(ECHO)
app_id = await published(world, entry_spec(wf, h, files=True), [("user", "pia", "use")])
_, secret = await auth_env.make_key(pia)
r = await auth_env.keyed(secret).post(f"/api/apps/{app_id}/entries/apply", json={"values": {}},
headers={"Idempotency-Key": "f1"})
assert r.status_code == 422 and r.json()["code"] == "key_files_unsupported"
# ---------------------------------------------------------------- 待办
def human(mode: str) -> dict:
nodes = [node("start", "input", fields=[{"name": "q"}]),
node("ask", "human", mode=mode, title="处理一下", assignee={"kind": "initiator"}),
node("ok", "output", fields=[{"name": "结论", "value": "通过"}])]
edges = [{"source": "start", "target": "ask"}, {"source": "ask", "target": "ok"}]
return {"nodes": nodes, "edges": edges}
async def _task_for(app_id: str, graph: dict, started_by: str) -> Task:
run = await run_manager.start(graph=graph, input_payload={"q": "x"}, app_id=app_id, app_version=1,
started_by=started_by)
await wait(run.id, ("interrupted",))
await run_manager.wait_idle(run.id)
for _ in range(100):
async with SessionLocal() as s:
task = (await s.execute(select(Task).where(Task.run_id == run.id, Task.status == "pending"))).scalars().first()
if task is not None:
return task
await asyncio.sleep(0.05)
raise AssertionError("没有开出待办")
async def test_personal_keys_answer_only_three_kinds_of_tasks(world):
pia = await auth_env.make_user("pia", "user")
wf, h = await workflow(ECHO)
app_id = await published(world, entry_spec(wf, h), [("user", "pia", "use")])
key, secret = await auth_env.make_key(pia, name="审批机器人")
c = auth_env.keyed(secret)
for mode, body in (("approve", {"decision": "approve"}), ("input", {"decision": "submit", "values": {"value": "补上"}}),
("edit", {"decision": "submit", "values": {"value": "改好"}})):
task = await _task_for(app_id, human(mode), "pia")
listed = [t["id"] for t in (await c.get("/api/tasks")).json()["items"]]
assert task.id in listed, "派给拥有者的待办列得出来"
r = await c.post(f"/api/tasks/{task.id}/respond", json=body)
assert r.status_code == 200 and r.json()["resolved"] is True, (mode, r.text)
async with SessionLocal() as s:
ev = (await s.execute(select(RunEvent).where(RunEvent.run_id == task.run_id,
RunEvent.type == "task.responded"))).scalar_one()
assert (ev.data["via"], ev.data["key_id"], ev.data["key_name"], ev.data["actor"]) == \
("key", key.id, "审批机器人", "pia")
async with SessionLocal() as s:
run = Run(workflow_name="写入", run_class="formal", app_id=app_id, origin_kind="app_entry", started_by="pia")
s.add(run)
await s.flush()
tasks = []
for kind in ("action_confirm", "dual_check", "reconcile", "tool"):
t = Task(run_id=run.id, kind=kind, app_id=app_id)
s.add(t)
await s.flush()
s.add(TaskAssignment(task_id=t.id, principal_kind="user", principal="pia", source="initiator"))
tasks.append(t.id)
await s.commit()
for task_id in tasks:
r = await c.post(f"/api/tasks/{task_id}/respond", json={"decision": "approve"})
assert r.status_code == 403 and r.json()["code"] == "key_task_kind", r.text
async def test_service_accounts_are_never_assigned_tasks(world):
svc = await auth_env.make_service()
wf, h = await workflow(ECHO)
app_id = await published(world, entry_spec(wf, h), [("user", "svc-portal", "use")])
task = await _task_for(app_id, human("approve"), "svc-portal")
async with SessionLocal() as s:
rows = list((await s.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id))).scalars())
assert [(a.principal_kind, a.principal) for a in rows] == [("anyone", "")], \
"服务账号发起的实例里派给发起人的,交给任何人"
_, secret = await auth_env.make_key(svc, scopes=("tasks.read", "tasks.respond"))
r = await auth_env.keyed(secret).post(f"/api/tasks/{task.id}/respond", json={"decision": "approve"})
assert r.json()["code"] == "key_scope_missing", "服务账号的密钥没有回复待办这一项"
admin = await auth_env.login("root")
r = await admin.post(f"/api/tasks/{task.id}/reassign", json={"to": [{"kind": "user", "name": "svc-portal"}]})
assert r.status_code == 422 and r.json()["code"] == "reassign_service", "改派给服务账号拒收"
# ---------------------------------------------------------------- 封顶在使用者
async def test_an_admins_key_gets_masked_artifacts_and_no_raw_outputs(world):
root = (await auth_env.login("root"))
await root.get("/api/auth/me")
async with SessionLocal() as s:
from app.db.models import User
admin_row = (await s.execute(select(User).where(User.username == "root"))).scalar_one()
name = f"crm-{uuid.uuid4().hex[:6]}"
s.add(DataSource(name=name, kind="sqlite", options={"mask_columns": ["phone"]}))
app = App(name="自己的", published_version=1, created_by="root")
s.add(app)
await s.flush()
run = Run(workflow_name="自己发起的实例", run_class="formal", app_id=app.id, origin_kind="app_entry",
started_by="root")
s.add(run)
await s.commit()
world["sources"].append(name)
world["apps"].append(app.id)
_, secret = await auth_env.make_key(admin_row)
c = auth_env.keyed(secret)
snap = {"source": name, "columns": ["name", "phone"], "rows": [["客户甲", "19900000123"]],
"sql": "SELECT name, phone FROM customers", "truncated": False}
query_id = await put_json(snap, kind="query_snapshot", run_id=run.id, node_id="q")
output_id = await put_json({"q": snap}, kind="node_output", run_id=run.id, node_id="q")
got = await c.get(f"/api/artifacts/{query_id}")
assert got.status_code == 200 and got.json()["content"]["rows"][0][1] != "19900000123", "遮罩列换掉了"
assert (await c.get(f"/api/artifacts/{output_id}")).json()["code"] == "artifact_kind_forbidden"
assert (await root.get(f"/api/artifacts/{output_id}")).status_code == 200, "同一个管理员用会话照旧拿原文"
assert (await c.get("/api/apps")).json()["items"] == [], "没有授权给他的应用,管理员的密钥也看不到"
async def test_viewdata_and_report_for_an_observer_service_account(world, monkeypatch, tmp_path):
from tests import test_report_masked_metrics as vipmod
from app.data.engine import engines
path = tmp_path / "vip.db"
import sqlite3
db = sqlite3.connect(path)
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);")
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", vipmod.CUSTOMERS)
db.commit()
db.close()
source_id = await vipmod.set_source(path, {"mask_columns": ["phone"]})
await engines.invalidate(source_id)
world["sources"].append("vip")
vipmod.script(monkeypatch, vipmod.CLEAN)
graph = vipmod.graph(on_violation="flag")
wf, h = await workflow(graph)
svc = await auth_env.make_service()
spec = {"format": "agentlab.app/1", "name": "会员看板",
"pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": h, "use": "view"}],
"views": {"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}},
"pages": [{"id": "home", "title": "首页", "body": [
{"id": "phone", "type": "metric", "props": {"label": "手机号"},
"bind": {"view": "latest", "metric": "card.phone_raw"}}]}]}
app_id = await published(world, spec, [("user", "svc-portal", "observe")])
from app.engine.launch import launch_run
async with SessionLocal() as s:
launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={},
actor="编辑", origin_kind="launcher")
run = await wait(launched.id)
assert run.status == "succeeded", run.error
_, secret = await auth_env.make_key(svc, scopes=("apps.read", "reports.read"))
c = auth_env.keyed(secret)
data = (await c.get(f"/api/apps/{app_id}/views/latest/data")).json()
assert data["format"] == "agentlab.viewdata/1" and data["state"] == "ok" and data["run_id"] == run.id
assert data["manifest_hash"] == run.manifest_hash and data["stamp"]["sealed"] is True
by_id = {m["id"]: m for m in data["metrics"]}
assert (by_id["phone_raw"]["value"], by_id["phone_raw"]["state"], by_id["phone_raw"]["text"]) == \
(None, "masked", "已遮罩"), "遮罩的指标不给原值"
assert (by_id["n_cust"]["value"], by_id["n_cust"]["state"]) == (3, "ok")
assert by_id["n_cust"]["caliber"] == "会员口径" and by_id["n_cust"]["evidence"]["run_id"] == run.id
assert vipmod.leaks(data) == []
page = (await c.get(f"/api/apps/{app_id}/pages/home")).json()
assert page["tree"][0]["data"]["text"] == by_id["phone_raw"]["text"], "和页面同一个判断"
evidence = await c.get(f"/api/runs/{run.id}/evidence/metrics/card/n_cust")
assert evidence.status_code == 200, "证据指针走得通"
report = await c.get(f"/api/runs/{run.id}/report.html")
assert report.status_code == 200 and vipmod.leaks(report.text) == [], "报告里没有遮罩列的原值"
async def test_viewdata_on_demand_does_not_launch_and_keeps_its_shape(world):
pia = await auth_env.make_user("pia", "user")
wf, h = await workflow(KPI)
app_id = await published(world, view_spec(wf, h, on_demand=True), [("user", "pia", "use")])
_, secret = await auth_env.make_key(pia, scopes=("apps.read", "apps.run"))
c = auth_env.keyed(secret)
params = {"params": json.dumps({"week": "W9"})}
empty = (await c.get(f"/api/apps/{app_id}/views/latest/data", params=params)).json()
assert empty["state"] == "empty" and empty["metrics"] == [] and "刷新" in empty["message"]
async with SessionLocal() as s:
assert (await s.execute(select(Run.id).where(Run.app_id == app_id))).first() is None, "读数据不发起运行"
missing = await c.post(f"/api/apps/{app_id}/views/latest/refresh", json={"params": {"week": "W9"}})
assert missing.json()["code"] == "key_idempotency_required", "刷新视图同样要带幂等键"
r = await c.post(f"/api/apps/{app_id}/views/latest/refresh", json={"params": {"week": "W9"}},
headers={"Idempotency-Key": "w9"})
assert r.status_code == 200, r.text
run = await wait(r.json()["run_id"])
assert run.origin.get("via") == "key"
again = await c.post(f"/api/apps/{app_id}/views/latest/refresh", json={"params": {"week": "W9"}},
headers={"Idempotency-Key": "w9"})
assert again.json()["run_id"] == run.id and again.json()["duplicate"] is True
full = (await c.get(f"/api/apps/{app_id}/views/latest/data", params=params)).json()
assert full["state"] == "ok" and full["params"] == {"week": "W9"}
assert {m["id"]: m["value"] for m in full["metrics"]} == {"gmv": 4321.5, "orders": 7}
assert full["stamp"]["calibers"] == [{"name": "周报口径", "version": "v3"}]
# 管理应用的人改了页面布局(加一块、换个 id):数据接口的形状不变
admin = await auth_env.login("root")
draft = (await admin.get(f"/api/apps/{app_id}")).json()["draft"]
draft["pages"][0]["body"] = [{"id": "renamed", "type": "metric", "props": {"label": "x"},
"bind": {"view": "latest", "metric": "card.orders"}}]
assert (await admin.patch(f"/api/apps/{app_id}", json={"draft": draft})).status_code == 200
assert (await admin.post(f"/api/apps/{app_id}/publish", json={})).json()["ok"]
after = (await c.get(f"/api/apps/{app_id}/views/latest/data", params=params)).json()
assert {k: v for k, v in after.items() if k != "app"} == {k: v for k, v in full.items() if k != "app"}
assert (await c.get(f"/api/apps/{app_id}/views/nope/data")).json()["code"] == "app_view_not_found"
async def test_service_account_with_observe_lists_all_instances(world):
pia = await auth_env.make_user("pia", "user")
svc = await auth_env.make_service()
wf, h = await workflow(ECHO)
app_id = await published(world, entry_spec(wf, h), [("user", "pia", "use"), ("user", "svc-portal", "observe")])
_, mine = await auth_env.make_key(pia)
started = await auth_env.keyed(mine).post(f"/api/apps/{app_id}/entries/apply", json={"values": {"amount": 1}},
headers={"Idempotency-Key": "a"})
run = await wait(started.json()["run_id"])
_, secret = await auth_env.make_key(svc, scopes=("apps.read",))
listed = (await auth_env.keyed(secret).get(f"/api/apps/{app_id}/instances", params={"who": "all"})).json()
assert [i["run_id"] for i in listed["items"]] == [run.id]
one = await auth_env.keyed(secret).get(f"/api/apps/{app_id}/instances/{run.id}")
assert one.status_code == 200
# ---------------------------------------------------------------- 钩子的收件记录
async def test_hook_receipts_are_listed_for_managers_with_signature_failures(world):
import time
from app.notify.core import sign
from app.process import hooks
pia = await auth_env.make_user("pia", "user") # noqa: F841 - 只有「查看全部实例」,没有管理权限
admin = await auth_env.login("root")
async with SessionLocal() as s:
app = App(name="收款", published_version=1, created_by="root")
s.add(app)
await s.flush()
s.add(AppVersion(app_id=app.id, version=1, spec={"hooks": [{"name": "paid", "kind": "signal"}]},
spec_hash="h" * 64, bindings={}))
s.add(AppGrant(app_id=app.id, principal_kind="user", principal="pia", role="observe"))
row, secret, _ = await hooks.rotate_hook(s, app.id, "paid", kind="signal")
await s.commit()
world["apps"].append(app.id)
hook_id = row.id
def signed(key: str, body: dict, *, bad: bool = False) -> tuple[dict, bytes]:
raw = json.dumps(body).encode()
ts = str(int(time.time()))
return {"X-AgentLab-Timestamp": ts, "X-AgentLab-Signature": f"sha256={'0' * 64 if bad else sign(secret, ts, raw)}",
"Idempotency-Key": key, "Content-Type": "application/json"}, raw
plain = auth_env.client()
headers, body = signed("k1", {"correlation": "none-waiting"})
assert (await plain.post(f"/api/hooks/{hook_id}", content=body, headers=headers)).status_code == 404
headers, body = signed("k2", {"correlation": "x"}, bad=True)
assert (await plain.post(f"/api/hooks/{hook_id}", content=body, headers=headers)).status_code == 401
out = (await admin.get(f"/api/apps/{app.id}/hooks/paid/receipts")).json()
assert [(i["idem_key"], i["status"]) for i in out["items"]] == [("k1", "rejected")], "验签失败的不落库"
assert "没有在等" in out["items"][0]["detail"] and out["signature_failures"] == 1
pia_c = await auth_env.login("pia")
assert (await pia_c.get(f"/api/apps/{app.id}/hooks/paid/receipts")).status_code == 403, "要管理权限"
+372
View File
@@ -0,0 +1,372 @@
"""访问密钥与服务账号:签发、认人、轮换、身份来源、限流、留痕(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]
+32
View File
@@ -0,0 +1,32 @@
/**
* 访问密钥与服务账号(/api/access-keys,docs/design/next-round/04-对外集成与触达.md A1–A7),挂在 api.accessKeys 上。形状见
* typesApp.ts。密钥明文只在建、轮换的回包里(key)出现一次,之后哪个接口都拿不到。
*/
import type {
AccessKeyIssued, AccessKeyReference, AccessKeyRequestRow, AccessKeyRow, AccessKeyScope, AccessKeySettings,
ServiceAccountRow,
} from '../typesApp'
import { get, patch, post, put } from './http'
const at = (id: string) => `/access-keys/${encodeURIComponent(id)}`
export const accessKeys = {
settings: () => get<AccessKeySettings>('/access-keys/settings'),
/** 个人访问密钥的开关(只给管理员) */
setPersonalKeys: (personal_keys: boolean) => put<AccessKeySettings>('/access-keys/settings', { personal_keys }),
/** 管理员:全部密钥;别人:自己的 */
list: async (): Promise<AccessKeyRow[]> => (await get<{ items: AccessKeyRow[] }>('/access-keys')).items,
/** 不给 service_account_id 是建自己的个人访问密钥 */
create: (body: { name: string; scopes: AccessKeyScope[]; app_ids?: string[] | null; expires_in_days: number;
service_account_id?: string }) => post<AccessKeyIssued>('/access-keys', body),
revoke: (id: string) => post<AccessKeyRow>(`${at(id)}/revoke`),
rotate: (id: string) => post<AccessKeyIssued>(`${at(id)}/rotate`),
requests: (id: string) => get<{ items: AccessKeyRequestRow[]; retain_days: number }>(`${at(id)}/requests`),
reference: () => get<AccessKeyReference>('/access-keys/reference'),
serviceAccounts: async (): Promise<ServiceAccountRow[]> =>
(await get<{ items: ServiceAccountRow[] }>('/access-keys/service-accounts')).items,
createServiceAccount: (body: { username: string; display_name?: string }) =>
post<ServiceAccountRow>('/access-keys/service-accounts', body),
updateServiceAccount: (id: string, body: { display_name?: string; disabled?: boolean }) =>
patch<ServiceAccountRow>(`/access-keys/service-accounts/${encodeURIComponent(id)}`, body),
}
+3 -1
View File
@@ -5,7 +5,7 @@
* 入口提交校验失败回 422,ApiError 的 body 里带 {errors: FormError[]}。
*/
import type {
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel,
} from '../typesApp'
import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
@@ -72,6 +72,8 @@ export const apps = {
setHookEnabled: (id: string, name: string, enabled: boolean) => patch<AppHookRow>(hookAt(id, name), { enabled }),
/** 删掉这个名字的密钥行:外部系统再用旧密钥签的请求一律拒收 */
deleteHook: (id: string, name: string) => del(hookAt(id, name)),
/** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */
hookReceipts: (id: string, name: string) => get<HookReceipts>(`${hookAt(id, name)}/receipts`),
/**
* 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次;
* 老服务端只回对话本身时由调用方再按 id 取一次
+3
View File
@@ -29,6 +29,7 @@ import { reportExport } from './reportExport'
import { apps, ledger } from './apps'
import { actions, tasks } from './tasks'
import { auth, identity } from './auth'
import { accessKeys } from './accessKeys'
import { capabilities, endpoints, notify } from './capabilities'
// 普通用户建应用(typesBlueprint.ts 是接口约定)
import { blueprints, files, personal, templates } from './blueprints'
@@ -958,6 +959,8 @@ export const api = {
actions,
auth,
identity,
// 访问密钥与服务账号(下一轮第 2 波)
accessKeys,
capabilities,
endpoints,
notify,
+22 -9
View File
@@ -37,6 +37,11 @@ export interface PrincipalPickerProps {
* 不看它
*/
source?: CandidateSource
/**
* 能不能选服务账号(docs/design/next-round/04-对外集成与触达.md A1):应用授权能(外部系统按授权读应用);岗位绑定、改派
* 不能(服务账号只持有访问密钥,回复不了待办)——缺省不能,名录里的服务账号不列、填了说清是服务账号
*/
allowService?: boolean
}
/** 边输入边取候选:停手这么久再去问,不是每敲一个字问一次 */
@@ -49,15 +54,20 @@ function loadDirectory(): Promise<Directory | null> {
return directory
}
/** 这一项在名录里对不上的地方:没有这个人或组(missing)、人停用了(disabled)。名录没取到、选的是所有人时不判 */
function problemOf(p: Principal, known: Directory | null): 'missing' | 'disabled' | null {
/**
* 这一项在名录里对不上的地方:没有这个人或组(missing)、人停用了(disabled)、是服务账号而这里不收(service)。名录没取到、
* 选的是所有人时不判
*/
function problemOf(p: Principal, known: Directory | null, allowService: boolean): 'missing' | 'disabled' | 'service' | null {
if (!known || p.kind === 'everyone') return null
if (p.kind === 'group') return known.groups.some((g) => g.name === p.name) ? null : 'missing'
const user = known.users.find((u) => u.username === p.name)
return !user ? 'missing' : user.disabled ? 'disabled' : null
return !user ? 'missing' : user.disabled ? 'disabled' : user.service && !allowService ? 'service' : null
}
export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label, source }: PrincipalPickerProps) {
export function PrincipalPicker({
value, onChange, allowEveryone = false, single = false, label, source, allowService = false,
}: PrincipalPickerProps) {
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const loginOn = useLoginOn()
// 名录给搭建者及以上(使用者读不到,不去取:取了也是 403)
@@ -112,12 +122,13 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
return
}
}
const problem = problemOf(item, known)
const problem = problemOf(item, known, allowService)
const soft = problem === 'missing' && oidc
if (problem && !(soft && anyway)) {
setRefused({
item, soft,
message: problem === 'disabled' ? PRINCIPAL_TEXT.disabledUser(item.name)
message: problem === 'service' ? PRINCIPAL_TEXT.serviceUser(item.name)
: problem === 'disabled' ? PRINCIPAL_TEXT.disabledUser(item.name)
: soft ? PRINCIPAL_TEXT.unknownOidc(item.name)
: item.kind === 'group' ? PRINCIPAL_TEXT.unknownGroup(item.name) : PRINCIPAL_TEXT.unknownUser(item.name),
})
@@ -134,7 +145,8 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
? (!pool ? [] : kind === 'user' ? pool.users.map((u) => ({ value: u.username, label: u.display_name }))
: pool.groups.map((g) => ({ value: g.name, label: g.label })))
: !known ? [] : kind === 'user'
? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name }))
? known.users.filter((u) => !u.disabled && (allowService || !u.service))
.map((u) => ({ value: u.username, label: u.display_name }))
: known.groups.map((g) => ({ value: g.name, label: g.label }))
/** 候选池要先输入开头才列(应用授权给了所有人)、列不全(超过上限) */
const poolHint = asking && pool
@@ -146,7 +158,7 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
{value.length > 0 && (
<ul className="mb-1.5 flex flex-wrap gap-1" aria-label={label}>
{value.map((p) => {
const problem = problemOf(p, known)
const problem = problemOf(p, known, allowService)
return (
<li key={`${p.kind}:${p.name}`} className="chip flex items-center gap-1" data-principal={p.kind}
data-principal-problem={problem ?? undefined}
@@ -156,7 +168,8 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single
{p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : shown.current.get(p.name) ?? p.name}
</span>
<span className="sr-only">({PRINCIPAL_TEXT[p.kind]})</span>
{problem && <span className="text-2xs">· {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}</span>}
{problem && <span className="text-2xs">· {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag
: problem === 'service' ? PRINCIPAL_TEXT.serviceTag : PRINCIPAL_TEXT.missingTag}</span>}
<button type="button" className="-mr-0.5 rounded text-faint hover:text-fg" aria-label={PRINCIPAL_TEXT.remove(p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : p.name)}
onClick={() => remove(p)}>
<X size={11} aria-hidden />
@@ -0,0 +1,69 @@
/**
* 「最近的请求」:外部系统发来的请求逐条列出——访问密钥的请求记录(设置 → 访问密钥)和钩子的收件记录(应用编辑 → 钩子)
* 共用这一个组件(docs/design/next-round/04-对外集成与触达.md A6「一种说法」)。外部系统说「我发过了」,这里查得到收到没有、
* 回了什么、为什么被拒。
*
* 只管画:每一行时间、结果(成功 / 被拒 / 处理中)、是什么(接口或幂等键)、原因或补充、涉及的运行或待办。取数和把服务端的
* 形状换成这里的行在调用方做(界面层不放逻辑:结果的判断服务端已经给了,这里只照着上色)。
*/
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ErrorState, Skeleton } from './ui'
import { formatDateTime } from '../lib/format'
import { RECENT_REQUESTS_TEXT as T } from '../lib/termsApp'
export interface RecentRequestRow {
key: string
at: string | null
/** ok 成功;bad 被拒;pending 处理中 */
tone: 'ok' | 'bad' | 'pending'
/** 结果那一格的字(「成功 200」「被拒」……) */
result: string
/** 是什么:接口(方法 + 路由模板)或幂等键 */
what: string
/** 原因、来源地址这类补充,没有为空 */
detail?: string | null
/** 涉及的运行或待办:label 是「运行」「实例」「待办」,to 给了就是能点的链接 */
ref?: { label: string; id: string; to?: string | null } | null
}
const TONE: Record<RecentRequestRow['tone'], string> = { ok: 'var(--ok)', bad: 'var(--err)', pending: 'var(--st-waiting)' }
export function RecentRequests({ rows, error, onRetry, summary, empty, attr }: {
rows: RecentRequestRow[] | null
error?: unknown
onRetry?: () => void
/** 列表上面的一句(近 1 小时验签失败几次、保留几天) */
summary?: ReactNode
/** 一条都没有时写什么 */
empty?: string
/** 给检查脚本认的 data- 属性 */
attr?: Record<string, string>
}) {
return (
<div className="space-y-1.5" data-recent-requests="" {...attr}>
{summary && <p className="text-2xs text-faint" data-recent-requests-summary="">{summary}</p>}
{error ? <ErrorState error={error} onRetry={onRetry} compact /> : !rows ? <Skeleton rows={3} height={20} /> : !rows.length ? (
<p className="text-2xs text-faint" data-recent-requests-empty="">{empty ?? T.empty}</p>
) : (
<ol className="divide-y rounded-lg border text-2xs" aria-label={T.label}>
{rows.map((r) => (
<li key={r.key} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 px-2.5 py-1.5" data-recent-request={r.tone}>
<time className="tnum w-32 shrink-0 text-faint">{r.at ? formatDateTime(r.at) : '—'}</time>
<span className="chip shrink-0" style={{ color: TONE[r.tone], borderColor: TONE[r.tone] }}>{r.result}</span>
<span className="mono min-w-0 flex-1 truncate" title={r.what}>{r.what}</span>
{r.ref && (
<span className="shrink-0 text-faint">
{r.ref.label}{' '}
{r.ref.to ? <Link className="mono underline-offset-2 hover:text-fg hover:underline" to={r.ref.to}>{r.ref.id.slice(0, 8)}</Link>
: <span className="mono" title={r.ref.id}>{r.ref.id.slice(0, 8)}</span>}
</span>
)}
{r.detail && <span className="basis-full text-dim" data-recent-request-detail="">{r.detail}</span>}
</li>
))}
</ol>
)}
</div>
)
}
+3 -1
View File
@@ -5588,9 +5588,11 @@ export const IDENTITY_TEXT = {
removeGroup: (name: string) => `删除组「${name}」?`,
removeGroupBody: '应用里绑定到这个组的岗位和授权将失效。',
offNote: '当前关闭登录:用户和组仅在开启登录后生效',
/** 服务账号(只持有访问密钥):角色、密码不在这里改 */
serviceManaged: '服务账号:没有密码、不能登录,在「设置 → 访问密钥」里管理',
adminOnly: '只有管理员可以管理身份',
audit: '审计',
auditHint: '管理类操作的留痕:登录方式、角色、组、授权、应用发布、端点、能力治理、版本退役',
auditHint: '管理类操作的留痕:登录方式、角色、组、授权、应用发布、端点、能力治理、版本退役、访问密钥',
auditEmpty: '还没有审计记录',
auditVerified: '已登录',
auditUnverified: '自报署名',
+123
View File
@@ -368,6 +368,9 @@ export const PRINCIPAL_TEXT = {
/** 已选的那一项不在名录里、或已停用:标在标签上 */
missingTag: '名录里没有',
disabledTag: '已停用',
/** 服务账号能拿应用授权,不能被派待办:岗位绑定、改派里不收 */
serviceUser: (name: string) => `「${name}」是服务账号,不能被派待办:请选人或组`,
serviceTag: '服务账号,不能被派待办',
/** 使用者选人:候选按这件事能选的人给(服务端 auth/directory 的候选池,05-已知遗留 (b)) */
notCandidate: (name: string) => `「${name}」不在可选的人里,请从候选中选择`,
typeToSearch: '能选的人较多:输入名字的开头查找',
@@ -679,3 +682,123 @@ export const REVIEW_TASK_TEXT = {
rejectNote: '退回的原因(选填)',
rejected: '已退回',
}
// ---------------------------------------------------------------------------
// 访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7;叫法定在总方案 X10:访问密钥,不叫令牌、token、
// 凭据——「token」专指模型用量,「凭据」专指出站端点的凭据)
// ---------------------------------------------------------------------------
export const ACCESS_KEY_TEXT = {
tab: '访问密钥',
hint: '外部系统和脚本用访问密钥调接口。访问密钥按使用者的权限算:只看得到授权给拥有者(或所在的组、所有人)的应用,看到的数据一律遮罩,管理员的密钥也一样',
loginOff: '未开启登录,访问密钥不生效:关登录时所有请求都等同管理员,带着密钥也不会按密钥的权限收窄。开启登录后才能建密钥',
viewKeys: '密钥',
viewReference: '接口说明',
personalSwitch: '允许个人访问密钥',
personalSwitchHint: '打开后每个人可以在这里给自己建访问密钥(写脚本拉数用)。关掉时建不了,已有的个人访问密钥也一并不生效。系统间集成请用服务账号的密钥',
personalSwitchOn: '已允许个人访问密钥',
personalSwitchOff: '已关闭个人访问密钥',
personalOff: '管理员还没有打开个人访问密钥。系统间集成请让管理员建服务账号的密钥',
serviceAccounts: '服务账号',
serviceHint: '给外部系统用的身份:用户名以 svc- 开头,没有密码、不能登录,角色固定是使用者。在应用的「岗位与授权」里给它授权;它不能被派待办。停用之后它的访问密钥全部失效',
serviceEmpty: '还没有服务账号',
createService: '新建服务账号',
serviceUsername: '用户名',
serviceUsernameHint: '以 svc- 开头,只用字母、数字、点、下划线和短横线,建好后不能修改',
serviceDisplay: '显示名',
serviceKeys: (n: number) => (n ? `${n} 把在用的密钥` : '没有在用的密钥'),
serviceCreated: (name: string) => `已建服务账号「${name}」`,
disabled: '已停用',
disable: '停用',
enable: '启用',
disableTitle: (name: string) => `停用服务账号「${name}」?`,
disableBody: '停用后它的访问密钥全部失效,用这些密钥的外部系统会收到 401。重新启用后没到期、没撤销的密钥就又能用。',
myKeys: '我的访问密钥',
allKeys: '全部访问密钥',
keysEmpty: '还没有访问密钥',
create: '新建访问密钥',
owner: '拥有者',
ownerMe: '我自己(个人访问密钥)',
ownerService: (name: string) => `服务账号 ${name}`,
noOwner: '还不能建:先打开个人访问密钥,或者建一个服务账号',
name: '名称',
nameHint: '写清是给哪个系统、哪个脚本用的',
scopes: '权限',
scopesHint: '新建时缺省只勾读的。发起和刷新时请求头里必须带幂等键;回复待办只给个人访问密钥',
scopeLabel: {
'apps.read': '读应用:列表、页面、视图数据、实例',
'apps.run': '发起入口、刷新视图',
'tasks.read': '读待办',
'tasks.respond': '回复待办:只限审批、补充输入、编辑稿件',
'reports.read': '读报告、证据和工件',
} as Record<string, string>,
serviceNoRespond: '服务账号不能被派待办,它的密钥不能回复待办',
apps: '限定应用',
appsHint: '不选就是拥有者有授权的全部应用。个人应用一律不能用访问密钥读',
appsAll: '不限应用',
appsCount: (n: number) => `限定 ${n} 个应用`,
appsNone: '没有可选的应用',
expires: '有效期(天)',
expiresHint: (max: number) => `必填,最长 ${max} 天。到期前请轮换`,
state: { active: '在用', revoked: '已撤销', expired: '已过期', owner_disabled: '拥有者已停用' } as Record<string, string>,
personalTag: '个人',
serviceTag: '服务账号',
expiresAt: (t: string) => `到期 ${t}`,
lastUsed: (t: string) => `最近使用 ${t}`,
neverUsed: '还没用过',
rotatedFrom: '由轮换签发',
revoke: '撤销',
revokeTitle: (name: string) => `撤销访问密钥「${name}」?`,
revokeBody: '撤销后立刻失效,用它的外部系统会收到 401,不能恢复。',
revoked: '已撤销',
rotate: '轮换',
rotateTitle: (name: string) => `轮换访问密钥「${name}」?`,
rotateBody: '会签发一把新的密钥(权限、限定的应用、有效期长短照旧),旧的在 24 小时后到期。请在这段时间里把外部系统换成新密钥。',
rotated: '已签发新密钥,旧密钥 24 小时后到期',
created: '访问密钥已建好',
onceTitle: (name: string) => `「${name}」的访问密钥`,
onceBody: '密钥只显示这一次:关掉之后谁都看不到(服务端只存它的哈希)。请现在复制,交给要用它的系统。',
onceUsage: '调接口时放在请求头里',
done: '我已保存',
requests: '最近的请求',
hideRequests: '收起',
requestsHint: (days: number) => `最近 100 条,保留 ${days} 天`,
reference: '接口说明',
referenceHint: '访问密钥能调的接口只有下面这些:这份说明和服务端放行的接口出自同一张表',
refAuth: '认证方式',
refBase: '对外访问地址',
refBaseNone: '还没有设置对外访问地址(设置 → 通用)',
refRate: (n: number) => `每把密钥每分钟最多 ${n} 次请求,超了回 429,按响应头 Retry-After 的秒数等一会再试`,
refIdempotent: (header: string) => `发起和刷新必须在请求头里带 ${header}:同一件事重发时带同一个键,就不会多发起一次`,
refCommon: '每个接口都可能回的错误码',
refColMethod: '方法',
refColPath: '路径',
refColScope: '权限',
refColSummary: '说明',
refColErrors: '可能的错误码',
refNeedsKey: '要幂等键',
refImplicit: '不用申请',
}
/** 「最近的请求」(访问密钥的请求记录、钩子的收件记录共用一个组件,04 细案 A6) */
export const RECENT_REQUESTS_TEXT = {
label: '最近的请求',
empty: '还没有请求',
run: '运行',
instance: '实例',
task: '待办',
status: (code: number) => (code >= 200 && code < 300 ? `成功 ${code}` : `被拒 ${code}`),
}
/** 钩子的收件记录(应用编辑 → 钩子) */
export const HOOK_RECEIPT_TEXT = {
show: '收件记录',
hide: '收起收件记录',
title: (name: string) => `钩子「${name}」最近收到的请求`,
status: { processing: '处理中', accepted: '已接受', rejected: '被拒' } as Record<string, string>,
empty: '还没有收到过验过签名的请求',
idem: '幂等键',
failures: (n: number) => (n
? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数`
: '近 1 小时没有验签失败的请求'),
}
+12 -3
View File
@@ -37,6 +37,8 @@ import { GeneralTab } from './settings/GeneralTab'
import { IdentityTab } from './settings/IdentityTab'
import { NotifyTab } from './settings/NotifyTab'
import { AuditTab } from './settings/AuditTab'
import { AccessKeysTab } from './settings/AccessKeysTab'
import { ACCESS_KEY_TEXT } from '../lib/termsApp'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
// 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去
@@ -49,12 +51,14 @@ const TABS = [
// 应用平台(A7):登录方式和人、通知渠道、管理类操作的留痕
{ key: 'identity', label: IDENTITY_TEXT.tab },
{ key: 'notify', label: NOTIFY_TEXT.tab },
// 访问密钥(下一轮第 2 波):管理员管服务账号和全部密钥,别人管自己的个人访问密钥
{ key: 'keys', label: ACCESS_KEY_TEXT.tab },
{ key: 'audit', label: IDENTITY_TEXT.audit },
]
/** 只给管理员的页签(§3.12):登录方式和人、审计 */
const ADMIN_TABS = new Set(['identity', 'audit'])
/** 开了登录的使用者只剩个人的:主题、通知渠道(个人) */
/** 开了登录的使用者只剩个人的:主题、通知渠道(个人);管理员开了个人访问密钥时另有自己的访问密钥 */
const USER_TABS = new Set(['prefs', 'notify'])
export function SettingsPage() {
@@ -63,7 +67,11 @@ export function SettingsPage() {
const builder = atLeast(me, 'builder')
const admin = atLeast(me, 'admin')
// 按角色收起:关登录时所有人等同管理员,全部页签照旧
const tabs = TABS.filter((t) => (builder ? admin || !ADMIN_TABS.has(t.key) : USER_TABS.has(t.key)))
// 访问密钥:管理员(关登录时人人等同管理员,页里照实说不生效)一直有;别人要管理员开了个人访问密钥才有——关着时建不了,
// 已有的个人密钥也不生效,给一页空的没有用
const keys = admin || !!me?.personal_keys
const tabs = TABS.filter((t) => (t.key === 'keys' ? keys
: builder ? admin || !ADMIN_TABS.has(t.key) : USER_TABS.has(t.key)))
// datasources 也算认得:否则 useTabRoute 会先把地址纠正回 providers,和下面的跳转打架
const [tab, setTab] = useTabRoute([...tabs.map((t) => t.key), ...(builder ? ['datasources'] : [])], builder ? 'providers' : 'prefs')
@@ -78,7 +86,7 @@ export function SettingsPage() {
return (
<div className="flex h-full flex-col">
<PageHeader icon={<SettingsIcon size={13} />} title="设置"
subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份、通知和访问密钥' : SETTINGS_ROLE_TEXT.subtitleUser} />
<Tabs tabs={tabs} active={tab} onChange={change} label="设置" idPrefix="settings" />
<TabPanel idPrefix="settings" tabKey={tab} className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl p-4">
@@ -88,6 +96,7 @@ export function SettingsPage() {
{tab === 'system' && <SystemTab />}
{tab === 'identity' && <IdentityTab />}
{tab === 'notify' && <NotifyTab />}
{tab === 'keys' && <AccessKeysTab />}
{tab === 'audit' && <AuditTab />}
</div>
</TabPanel>
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { KeyRound, Plus } from 'lucide-react'
import { api } from '../../../api/client'
import type { AppHookRow } from '../../../typesApp'
import type { AppHookRow, HookReceipts } from '../../../typesApp'
import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests'
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp'
import {
CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../../components/ui'
@@ -115,7 +117,8 @@ function Grants() {
<div className="text-xs font-medium" data-app-grant-title="">{GRANT_ROLE_LABEL[role]}</div>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{GRANT_ROLE_HINT[role]}</p>
</div>
<PrincipalPicker label={GRANT_ROLE_LABEL[role]} allowEveryone={role !== 'manage'}
{/* 服务账号能拿授权(外部系统按授权读应用);岗位绑定那边不能选它 */}
<PrincipalPicker label={GRANT_ROLE_LABEL[role]} allowEveryone={role !== 'manage'} allowService
value={items.filter((g) => g.role === role).map((g) => ({ kind: g.principal_kind, name: g.principal }))}
onChange={(v) => {
setItems((cur) => [...(cur ?? []).filter((g) => g.role !== role),
@@ -142,6 +145,8 @@ export function HooksSection() {
const [loadError, setLoadError] = useState<unknown>(null)
const [busy, setBusy] = useState<string | null>(null)
const [shown, setShown] = useState<{ name: string; secret: string; path: string | null } | null>(null)
/** 展开了哪一个钩子的收件记录 */
const [receiptsOf, setReceiptsOf] = useState<string | null>(null)
const load = useCallback(() => {
api.apps.hooks(appId).then((list) => { setRows(list); setLoadError(null) }).catch(setLoadError)
}, [appId])
@@ -200,8 +205,12 @@ export function HooksSection() {
}
const status = (name: string, declared: boolean) => (
<HookStatus name={name} state={stateOf(name)} declared={declared} busy={busy === name} loaded={!!rows}
onRotate={() => void rotate(name)} onToggle={(v) => void toggle(name, v)} onRemove={() => void removeKey(name)} />
<>
<HookStatus name={name} state={stateOf(name)} declared={declared} busy={busy === name} loaded={!!rows}
receipts={receiptsOf === name} onReceipts={() => setReceiptsOf(receiptsOf === name ? null : name)}
onRotate={() => void rotate(name)} onToggle={(v) => void toggle(name, v)} onRemove={() => void removeKey(name)} />
{receiptsOf === name && stateOf(name)?.has_secret && <HookReceiptsPanel appId={appId} name={name} />}
</>
)
return (
@@ -263,13 +272,15 @@ export function HooksSection() {
)
}
/** 一行钩子的服务端状态:发布版里有没有、生成过密钥没有、启用;生成 / 轮换、删除密钥 */
function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, onRemove }: {
/** 一行钩子的服务端状态:发布版里有没有、生成过密钥没有、启用;生成 / 轮换、删除密钥;展开收件记录 */
function HookStatus({ name, state, declared, busy, loaded, receipts, onReceipts, onRotate, onToggle, onRemove }: {
name: string
state: AppHookRow | undefined
declared: boolean
busy: boolean
loaded: boolean
receipts: boolean
onReceipts: () => void
onRotate: () => void
onToggle: (enabled: boolean) => void
onRemove: () => void
@@ -287,6 +298,12 @@ function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, o
: state?.in_published ? <span className="text-faint">{T.hookPublished}</span>
: <span style={{ color: 'var(--st-waiting)' }}>{T.hookNotPublished}</span>}
<span className="flex-1" />
{has && (
<button type="button" className="btn btn-xs btn-ghost" onClick={onReceipts} aria-expanded={receipts}
data-app-hook-receipts-toggle={name}>
{receipts ? HOOK_RECEIPT_TEXT.hide : HOOK_RECEIPT_TEXT.show}
</button>
)}
{has && (
<label className="flex items-center gap-1" title={T.hookEnabledHint}>
<input type="checkbox" className="accent-[var(--accent)]" checked={state!.enabled} disabled={busy}
@@ -308,6 +325,35 @@ function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, o
)
}
/**
* 钩子的收件记录(04 细案 A6):最近 100 条验过签名的请求(状态、原因、发起的实例或解决的待办)和近 1 小时验签失败的次数。
* 和访问密钥的「最近的请求」同一个组件
*/
function HookReceiptsPanel({ appId, name }: { appId: string; name: string }) {
const [data, setData] = useState<HookReceipts | null>(null)
const [error, setError] = useState<unknown>(null)
const load = useCallback(() => {
setError(null)
api.apps.hookReceipts(appId, name).then(setData).catch(setError)
}, [appId, name])
useEffect(load, [load])
const rows: RecentRequestRow[] | null = data ? data.items.map((r, i) => ({
key: `${r.idem_key}-${i}`, at: r.received_at,
tone: r.status === 'accepted' ? 'ok' : r.status === 'rejected' ? 'bad' : 'pending',
result: HOOK_RECEIPT_TEXT.status[r.status] ?? r.status, what: `${HOOK_RECEIPT_TEXT.idem} ${r.idem_key}`, detail: r.detail,
ref: r.run_id ? { label: RECENT_REQUESTS_TEXT.instance, id: r.run_id,
to: `/apps/${encodeURIComponent(appId)}/instances/${encodeURIComponent(r.run_id)}` }
: r.task_id ? { label: RECENT_REQUESTS_TEXT.task, id: r.task_id } : null,
})) : null
return (
<div className="space-y-1 rounded-lg border bg-bg px-2.5 py-2" data-app-hook-receipts={name}>
<div className="text-2xs font-medium">{HOOK_RECEIPT_TEXT.title(name)}</div>
<RecentRequests rows={rows} error={error} onRetry={load} empty={HOOK_RECEIPT_TEXT.empty}
summary={data ? HOOK_RECEIPT_TEXT.failures(data.signature_failures) : undefined} />
</div>
)
}
/** 刚生成、轮换的密钥:只显示这一次,附复制按钮和入站地址;关掉、离开这一节就没有了 */
function SecretOnce({ shown, onDismiss }: { shown: { name: string; secret: string; path: string | null }; onDismiss: () => void }) {
const url = shown.path ? `${window.location.origin}${shown.path}` : null
@@ -0,0 +1,506 @@
import { useCallback, useEffect, useState } from 'react'
import { BookOpen, KeyRound, Plus, Users } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import {
CopyButton, EmptyState, ErrorState, Field, Modal, Notice, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../components/ui'
import { RecentRequests, type RecentRequestRow } from '../../components/RecentRequests'
import { formatDateTime } from '../../lib/format'
import { ACCESS_KEY_TEXT as T, RECENT_REQUESTS_TEXT } from '../../lib/termsApp'
import type {
AccessKeyIssued, AccessKeyReference, AccessKeyRequestRow, AccessKeyRow, AccessKeyScope, AccessKeySettings,
AppSummary, ServiceAccountRow,
} from '../../typesApp'
/*
* 设置 → 访问密钥(docs/design/next-round/04-对外集成与触达.md A1、A6、A7)。
*
* 管理员:个人访问密钥的开关、服务账号(建、停用)、全部密钥;别人:自己的个人访问密钥,管理员打开开关之后才能建。
* 密钥明文只在建、轮换的回包里有,这里显示一次、附复制按钮,关掉就没有了(服务端只存哈希)。撤销、轮换写审计;每把密钥
* 能展开「最近的请求」(和钩子的收件记录同一个组件)。「接口说明」从服务端门禁那张表生成,和真实放行的接口出自一处。
*
* 关登录时访问密钥不生效:照实写一句,不给建密钥的入口(建了也用不了)。权限、到期、能不能轮换都由服务端判,这里照它给的
* 状态显示(界面层不放逻辑)。
*/
type View = 'keys' | 'reference'
export function AccessKeysTab() {
const [settings, setSettings] = useState<AccessKeySettings | null>(null)
const [error, setError] = useState<unknown>(null)
const [view, setView] = useState<View>('keys')
const load = useCallback(() => {
setError(null)
api.accessKeys.settings().then(setSettings).catch(setError)
}, [])
useEffect(load, [load])
if (error) return <ErrorState error={error} onRetry={load} />
if (!settings) return <Skeleton rows={4} height={40} gap={10} />
return (
<div className="space-y-6" data-access-keys={settings.admin ? 'admin' : 'self'}>
<p className="text-xs leading-relaxed text-dim">{T.hint}</p>
{!settings.login_on && <Notice tone="warn" attr={{ 'data-access-keys-login-off': '' }}>{T.loginOff}</Notice>}
{/* 页内切换(不是又一层页签):密钥、接口说明 */}
<div className="flex gap-1" role="group" aria-label={T.tab}>
{([['keys', T.viewKeys, KeyRound], ['reference', T.viewReference, BookOpen]] as const).map(([key, label, Icon]) => (
<button key={key} type="button" aria-pressed={view === key} data-access-keys-view={key}
className={clsx('btn btn-sm', view === key && 'btn-primary')} onClick={() => setView(key)}>
<Icon size={11} aria-hidden /> {label}
</button>
))}
</div>
{view === 'reference' ? <Reference /> : (
<>
{settings.admin && settings.login_on && <PersonalSwitch settings={settings} onChange={setSettings} />}
{settings.admin && settings.login_on && <ServiceAccounts />}
{settings.login_on && <Keys settings={settings} />}
</>
)}
</div>
)
}
// ---------------------------------------------------------------- 个人访问密钥的开关(管理员)
function PersonalSwitch({ settings, onChange }: { settings: AccessKeySettings; onChange: (s: AccessKeySettings) => void }) {
const [busy, setBusy] = useState(false)
const flip = async (on: boolean) => {
setBusy(true)
try {
onChange(await api.accessKeys.setPersonalKeys(on))
toast.ok(on ? T.personalSwitchOn : T.personalSwitchOff)
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<label className="flex items-start gap-2 text-xs" data-access-keys-personal-switch={settings.personal_keys ? 'on' : 'off'}>
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" checked={settings.personal_keys} disabled={busy}
onChange={(e) => void flip(e.target.checked)} />
<span>{T.personalSwitch}<span className="mt-0.5 block text-2xs text-faint">{T.personalSwitchHint}</span></span>
</label>
)
}
// ---------------------------------------------------------------- 服务账号(管理员)
function ServiceAccounts() {
const [rows, setRows] = useState<ServiceAccountRow[] | null>(null)
const [error, setError] = useState<unknown>(null)
const [creating, setCreating] = useState(false)
const load = useCallback(() => {
api.accessKeys.serviceAccounts().then((items) => { setRows(items); setError(null) }).catch(setError)
}, [])
useEffect(load, [load])
const toggle = async (row: ServiceAccountRow) => {
if (!row.disabled && !(await confirmDialog({ title: T.disableTitle(row.username), body: T.disableBody, danger: true,
confirmLabel: T.disable }))) return
try {
await api.accessKeys.updateServiceAccount(row.id, { disabled: !row.disabled })
load()
} catch (e) {
toast.error(e)
}
}
return (
<section data-service-accounts="">
<SectionBar title={T.serviceAccounts} hint={T.serviceHint}>
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)} data-service-account-create="">
<Plus size={12} aria-hidden /> {T.createService}
</button>
</SectionBar>
{error ? <ErrorState error={error} onRetry={load} compact /> : !rows ? <Skeleton rows={2} height={36} /> : !rows.length ? (
<EmptyState icon={<Users size={20} />} title={T.serviceEmpty} />
) : (
<div className="divide-y rounded-lg border">
{rows.map((r) => (
<div key={r.id} className="flex items-center gap-2 px-3 py-2 text-xs" data-service-account={r.username}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="mono font-medium">{r.username}</span>
{r.display_name !== r.username && <span className="text-dim">{r.display_name}</span>}
{r.disabled && <span className="chip text-faint">{T.disabled}</span>}
</div>
<div className="text-2xs text-faint">{T.serviceKeys(r.keys)}</div>
</div>
<button type="button" className="btn btn-xs btn-ghost" onClick={() => void toggle(r)}
data-service-account-toggle={r.disabled ? 'enable' : 'disable'}>
{r.disabled ? T.enable : T.disable}
</button>
</div>
))}
</div>
)}
{creating && <ServiceCreator onClose={() => setCreating(false)} onSaved={() => { setCreating(false); load() }} />}
</section>
)
}
function ServiceCreator({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
const [username, setUsername] = useState('svc-')
const [display, setDisplay] = useState('')
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
try {
const row = await api.accessKeys.createServiceAccount({ username: username.trim(), display_name: display.trim() || undefined })
toast.ok(T.serviceCreated(row.username))
onSaved()
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<Modal open onClose={onClose} title={T.createService} width={480}
footer={<>
<button type="button" className="btn" onClick={onClose}>取消</button>
<button type="button" className="btn btn-primary" disabled={busy || username.trim().length < 5}
onClick={() => void save()} data-service-account-save="">
{busy ? <Spinner size={11} /> : null} {T.createService}
</button>
</>}>
<div className="grid grid-cols-2 gap-3" data-service-account-form="">
<Field label={T.serviceUsername} hint={T.serviceUsernameHint} required>
{(p) => <input {...p} className="field mono" value={username} autoComplete="off"
onChange={(e) => setUsername(e.target.value)} data-service-account-username="" />}
</Field>
<Field label={T.serviceDisplay}>
{(p) => <input {...p} className="field" value={display} onChange={(e) => setDisplay(e.target.value)} />}
</Field>
</div>
</Modal>
)
}
// ---------------------------------------------------------------- 密钥
function Keys({ settings }: { settings: AccessKeySettings }) {
const [rows, setRows] = useState<AccessKeyRow[] | null>(null)
const [error, setError] = useState<unknown>(null)
const [creating, setCreating] = useState(false)
const [issued, setIssued] = useState<AccessKeyIssued | null>(null)
const [open, setOpen] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const load = useCallback(() => {
api.accessKeys.list().then((items) => { setRows(items); setError(null) }).catch(setError)
}, [])
useEffect(load, [load])
const canCreate = settings.admin || settings.can_create_personal
const revoke = async (row: AccessKeyRow) => {
if (!(await confirmDialog({ title: T.revokeTitle(row.name), body: T.revokeBody, danger: true, confirmLabel: T.revoke }))) return
setBusy(row.id)
try {
await api.accessKeys.revoke(row.id)
toast.ok(T.revoked)
load()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
const rotate = async (row: AccessKeyRow) => {
if (!(await confirmDialog({ title: T.rotateTitle(row.name), body: T.rotateBody, confirmLabel: T.rotate }))) return
setBusy(row.id)
try {
setIssued(await api.accessKeys.rotate(row.id))
toast.ok(T.rotated)
load()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
return (
<section data-access-key-list="">
<SectionBar title={settings.admin ? T.allKeys : T.myKeys}>
{canCreate && (
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)} data-access-key-create="">
<Plus size={12} aria-hidden /> {T.create}
</button>
)}
</SectionBar>
{!settings.admin && !settings.personal_keys && (
<Notice tone="info" className="mb-2" attr={{ 'data-access-keys-personal-off': '' }}>{T.personalOff}</Notice>
)}
{issued && <KeyOnce issued={issued} onDone={() => setIssued(null)} />}
{error ? <ErrorState error={error} onRetry={load} compact /> : !rows ? <Skeleton rows={3} height={44} /> : !rows.length ? (
<EmptyState icon={<KeyRound size={20} />} title={T.keysEmpty} />
) : (
<div className="divide-y rounded-lg border">
{rows.map((r) => (
<div key={r.id} className="space-y-1.5 px-3 py-2 text-xs" data-access-key={r.name} data-access-key-state={r.state}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{r.name}</span>
<code className="mono text-2xs text-faint">{r.prefix}…</code>
<span className="chip">{r.personal ? T.personalTag : T.serviceTag}</span>
<span className="chip" style={r.state === 'active' ? { color: 'var(--ok)', borderColor: 'var(--ok)' }
: { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' }}>{T.state[r.state] ?? r.state}</span>
{r.rotated_from && <span className="text-2xs text-faint">{T.rotatedFrom}</span>}
<span className="flex-1" />
<button type="button" className="btn btn-xs btn-ghost" onClick={() => setOpen(open === r.id ? null : r.id)}
aria-expanded={open === r.id} data-access-key-requests-toggle={r.name}>
{open === r.id ? T.hideRequests : T.requests}
</button>
{r.state === 'active' && (
<>
<button type="button" className="btn btn-xs" disabled={busy === r.id} onClick={() => void rotate(r)}
data-access-key-rotate={r.name}>
{busy === r.id ? <Spinner size={10} /> : null} {T.rotate}
</button>
<button type="button" className="btn btn-xs btn-ghost" disabled={busy === r.id} onClick={() => void revoke(r)}
data-access-key-revoke={r.name}>
{T.revoke}
</button>
</>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-faint">
{settings.admin && <span>{T.owner}:<span className="mono">{r.owner.username ?? '—'}</span></span>}
<span>{T.expiresAt(formatDateTime(r.expires_at))}</span>
<span>{r.last_used_at ? T.lastUsed(formatDateTime(r.last_used_at)) : T.neverUsed}</span>
<span>{r.app_ids ? T.appsCount(r.app_ids.length) : T.appsAll}</span>
</div>
<div className="flex flex-wrap gap-1" data-access-key-scopes="">
{r.scopes.map((s) => <span key={s} className="chip text-2xs" title={T.scopeLabel[s]}>{s}</span>)}
</div>
{open === r.id && <KeyRequests id={r.id} />}
</div>
))}
</div>
)}
{creating && (
<KeyCreator settings={settings} onClose={() => setCreating(false)}
onIssued={(k) => { setCreating(false); setIssued(k); toast.ok(T.created); load() }} />
)}
</section>
)
}
/** 刚建好、刚轮换的密钥:只显示这一次 */
function KeyOnce({ issued, onDone }: { issued: AccessKeyIssued; onDone: () => void }) {
return (
<div className="mb-3 space-y-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--st-waiting)' }} role="status"
data-access-key-once={issued.name}>
<div className="flex items-center gap-1.5 text-xs font-semibold">
<KeyRound size={12} aria-hidden /> {T.onceTitle(issued.name)}
</div>
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>{T.onceBody}</p>
<div className="flex items-center gap-2">
<code className="mono min-w-0 flex-1 break-all rounded border bg-bg px-2 py-1 text-2xs" data-access-key-secret="">
{issued.key}
</code>
<CopyButton text={issued.key} />
</div>
<div className="text-2xs text-faint">
{T.onceUsage}:<code className="mono">Authorization: Bearer {issued.prefix}…</code>
</div>
<div className="flex justify-end">
<button type="button" className="btn btn-sm" onClick={onDone} data-access-key-once-done="">{T.done}</button>
</div>
</div>
)
}
function KeyCreator({ settings, onClose, onIssued }: {
settings: AccessKeySettings
onClose: () => void
onIssued: (k: AccessKeyIssued) => void
}) {
// null:还在取(管理员才取);取到之后才判「还不能建」
const [services, setServices] = useState<ServiceAccountRow[] | null>(settings.admin ? null : [])
const [apps, setApps] = useState<AppSummary[] | null>(null)
// 拥有者:'' 是我自己(个人访问密钥),否则是服务账号 id
const [owner, setOwner] = useState<string | null>(settings.can_create_personal ? '' : null)
const [name, setName] = useState('')
const [scopes, setScopes] = useState<AccessKeyScope[]>([...settings.read_scopes])
const [limited, setLimited] = useState<string[]>([])
const [days, setDays] = useState(settings.expires_default_days)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (settings.admin) {
api.accessKeys.serviceAccounts().then((rows) => {
const usable = rows.filter((r) => !r.disabled)
setServices(usable)
setOwner((cur) => (cur === null && usable.length ? usable[0].id : cur))
}).catch(() => setServices([]))
}
api.apps.list().then(({ items }) => setApps(items.filter((a) => (a.scope ?? 'shared') !== 'personal' && a.status !== 'archived')))
.catch(() => setApps([]))
}, [settings.admin])
const service = owner !== null && owner !== ''
const personalOnly = new Set(settings.personal_scopes)
const picked = scopes.filter((s) => !(service && personalOnly.has(s)))
const tick = (s: AccessKeyScope, on: boolean) => setScopes((cur) => (on ? [...cur, s] : cur.filter((x) => x !== s)))
const save = async () => {
if (owner === null) return
setBusy(true)
try {
onIssued(await api.accessKeys.create({
name: name.trim(), scopes: picked, app_ids: limited.length ? limited : null, expires_in_days: days,
...(service ? { service_account_id: owner } : {}),
}))
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
const daysOk = Number.isInteger(days) && days >= 1 && days <= settings.expires_max_days
return (
<Modal open onClose={onClose} title={T.create} width={600}
footer={<>
<button type="button" className="btn" onClick={onClose}>取消</button>
<button type="button" className="btn btn-primary" data-access-key-save=""
disabled={busy || owner === null || !name.trim() || !picked.length || !daysOk} onClick={() => void save()}>
{busy ? <Spinner size={11} /> : null} {T.create}
</button>
</>}>
<div className="space-y-3" data-access-key-form="">
{owner === null && services !== null && !services.length && <Notice tone="warn">{T.noOwner}</Notice>}
{settings.admin && (
<Field label={T.owner}>
{(p) => (
<select {...p} className="field" value={owner ?? ''} onChange={(e) => setOwner(e.target.value)} data-access-key-owner="">
{settings.can_create_personal && <option value="">{T.ownerMe}</option>}
{(services ?? []).map((s) => <option key={s.id} value={s.id}>{T.ownerService(s.username)}</option>)}
</select>
)}
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={T.name} hint={T.nameHint} required>
{(p) => <input {...p} className="field" value={name} maxLength={100} onChange={(e) => setName(e.target.value)}
data-access-key-name="" />}
</Field>
<Field label={T.expires} hint={T.expiresHint(settings.expires_max_days)} required
error={daysOk ? undefined : T.expiresHint(settings.expires_max_days)}>
{(p) => <input {...p} className="field tnum" type="number" min={1} max={settings.expires_max_days} value={days}
onChange={(e) => setDays(Number(e.target.value))} data-access-key-days="" />}
</Field>
</div>
<fieldset className="space-y-1 rounded-lg border px-3 py-2" data-access-key-scope-list="">
<legend className="px-1 text-xs font-medium">{T.scopes}</legend>
<p className="text-2xs text-faint">{T.scopesHint}</p>
{settings.scopes.map((s) => {
const blocked = service && personalOnly.has(s)
return (
<label key={s} className={clsx('flex items-start gap-2 text-xs', blocked && 'opacity-60')} data-access-key-scope={s}>
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" checked={!blocked && scopes.includes(s)}
disabled={blocked} onChange={(e) => tick(s, e.target.checked)} />
<span>
<code className="mono">{s}</code> {T.scopeLabel[s]}
{blocked && <span className="block text-2xs text-faint">{T.serviceNoRespond}</span>}
</span>
</label>
)
})}
</fieldset>
<fieldset className="space-y-1 rounded-lg border px-3 py-2" data-access-key-app-list="">
<legend className="px-1 text-xs font-medium">{T.apps}</legend>
<p className="text-2xs text-faint">{T.appsHint}</p>
{!apps ? <Skeleton rows={2} height={16} /> : !apps.length ? <p className="text-2xs text-faint">{T.appsNone}</p> : (
<div className="max-h-40 space-y-0.5 overflow-y-auto">
{apps.map((a) => (
<label key={a.id} className="flex items-center gap-2 text-xs" data-access-key-app={a.id}>
<input type="checkbox" className="accent-[var(--accent)]" checked={limited.includes(a.id)}
onChange={(e) => setLimited((cur) => (e.target.checked ? [...cur, a.id] : cur.filter((x) => x !== a.id)))} />
{a.name}
</label>
))}
</div>
)}
</fieldset>
</div>
</Modal>
)
}
/** 一把密钥最近的请求 */
function KeyRequests({ id }: { id: string }) {
const [rows, setRows] = useState<RecentRequestRow[] | null>(null)
const [days, setDays] = useState<number | null>(null)
const [error, setError] = useState<unknown>(null)
const load = useCallback(() => {
setError(null)
api.accessKeys.requests(id).then(({ items, retain_days }) => { setRows(items.map(requestRow)); setDays(retain_days) })
.catch(setError)
}, [id])
useEffect(load, [load])
return <RecentRequests rows={rows} error={error} onRetry={load} summary={days != null ? T.requestsHint(days) : undefined}
attr={{ 'data-access-key-requests': id }} />
}
function requestRow(r: AccessKeyRequestRow, i: number): RecentRequestRow {
return {
key: `${r.at}-${i}`, at: r.at, tone: r.status >= 200 && r.status < 300 ? 'ok' : 'bad',
result: RECENT_REQUESTS_TEXT.status(r.status), what: `${r.method} ${r.route}`, detail: r.ip,
ref: r.run_id ? { label: RECENT_REQUESTS_TEXT.run, id: r.run_id } : null,
}
}
// ---------------------------------------------------------------- 接口说明
function Reference() {
const [ref, setRef] = useState<AccessKeyReference | null>(null)
const [error, setError] = useState<unknown>(null)
const load = useCallback(() => {
setError(null)
api.accessKeys.reference().then(setRef).catch(setError)
}, [])
useEffect(load, [load])
if (error) return <ErrorState error={error} onRetry={load} />
if (!ref) return <Skeleton rows={6} height={24} />
return (
<section className="space-y-3 text-xs" data-access-key-reference={ref.routes.length}>
<SectionBar title={T.reference} hint={T.referenceHint} />
<dl className="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-1">
<dt className="text-faint">{T.refAuth}</dt>
<dd><code className="mono">{ref.header}</code></dd>
<dt className="text-faint">{T.refBase}</dt>
<dd>{ref.base_url ? <code className="mono">{ref.base_url}</code> : <span className="text-faint">{T.refBaseNone}</span>}</dd>
</dl>
<p className="text-2xs text-dim">{T.refIdempotent(ref.idempotency_header)}</p>
<p className="text-2xs text-dim">{T.refRate(ref.rate_per_minute)}</p>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-2xs">
<thead className="bg-bg text-left text-faint">
<tr>
<th className="px-2 py-1.5 font-medium">{T.refColMethod}</th>
<th className="px-2 py-1.5 font-medium">{T.refColPath}</th>
<th className="px-2 py-1.5 font-medium">{T.refColScope}</th>
<th className="px-2 py-1.5 font-medium">{T.refColSummary}</th>
<th className="px-2 py-1.5 font-medium">{T.refColErrors}</th>
</tr>
</thead>
<tbody className="divide-y">
{ref.routes.map((r) => (
<tr key={`${r.method} ${r.path}`} className="align-top" data-access-key-route={`${r.method} ${r.path}`}>
<td className="mono px-2 py-1.5">{r.method}</td>
<td className="mono px-2 py-1.5">{r.path}</td>
<td className="mono px-2 py-1.5">{r.scope === ref.implicit_scope ? T.refImplicit : r.scope}</td>
<td className="px-2 py-1.5">
{r.summary}
{r.idempotent && <span className="chip ml-1">{T.refNeedsKey}</span>}
</td>
<td className="mono px-2 py-1.5 text-faint">{r.errors.join(' ')}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-2xs">
<span className="text-faint">{T.refCommon}:</span>
<code className="mono">{ref.common_errors.join(' ')}</code>
</div>
</section>
)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { AuditRow } from '../../typesApp'
const KINDS: Record<string, string> = {
auth: '登录方式', user: '用户', group: '组', app: '应用', grant: '授权', endpoint: '端点', capability: '能力',
workflow_version: '工作流版本', channel: '通知渠道',
workflow_version: '工作流版本', channel: '通知渠道', access_key: '访问密钥',
}
export function AuditTab() {
+18 -11
View File
@@ -6,7 +6,7 @@ import {
useRadioGroup,
} from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { AUTH_MODE_LABEL, IDENTITY_TEXT as T, ROLE_LABEL } from '../../lib/terms'
import { ACCESS_KEY_TEXT, AUTH_MODE_LABEL, IDENTITY_TEXT as T, ROLE_LABEL } from '../../lib/terms'
import { useAtLeast, useAuth } from '../../store/auth'
import type { AuthMode, GroupRow, Role, UserRow } from '../../typesApp'
@@ -192,22 +192,29 @@ function UsersSection({ mode }: { mode: AuthMode }) {
<span className="mono font-medium">{u.username}</span>
{u.display_name && u.display_name !== u.username && <span className="text-dim">{u.display_name}</span>}
{u.source === 'oidc' && <span className="chip">{T.sourceOidc}</span>}
{u.kind === 'service' && <span className="chip" data-user-service="">{ACCESS_KEY_TEXT.serviceTag}</span>}
{u.disabled && <span className="chip text-faint">{T.disabled}</span>}
</div>
<div className="text-2xs text-faint">
{u.email ? `${u.email} · ` : ''}{u.last_login_at ? T.lastLogin(formatDateTime(u.last_login_at)) : T.neverLogin}
{u.kind === 'service' ? T.serviceManaged
: <>{u.email ? `${u.email} · ` : ''}{u.last_login_at ? T.lastLogin(formatDateTime(u.last_login_at)) : T.neverLogin}</>}
</div>
</div>
<select className="field h-7 w-28 py-0 text-xs" value={u.role} aria-label={`${T.role}:${u.username}`}
onChange={(e) => void update(u, { role: e.target.value as Role })}>
{ROLES.map((r) => <option key={r} value={r}>{ROLE_LABEL[r]}</option>)}
</select>
{u.source === 'local' && (
<button className="btn btn-xs btn-ghost" onClick={() => void resetPassword(u)}>{T.passwordReset}</button>
{/* 服务账号的角色固定是使用者、没有密码,停用和启用在「访问密钥」里做(服务端同样拒改) */}
{u.kind !== 'service' && (
<>
<select className="field h-7 w-28 py-0 text-xs" value={u.role} aria-label={`${T.role}:${u.username}`}
onChange={(e) => void update(u, { role: e.target.value as Role })}>
{ROLES.map((r) => <option key={r} value={r}>{ROLE_LABEL[r]}</option>)}
</select>
{u.source === 'local' && (
<button className="btn btn-xs btn-ghost" onClick={() => void resetPassword(u)}>{T.passwordReset}</button>
)}
<button className="btn btn-xs btn-ghost" onClick={() => void update(u, { disabled: !u.disabled })}>
{u.disabled ? T.enable : T.disable}
</button>
</>
)}
<button className="btn btn-xs btn-ghost" onClick={() => void update(u, { disabled: !u.disabled })}>
{u.disabled ? T.enable : T.disable}
</button>
</div>
))}
</div>
+147 -1
View File
@@ -26,6 +26,11 @@ export interface Principal {
* 设置,开关随认人、登录接口一起给(服务端 api/auth.principal_full);老服务端不给时按关
*/
user_personal_apps?: boolean
/**
* 管理员开没开个人访问密钥(auth.personal_keys,docs/design/next-round/04-对外集成与触达.md A1):关着时不是管理员的人在设置
* 里看不到「访问密钥」(建不了,已有的个人密钥也不生效)。老服务端不给时按关
*/
personal_keys?: boolean
}
export interface UserRow {
@@ -38,6 +43,8 @@ export interface UserRow {
source: 'local' | 'oidc'
last_login_at: string | null
created_at: string
/** person 人;service 服务账号(只持有访问密钥,在「设置 → 访问密钥」里管)。老服务端没有这一项,按人算 */
kind?: 'person' | 'service'
}
export interface GroupRow {
@@ -50,7 +57,8 @@ export interface GroupRow {
/** 选人用的名录(GET /api/directory,搭建者及以上只读):只有用户名、显示名、是否停用和组名 */
export interface Directory {
users: { username: string; display_name: string; disabled: boolean }[]
/** service:服务账号(能拿应用授权,不能被派待办:岗位绑定、改派里不能选它) */
users: { username: string; display_name: string; disabled: boolean; service?: boolean }[]
groups: { name: string; label: string }[]
}
@@ -874,3 +882,141 @@ export interface LookObject {
/** 候选太多、先按需求检索:交给挑选模型的表有几张 */
prefiltered?: number
}
// ---------------------------------------------------------------------------
// 访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7,服务端 api/access_keys.py、auth/keys.py)
// ---------------------------------------------------------------------------
/** 能申请的权限(auth/keys.SCOPES);另有不用申请的 me */
export type AccessKeyScope = 'apps.read' | 'apps.run' | 'tasks.read' | 'tasks.respond' | 'reports.read'
/** 一把密钥现在能不能用:能用 / 已撤销 / 已过期 / 拥有者停用了 */
export type AccessKeyState = 'active' | 'revoked' | 'expired' | 'owner_disabled'
export interface AccessKeyRow {
id: string
name: string
/** 明文开头一截(agl_ 加随机串前 8 位),只用来认是哪一把 */
prefix: string
scopes: AccessKeyScope[]
/** 限定的应用;null 是不限应用 */
app_ids: string[] | null
owner: { id: string; username: string | null; display_name: string | null; kind: 'person' | 'service' | null }
/** 个人访问密钥(拥有者是人);false 是服务账号的 */
personal: boolean
state: AccessKeyState
expires_at: string
revoked_at: string | null
revoked_by: string | null
last_used_at: string | null
last_used_ip: string | null
/** 轮换出来的新密钥记着从哪一把换来 */
rotated_from: string | null
created_by: string | null
created_at: string
}
/** 建、轮换的回包:明文 key 只在这一次出现 */
export interface AccessKeyIssued extends AccessKeyRow {
key: string
/** 轮换时:旧的那一把(24 小时后到期) */
previous?: AccessKeyRow
}
export interface ServiceAccountRow {
id: string
username: string
display_name: string
disabled: boolean
created_by: string | null
created_at: string
/** 没撤销的密钥有几把 */
keys: number
}
export interface AccessKeySettings {
/** 开没开登录:关登录时访问密钥不生效 */
login_on: boolean
/** 个人访问密钥的开关(管理员开,缺省关) */
personal_keys: boolean
admin: boolean
can_create_personal: boolean
scopes: AccessKeyScope[]
/** 新建时缺省勾上的(只有读的) */
read_scopes: AccessKeyScope[]
/** 只给个人密钥的(回复待办) */
personal_scopes: AccessKeyScope[]
expires_default_days: number
expires_max_days: number
}
/** 一把密钥的一次请求(access_key_requests):路由模板,不是带参数的路径 */
export interface AccessKeyRequestRow {
at: string
method: string
route: string
status: number
run_id: string | null
ip: string | null
}
/** 接口说明(GET /api/access-keys/reference):从门禁那张表生成 */
export interface AccessKeyReference {
base_url: string | null
header: string
idempotency_header: string
scopes: AccessKeyScope[]
implicit_scope: string
personal_scopes: AccessKeyScope[]
rate_per_minute: number
common_errors: string[]
routes: { method: string; path: string; scope: string; summary: string; errors: string[]; idempotent: boolean }[]
}
/** 钩子最近收到的请求(GET /api/apps/{id}/hooks/{name}/receipts) */
export interface HookReceiptRow {
idem_key: string
status: 'processing' | 'accepted' | 'rejected'
received_at: string | null
run_id: string | null
task_id: string | null
detail: string | null
}
export interface HookReceipts {
items: HookReceiptRow[]
/** 最近一小时验签失败了几次(不落库,只在服务端进程里数) */
signature_failures: number
window_s: number
}
/**
* 视图数据 agentlab.viewdata/1(GET /api/apps/{id}/views/{view}/data,A5):给外部系统拉封存的数。前端暂时不用,类型照服务端
* apps/resolve.view_data 写
*/
export interface ViewData {
format: 'agentlab.viewdata/1'
app: { id: string; version: number }
view: { id: string; kind: 'latest_sealed' | 'on_demand' }
state: 'ok' | 'empty' | 'running' | 'error'
/** 按需视图:规整后的参数 */
params?: Record<string, unknown>
run_id: string | null
manifest_hash: string | null
stamp: Record<string, any> | null
metrics: {
node: string
node_label: string
caliber: string | null
caliber_version: string | null
id: string
name: string
unit: string
/** 原值;遮罩的为 null */
value: unknown
text: string
state: 'ok' | 'masked' | 'missing'
evidence: { run_id: string; eid: string | null; node: string; metric: string }
}[]
message?: string
}
+369
View File
@@ -0,0 +1,369 @@
// 设置 → 访问密钥的检查(docs/design/next-round/04-对外集成与触达.md A1、A6、A7)。名字不叫 check-tokens:那个名字查的是设计令牌。
//
// 守的是:
// - 管理员:个人访问密钥的开关;服务账号(建、停用前确认);全部密钥(拥有者、状态、到期、最近使用、权限);
// - 新建密钥:缺省只勾读的权限;服务账号不能勾回复待办;有效期必填、最长 365 天;建好后明文只显示这一次,点「我已保存」就没了;
// - 轮换:先确认,签出的新密钥同样只显示一次;撤销:先确认;
// - 「最近的请求」(和钩子的收件记录同一个组件):接口写路由模板,成功、被拒分开上色;
// - 接口说明:照服务端那张表列出方法、路径、权限、说明、错误码,要幂等键的标出来;
// - 使用者:管理员开了个人访问密钥才有这一页,只看自己的,建的时候不选拥有者;没开时设置里没有这一页;
// - 关登录:照实说访问密钥不生效,不给建密钥的入口,接口说明照看;
// - 身份页:服务账号那一行标出来,没有角色、密码这些控件。
//
// 不写库:/api/auth/me、/api/access-keys/*、/api/apps、/api/users、/api/groups、/api/auth/settings 全部伪造,伪造的接口改一份
// 内存里的数据。其余写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-access-keys.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.ACCESS_KEYS_ONLY ?? '').split(',').filter(Boolean)
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const waitFor = async (page, fn, ms = 5000) => {
for (let t = 0; t < ms; t += 100) {
if (await fn().catch(() => false)) return true
await page.waitForTimeout(100)
}
return false
}
const clone = (v) => JSON.parse(JSON.stringify(v))
// ---------------------------------------------------------------- 夹具(通用示例名)
const ADMIN = { mode: 'local', username: 'root', display_name: '管理员', role: 'admin', groups: [], verified: true }
const USER = { mode: 'local', username: 'pia', display_name: '小皮', role: 'user', groups: [], verified: true }
const OFF = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const SCOPES = ['apps.read', 'apps.run', 'tasks.read', 'tasks.respond', 'reports.read']
const settingsOf = (over = {}) => ({
login_on: true, personal_keys: false, admin: true, can_create_personal: false, scopes: SCOPES,
read_scopes: ['apps.read', 'tasks.read', 'reports.read'], personal_scopes: ['tasks.respond'],
expires_default_days: 90, expires_max_days: 365, ...over,
})
const SERVICES = [
{ id: 'sa1', username: 'svc-portal', display_name: '门户', disabled: false, created_by: 'root', created_at: '2026-10-01T02:00:00Z', keys: 1 },
]
const keyRow = (over = {}) => ({
id: 'k1', name: '门户拉数', prefix: 'agl_AbCd1234', scopes: ['apps.read', 'reports.read'], app_ids: null,
owner: { id: 'sa1', username: 'svc-portal', display_name: '门户', kind: 'service' }, personal: false, state: 'active',
expires_at: '2026-12-30T02:00:00Z', revoked_at: null, revoked_by: null, last_used_at: '2026-10-15T08:00:00Z',
last_used_ip: '10.0.0.5', rotated_from: null, created_by: 'root', created_at: '2026-10-01T02:00:00Z', ...over,
})
const KEYS = [
keyRow(),
keyRow({ id: 'k2', name: '旧脚本', prefix: 'agl_ZzYy9876', personal: true, state: 'revoked', revoked_at: '2026-10-10T02:00:00Z',
owner: { id: 'u2', username: 'pia', display_name: '小皮', kind: 'person' }, last_used_at: null, scopes: ['apps.read'] }),
]
const APPS = [
{ id: 'app-w', name: '周报看板', description: '', icon: 'app', status: 'active', published_version: 1, level: 'published',
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: null, updated_at: '2026-10-01T02:00:00Z', scope: 'shared' },
{ id: 'app-p', name: '我的看板', description: '', icon: 'app', status: 'active', published_version: null, level: null,
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: null, updated_at: '2026-10-01T02:00:00Z', scope: 'personal' },
]
const REFERENCE = {
base_url: 'https://agentlab.example.com', header: 'Authorization: Bearer agl_…', idempotency_header: 'Idempotency-Key',
scopes: SCOPES, implicit_scope: 'me', personal_scopes: ['tasks.respond'], rate_per_minute: 120,
common_errors: ['key_invalid', 'key_expired', 'key_revoked', 'key_route_forbidden'],
routes: [
{ method: 'GET', path: '/api/auth/me', scope: 'me', summary: '这把密钥认出来是谁', errors: [], idempotent: false },
{ method: 'GET', path: '/api/apps/{app_id}/views/{view_id}/data', scope: 'apps.read', summary: '视图数据', errors: ['app_not_found'],
idempotent: false },
{ method: 'POST', path: '/api/apps/{app_id}/entries/{entry_id}', scope: 'apps.run', summary: '按入口发起一个实例',
errors: ['key_idempotency_required', 'key_files_unsupported'], idempotent: true },
],
}
const REQUESTS = [
{ at: '2026-10-15T08:00:00Z', method: 'GET', route: '/api/apps/{app_id}/views/{view_id}/data', status: 200, run_id: 'run-abc12345', ip: '10.0.0.5' },
{ at: '2026-10-15T07:59:00Z', method: 'GET', route: '/api/runs', status: 403, run_id: null, ip: '10.0.0.5' },
]
async function open(path, { me = ADMIN, settings = settingsOf(), keys = KEYS, services = SERVICES, users = [] } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { settings: clone(settings), keys: clone(keys), services: clone(services), switches: [], serviceWrites: [],
creates: [], revokes: [], rotates: [], requests: [], references: 0 }
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/auth\/me$/, (route) => json(route, me))
await page.route(/\/api\/apps(\?.*)?$/, (route) => json(route, { items: APPS, can_create: { shared: true, personal: false } }))
await page.route(/\/api\/users$/, (route) => json(route, { items: users }))
await page.route(/\/api\/groups$/, (route) => json(route, { items: [] }))
await page.route(/\/api\/auth\/settings$/, (route) => json(route, { mode: me.mode, env_locked: false, oidc: {}, user_can_chat: false }))
await page.route(/\/api\/access-keys(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
const parts = new URL(req.url()).pathname.split('/').slice(2) // ['access-keys', ...]
const [, a, b] = parts
if (a === 'settings') {
if (req.method() === 'PUT') {
state.switches.push(req.postDataJSON())
state.settings.personal_keys = req.postDataJSON().personal_keys
}
return json(route, state.settings)
}
if (a === 'reference') { state.references += 1; return json(route, REFERENCE) }
if (a === 'service-accounts') {
if (req.method() === 'GET') return json(route, { items: state.services })
const body = req.postDataJSON()
state.serviceWrites.push({ method: req.method(), id: b ?? null, body })
if (req.method() === 'POST') {
const row = { id: `sa${state.services.length + 1}`, username: body.username, display_name: body.display_name || body.username,
disabled: false, created_by: 'root', created_at: '2026-10-16T02:00:00Z', keys: 0 }
state.services.push(row)
return json(route, row, 201)
}
const row = state.services.find((s) => s.id === b)
Object.assign(row, body)
return json(route, row)
}
if (!a) {
if (req.method() === 'GET') return json(route, { items: state.keys })
const body = req.postDataJSON()
state.creates.push(body)
const row = keyRow({ id: `k${state.keys.length + 1}`, name: body.name, prefix: 'agl_NewKey00', scopes: body.scopes,
app_ids: body.app_ids, personal: !body.service_account_id, last_used_at: null })
state.keys.unshift(row)
return json(route, { ...row, key: 'agl_NewKey00-only-shown-once-secret' }, 201)
}
if (b === 'revoke') {
state.revokes.push(a)
const row = state.keys.find((k) => k.id === a)
Object.assign(row, { state: 'revoked', revoked_at: '2026-10-16T02:00:00Z' })
return json(route, row)
}
if (b === 'rotate') {
state.rotates.push(a)
const old = state.keys.find((k) => k.id === a)
const row = { ...old, id: `${a}-r`, prefix: 'agl_Rotated0', rotated_from: a }
state.keys.unshift(row)
return json(route, { ...row, key: 'agl_Rotated0-new-secret-after-rotation', previous: old })
}
if (b === 'requests') { state.requests.push(a); return json(route, { items: REQUESTS, retain_days: 90 }) }
return json(route, { detail: '没有这个接口' }, 404)
})
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
return { page, state, errors }
}
const confirm = async (page, label) => {
const dlg = page.locator('[role="dialog"]').last()
await dlg.waitFor()
await dlg.getByRole('button', { name: label, exact: true }).click()
}
const writes = []
const browser = await chromium.launch({ executablePath: CHROME })
// ================================================================
await section('管理员:个人访问密钥的开关、服务账号、全部密钥', async () => {
const { page, state, errors } = await open('/settings/keys')
check('设置页有「访问密钥」页签', await page.getByRole('tab', { name: '访问密钥', exact: true }).count() === 1,
(await page.locator('[role=tab]').allInnerTexts()).join(' | '))
const root = page.locator('[data-access-keys="admin"]')
check('按管理员显示', await waitFor(page, async () => await root.count() === 1))
check('说清访问密钥按使用者的权限算、看到的一律遮罩', (await root.innerText()).includes('按使用者的权限算'))
const sw = page.locator('[data-access-keys-personal-switch]')
check('个人访问密钥的开关缺省关', await sw.getAttribute('data-access-keys-personal-switch') === 'off')
// 受控的开关:等服务端回了才翻过来,所以点它、再等它变
await sw.locator('input').click()
check('打开开关:只改这一项', await waitFor(page, async () => state.switches.length === 1)
&& state.switches[0].personal_keys === true
&& await waitFor(page, async () => await sw.getAttribute('data-access-keys-personal-switch') === 'on'), JSON.stringify(state.switches))
check('服务账号列出来,标着在用的密钥数', (await page.locator('[data-service-account="svc-portal"]').innerText()).includes('1 把在用的密钥'))
await page.locator('[data-service-account-create]').click()
const form = page.locator('[data-service-account-form]')
check('新建服务账号:用户名预填 svc- 开头', await form.locator('[data-service-account-username]').inputValue() === 'svc-')
await form.locator('[data-service-account-username]').fill('svc-bi')
await page.locator('[data-service-account-save]').click()
check('建服务账号按填的用户名发', await waitFor(page, async () => state.serviceWrites.length === 1)
&& state.serviceWrites[0].body.username === 'svc-bi', JSON.stringify(state.serviceWrites))
check('建好之后列出来', await waitFor(page, async () => await page.locator('[data-service-account="svc-bi"]').count() === 1))
await page.locator('[data-service-account="svc-portal"] [data-service-account-toggle="disable"]').click()
await confirm(page, '停用')
check('停用前先确认,确认后才发', await waitFor(page, async () => state.serviceWrites.length === 2)
&& state.serviceWrites[1].body.disabled === true && state.serviceWrites[1].id === 'sa1')
const list = page.locator('[data-access-key-list]')
check('全部密钥:服务账号的和别人的个人密钥都在', await list.locator('[data-access-key]').count() === 2)
const active = list.locator('[data-access-key="门户拉数"]')
check('一把密钥:开头一截、拥有者、到期、最近使用、权限', (await active.innerText()).includes('agl_AbCd1234')
&& (await active.innerText()).includes('svc-portal') && (await active.innerText()).includes('到期')
&& (await active.innerText()).includes('最近使用') && await active.locator('[data-access-key-scopes] .chip').count() === 2)
const revoked = list.locator('[data-access-key="旧脚本"]')
check('撤销了的照实标出,不再给轮换和撤销', await revoked.getAttribute('data-access-key-state') === 'revoked'
&& (await revoked.innerText()).includes('已撤销') && await revoked.locator('[data-access-key-rotate]').count() === 0)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('新建密钥:缺省只勾读的;服务账号不能回复待办;明文只显示一次', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-key-create]').click()
const form = page.locator('[data-access-key-form]')
await form.waitFor()
check('拥有者缺省是服务账号(个人访问密钥没开)', await waitFor(page, async () =>
await form.locator('[data-access-key-owner]').inputValue() === 'sa1'))
const checked = async (s) => form.locator(`[data-access-key-scope="${s}"] input`).isChecked()
check('缺省只勾读的三项', await checked('apps.read') && await checked('tasks.read') && await checked('reports.read')
&& !(await checked('apps.run')))
check('服务账号:回复待办勾不了、说清原因', await form.locator('[data-access-key-scope="tasks.respond"] input').isDisabled()
&& (await form.locator('[data-access-key-scope="tasks.respond"]').innerText()).includes('不能被派待办'))
check('个人应用不在限定应用里', await form.locator('[data-access-key-app="app-p"]').count() === 0
&& await form.locator('[data-access-key-app="app-w"]').count() === 1)
check('名称空着不能建', await page.locator('[data-access-key-save]').isDisabled())
await form.locator('[data-access-key-name]').fill('BI 拉数')
await form.locator('[data-access-key-days]').fill('400')
check('有效期超过 365 天不能建、说清最长几天', await page.locator('[data-access-key-save]').isDisabled()
&& (await form.innerText()).includes('最长 365 天'))
await form.locator('[data-access-key-days]').fill('30')
await form.locator('[data-access-key-scope="apps.run"] input').check()
await form.locator('[data-access-key-app="app-w"] input').check()
await page.locator('[data-access-key-save]').click()
const body = (await waitFor(page, async () => state.creates.length === 1)) ? state.creates[0] : null
check('建的时候发名称、权限、限定应用、有效期、服务账号', body?.name === 'BI 拉数' && body.expires_in_days === 30
&& body.service_account_id === 'sa1' && JSON.stringify(body.app_ids) === '["app-w"]'
&& body.scopes.includes('apps.run') && !body.scopes.includes('tasks.respond'), JSON.stringify(body))
const once = page.locator('[data-access-key-once="BI 拉数"]')
check('明文只显示这一次:值、复制、说明', await waitFor(page, async () => await once.count() === 1)
&& (await once.locator('[data-access-key-secret]').innerText()).includes('agl_NewKey00-only-shown-once-secret')
&& (await once.innerText()).includes('只显示这一次') && await once.getByRole('button', { name: '复制' }).count() === 1)
await once.locator('[data-access-key-once-done]').click()
check('点「我已保存」之后页面上没有明文了', await page.locator('[data-access-key-secret]').count() === 0
&& !(await page.locator('body').innerText()).includes('only-shown-once-secret'))
})
await section('轮换与撤销:先确认;轮换出的新密钥只显示一次', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-key-rotate="门户拉数"]').click()
const dlg = page.locator('[role="dialog"]').last()
await dlg.waitFor()
check('轮换前说清旧密钥 24 小时后到期', (await dlg.innerText()).includes('24 小时后到期'))
await confirm(page, '轮换')
check('确认后才发轮换', await waitFor(page, async () => state.rotates.length === 1) && state.rotates[0] === 'k1')
check('新密钥只显示这一次', await waitFor(page, async () =>
(await page.locator('[data-access-key-secret]').innerText()).includes('agl_Rotated0-new-secret-after-rotation')))
check('列表里标出由轮换签发', await waitFor(page, async () =>
(await page.locator('[data-access-key-list]').innerText()).includes('由轮换签发')))
await page.locator('[data-access-key-revoke="门户拉数"]').last().click()
await page.locator('[role="dialog"]').last().getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(200)
check('撤销取消了就不发', state.revokes.length === 0)
await page.locator('[data-access-key-revoke="门户拉数"]').last().click()
await confirm(page, '撤销')
check('确认后才撤销', await waitFor(page, async () => state.revokes.length === 1))
})
await section('最近的请求:路由模板、成功和被拒分开', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-key-requests-toggle="门户拉数"]').click()
const box = page.locator('[data-access-key-requests="k1"]')
check('按这把密钥取', await waitFor(page, async () => state.requests.includes('k1')))
check('两条请求,写的是路由模板', await waitFor(page, async () => await box.locator('[data-recent-request]').count() === 2)
&& (await box.innerText()).includes('/api/apps/{app_id}/views/{view_id}/data'))
check('成功的、被拒的分开标', await box.locator('[data-recent-request="ok"]').count() === 1
&& (await box.locator('[data-recent-request="bad"]').innerText()).includes('被拒 403'))
check('写明保留几天', (await box.locator('[data-recent-requests-summary]').innerText()).includes('保留 90 天'))
})
await section('接口说明:照服务端那张表列出', async () => {
const { page, state } = await open('/settings/keys')
await page.locator('[data-access-keys-view="reference"]').click()
const ref = page.locator('[data-access-key-reference]')
// 开发模式下 effect 执行两次,取一次、两次都算
check('取了接口说明', await waitFor(page, async () => state.references >= 1 && await ref.count() === 1))
check('每个接口一行:方法、路径、权限、说明、错误码', await ref.locator('[data-access-key-route]').count() === 3
&& (await ref.locator('[data-access-key-route="POST /api/apps/{app_id}/entries/{entry_id}"]').innerText()).includes('key_idempotency_required'))
check('要幂等键的标出来', (await ref.locator('[data-access-key-route="POST /api/apps/{app_id}/entries/{entry_id}"]').innerText()).includes('要幂等键'))
check('me 写不用申请', (await ref.locator('[data-access-key-route="GET /api/auth/me"]').innerText()).includes('不用申请'))
check('认证方式、对外访问地址、限流、通用错误码都写了', (await ref.innerText()).includes('Authorization: Bearer agl_…')
&& (await ref.innerText()).includes('https://agentlab.example.com') && (await ref.innerText()).includes('每分钟最多 120 次')
&& (await ref.innerText()).includes('key_route_forbidden'))
})
await section('使用者:开了个人访问密钥才有这一页,只看自己的', async () => {
const { page, state } = await open('/settings/keys', {
me: { ...USER, personal_keys: true },
settings: settingsOf({ admin: false, personal_keys: true, can_create_personal: true }),
keys: [keyRow({ id: 'k9', name: '我的脚本', personal: true, owner: { id: 'u2', username: 'pia', display_name: '小皮', kind: 'person' } })],
})
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('页签:偏好、通知、访问密钥', tabs.some((t) => t.includes('访问密钥')) && tabs.length === 3, tabs.join(' | '))
check('按本人显示:没有开关、没有服务账号', await page.locator('[data-access-keys="self"]').count() === 1
&& await page.locator('[data-access-keys-personal-switch]').count() === 0 && await page.locator('[data-service-accounts]').count() === 0)
check('标题是「我的访问密钥」', (await page.locator('[data-access-key-list]').innerText()).includes('我的访问密钥'))
await page.locator('[data-access-key-create]').click()
const form = page.locator('[data-access-key-form]')
await form.waitFor()
check('建自己的:不选拥有者,回复待办可以勾', await form.locator('[data-access-key-owner]').count() === 0
&& await form.locator('[data-access-key-scope="tasks.respond"] input').isEnabled())
await form.locator('[data-access-key-name]').fill('日报')
await page.locator('[data-access-key-save]').click()
check('发的是个人访问密钥(不带服务账号)', await waitFor(page, async () => state.creates.length === 1)
&& !('service_account_id' in state.creates[0]), JSON.stringify(state.creates[0]))
})
await section('使用者:没开个人访问密钥时设置里没有这一页', async () => {
const { page } = await open('/settings/keys', { me: USER, settings: settingsOf({ admin: false }) })
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('没有「访问密钥」页签', !tabs.some((t) => t.includes('访问密钥')), tabs.join(' | '))
check('地址纠正到偏好设置', new URL(page.url()).pathname === '/settings/prefs', page.url())
})
await section('关登录:照实说访问密钥不生效,不给建的入口', async () => {
const { page } = await open('/settings/keys', { me: OFF, settings: settingsOf({ login_on: false }) })
check('说清未开启登录时不生效', await waitFor(page, async () => await page.locator('[data-access-keys-login-off]').count() === 1)
&& (await page.locator('[data-access-keys-login-off]').innerText()).includes('未开启登录'))
check('没有建密钥、建服务账号的入口', await page.locator('[data-access-key-create]').count() === 0
&& await page.locator('[data-service-account-create]').count() === 0)
await page.locator('[data-access-keys-view="reference"]').click()
check('接口说明照看', await waitFor(page, async () => await page.locator('[data-access-key-route]').count() === 3))
})
await section('身份页:服务账号那一行标出来,没有角色和密码', async () => {
const users = [
{ id: 'u1', username: 'root', display_name: '管理员', email: null, role: 'admin', disabled: false, source: 'local',
last_login_at: null, created_at: '2026-10-01T02:00:00Z', kind: 'person' },
{ id: 'sa1', username: 'svc-portal', display_name: '门户', email: null, role: 'user', disabled: false, source: 'local',
last_login_at: null, created_at: '2026-10-01T02:00:00Z', kind: 'service' },
]
const { page } = await open('/settings/identity', { users })
const svc = page.locator('[data-user="svc-portal"]')
check('服务账号标出来、说清在哪管', await waitFor(page, async () => await svc.locator('[data-user-service]').count() === 1)
&& (await svc.innerText()).includes('设置 → 访问密钥'))
check('服务账号没有角色、重设密码、停用这些控件', await svc.locator('select').count() === 0 && await svc.locator('button').count() === 0)
check('人照旧有角色', await page.locator('[data-user="root"] select').count() === 1)
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)
+2 -1
View File
@@ -98,6 +98,7 @@ const ORDER = [
'inbox', // 待办箱:会签进度、超时横幅、动作确认的哈希卡、核实待办
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
'personal', // 个人应用:建、刷新、提交审核
@@ -114,7 +115,7 @@ const ESTIMATE = {
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30,
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30,
// 普通用户建应用:还没实测,按同类页面估
blueprint: 90, personal: 45, records: 30, files: 30,
}
+22 -1
View File
@@ -114,7 +114,8 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z', draft: clone(spec),
draft_bindings: { slots: {} }, created_by: '检查脚本', ...(backfill ? { backfill } : {}) }
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [],
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], backfills: [], turns: [], turnPatches: [], conversations: 0,
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], receipts: [], backfills: [], turns: [], turnPatches: [],
conversations: 0,
archives: 0 }
await page.route(/\/api\//, (route) => {
@@ -165,6 +166,15 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
const [, , , , , name, action] = url.pathname.split('/')
if (!name) return json(route, { items: state.hooks })
const hook = decodeURIComponent(name)
// 收件记录(04 细案 A6):形状同服务端 process/hooks.recent_receipts
if (action === 'receipts') {
state.receipts.push(hook)
return json(route, { window_s: 3600, signature_failures: 2, items: [
{ idem_key: 'pay-2', status: 'rejected', received_at: '2026-10-14T03:00:00Z', run_id: null, task_id: null,
detail: '没有在等这个关联键的待办(可能已处理过,或还没走到这一步)' },
{ idem_key: 'pay-1', status: 'accepted', received_at: '2026-10-14T02:00:00Z', run_id: 'run-hook-1', task_id: 'task-1',
detail: null }] })
}
if (action === 'rotate') {
state.rotates.push(hook)
return json(route, { id: `h-${hook}`, name: hook, kind: 'signal', entry_id: null, enabled: true, has_secret: true,
@@ -481,6 +491,17 @@ await section('钩子:按名字列出声明和密钥状态;生成的密钥
&& (await shown.locator('[data-app-hook-secret-value]').innerText()).includes('secret-of-paid-0123456789')
&& (await shown.innerText()).includes('只显示这一次') && await shown.getByRole('button', { name: '复制' }).count() >= 1)
check('……附入站地址', (await shown.locator('[data-app-hook-url]').innerText()).endsWith('/api/hooks/h-paid'))
// 收件记录(04 细案 A6):生成过密钥的钩子能展开,列出状态、原因和发起的实例,另写近 1 小时验签失败几次
await page.locator('[data-app-hook-receipts-toggle="old"]').click()
const receipts = page.locator('[data-app-hook-receipts="old"]')
check('收件记录按名字取', await waitFor(page, async () => state.receipts.includes('old')), JSON.stringify(state.receipts))
check('……被拒的标红、写原因,接受的带实例链接', await waitFor(page, async () => await receipts.locator('[data-recent-request]').count() === 2)
&& await receipts.locator('[data-recent-request="bad"] [data-recent-request-detail]').innerText().then((t) => t.includes('没有在等'))
&& await receipts.locator('[data-recent-request="ok"] a[href$="/instances/run-hook-1"]').count() === 1)
check('……近 1 小时验签失败的次数照实写', (await receipts.locator('[data-recent-requests-summary]').innerText()).includes('验签失败 2 次'))
// 伪造的列表在生成之后不变:paid 那一行仍是没生成过密钥
check('没生成过密钥的钩子没有收件记录可看', await paid.locator('[data-app-hook-state="none"]').count() === 1
&& await paid.locator('[data-app-hook-receipts-toggle]').count() === 0)
await page.locator('[data-app-hook-delete="old"]').click()
await page.locator('[role="dialog"]').getByRole('button', { name: '删除密钥' }).click()
check('删除密钥按名字发', await waitFor(page, async () => state.hookDeletes.length === 1) && state.hookDeletes[0] === 'old')