From 19329c873ee0b1bee8e155a612e6c2fbf7084fec Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 13:48:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E4=B8=8E=E6=9C=8D=E5=8A=A1=E8=B4=A6=E5=8F=B7=E7=9A=84=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=88=E4=B8=8B=E4=B8=80=E8=BD=AE=E7=AC=AC=202=20?= =?UTF-8?q?=E6=B3=A2=20W2-d=EF=BC=8C=E7=BB=86=E6=A1=88=2004=20A1=E2=80=93A?= =?UTF-8?q?6=E3=80=81=E6=80=BB=E6=96=B9=E6=A1=88=20X8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部系统(门户、OA、BI)和个人脚本要用一个身份调接口,以前只能拿个人会话 cookie 去模拟。这次加访问密钥: - 签发(A1):users.kind 区分人和服务账号(svc- 开头、没有口令、不能登录、角色固定使用者,管理员建);access_keys 只存 sha256 和开头一截,明文 agl_ 加 32 字节随机串只显示一次;到期必填(最长 365 天、缺省 90 天)、可撤销、可轮换(新钥 + 旧钥 24 小时后到期);个人访问密钥由管理员开关 auth.personal_keys 管,缺省关,关掉时已有的个人密钥一并不认 - 认人与门禁(A2):Principal 加 via / key_id / key_name / scopes / app_ids(全部带默认值);中间件认 Bearer agl_…,不看 cookie、不查 CSRF、不看 X-Actor,认不出的各回 401 带各自的 code、绝不退回匿名;WebSocket 带密钥一律拒;关登录时 409 keys_need_login。KEY_ROUTES(方法 + 路由模板 → scope)一张表,FastAPI(dependencies=[Depends(key_gate)]) 挂一次;不在表里的 403 key_route_forbidden,缺 scope、限定应用、个人应用和试运行各有各的 403;每把密钥每分钟 120 次(429 + Retry-After) - 封顶(A3):按密钥认出来的一律是使用者(拥有者现在的组),现有的使用者规则和遮罩全部自动生效,管理员的密钥也一样 - 写操作(A4):发起入口、刷新视图必须带 Idempotency-Key,api_receipts 按(拥有者, 键)去重,轮换后照样认得出;状态机从 process/hooks.py 抽到 process/receipts.py,钩子和密钥共用(钩子现有测试原样通过),另按请求指纹拒掉同一个键办另一件事; 有文件控件的入口 422;回复待办只给个人密钥、只限审批 / 补充输入 / 编辑稿件;run.origin、task.responded 记 via=key、哪把密钥 - 视图数据(A5):GET /api/apps/{id}/views/{view}/data(agentlab.viewdata/1),印章、清单哈希、口径卡指标原值和渲染文字, 遮罩的 value 为 null(和页面同一个判断),按需视图只读缓存、不发起运行,只给正式运行 - 留痕(A6):建 / 撤销 / 轮换密钥、建 / 停用服务账号、开关个人密钥写审计;请求记录进程内缓冲、调度循环一拍里批量写、 保留 90 天;last_used_at 至少隔 1 分钟写一次;钩子收件记录接口(manage),验签失败只在进程内按钩子计数 - 名录与派单:服务账号在名录里标出,岗位绑定、选人候选、提交审核、改派里排除,应用授权照收;服务账号发起的实例里派给发起人 的待办交给任何人 - 接口说明(A7):GET /api/access-keys/reference 从 KEY_ROUTES 生成(要登录) 测试:签发与各种 401、门禁合约(遍历全站路由)、CSRF 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、 半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。 --- backend/app/api/access_keys.py | 377 ++++++++++++++- backend/app/api/apps.py | 107 ++++- backend/app/api/auth.py | 5 + backend/app/api/deps.py | 14 +- backend/app/api/identity.py | 15 +- backend/app/api/tasks.py | 35 +- backend/app/apps/entries.py | 14 +- backend/app/apps/lint.py | 3 +- backend/app/apps/resolve.py | 113 ++++- backend/app/auth/__init__.py | 12 +- backend/app/auth/directory.py | 53 +- backend/app/auth/keys.py | 666 ++++++++++++++++++++++++++ backend/app/auth/middleware.py | 73 ++- backend/app/auth/sessions.py | 3 +- backend/app/db/models_apps.py | 28 ++ backend/app/db/models_identity.py | 48 ++ backend/app/main.py | 6 +- backend/app/process/hooks.py | 101 ++-- backend/app/process/receipts.py | 92 ++++ backend/app/process/tasks.py | 55 ++- backend/app/scheduler/loop.py | 13 +- backend/tests/fakes/auth_env.py | 64 ++- backend/tests/test_access_key_gate.py | 173 +++++++ backend/tests/test_access_key_use.py | 470 ++++++++++++++++++ backend/tests/test_access_keys.py | 341 +++++++++++++ 25 files changed, 2794 insertions(+), 87 deletions(-) create mode 100644 backend/app/auth/keys.py create mode 100644 backend/app/process/receipts.py create mode 100644 backend/tests/test_access_key_gate.py create mode 100644 backend/tests/test_access_key_use.py create mode 100644 backend/tests/test_access_keys.py diff --git a/backend/app/api/access_keys.py b/backend/app/api/access_keys.py index eea2b9a0..ba50d365 100644 --- a/backend/app/api/access_keys.py +++ b/backend/app/api/access_keys.py @@ -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} diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 8d97e99d..38f99a3a 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -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 # -------------------------------------------------------------------------- diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 19fc9cf6..3e03ecc5 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -62,6 +62,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) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 9b65b17e..89023f31 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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: diff --git a/backend/app/api/identity.py b/backend/app/api/identity.py index 845f9b64..6a7c2530 100644 --- a/backend/app/api/identity.py +++ b/backend/app/api/identity.py @@ -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()]} diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 8089e350..27224f56 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -62,13 +62,42 @@ 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.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_(("personal_app", "trial"))))).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}") diff --git a/backend/app/apps/entries.py b/backend/app/apps/entries.py index 76b6980a..49126e13 100644 --- a/backend/app/apps/entries.py +++ b/backend/app/apps/entries.py @@ -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]: """占住表单里的文件;引用不对(不是自己传的、在别处传的、已经用过)按表单错误交回,落到那个字段上。""" diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index f4b6a896..656232b5 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -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: diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 0a23a758..f044bdb7 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index 361b5153..3a1194f5 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -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 diff --git a/backend/app/auth/directory.py b/backend/app/auth/directory.py index fa88a6b7..9449aedd 100644 --- a/backend/app/auth/directory.py +++ b/backend/app/auth/directory.py @@ -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}) diff --git a/backend/app/auth/keys.py b/backend/app/auth/keys.py new file mode 100644 index 00000000..665fe68c --- /dev/null +++ b/backend/app/auth/keys.py @@ -0,0 +1,666 @@ +"""访问密钥(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) + 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 ("personal_app", "trial"): + 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 BaseException as e: + 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} diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index 3a9cfaa8..00200f72 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -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}) @@ -107,13 +117,18 @@ class AuthMiddleware: # 登录设置在启动时读进缓存(main.py 的 _platform_startup),改设置的接口改完就刷新;这里不查库 mode = current_mode() headers = _headers(scope) + from app.auth import keys + + 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 +167,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) diff --git a/backend/app/auth/sessions.py b/backend/app/auth/sessions.py index 2719914b..2951366e 100644 --- a/backend/app/auth/sessions.py +++ b/backend/app/auth/sessions.py @@ -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 diff --git a/backend/app/db/models_apps.py b/backend/app/db/models_apps.py index aa1f1d74..a088b6d9 100644 --- a/backend/app/db/models_apps.py +++ b/backend/app/db/models_apps.py @@ -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) diff --git a/backend/app/db/models_identity.py b/backend/app/db/models_identity.py index 1495e138..b0efc4a6 100644 --- a/backend/app/db/models_identity.py +++ b/backend/app/db/models_identity.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 88f6946d..2aa12e81 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 @@ -182,11 +183,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 就在它外面一层: diff --git a/backend/app/process/hooks.py b/backend/app/process/hooks.py index f9ea52ab..8afee1e5 100644 --- a/backend/app/process/hooks.py +++ b/backend/app/process/hooks.py @@ -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} diff --git a/backend/app/process/receipts.py b/backend/app/process/receipts.py new file mode 100644 index 00000000..352eaaf4 --- /dev/null +++ b/backend/app/process/receipts.py @@ -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() diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 818b4f76..e2ee4842 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -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")) diff --git a/backend/app/scheduler/loop.py b/backend/app/scheduler/loop.py index ee6373c2..422bacd3 100644 --- a/backend/app/scheduler/loop.py +++ b/backend/app/scheduler/loop.py @@ -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("定时任务调度已停止") diff --git a/backend/tests/fakes/auth_env.py b/backend/tests/fakes/auth_env.py index a264ff6a..7c392791 100644 --- a/backend/tests/fakes/auth_env.py +++ b/backend/tests/fakes/auth_env.py @@ -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) diff --git a/backend/tests/test_access_key_gate.py b/backend/tests/test_access_key_gate.py new file mode 100644 index 00000000..f3a9fc99 --- /dev/null +++ b/backend/tests/test_access_key_gate.py @@ -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 diff --git a/backend/tests/test_access_key_use.py b/backend/tests/test_access_key_use.py new file mode 100644 index 00000000..3165f432 --- /dev/null +++ b/backend/tests/test_access_key_use.py @@ -0,0 +1,470 @@ +"""用访问密钥用应用(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", "服务账号的密钥没有回复待办这一项" + + +# ---------------------------------------------------------------- 封顶在使用者 + + +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, "要管理权限" diff --git a/backend/tests/test_access_keys.py b/backend/tests/test_access_keys.py new file mode 100644 index 00000000..28a6974d --- /dev/null +++ b/backend/tests/test_access_keys.py @@ -0,0 +1,341 @@ +"""访问密钥与服务账号:签发、认人、轮换、身份来源、限流、留痕(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 From 0b825f40ee523e1e15f3d5b5163f0cfa78bf96f7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 14:11:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E8=AE=BF=E9=97=AE=E5=AF=86=E9=92=A5=E3=80=8D=E4=B8=8E?= =?UTF-8?q?=E9=92=A9=E5=AD=90=E7=9A=84=E6=94=B6=E4=BB=B6=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=88=E4=B8=8B=E4=B8=80=E8=BD=AE=E7=AC=AC=202=20=E6=B3=A2?= =?UTF-8?q?=20W2-d=20=E5=89=8D=E7=AB=AF=EF=BC=8C=E7=BB=86=E6=A1=88=2004=20?= =?UTF-8?q?A1=E3=80=81A6=E3=80=81A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置 → 访问密钥:管理员看个人访问密钥的开关、服务账号(建、停用前确认)和全部密钥;别人只看自己的个人访问密钥,管理员 打开开关之后才有这一页(认人接口多给一项 personal_keys:关着时建不了、已有的也不生效,给一页空的没有用)。新建时缺省只勾读 的权限,服务账号勾不了回复待办,有效期必填最长 365 天;明文只显示这一次,点「我已保存」就没了;轮换、撤销先确认;关登录时 照实说访问密钥不生效、不给建的入口 - 「最近的请求」做成共用组件(components/RecentRequests.tsx):每把密钥的请求记录、钩子的收件记录都用它,接口写路由模板, 成功、被拒、处理中分开上色 - 接口说明:照服务端门禁那张表列出方法、路径、权限、说明、错误码,标出要幂等键的 - 应用编辑 → 钩子:生成过密钥的钩子能展开收件记录(状态、原因、发起的实例),写明近 1 小时验签失败几次 - 名录里的服务账号:选人控件缺省不收(岗位绑定、改派不能派给它),应用授权照收;身份页那一行标出服务账号,不给角色和密码的 控件;审计按「访问密钥」筛 - 检查:新建 check-access-keys(不叫 check-tokens,那个名字查的是设计令牌),check-app-editor 的钩子一段加收件记录;登记进 check-all --- backend/app/api/auth.py | 5 +- backend/app/auth/keys.py | 2 +- frontend/src/api/accessKeys.ts | 32 ++ frontend/src/api/apps.ts | 4 +- frontend/src/api/client.ts | 3 + frontend/src/components/PrincipalPicker.tsx | 31 +- frontend/src/components/RecentRequests.tsx | 69 +++ frontend/src/lib/terms.ts | 4 +- frontend/src/lib/termsApp.ts | 123 +++++ frontend/src/pages/SettingsPage.tsx | 15 +- .../src/pages/apps/edit/AccessSections.tsx | 58 +- frontend/src/pages/settings/AccessKeysTab.tsx | 505 ++++++++++++++++++ frontend/src/pages/settings/AuditTab.tsx | 2 +- frontend/src/pages/settings/IdentityTab.tsx | 29 +- frontend/src/typesApp.ts | 148 ++++- scripts/check-access-keys.mjs | 369 +++++++++++++ scripts/check-all.mjs | 3 +- scripts/check-app-editor.mjs | 23 +- 18 files changed, 1388 insertions(+), 37 deletions(-) create mode 100644 frontend/src/api/accessKeys.ts create mode 100644 frontend/src/components/RecentRequests.tsx create mode 100644 frontend/src/pages/settings/AccessKeysTab.tsx create mode 100644 scripts/check-access-keys.mjs diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 3e03ecc5..45fd4d20 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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]: diff --git a/backend/app/auth/keys.py b/backend/app/auth/keys.py index 665fe68c..b1d9b0a8 100644 --- a/backend/app/auth/keys.py +++ b/backend/app/auth/keys.py @@ -309,7 +309,7 @@ KEY_ROUTES: dict[tuple[str, str], KeyRoute] = { (*_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;" + "apps.run", "按需视图按参数运行一次(命中缓存或接上同一组参数正在运行的那次就不再发起)。必须带 Idempotency-Key;" "沿用每个应用每天的次数上限", (*_APP, *_PUBLISHED, *_IDEM, "app_view_refused", "app_needs_upgrade", "app_view_daily_limit", "form_invalid", "run_launch_refused"), idempotent=True), diff --git a/frontend/src/api/accessKeys.ts b/frontend/src/api/accessKeys.ts new file mode 100644 index 00000000..48893913 --- /dev/null +++ b/frontend/src/api/accessKeys.ts @@ -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('/access-keys/settings'), + /** 个人访问密钥的开关(只给管理员) */ + setPersonalKeys: (personal_keys: boolean) => put('/access-keys/settings', { personal_keys }), + /** 管理员:全部密钥;别人:自己的 */ + list: async (): Promise => (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('/access-keys', body), + revoke: (id: string) => post(`${at(id)}/revoke`), + rotate: (id: string) => post(`${at(id)}/rotate`), + requests: (id: string) => get<{ items: AccessKeyRequestRow[]; retain_days: number }>(`${at(id)}/requests`), + reference: () => get('/access-keys/reference'), + serviceAccounts: async (): Promise => + (await get<{ items: ServiceAccountRow[] }>('/access-keys/service-accounts')).items, + createServiceAccount: (body: { username: string; display_name?: string }) => + post('/access-keys/service-accounts', body), + updateServiceAccount: (id: string, body: { display_name?: string; disabled?: boolean }) => + patch(`/access-keys/service-accounts/${encodeURIComponent(id)}`, body), +} diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 44f603c0..221d6964 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -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(hookAt(id, name), { enabled }), /** 删掉这个名字的密钥行:外部系统再用旧密钥签的请求一律拒收 */ deleteHook: (id: string, name: string) => del(hookAt(id, name)), + /** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */ + hookReceipts: (id: string, name: string) => get(`${hookAt(id, name)}/receipts`), /** * 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次; * 老服务端只回对话本身时由调用方再按 id 取一次 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 079342e4..8cccd3ce 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,6 +28,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' @@ -907,6 +908,8 @@ export const api = { actions, auth, identity, + // 访问密钥与服务账号(下一轮第 2 波) + accessKeys, capabilities, endpoints, notify, diff --git a/frontend/src/components/PrincipalPicker.tsx b/frontend/src/components/PrincipalPicker.tsx index c1b68c6e..dd2f3424 100644 --- a/frontend/src/components/PrincipalPicker.tsx +++ b/frontend/src/components/PrincipalPicker.tsx @@ -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 { 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 && (
    {value.map((p) => { - const problem = problemOf(p, known) + const problem = problemOf(p, known, allowService) return (
  • ({PRINCIPAL_TEXT[p.kind]}) - {problem && · {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}} + {problem && · {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag + : problem === 'service' ? PRINCIPAL_TEXT.serviceTag : PRINCIPAL_TEXT.missingTag}} + )} {has && (