From e82d7fa55d32a4e088316a14d7fc9a142527dd75 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:19:50 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9B=86=E6=88=90=E6=8E=A5=E5=8F=A3=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E9=94=99=E8=AF=AF=E7=A0=81=EF=BC=8C=E5=BC=80=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=97=B6=E6=94=B6=E8=B5=B7=20/docs=E3=80=81/redoc?= =?UTF-8?q?=E3=80=81/openapi.json=EF=BC=88=E8=A6=81=E6=90=AD=E5=BB=BA?= =?UTF-8?q?=E8=80=85=E7=99=BB=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人 敞开(中间件只拦 /api/ 开头的路径)。 错误码(detail 文字一字不改,老前端照常显示;码都登记在 api/coded.py): - api/apps.py 13 处:应用不存在(app_not_found,同入口发起时的码;没有授权的人也回它)、版本、实例不存在,钩子没声明、 没生成密钥,岗位绑定、个人应用草稿、授权、页面参数不合规;派生时工作流、版本不存在(apps/derive.py 的 DeriveRefused 带上码);视图、入口、补算的拒绝原样带码,只有引擎不发起运行(LaunchRefused)没带码的几种补上 run_launch_refused - api/tasks.py 3 处:待办不存在、查看全部要搭建者(role_required);服务层没带码的拒绝按状态补上同一类的码 (404 task_not_found、403 task_forbidden、409 task_closed、422 task_invalid),没改 process/tasks.py - api/artifacts.py 5 处:没有权限、这类原文只给搭建者、不存在、内容和哈希对不上 - api/deps.py 的 _forbidden(run_forbidden)、运行和证据接口的角色拒绝(role_required)、个人运行只给拥有者 (run_private)、问数据(chat_forbidden);ensure_role / 路由级依赖走 auth.require,抽出 auth.refusal 一处判断, 没登录 401 auth_required、角色不够 403 role_required /docs:开登录时中间件把 /docs(含子路径)、/redoc、/openapi.json 当成 /api 一样拦,和路由级依赖用同一个 auth.refusal: 匿名 401,不到搭建者 403。关登录照旧(test_provenance_contract.py 的断言不受影响)。 测试 tests/test_integration_errors.py:这五个模块里不许再直接抛 HTTPException(静态检查);应用、待办、工件、角色、运行 拒绝逐类核对 code 和原 detail;/docs 关登录照旧 200,开登录匿名 401、使用者 403、搭建者 200。去掉中间件的改动回跑, 开登录时匿名打开 /docs 是 200,测试失败。 --- backend/app/api/apps.py | 41 +++--- backend/app/api/artifacts.py | 15 ++- backend/app/api/auth.py | 3 +- backend/app/api/coded.py | 44 +++++++ backend/app/api/deps.py | 23 ++-- backend/app/api/tasks.py | 23 ++-- backend/app/apps/derive.py | 12 +- backend/app/auth/__init__.py | 27 ++-- backend/app/auth/middleware.py | 17 ++- backend/tests/test_integration_errors.py | 159 +++++++++++++++++++++++ 10 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 backend/tests/test_integration_errors.py diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 68b04481..6d0b281f 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -12,14 +12,18 @@ import dataclasses import json from typing import Any -from fastapi import APIRouter, Depends, Header, HTTPException, Query +from fastapi import APIRouter, Depends, Header, Query from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from app.api.coded import CodedHTTPException +from app.api.coded import ( + APP_BINDINGS_INVALID, APP_DRAFT_INVALID, APP_GRANTS_INVALID, APP_HOOK_NOT_DECLARED, APP_HOOK_NOT_FOUND, + APP_INSTANCE_NOT_FOUND, APP_NOT_FOUND, APP_PARAMS_INVALID, APP_VERSION_NOT_FOUND, RUN_LAUNCH_REFUSED, + CodedHTTPException, +) from app.api.deps import Principal, actor_of, current_principal from app.apps.access import ROLE_TEXT, app_role, at_least, grants_of from app.db.base import get_session @@ -60,11 +64,11 @@ def _principal(x_actor: str | None) -> Principal: async def _app(session: AsyncSession, app_id: str, principal: Principal, need: str) -> tuple[App, str]: app = await session.get(App, app_id) if app is None: - raise HTTPException(404, APP_GONE) + raise CodedHTTPException(404, APP_GONE, APP_NOT_FOUND) role = await app_role(session, principal, app) if role is None: - # 没有任何授权的人不该知道这个应用在不在 - raise HTTPException(404, APP_GONE) + # 没有任何授权的人不该知道这个应用在不在:码和不存在的一样 + raise CodedHTTPException(404, APP_GONE, APP_NOT_FOUND) if not at_least(role, need): raise CodedHTTPException(403, f"需要这个应用的「{ROLE_TEXT[need]}」权限", NO_ACCESS) return app, role @@ -314,7 +318,7 @@ async def derive(payload: DeriveIn, session: AsyncSession = Depends(get_session) try: return {"spec": await derive_app(session, payload.workflow_id, payload.version, name=payload.name)} except DeriveRefused as e: - raise HTTPException(e.status_code, e.message) from e + raise CodedHTTPException(e.status_code, e.message, e.code) from e @router.get("/{app_id}") @@ -359,13 +363,13 @@ async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Dep app, role = await _app(session, app_id, principal, "manage") _refuse_archived(app, "修改") if payload.draft_bindings is not None and (problem := _bindings_problem(payload.draft_bindings)): - raise HTTPException(422, problem) + raise CodedHTTPException(422, problem, APP_BINDINGS_INVALID) if app.scope == "personal" and payload.draft is not None: # 个人应用的草稿就是在用的那一份:只许改呈现,固定的蓝图、视图来源只由「存成我的应用」改 from app.blueprints.personal import draft_problem if problem := draft_problem(app.draft or {}, payload.draft): - raise HTTPException(422, problem) + raise CodedHTTPException(422, problem, APP_DRAFT_INVALID) for key in ("name", "description", "icon"): if (value := getattr(payload, key)) is not None: setattr(app, key, value) @@ -568,7 +572,7 @@ async def get_version(app_id: str, version: int, session: AsyncSession = Depends row = (await session.execute(select(AppVersion).where( AppVersion.app_id == app.id, AppVersion.version == version))).scalar_one_or_none() if row is None: - raise HTTPException(404, f"「{app.name}」没有 v{version}") + raise CodedHTTPException(404, f"「{app.name}」没有 v{version}", APP_VERSION_NOT_FOUND) # 岗位绑定是这一个部署里的人和组:管理者才看得到 return {**_version_row(row), "spec": row.spec, "bindings": row.bindings if role == "manage" else {"slots": {}}} @@ -611,7 +615,7 @@ async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Dep _refuse_personal(app, "授权给别人") wanted, problem = await grants_problem(session, [(i.principal_kind, i.principal, i.role) for i in payload.items]) if problem: - raise HTTPException(422, problem) + raise CodedHTTPException(422, problem, APP_GRANTS_INVALID) return {"items": await replace_grants(session, app, wanted, actor=actor_of(x_actor))} @@ -713,7 +717,8 @@ async def rotate_hook(app_id: str, name: str, session: AsyncSession = Depends(ge draft, published, rows = await _hook_state(session, app) declared = draft.get(name) or published.get(name) if declared is None: - raise HTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它") + raise CodedHTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它", + APP_HOOK_NOT_DECLARED) # 从读行锁到提交:两位管理者同时点「生成密钥」,后到的那次读得到前一次插入的行,只轮换它(见 process/hooks.py) async with hooks.hook_lock(app.id, name): row, secret, created = await hooks.rotate_hook(session, app.id, name, kind=declared["kind"], @@ -731,7 +736,7 @@ async def _hook_named(session: AsyncSession, app: App, name: str) -> AppHook: row = await hook_row(session, app.id, name) if row is None: - raise HTTPException(404, f"钩子「{name}」还没有生成密钥") + raise CodedHTTPException(404, f"钩子「{name}」还没有生成密钥", APP_HOOK_NOT_FOUND) return row @@ -778,9 +783,9 @@ def _params(raw: str | None) -> dict[str, Any]: try: value = json.loads(raw) except ValueError as e: - raise HTTPException(422, "页面参数需要是 JSON 对象") from e + raise CodedHTTPException(422, "页面参数需要是 JSON 对象", APP_PARAMS_INVALID) from e if not isinstance(value, dict): - raise HTTPException(422, "页面参数需要是 JSON 对象") + raise CodedHTTPException(422, "页面参数需要是 JSON 对象", APP_PARAMS_INVALID) return value @@ -788,14 +793,16 @@ async def _instance(session: AsyncSession, app: App, run_id: str, principal: Pri """实例要属于这个应用;只有 use 的人只能看自己发起的(关登录时都能看)。""" run = await session.get(Run, run_id) if run is None or run.app_id != app.id or run.origin_kind not in ("app_entry", "hook"): - raise HTTPException(404, "实例不存在") + raise CodedHTTPException(404, "实例不存在", APP_INSTANCE_NOT_FOUND) if principal.mode != "off" and not at_least(role, "observe") and run.started_by != principal.username: raise CodedHTTPException(403, "只能查看自己发起的实例", NO_ACCESS) return run -def _view_refused(e: Any) -> HTTPException: - return CodedHTTPException(e.status_code, e.message, e.code) if e.code else HTTPException(e.status_code, e.message) +def _view_refused(e: Any) -> CodedHTTPException: + """视图、入口、补算、发起运行的拒绝 → 带 code 的 HTTP 错误。只有引擎不发起运行(LaunchRefused)有不带码的几种(工作流 + 没发布、版本不存在、图没通过校验……),补上同一类的 run_launch_refused,detail 照旧说清是哪一种。""" + return CodedHTTPException(e.status_code, e.message, e.code or RUN_LAUNCH_REFUSED) @router.get("/{app_id}/pages/{page_id}") diff --git a/backend/app/api/artifacts.py b/backend/app/api/artifacts.py index e8099dbe..e210ffba 100644 --- a/backend/app/api/artifacts.py +++ b/backend/app/api/artifacts.py @@ -2,9 +2,12 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter from sqlalchemy import select +from app.api.coded import ( + ARTIFACT_FORBIDDEN, ARTIFACT_KIND_FORBIDDEN, ARTIFACT_NOT_FOUND, ARTIFACT_TAMPERED, CodedHTTPException, +) from app.api.deps import can_read_artifact, current_principal from app.core import artifact_store @@ -23,14 +26,14 @@ async def get_artifact(artifact_id: str) -> dict[str, Any]: principal = current_principal() restricted = principal.mode != "off" and not principal.at_least("builder") if principal.mode != "off" and not await can_read_artifact(principal, artifact_id): - raise HTTPException(403, "没有权限查看这件工件") + raise CodedHTTPException(403, "没有权限查看这件工件", ARTIFACT_FORBIDDEN) try: content = artifact_store.load(artifact_id) except ValueError as e: # 哈希对不上:明确报给调用方,这正是工件库存在的意义 - raise HTTPException(409, str(e)) from e + raise CodedHTTPException(409, str(e), ARTIFACT_TAMPERED) from e if content is None: - raise HTTPException(404, "工件不存在,可能已被删除") + raise CodedHTTPException(404, "工件不存在,可能已被删除", ARTIFACT_NOT_FOUND) if restricted: content = await _masked_for_user(principal, artifact_id, content) return {"id": artifact_id, "content": content} @@ -68,7 +71,7 @@ async def _masked_for_user(principal: Any, artifact_id: str, content: Any) -> An return await _masked_metrics(principal, artifact_id, content, rows) if _RECEIPT in kinds and isinstance(content, dict): return await _masked_receipt(content, rows) - raise HTTPException(403, "这类工件的原文只对搭建者及以上的角色开放") + raise CodedHTTPException(403, "这类工件的原文只对搭建者及以上的角色开放", ARTIFACT_KIND_FORBIDDEN) async def _masked_snapshot(content: dict[str, Any], *, endpoint: bool) -> dict[str, Any]: @@ -106,7 +109,7 @@ async def _masked_metrics(principal: Any, artifact_id: str, card: dict[str, Any] owner = (run_id, node_id or "") break if owner is None: - raise HTTPException(403, "没有权限查看这件工件") + raise CodedHTTPException(403, "没有权限查看这件工件", ARTIFACT_FORBIDDEN) sealed = await _sealed(owner[0]) masks = _Masks(sealed) metrics: list[Any] = [] diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 60e58654..19fc9cf6 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.coded import AUTH_REQUIRED from app.api.deps import Principal, current_principal, decode_actor, ensure_role from app.auth import ( MODES, SETTING_KEY, current_mode, env_mode, load_settings, remember_settings, stored_settings, user_can_chat, @@ -59,7 +60,7 @@ async def me(x_actor: str | None = Header(default=None), session: AsyncSession = return await principal_full(session, Principal(username=actor, display_name=actor, role="admin", mode="off")) if not principal.username: can_bootstrap = principal.mode == "local" and not await _local_admin_exists(session) - return JSONResponse(status_code=401, content={"detail": "请先登录", "code": "auth_required", + return JSONResponse(status_code=401, content={"detail": "请先登录", "code": AUTH_REQUIRED, "mode": principal.mode, "can_bootstrap": can_bootstrap}) return await principal_full(session, principal) diff --git a/backend/app/api/coded.py b/backend/app/api/coded.py index da319c42..27e7ea66 100644 --- a/backend/app/api/coded.py +++ b/backend/app/api/coded.py @@ -29,6 +29,50 @@ CATALOG_DRAFT_BUSY = "catalog_draft_busy" CATALOG_DRAFT_MODEL_UNAVAILABLE = "catalog_draft_model_unavailable" CATALOG_DRAFT_FINISHED = "catalog_draft_finished" +# ---------------------------------------------------------------- 集成接口(04 细案 A7) +# 外部系统用访问密钥调的应用、待办、工件接口,以及角色、运行读权限的拒绝:按 code 认是哪种错,detail 照旧是给人看的那句 + +#: 身份:没登录(401,中间件、auth.require 都回它)、角色不够(403,路由级依赖、接口内部的 ensure_role、开登录时的 /docs) +AUTH_REQUIRED = "auth_required" +ROLE_REQUIRED = "role_required" +#: 运行:这次运行读不了(403,api/deps 的证据、运行接口);别人的个人应用刷新、个人蓝图试运行只给拥有者(403) +RUN_FORBIDDEN = "run_forbidden" +RUN_PRIVATE = "run_private" +#: 问数据和助手:使用者没开问数据,或者这个接口不在问数据的范围里(403) +CHAT_FORBIDDEN = "chat_forbidden" + +#: 应用(api/apps.py)。app_not_found 同入口发起时的 apps/entries.APP_GONE;没有任何授权的人也回它(不该知道应用在不在) +APP_NOT_FOUND = "app_not_found" +APP_VERSION_NOT_FOUND = "app_version_not_found" +APP_INSTANCE_NOT_FOUND = "app_instance_not_found" +#: 草稿里、当前发布版里都没有声明这个钩子(404);声明了、还没生成密钥(404) +APP_HOOK_NOT_DECLARED = "app_hook_not_declared" +APP_HOOK_NOT_FOUND = "app_hook_not_found" +#: 改草稿、授权时写进来的不合规(422):岗位绑定、个人应用的草稿(只许改呈现)、授权的人和组 +APP_BINDINGS_INVALID = "app_bindings_invalid" +APP_DRAFT_INVALID = "app_draft_invalid" +APP_GRANTS_INVALID = "app_grants_invalid" +#: 页面参数不是 JSON 对象(422) +APP_PARAMS_INVALID = "app_params_invalid" +#: 发起实例、刷新视图、补算时引擎不发起运行,又没有更细的码(engine/launch.LaunchRefused 没带 code 的那几种) +RUN_LAUNCH_REFUSED = "run_launch_refused" +# 派生应用草稿时工作流、版本不存在(404)的码在 apps/derive.py(WORKFLOW_GONE、VERSION_GONE);入口、视图、补算的码 +# 在 apps/entries.py、apps/resolve.py、apps/backfill.py,同这里一样都是 app_ 开头 + +#: 待办(api/tasks.py)。服务层(process/tasks.py 的 TaskRefused)没带码的拒绝按状态补上同一类的码:404 不存在;403 +#: 不能这样做(不是管理员也不是应用的管理者,不能改派、催办);409 待办已经处理过、关闭了、刚被别人回复,或者所属的 +#: 运行不再等这条回复——刷新看最新状态;422 回复的内容不对。带了码的照用自己的(task_not_assigned、form_invalid……) +TASK_NOT_FOUND = "task_not_found" +TASK_FORBIDDEN = "task_forbidden" +TASK_CLOSED = "task_closed" +TASK_INVALID = "task_invalid" + +#: 工件(api/artifacts.py):没有权限(403)、这类工件的原文只给搭建者(403)、不存在(404)、内容和哈希对不上(409) +ARTIFACT_FORBIDDEN = "artifact_forbidden" +ARTIFACT_KIND_FORBIDDEN = "artifact_kind_forbidden" +ARTIFACT_NOT_FOUND = "artifact_not_found" +ARTIFACT_TAMPERED = "artifact_tampered" + #: 设置(api/settings.py):对外访问地址由环境变量 AGENTLAB_PUBLIC_URL 指定时,设置里那一项不能改成别的值(409) PUBLIC_URL_ENV_LOCKED = "public_url_env_locked" diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6fa03e2d..4c9913f2 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -26,9 +26,11 @@ from dataclasses import dataclass, field from typing import Any from urllib.parse import unquote -from fastapi import Depends, HTTPException +from fastapi import Depends from starlette.requests import HTTPConnection +from app.api.coded import CHAT_FORBIDDEN, ROLE_REQUIRED, RUN_FORBIDDEN, RUN_PRIVATE, CodedHTTPException + #: 登录方式 AUTH_MODES = ("off", "local", "oidc") #: 角色由高到低 @@ -140,7 +142,7 @@ async def can_read_artifact(principal: Principal, artifact_id: str) -> bool: def ensure_role(role: str) -> None: - """接口内部用:至少是 role,否则 401(没登录)/ 403(角色不够)。关登录时放行。 + """接口内部用:至少是 role,否则 401(没登录,auth_required)/ 403(角色不够,role_required)。关登录时放行。 用在要看请求内容才知道要什么角色的地方(比如 MCP 的 stdio 只有管理员能建)。""" from app.auth import require @@ -169,8 +171,8 @@ async def ensure_conversation_access(session: Any, conversation_id: str | None) return await check(session, conversation_id) -def _forbidden() -> HTTPException: - return HTTPException(403, "没有权限查看这次运行") +def _forbidden() -> CodedHTTPException: + return CodedHTTPException(403, "没有权限查看这次运行", RUN_FORBIDDEN) #: user 角色在自己的问数据运行上能读的(GET /api/runs/{id} 之后的部分):运行记录、事件、运行时的图,加上实时流 @@ -235,13 +237,13 @@ async def _run_scope(conn: HTTPConnection) -> None: return ensure_role("user") if not user_can_chat(): - raise HTTPException(403, _RUNS_FOR_BUILDERS) + raise CodedHTTPException(403, _RUNS_FOR_BUILDERS, ROLE_REQUIRED) method = conn.scope.get("method", "GET") run_id = conn.path_params.get("run_id") if run_id is None: if method == "POST" and conn.url.path.rstrip("/") == "/api/runs": return - raise HTTPException(403, _RUNS_FOR_BUILDERS) + raise CodedHTTPException(403, _RUNS_FOR_BUILDERS, ROLE_REQUIRED) run = await _load_run(run_id) rest = conn.url.path.rstrip("/").removeprefix(f"/api/runs/{run_id}") if method == "GET" and rest in _CHAT_RUN_READS: @@ -252,7 +254,7 @@ async def _run_scope(conn: HTTPConnection) -> None: raise _forbidden() if method == "POST" and rest == "/cancel": return - raise HTTPException(403, "没有权限:这项操作需要搭建者及以上的角色") + raise CodedHTTPException(403, "没有权限:这项操作需要搭建者及以上的角色", ROLE_REQUIRED) async def _load_run(run_id: str | None) -> Any: @@ -282,7 +284,7 @@ async def _refuse_private(conn: HTTPConnection, principal: Principal) -> None: from fastapi import WebSocketException, status raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) - raise HTTPException(403, PRIVATE_RUN) + raise CodedHTTPException(403, PRIVATE_RUN, RUN_PRIVATE) async def _private_run_scope(conn: HTTPConnection) -> None: @@ -310,7 +312,7 @@ async def _evidence_scope(conn: HTTPConnection) -> None: return ensure_role("user") if conn.scope.get("method", "GET") != "GET": - raise HTTPException(403, "没有权限:这项操作需要搭建者及以上的角色") + raise CodedHTTPException(403, "没有权限:这项操作需要搭建者及以上的角色", ROLE_REQUIRED) run_id = conn.path_params.get("run_id") if not run_id or not await can_read_run(principal, run_id): raise _forbidden() @@ -327,7 +329,8 @@ async def _chat_scope(conn: HTTPConnection) -> None: method = conn.scope.get("method", "GET") if user_can_chat() and (path.startswith("/api/conversations") or (method, path) in _CHAT_ROUTES): return - raise HTTPException(403, "没有权限:问数据和助手需要搭建者及以上的角色,或由管理员为使用者开启问数据") + raise CodedHTTPException(403, "没有权限:问数据和助手需要搭建者及以上的角色,或由管理员为使用者开启问数据", + CHAT_FORBIDDEN) #: user 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单 diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 8418558d..8089e350 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1,17 +1,20 @@ """待办(BE-2):列表、计数、详情、回复、改派、催办。设计文档 §3.7、§3.10。 身份经 api/deps.current_principal 取;关登录时它没有名字,用自报的署名(X-Actor)补上,回复人一栏才写得出人。 -回复被拒回 403 / 404 / 409 / 422,{detail, code};表单不通过时另带 errors(同应用入口的 422)。 +回复被拒回 403 / 404 / 409 / 422,{detail, code},一律带 code(外部系统按它认);表单不通过时另带 errors(同应用入口的 +422)。服务层没带码的拒绝按状态补上同一类的码(api/coded.py 的 TASK_*)。 """ from __future__ import annotations from typing import Any, Literal -from fastapi import APIRouter, Header, HTTPException, Query +from fastapi import APIRouter, Header, Query from fastapi.responses import JSONResponse from pydantic import BaseModel, Field -from app.api.coded import CodedHTTPException +from app.api.coded import ( + ROLE_REQUIRED, TASK_CLOSED, TASK_FORBIDDEN, TASK_INVALID, TASK_NOT_FOUND, CodedHTTPException, +) from app.api.deps import current_principal, decode_actor from app.process import tasks as task_service from app.process.tasks import FormInvalid, TaskRefused @@ -23,12 +26,14 @@ def principal_of(x_actor: str | None) -> Any: return task_service.principal_with_actor(current_principal(), decode_actor(x_actor)) -def refused(e: TaskRefused) -> HTTPException | JSONResponse: +#: 服务层(process/tasks.py、process/actions.py、blueprints/personal.py)没带码的拒绝按状态归到哪一类 +_CODE_BY_STATUS = {404: TASK_NOT_FOUND, 403: TASK_FORBIDDEN, 409: TASK_CLOSED, 422: TASK_INVALID} + + +def refused(e: TaskRefused) -> CodedHTTPException | JSONResponse: if isinstance(e, FormInvalid): return JSONResponse({"detail": str(e), "code": e.code, "errors": e.errors}, status_code=422) - if e.code: - return CodedHTTPException(e.status_code, str(e), e.code) - return HTTPException(e.status_code, str(e)) + return CodedHTTPException(e.status_code, str(e), e.code or _CODE_BY_STATUS.get(e.status_code, TASK_CLOSED)) def _raise(e: TaskRefused) -> Any: @@ -53,7 +58,7 @@ async def list_tasks( ) -> dict[str, Any]: principal = principal_of(x_actor) if assigned == "all" and principal.mode != "off" and not principal.at_least("builder"): - raise HTTPException(403, "只能查看派给自己的待办") + raise CodedHTTPException(403, "只能查看派给自己的待办", ROLE_REQUIRED) 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) @@ -71,7 +76,7 @@ async def get_task(task_id: str, x_actor: str | None = Header(default=None)) -> principal = principal_of(x_actor) out = await task_service.detail(task_id, principal) if out is None or not task_service.visible(out, principal): - raise HTTPException(404, "待办不存在,所属的运行可能已被删除") + raise CodedHTTPException(404, "待办不存在,所属的运行可能已被删除", TASK_NOT_FOUND) return out diff --git a/backend/app/apps/derive.py b/backend/app/apps/derive.py index 069b9672..7fbb84d4 100644 --- a/backend/app/apps/derive.py +++ b/backend/app/apps/derive.py @@ -24,13 +24,19 @@ VIEW = "latest" INSTANCE_VIEW = "inst" ENTRY = "start" RECONCILER = "reconciler" +#: 派生不了时的机器码:工作流、版本不存在(404) +WORKFLOW_GONE = "workflow_not_found" +VERSION_GONE = "workflow_version_not_found" class DeriveRefused(Exception): - def __init__(self, status_code: int, message: str) -> None: + """派生不了。code 是给程序认的机器码,接口照它回。""" + + def __init__(self, status_code: int, message: str, code: str) -> None: super().__init__(message) self.status_code = status_code self.message = message + self.code = code def _safe(text: str) -> str: @@ -45,10 +51,10 @@ async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, n workflow = await session.get(Workflow, workflow_id) if workflow is None: - raise DeriveRefused(404, "工作流不存在,可能已被删除") + raise DeriveRefused(404, "工作流不存在,可能已被删除", WORKFLOW_GONE) row = await version_row(session, workflow_id, version) if row is None: - raise DeriveRefused(404, f"「{workflow.name}」不存在 v{version} 版本") + raise DeriveRefused(404, f"「{workflow.name}」不存在 v{version} 版本", VERSION_GONE) state = PinState(alias=PIN, pin={"workflow_id": workflow_id, "version": version}, workflow=workflow, row=row) schedule = (await session.execute(select(Schedule).where( Schedule.workflow_id == workflow_id, Schedule.run_class == "formal", Schedule.enabled.is_(True)) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index def0941d..361b5153 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -15,7 +15,9 @@ import logging import os from typing import Any -from fastapi import Depends, HTTPException +from fastapi import Depends + +from app.api.coded import AUTH_REQUIRED, ROLE_REQUIRED, CodedHTTPException logger = logging.getLogger(__name__) @@ -77,17 +79,24 @@ def remember_settings(value: dict[str, Any]) -> None: _loaded = True +def refusal(principal: Any, role: str) -> CodedHTTPException | None: + """principal 够不够 role:够(或关登录)是 None;不够交回该回的错——没登录 401(auth_required),角色不够 403 + (role_required)。路由级依赖、接口内部的 ensure_role、中间件拦 /docs 都用它,同一个判断、同一句话。""" + if principal.mode == "off": + return None + if not principal.username: + return CodedHTTPException(401, "请先登录", AUTH_REQUIRED) + if not principal.at_least(role): + return CodedHTTPException(403, f"没有权限:这项操作需要{ROLE_LABEL.get(role, role)}及以上的角色", ROLE_REQUIRED) + return None + + def require(role: str) -> None: - """当前请求至少是 role,否则 401(没登录)/ 403(角色不够)。关登录时放行。""" + """当前请求至少是 role,否则 401(没登录)/ 403(角色不够),都带 code。关登录时放行。""" from app.api.deps import current_principal - principal = current_principal() - if principal.mode == "off": - return - if not principal.username: - raise HTTPException(401, "请先登录") - if not principal.at_least(role): - raise HTTPException(403, f"没有权限:这项操作需要{ROLE_LABEL.get(role, role)}及以上的角色") + if (refused := refusal(current_principal(), role)) is not None: + raise refused def role_dependency(role: str) -> Any: diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index b3e44a2a..3a9cfaa8 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -9,6 +9,8 @@ - 会话 cookie → Principal(认证过的用户名、角色、组);没有会话是匿名(username=None),X-Actor 一律不看; - /api 下除了登录本身、健康检查、入站钩子,匿名一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底: 哪个路由忘了挂依赖,也不会对没登录的人敞开; +- 接口说明(FastAPI 自带的 /docs、/redoc、/openapi.json)和 /api 一样拦:匿名 401,不到搭建者 403。它们是 FastAPI 自己 + 挂的路由,挂不上路由级依赖,所以角色也在这里判。以前只拦 /api/ 开头的,开了登录接口全貌也对匿名的人敞开; - 非 GET 的请求必须带 X-AgentLab-Request 头,带了 Origin 的必须是本站或登记的前端地址(CSRF)。自定义请求头跨站发不出来 (要过 CORS 预检,只放行配置里的前端地址),SameSite=Lax 的 cookie 也不随跨站的表单提交带过去; - WebSocket:没登录的握手前就关掉;登录了的交给路由,按运行判(api/deps.py 的 run_scope:user 角色只能连自己能读、 @@ -20,6 +22,7 @@ import json from http.cookies import SimpleCookie from typing import Any +from app.api.coded import AUTH_REQUIRED from app.core.config import settings #: 开登录时匿名也能访问的接口 @@ -27,6 +30,8 @@ _PUBLIC = frozenset({"/api/health", "/api/auth/me", "/api/auth/login", "/api/aut "/api/auth/oidc/callback", "/api/auth/jwks", "/api/auth/bootstrap"}) #: 只认签名、不走会话的入站钩子(也不查 CSRF:调用方是外部系统,没有浏览器的 Origin) _HOOKS = "/api/hooks/" +#: FastAPI 自带的接口说明(main.py 里 FastAPI() 的缺省地址):开登录时要搭建者 +_DOCS = ("/docs", "/redoc", "/openapi.json") _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) CSRF_HEADER = "x-agentlab-request" @@ -48,6 +53,10 @@ def _cookie(headers: dict[str, str], name: str) -> str | None: return morsel.value if morsel else None +def _is_docs(path: str) -> bool: + return any(path == p or path.startswith(p + "/") for p in _DOCS) + + def _origin_ok(origin: str, headers: dict[str, str], scope: dict[str, Any]) -> bool: from app.core.public_url import origin_of, public_base_url @@ -93,7 +102,7 @@ class AuthMiddleware: await self.app(scope, receive, send) return from app.api.deps import reset_principal, set_principal - from app.auth import current_mode + from app.auth import current_mode, refusal # 登录设置在启动时读进缓存(main.py 的 _platform_startup),改设置的接口改完就刷新;这里不查库 mode = current_mode() @@ -131,7 +140,11 @@ class AuthMiddleware: return if (scope["type"] == "http" and is_api and not principal.username and path not in _PUBLIC and not path.startswith(_HOOKS)): - await _reply(send, 401, "请先登录", "auth_required") + await _reply(send, 401, "请先登录", AUTH_REQUIRED) + return + if scope["type"] == "http" and _is_docs(path) and (refused := refusal(principal, "builder")) is not None: + # 和路由级依赖(auth.require)同一个判断、同一句话:没登录 401,角色不够 403 + await _reply(send, refused.status_code, refused.detail, refused.code) return token = set_principal(principal) diff --git a/backend/tests/test_integration_errors.py b/backend/tests/test_integration_errors.py new file mode 100644 index 00000000..cd8c0262 --- /dev/null +++ b/backend/tests/test_integration_errors.py @@ -0,0 +1,159 @@ +"""集成接口的错误码与 /docs(04 细案 A7)。 + +外部系统按 code 认是哪种错,不按中文句子:应用(api/apps.py)、待办(api/tasks.py)、工件(api/artifacts.py)的错误, +和角色、运行读权限的拒绝(api/deps.py、auth.require)全部带 code;detail 还是原来那一句,老前端照常显示。 + +开了登录,/docs、/redoc、/openapi.json 和 /api 一样要登录、要搭建者:以前中间件只拦 /api/ 开头的路径,接口全貌对 +匿名的人敞开。关登录照旧(tests/test_provenance_contract.py 的断言不受影响)。 +""" +from __future__ import annotations + +import ast +import uuid +from pathlib import Path + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.api import coded +from app.db.base import SessionLocal +from app.db.models_apps import App +from app.main import app as main_app +from tests.fakes import auth_env + +APP_DIR = Path(__file__).resolve().parents[1] / "app" +DOCS = ("/docs", "/redoc", "/openapi.json") + + +@pytest.fixture(autouse=True) +async def clean(): + await auth_env.reset() + try: + yield + finally: + await auth_env.reset() + + +def client() -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=main_app), base_url="http://test") + + +async def new_app() -> str: + async with SessionLocal() as session: + row = App(name=f"报销-{uuid.uuid4().hex[:6]}") + session.add(row) + await session.commit() + return row.id + + +def test_no_bare_http_exception_left_in_the_integration_modules(): + """这几个模块里不许再直接抛 HTTPException(没有 code):一律 CodedHTTPException。""" + for rel in ("api/apps.py", "api/tasks.py", "api/artifacts.py", "api/deps.py", "auth/__init__.py"): + tree = ast.parse((APP_DIR / rel).read_text()) + bare = [node.lineno for node in ast.walk(tree) if isinstance(node, ast.Call) + and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) == "HTTPException"] + assert not bare, f"{rel} 第 {bare} 行抛的 HTTPException 没有 code" + + +def test_codes_agree_with_the_ones_already_in_use(): + from app.apps import entries + + assert coded.APP_NOT_FOUND == entries.APP_GONE, "入口发起时的「应用不存在」和这里同一个码" + + +async def test_app_errors_carry_codes_and_keep_their_detail(): + app_id = await new_app() + async with client() as c: + r = await c.get("/api/apps/nope-nope") + assert (r.status_code, r.json()) == (404, {"detail": "应用不存在,可能已被删除", "code": "app_not_found"}) + r = await c.get(f"/api/apps/{app_id}/versions/9") + assert r.status_code == 404 and r.json()["code"] == "app_version_not_found" and "没有 v9" in r.json()["detail"] + r = await c.get(f"/api/apps/{app_id}/pages/home", params={"params": "[1, 2]", "draft": 1}) + assert (r.status_code, r.json()) == (422, {"detail": "页面参数需要是 JSON 对象", "code": "app_params_invalid"}) + r = await c.patch(f"/api/apps/{app_id}", json={"draft_bindings": {"slots": {"财务": "不是列表"}}}) + assert r.status_code == 422 and r.json()["code"] == "app_bindings_invalid", r.text + r = await c.post(f"/api/apps/{app_id}/hooks/nope/rotate") + assert r.status_code == 404 and r.json()["code"] == "app_hook_not_declared" + r = await c.patch(f"/api/apps/{app_id}/hooks/nope", json={"enabled": False}) + assert r.status_code == 404 and r.json()["code"] == "app_hook_not_found", r.text + r = await c.post("/api/apps/derive", json={"workflow_id": "nope-nope", "version": 1}) + assert (r.status_code, r.json()["code"]) == (404, "workflow_not_found") + + +async def test_task_errors_carry_codes(): + async with client() as c: + r = await c.get("/api/tasks/nope-nope") + assert (r.status_code, r.json()) == (404, {"detail": "待办不存在,所属的运行可能已被删除", "code": "task_not_found"}) + # 服务层没带码的拒绝(这里是待办不存在)按状态补上同一类的码 + r = await c.post("/api/tasks/nope-nope/respond", json={"decision": "approve"}) + assert (r.status_code, r.json()["code"]) == (404, "task_not_found") + r = await c.post("/api/tasks/nope-nope/remind") + assert (r.status_code, r.json()["code"]) == (404, "task_not_found") + + +async def test_artifact_errors_carry_codes(): + async with client() as c: + r = await c.get(f"/api/artifacts/{'0' * 64}") + assert (r.status_code, r.json()) == (404, {"detail": "工件不存在,可能已被删除", "code": "artifact_not_found"}) + + +async def test_role_and_run_refusals_carry_codes(): + await auth_env.turn_on_local() + await auth_env.make_user("uma", "user") + uma = await auth_env.login("uma") + r = await uma.get("/api/workflows") + assert r.status_code == 403 and r.json() == {"detail": "没有权限:这项操作需要搭建者及以上的角色", + "code": "role_required"} + r = await uma.get("/api/tasks", params={"assigned": "all"}) + assert r.status_code == 403 and r.json()["code"] == "role_required" + r = await uma.get("/api/runs/nope-nope/evidence") + assert r.status_code == 403 and r.json() == {"detail": "没有权限查看这次运行", "code": "run_forbidden"} + r = await uma.get(f"/api/artifacts/{'0' * 64}") + assert r.status_code == 403 and r.json() == {"detail": "没有权限查看这件工件", "code": "artifact_forbidden"} + r = await uma.get("/api/runs") + assert r.status_code == 403 and r.json()["code"] == "role_required" + + +async def test_ensure_role_inside_an_endpoint_carries_codes(): + from app.api.deps import Principal, ensure_role, reset_principal, set_principal + + for principal, status, code in ((Principal(username=None, role="", mode="local"), 401, "auth_required"), + (Principal(username="bob", role="builder", verified=True, mode="local"), 403, + "role_required")): + token = set_principal(principal) + try: + with pytest.raises(coded.CodedHTTPException) as e: + ensure_role("admin") + finally: + reset_principal(token) + assert (e.value.status_code, e.value.code) == (status, code) + assert "管理员" in e.value.detail + + +# ---------------------------------------------------------------- /docs + + +async def test_docs_stay_open_when_login_is_off(): + async with client() as c: + for path in DOCS: + assert (await c.get(path)).status_code == 200, path + + +async def test_docs_need_a_builder_once_login_is_on(): + await auth_env.turn_on_local() + await auth_env.make_user("uma", "user") + await auth_env.make_user("bob", "builder") + async with client() as anonymous: + for path in DOCS: + r = await anonymous.get(path) + assert r.status_code == 401 and r.json()["code"] == "auth_required", path + # 子路径(Swagger 的 OAuth 回跳页)一样 + assert (await anonymous.get("/docs/oauth2-redirect")).status_code == 401 + uma = await auth_env.login("uma") + for path in DOCS: + r = await uma.get(path) + assert r.status_code == 403 and r.json()["code"] == "role_required", path + bob = await auth_env.login("bob") + for path in DOCS: + assert (await bob.get(path)).status_code == 200, path + assert "/api/apps/{app_id}" in (await bob.get("/openapi.json")).json()["paths"]