mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
集成接口补齐错误码,开登录时收起 /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:
+24
-17
@@ -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}")
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user