集成接口补齐错误码,开登录时收起 /docs、/redoc、/openapi.json(要搭建者登录)

外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人
敞开(中间件只拦 /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,测试失败。
This commit is contained in:
ThearchyHelios
2026-10-16 10:19:50 +08:00
parent d8034ca71a
commit e82d7fa55d
10 changed files with 307 additions and 57 deletions
+24 -17
View File
@@ -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}")
+9 -6
View File
@@ -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] = []
+2 -1
View File
@@ -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)
+44
View File
@@ -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"
+13 -10
View File
@@ -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 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单
+14 -9
View File
@@ -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
+9 -3
View File
@@ -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))
+18 -9
View File
@@ -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:
+15 -2
View File
@@ -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)
+159
View File
@@ -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"]