diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 68b04481..8d97e99d 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, FORM_INVALID, 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}") @@ -834,7 +841,7 @@ async def start(app_id: str, entry_id: str, payload: StartIn, session: AsyncSess try: run = await start_entry(app.id, entry_id, payload.values, principal) except FormRejected as e: - return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "errors": e.errors}) + return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "code": FORM_INVALID, "errors": e.errors}) except (EntryRefused, LaunchRefused) as e: raise _view_refused(e) from e return {"run_id": run.id, "title": instance_title(run)} @@ -905,7 +912,7 @@ async def refresh_view(app_id: str, view_id: str, payload: RefreshIn, session: A run, cached = await run_view(session, app=app, version=version, view_id=view_id, params=payload.params, principal=principal, actor=actor_of(x_actor)) except FormRejected as e: - return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "errors": e.errors}) + return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "code": FORM_INVALID, "errors": e.errors}) except (ViewRefused, LaunchRefused) as e: raise _view_refused(e) from e return {"run_id": run.id, "cached": cached} 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 6a4c87d9..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, @@ -25,7 +26,6 @@ from app.auth import oidc, sessions from app.auth.audit import record from app.auth.passwords import hash_password, password_problem, verify_password from app.core import crypto -from app.core.config import settings from app.db.base import get_session from app.db.models import Setting, User @@ -60,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) @@ -113,7 +113,10 @@ async def logout(request: Request, session: AsyncSession = Depends(get_session)) def _redirect_uri(request: Request) -> str: - base = settings.public_url.rstrip("/") or str(request.base_url).rstrip("/") + """回调地址:对外访问地址(环境变量优先,其次设置,core/public_url.py),都没有按请求自己的地址拼。""" + from app.core.public_url import public_base_url + + base = public_base_url() or str(request.base_url).rstrip("/") return f"{base}/api/auth/oidc/callback" diff --git a/backend/app/api/coded.py b/backend/app/api/coded.py index d8fdba23..4227b648 100644 --- a/backend/app/api/coded.py +++ b/backend/app/api/coded.py @@ -29,6 +29,55 @@ 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" +#: 入口表单、按需视图的参数没通过校验(422,另带 errors:[{path, code, message}])。同待办回复、入站钩子的表单错误 +FORM_INVALID = "form_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" + class CodedHTTPException(HTTPException): """field:出错的是哪一项(表单据此把报错落到那一格)。可选,没有就不出现在返回里。""" diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index eebbc76e..9b65b17e 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") #: 角色由高到低 @@ -149,7 +151,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 @@ -178,8 +180,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} 之后的部分):运行记录、事件、运行时的图,加上实时流 @@ -244,13 +246,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: @@ -261,7 +263,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: @@ -291,7 +293,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: @@ -319,7 +321,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() @@ -336,7 +338,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/settings.py b/backend/app/api/settings.py index 83fcaaf7..c45a685a 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -10,7 +10,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api import health -from app.api.coded import CodedHTTPException +from app.api.coded import PUBLIC_URL_ENV_LOCKED, CodedHTTPException +from app.core import public_url from app.core.errors import explain, not_configured, raw from app.core.config import settings as app_settings from app.core.crypto import encrypt, mask @@ -604,35 +605,32 @@ def catalog_draft_values(stored: Any) -> dict[str, Any]: # -------------------------------------------------------------------------- -# 通知:定时任务推送 Webhook 时拼运行链接用的对外访问地址 +# 对外访问地址:通知、Webhook 里的链接,单点登录的回调,来源核对(core/public_url.py) # -------------------------------------------------------------------------- #: 设置里 notify 这一组。public_base_url 是别人打开 AgentLab 用的地址(https://agentlab.example.com),通知里的 -#: 运行链接 = 它 + /runs/{运行 id}。服务端不知道自己在反向代理后面叫什么,只能由人填;留空的通知里不附链接 -NOTIFY_GROUP = "notify" -NOTIFY_DEFAULTS: dict[str, Any] = {"public_base_url": None} -NOTIFY_SETTING_LABEL = {"public_base_url": "对外访问地址"} +#: 运行链接 = 它 + /runs/{运行 id}。服务端不知道自己在反向代理后面叫什么,只能由人填;留空的通知里不附链接。 +#: 全站只认一个对外地址(core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL 设了的话这一项不生效、不能改(PUT 回 409)。 +#: 键名沿用 notify 一组,界面上在「设置 → 通用」 +NOTIFY_GROUP = public_url.SETTING_GROUP +NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None} +NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址"} +#: GET /api/settings 另给的只读一组:{value: 生效的对外访问地址, env_locked: 是不是环境变量指定的}。设置页据此显示 +#: 「由环境变量指定」;PUT 带上它也不落库(同 limits) +PUBLIC_URL_GROUP = "public_url" def _base_url_problem(value: Any) -> str | None: if value is None or (isinstance(value, str) and not value.strip()): return None - label = NOTIFY_SETTING_LABEL["public_base_url"] + label = NOTIFY_SETTING_LABEL[public_url.SETTING_KEY] hint = (f"「{label}」需要是 http:// 或 https:// 开头的完整地址(例如 https://agentlab.example.com)," - f"留空表示通知里不附运行链接,当前为「{value}」") + f"留空表示通知里不附链接,当前为「{value}」") if not isinstance(value, str): return hint - from urllib.parse import urlsplit - - try: - parts = urlsplit(value.strip()) - except ValueError: - return hint - if parts.scheme not in ("http", "https") or not parts.hostname or " " in value.strip(): - return hint - if parts.query or parts.fragment: - return f"「{label}」不能带 ? 或 # 之后的部分,运行链接会接在地址后面,当前为「{value}」" - return None + if (problem := public_url.url_problem(value)) == "suffix": + return f"「{label}」不能带 ? 或 # 之后的部分,链接会接在地址后面,当前为「{value}」" + return hint if problem else None def notify_problem(value: Any) -> str | None: @@ -651,18 +649,23 @@ def notify_problem(value: Any) -> str | None: def notify_values(stored: Any) -> dict[str, Any]: """库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。""" stored = stored if isinstance(stored, dict) else {} - url = stored.get("public_base_url") - if not isinstance(url, str) or not url.strip() or _base_url_problem(url): - return {"public_base_url": None} - return {"public_base_url": url.strip().rstrip("/")} + return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY))} async def notify_settings(session: AsyncSession) -> dict[str, Any]: - """设置里的 notify 一组,没存过的补默认。定时任务推送 Webhook 时从这里读。""" + """设置里存的 notify 一组,没存过的补默认。要拼链接的地方不读它:一律经 core/public_url(环境变量优先)。""" row = await session.get(Setting, NOTIFY_GROUP) return notify_values(row.value if row else None) +def public_url_values(stored_notify: Any) -> dict[str, Any]: + """GET /api/settings 里只读的 public_url 一组:生效的那个地址(环境变量优先,其次库里存的这一项),是不是环境变量 + 指定的。按库里存的算,不读进程缓存:设置页看到的就是落库的样子。""" + locked = public_url.env_locked() + value = public_url.env_url() if locked else notify_values(stored_notify)[public_url.SETTING_KEY] + return {"value": value, "env_locked": locked} + + # -------------------------------------------------------------------------- # 应用:按需视图的每日上限(D-j)、使用者能不能建个人应用(docs/design/normal-user-apps.md N7) # -------------------------------------------------------------------------- @@ -754,7 +757,7 @@ DEFAULT_SETTINGS: dict[str, Any] = { JUDGE_GROUP: dict(JUDGE_DEFAULTS), # 核查(双盲复算、反方审稿、审批质询):核查模型、三项的开关和上限、每日金额上限。和裁判一组一样由用户定 VERIFY_GROUP: verify_values(None), - # 通知:定时任务的 Webhook 里拼运行链接用的对外访问地址 + # 对外访问地址(存在 notify 一组):通知、Webhook 里的链接,单点登录的回调,来源核对。环境变量优先(core/public_url.py) NOTIFY_GROUP: notify_values(None), # 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型 CATALOG_DRAFT_GROUP: catalog_draft_values(None), @@ -808,6 +811,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str # limits 是服务端配置(环境变量),只读:库里可能存着旧版设置页写进去的一份 #(见过 max_agent_steps: 25),它从来不生效,却会盖住真实的上限,界面照着错的数许诺 stored.pop("limits", None) + stored.pop(PUBLIC_URL_GROUP, None) # 裁判、核查的每日计数是引擎记的账,不是用户设置:设置页整组回写时带着一份旧的,会把今天的花费清零 stored.pop(JUDGE_SPEND, None) stored.pop(VERIFY_SPEND, None) @@ -818,6 +822,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP)) merged[VERIFY_GROUP] = verify_values(stored.get(VERIFY_GROUP)) merged[NOTIFY_GROUP] = notify_values(stored.get(NOTIFY_GROUP)) + merged[PUBLIC_URL_GROUP] = public_url_values(stored.get(NOTIFY_GROUP)) merged[CATALOG_DRAFT_GROUP] = catalog_draft_values(stored.get(CATALOG_DRAFT_GROUP)) merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP)) for key, value in stored.items(): @@ -910,6 +915,11 @@ async def put_settings( raise HTTPException(422, problem) # 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠 payload.values[NOTIFY_GROUP] = notify_values(payload.values[NOTIFY_GROUP]) + if public_url.env_locked() and payload.values[NOTIFY_GROUP] != await notify_settings(session): + # 环境变量指定了对外访问地址:改这一项不会生效,照实拒绝,不悄悄存一个用不上的值。原样写回(整组回写)不算改 + raise CodedHTTPException( + 409, f"对外访问地址由环境变量 {public_url.ENV_NAME} 指定({public_url.env_url()}),不能在设置里修改。" + "要改请改部署配置后重启服务", PUBLIC_URL_ENV_LOCKED, field=f"{NOTIFY_GROUP}.{public_url.SETTING_KEY}") if APPS_GROUP in payload.values: if problem := apps_problem(payload.values[APPS_GROUP]): raise HTTPException(422, problem) @@ -917,9 +927,9 @@ async def put_settings( if found := await _provider_problem(session, payload.values): raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0]) for key, value in payload.values.items(): - if key in (TRUST_KEY, "limits", JUDGE_SPEND, VERIFY_SPEND, *_hidden_groups()): + if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, *_hidden_groups()): # 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的, - # 会把刚在工具页改的档位冲掉。limits 是环境变量定的,写进库也不生效。 + # 会把刚在工具页改的档位冲掉。limits 是环境变量定的、public_url 是算出来的,写进库也不生效。 # 裁判、核查的每日计数是引擎记的账,只有引擎自己写。登录设置只经 /api/auth/settings 改(_hidden_groups) continue row = await session.get(Setting, key) @@ -928,6 +938,9 @@ async def put_settings( else: session.add(Setting(key=key, value=value)) await session.commit() + if NOTIFY_GROUP in payload.values: + # 中间件、单点登录的回调按进程缓存取对外访问地址:写完库就刷新,不用重启 + public_url.remember(payload.values[NOTIFY_GROUP][public_url.SETTING_KEY]) return await get_settings(session) 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 05da0def..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 角色只能连自己能读、 @@ -19,8 +21,8 @@ from __future__ import annotations import json from http.cookies import SimpleCookie from typing import Any -from urllib.parse import urlsplit +from app.api.coded import AUTH_REQUIRED from app.core.config import settings #: 开登录时匿名也能访问的接口 @@ -28,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" @@ -49,11 +53,17 @@ 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 + allowed = {o.rstrip("/") for o in settings.cors_origins} - if settings.public_url: - parts = urlsplit(settings.public_url) - allowed.add(f"{parts.scheme}://{parts.netloc}") + # 对外访问地址(环境变量或设置里的那一项,core/public_url.py):反向代理后面请求的 Host 不一定是它 + if address := public_base_url(): + allowed.add(origin_of(address)) host = headers.get("x-forwarded-host") or headers.get("host") if host: scheme = headers.get("x-forwarded-proto") or scope.get("scheme") or "http" @@ -92,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() @@ -130,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/app/core/config.py b/backend/app/core/config.py index 9dcf013a..955e7242 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,9 +1,10 @@ from __future__ import annotations import ipaddress +import re from functools import lru_cache from pathlib import Path -from typing import Annotated +from typing import Annotated, Any from pydantic import field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict @@ -11,6 +12,9 @@ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict BACKEND_DIR = Path(__file__).resolve().parents[2] PROJECT_DIR = BACKEND_DIR.parent +#: 嵌入白名单里一项的主机部分:域名(可以 *. 开头)或方括号里的 IPv6,可以带端口;不许路径、查询、用户名 +_ORIGIN_HOST = re.compile(r"(?:\*\.)?(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]+(?::\d{1,5})?|\[[0-9A-Fa-f:.]+\](?::\d{1,5})?") + # session_id 拼进路径前必须过这一关。允许的字符刻意收得很窄:字母数字加 # 连字符下划线——点号也不放行,免得出现 ".." 这种能往上走的名字。 _SESSION_MAX = 64 @@ -60,10 +64,18 @@ class Settings(BaseSettings): auth_session_max_days: int = 7 # 会话 cookie 带不带 Secure。不设时按请求本身是不是 https 定:本机 http 开发照常能登录 auth_cookie_secure: bool | None = None - # 对外的访问地址(反向代理后面时填,如 https://agentlab.example.com)。OIDC 的回调地址按它拼; - # 不设时按请求的 Host 拼 + # 对外的访问地址(反向代理后面时填,如 https://agentlab.example.com)。全站只认一个(core/public_url.py): + # 设了它,设置里的「对外访问地址」就不生效、不能改;通知和 Webhook 的链接、OIDC 的回调、来源核对都按它拼。 + # 不设时用设置里的那一项,再没有就按请求的 Host 拼(通知里不附链接) public_url: str = "" + # --- 安全响应头(app/web_security.py)--- + # 允许哪些站点用 iframe 嵌整站(同站的门户,如 https://portal.example.com),逗号分隔。本站自己总是允许的。 + # 不设时只有本站能嵌(frame-ancestors 'self',另加 X-Frame-Options: SAMEORIGIN) + frame_ancestors: Annotated[list[str], NoDecode] = [] + # 分享页(/s)允许被哪些站点嵌,逗号分隔,写法同上。分享页只认这一项,不认上面那一项 + embed_origins: Annotated[list[str], NoDecode] = [] + # --- 沙箱 --- sandbox_backend: str = "auto" # auto | microvm | seatbelt | bubblewrap | local | off sandbox_timeout: int = 30 @@ -121,7 +133,8 @@ class Settings(BaseSettings): "https://223.5.5.5/resolve", ] - @field_validator("http_tool_allowlist", "http_tool_fake_ip_ranges", "http_tool_doh_urls", mode="before") + @field_validator("http_tool_allowlist", "http_tool_fake_ip_ranges", "http_tool_doh_urls", "frame_ancestors", + "embed_origins", mode="before") @classmethod def _comma_list(cls, value: object) -> object: # 环境变量里写逗号分隔;也兼容 JSON 数组的写法 @@ -134,6 +147,37 @@ class Settings(BaseSettings): return [part.strip() for part in text.split(",") if part.strip()] return value + @field_validator("public_url") + @classmethod + def _valid_public_url(cls, value: str) -> str: + # 写错了启动时就报出来:以前拼出 agentlab.example.com/api/auth/oidc/callback 这样的回调,到登录时才发现 + from app.core.public_url import ENV_NAME, normalize, url_problem + + if not value.strip(): + return "" + if problem := url_problem(value): + what = "不能带 ? 或 # 之后的部分" if problem == "suffix" else "需要是 http:// 或 https:// 开头的完整地址" + raise ValueError(f"{ENV_NAME}={value!r} {what},例如 https://agentlab.example.com") + return normalize(value) or "" + + @field_validator("frame_ancestors", "embed_origins") + @classmethod + def _valid_origins(cls, value: list[str], info: Any) -> list[str]: + # 只收「协议://主机[:端口]」,主机可以写 *.example.com。路径、裸 *、没写协议的写错了启动时就报出来,别到嵌入 + # 失败(或者整站被谁都能嵌)才发现。'self' 本来就有,写了也不重复 + name = f"AGENTLAB_{(info.field_name or '').upper()}" + out: list[str] = [] + for raw in value: + item = raw.strip().rstrip("/") + if item == "'self'": + continue + scheme, _, host = item.partition("://") + if scheme.lower() not in ("http", "https") or not _ORIGIN_HOST.fullmatch(host): + raise ValueError(f"{name} 里的「{raw}」需要写成「协议://主机」(可以带端口),例如 https://portal.example.com") + if (origin := f"{scheme.lower()}://{host.lower()}") not in out: + out.append(origin) + return out + @field_validator("auth_cookie_secure", mode="before") @classmethod def _blank_is_auto(cls, value: object) -> object: diff --git a/backend/app/core/public_url.py b/backend/app/core/public_url.py new file mode 100644 index 00000000..1bd5a2cc --- /dev/null +++ b/backend/app/core/public_url.py @@ -0,0 +1,97 @@ +"""对外访问地址:别人打开 AgentLab 用的那个地址(https://agentlab.example.com),全站只有这一个说法(04 细案 B4)。 + +用到它的地方:通知和定时任务 Webhook 里的链接、单点登录的回调地址、非 GET 请求的来源核对(auth/middleware.py); +之后的分享链接、接口说明也从这里取。服务端不知道自己在反向代理后面叫什么,只能由人告诉它。 + +取值(public_base_url): +- 环境变量 AGENTLAB_PUBLIC_URL 优先:部署时定死,设置页上显示「由环境变量指定」、不能改(同登录方式的 env_locked); +- 没设时取设置里 notify 一组的 public_base_url:沿用这个键,界面上叫「对外访问地址」,在「设置 → 通用」里改; +- 都没有是 None:通知里不附链接,单点登录的回调和来源核对按请求自己的 Host 认。 + +以前两处各管各的:环境变量只管回调和来源核对、设置只管通知里的链接——只配了环境变量的部署,通知里不带链接;只在设置里 +填了的,回调又按请求的 Host 拼。违背「一种说法」。 + +设置的值缓存在进程里:中间件按请求核对来源,不能每次查库。启动时读一次(main._platform_startup),改设置的接口写完库就 +remember;手里本来就有会话的异步路径(通知、定时任务 Webhook)用 refresh 取,顺手按库里的值对齐缓存。和登录设置的缓存一样 +以单进程部署为前提。 + +这个模块不在顶层导入 core/config:config 的校验器要用这里的 url_problem,顶层互相导入会在启动时绕成环。 +""" +from __future__ import annotations + +from typing import Any +from urllib.parse import urlsplit + +#: 设置里存它的那一组、那一项(api/settings.py 的 NOTIFY_GROUP 就是这一组) +SETTING_GROUP = "notify" +SETTING_KEY = "public_base_url" +#: 环境变量的名字(core/config.Settings.public_url 按 AGENTLAB_ 前缀读它) +ENV_NAME = "AGENTLAB_PUBLIC_URL" + +_stored: str | None = None + + +def url_problem(value: str) -> str | None: + """地址的形状有什么问题:None 是没问题;"form" 不是 http:// 或 https:// 开头的完整地址;"suffix" 带了 ? 或 # + (链接要接在地址后面)。设置接口按它出中文的说法,环境变量按它在启动时报错。可以带路径(反向代理挂在子路径下)。""" + text = value.strip() + try: + parts = urlsplit(text) + except ValueError: + return "form" + if parts.scheme not in ("http", "https") or not parts.hostname or " " in text: + return "form" + if parts.query or parts.fragment: + return "suffix" + return None + + +def normalize(value: Any) -> str | None: + """存着的(或刚填的)值 → 去掉首尾空白和末尾的 /;空的、形状不对的当没填(None)。末尾带 / 的地址拼链接会多出一个斜杠。""" + if not isinstance(value, str) or not value.strip() or url_problem(value): + return None + return value.strip().rstrip("/") + + +def env_url() -> str | None: + """环境变量给的地址;没设为 None。形状在启动时由 config 的校验器查过、末尾的 / 去掉了。""" + from app.core.config import settings + + return settings.public_url or None + + +def env_locked() -> bool: + """环境变量设了:设置里的那一项不生效、不能改。""" + return env_url() is not None + + +def public_base_url() -> str | None: + """对外访问地址:环境变量优先,其次设置(进程内缓存),都没有是 None。不查库,中间件里也能调。""" + return env_url() or _stored + + +def remember(value: Any) -> None: + """改设置的接口写完库之后调:缓存换成新存的值(形状不对、空的当没填)。""" + global _stored + _stored = normalize(value) + + +async def refresh(session: Any = None) -> str | None: + """按库里存的设置对齐缓存,交回对外访问地址。启动时、手里有会话的异步路径用。""" + from app.db.base import SessionLocal + from app.db.models import Setting + + if session is None: + async with SessionLocal() as own: + row = await own.get(Setting, SETTING_GROUP) + else: + row = await session.get(Setting, SETTING_GROUP) + value = row.value if row is not None else None + remember(value.get(SETTING_KEY) if isinstance(value, dict) else None) + return public_base_url() + + +def origin_of(url: str) -> str: + """地址里的「协议://主机[:端口]」:来源核对按它比。""" + parts = urlsplit(url) + return f"{parts.scheme}://{parts.netloc}" diff --git a/backend/app/main.py b/backend/app/main.py index b2647a1b..045cb390 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,7 @@ from app.core.config import settings from app.db.base import init_db from app.engine.runner import run_manager from app.seed import seed_defaults +from app.web_security import SecurityHeaders logging.basicConfig( level=logging.INFO, @@ -145,16 +146,20 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def _platform_startup() -> None: - """应用平台的启动整理(BE-1):登录设置读进缓存;自定义工具请求头、MCP env 和请求头里的存量明文加密一遍(幂等); - 存量的内网 MCP 服务改为绑定登记端点(只在升级后第一次启动时做);能力登记同步一次。后三样出错只记日志:最坏是登记 - 暂时不全,不该让服务起不来。""" + """应用平台的启动整理(BE-1):登录设置、对外访问地址读进缓存;自定义工具请求头、MCP env 和请求头里的存量明文加密 + 一遍(幂等);存量的内网 MCP 服务改为绑定登记端点(只在升级后第一次启动时做);能力登记同步一次。后三样出错只记 + 日志:最坏是登记暂时不全,不该让服务起不来。""" from app.auth import current_mode, load_settings from app.capabilities import registry as capability_registry from app.capabilities.secrets import seal_existing + from app.core import public_url from app.db.base import SessionLocal await load_settings(force=True) logger.info("登录方式:%s", current_mode()) + # 对外访问地址(core/public_url.py)同样读进缓存:中间件核对来源时不查库 + if address := await public_url.refresh(): + logger.info("对外访问地址:%s(%s)", address, "环境变量" if public_url.env_locked() else "设置") try: if sealed := await seal_existing(): logger.info("已加密 %d 处存量的明文密钥(自定义工具请求头、MCP 环境变量和请求头)", sealed) @@ -192,6 +197,8 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +# 安全响应头(app/web_security.py):最后加、在最外层,CORS 预检和登录检查自己回的 401 / 403 也带上 +app.add_middleware(SecurityHeaders) # 路由级权限(§3.12)。关登录时这些依赖全部放行;开了登录,中间件先挡掉没登录的,这里再按角色和运行挡 _BUILDER = [require_role("builder")] diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index 44d1555c..b2b1f218 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -7,7 +7,7 @@ 同一个渠道只推一次。待办改派、升级、催办都是「有一条待办要你处理」,订阅了 task.opened 的渠道一并收到。 消息里只放标题、状态、链接(P4,沿用定时任务 Webhook 的规矩):不放报告里的数字,待办的标题用节点在图上的名字 -(搭图时起的),不用按运行数据渲染出来的那一句。链接要设了对外访问地址(设置里的 notify)才有。 +(搭图时起的),不用按运行数据渲染出来的那一句。链接要有对外访问地址(环境变量或设置,core/public_url.py)才有。 推送放在后台(spawn):接收端挂住时超时加重试要几十秒,不能拖着开待办、运行收尾。测试用 drain 等它们推完。 @@ -153,9 +153,10 @@ def generic_body(message: dict[str, Any], msg: Message) -> dict[str, Any]: async def _base_url(session: Any) -> str | None: - from app.api.settings import notify_settings + """拼链接用的对外访问地址:全站同一个(环境变量优先,其次设置),手里有会话就按库里的值对齐缓存。""" + from app.core import public_url - return (await notify_settings(session))["public_base_url"] + return await public_url.refresh(session) async def _channels(session: Any, event: str, run: Run | None, users: list[str]) -> list[NotifyChannel]: diff --git a/backend/app/scheduler/webhook.py b/backend/app/scheduler/webhook.py index 2062db3c..23ea36c6 100644 --- a/backend/app/scheduler/webhook.py +++ b/backend/app/scheduler/webhook.py @@ -424,9 +424,10 @@ async def notify_due(now: datetime | None = None) -> int: .order_by(ScheduleFire.created_at) )).all() if rows: - from app.api.settings import notify_settings + # 链接里的地址:全站同一个对外访问地址(环境变量优先,其次设置,core/public_url.py) + from app.core import public_url - base_url = (await notify_settings(session))["public_base_url"] + base_url = await public_url.refresh(session) for fire, run in rows: seen = await _state_of(session, fire, run, now) if seen.state is None or seen.state == fire.notified_status: diff --git a/backend/app/web.py b/backend/app/web.py index d2c36605..30602b95 100644 --- a/backend/app/web.py +++ b/backend/app/web.py @@ -9,7 +9,9 @@ - /api 下不存在的路径照旧是 JSON 404。排除 /api 写在路由的路径规则里,不在处理函数里判断—— 否则 POST /api/不存在 会因为「路径对上了、方法不对」变成 405; - 只注册 GET / HEAD 的 HTTP 路由,WebSocket 不经过这里; -- index.html 不缓存,发版后刷新即生效;assets/ 下的文件名带内容哈希,长缓存。 +- index.html 不缓存,发版后刷新即生效;assets/ 下的文件名带内容哈希,长缓存; +- index.html 另带严格 script-src 的上报策略(Content-Security-Policy-Report-Only):启动时按构建产物里的内联脚本算 + sha256(web_security.script_policy),只上报、不拦。其余安全头由 web_security 的中间件统一补。 """ from __future__ import annotations @@ -20,6 +22,8 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from starlette.convertors import Convertor, register_url_convertor +from app.web_security import REPORT_ONLY_HEADER, script_policy + _NO_CACHE = "no-cache" _IMMUTABLE = "public, max-age=31536000, immutable" @@ -72,7 +76,12 @@ def mount_web(app: FastAPI, dist: Path) -> None: "先构建前端(cd frontend && pnpm build),或者去掉这个设置" ) + # 哈希按启动这一刻的 index.html 算:换了构建产物要重启服务,哈希才跟着换(只上报不拦,对不上也只多几条上报) + page_headers = {"Cache-Control": _NO_CACHE, REPORT_ONLY_HEADER: script_policy(index.read_text(encoding="utf-8"))} + def respond(target: Path, *, immutable: bool = False) -> FileResponse: + if target in (index, index.resolve()): + return FileResponse(target, media_type=_media_type(target), headers=page_headers) return FileResponse( target, media_type=_media_type(target), diff --git a/backend/app/web_security.py b/backend/app/web_security.py new file mode 100644 index 00000000..a7ae8646 --- /dev/null +++ b/backend/app/web_security.py @@ -0,0 +1,90 @@ +"""安全响应头(04 细案 C1):页面不能被别的站点套进 iframe 诱导点击,浏览器不按内容猜类型,链接不把完整地址带给别的站点。 + +以前服务端一个安全头都不发:任何站点都能把 AgentLab 嵌进 iframe。同站的门户页面(SameSite=Lax 的会话 cookie 会随请求 +带上)和关登录的部署,都有被点击劫持的可能;也没有一条「允许谁嵌」的白名单可配。 + +纯 ASGI 中间件(同 auth/middleware.py,main.py 里加在最外层):在 http.response.start 上补头,WebSocket 不经过它。 +所有 HTTP 响应(页面、/api、登录检查自己回的 401 / 403、CORS 预检)都补: +- X-Content-Type-Options: nosniff; +- Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer:分享的 key 在地址的 # 后面,本来不随 + Referer 走,这里再收一道); +- Content-Security-Policy: frame-ancestors。缺省只有 'self';AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户;分享页 + /s 只认 AGENTLAB_EMBED_ORIGINS(分享链接第 3 波才加,路由在那时挂,头的规则先在这里定好); +- 只有 frame-ancestors 是 'self' 时另加 X-Frame-Options: SAMEORIGIN,给不认 CSP 的老浏览器。XFO 写不了白名单,有白名单 + 时只发 CSP:两个都发的话,只认 XFO 的老浏览器会把白名单里的门户也拦掉(认 CSP 的浏览器以 frame-ancestors 为准)。 +接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复。 + +严格的 script-src 先只上报、不拦(Content-Security-Policy-Report-Only):页面里有一段首帧前定主题的内联脚本,强制执行 +的话写错一个哈希页面就会先闪一帧深色,构建会不会产出别的内联内容也没核实。所以 mount_web(app/web.py)启动时给构建产物 +index.html 里的内联脚本算 sha256(script_policy),只在页面这一个响应上发;观察一个版本没有误报,再改成强制执行。没配 +上报地址:违规只在浏览器的控制台里看得到。接口返回的 HTML(导出的报告、可验证报告)文件里自带 meta CSP,不发它,也不动。 + +HSTS 交给终结 https 的反向代理:这里不知道对外是不是 https、要不要连子域名一起管,发错了浏览器会按 max-age 记很久, +撤不回来。 +""" +from __future__ import annotations + +import base64 +import hashlib +import re +from typing import Any + +#: 分享页的路径(/s#<分享 key>):只认 /s 这一段,/settings、/studio 不算 +SHARE_PATH = "/s" +_SELF = "'self'" +_DEFAULT_REFERRER = b"strict-origin-when-cross-origin" +_SHARE_REFERRER = b"no-referrer" +#: 页面里不带 src 的 ", re.IGNORECASE | re.DOTALL) +REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only" + + +def is_share_path(path: str) -> bool: + return path == SHARE_PATH or path.startswith(SHARE_PATH + "/") + + +def frame_policy(origins: list[str]) -> tuple[bytes, bool]: + """(CSP 的值, 要不要另加 X-Frame-Options)。origins 是白名单(config 里校验过、去掉了 'self')。""" + sources = [_SELF, *[o for o in origins if o != _SELF]] + return f"frame-ancestors {' '.join(sources)}".encode(), len(sources) == 1 + + +def script_policy(html: str) -> str: + """页面的严格 script-src:本站的脚本文件('self')加上页面里每段内联脚本的 sha256(按原文的 UTF-8 字节算,同浏览器)。""" + hashes = [base64.b64encode(hashlib.sha256(body.encode()).digest()).decode() + for body in _INLINE_SCRIPT.findall(html)] + return " ".join(["script-src", _SELF, *(f"'sha256-{h}'" for h in dict.fromkeys(hashes))]) + + +class SecurityHeaders: + """frame_ancestors / embed_origins 不传时取 core/config 的 AGENTLAB_FRAME_ANCESTORS、AGENTLAB_EMBED_ORIGINS(测试传别的)。""" + + def __init__(self, app: Any, *, frame_ancestors: list[str] | None = None, + embed_origins: list[str] | None = None) -> None: + from app.core.config import settings + + self.app = app + self.site = frame_policy(settings.frame_ancestors if frame_ancestors is None else frame_ancestors) + self.share = frame_policy(settings.embed_origins if embed_origins is None else embed_origins) + + async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + share = is_share_path(scope.get("path") or "") + frame, xfo = self.share if share else self.site + wanted = [(b"x-content-type-options", b"nosniff"), + (b"referrer-policy", _SHARE_REFERRER if share else _DEFAULT_REFERRER), + (b"content-security-policy", frame)] + if xfo: + wanted.append((b"x-frame-options", b"SAMEORIGIN")) + + async def send_with_headers(message: dict[str, Any]) -> None: + if message["type"] == "http.response.start": + headers = list(message.get("headers") or []) + present = {name.lower() for name, _ in headers} + headers += [(name, value) for name, value in wanted if name not in present] + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_with_headers) diff --git a/backend/tests/test_integration_errors.py b/backend/tests/test_integration_errors.py new file mode 100644 index 00000000..f3e39b6f --- /dev/null +++ b/backend/tests/test_integration_errors.py @@ -0,0 +1,169 @@ +"""集成接口的错误码与 /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 _called(node: ast.AST, name: str) -> bool: + return isinstance(node, ast.Call) and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) == name + + +def test_no_error_without_a_code_left_in_the_integration_modules(): + """这几个模块里不许再直接抛 HTTPException(没有 code),一律 CodedHTTPException;直接回 JSONResponse 的错误(表单 + 没通过校验那几处)内容里也要有 code。""" + 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 _called(node, "HTTPException")] + assert not bare, f"{rel} 第 {bare} 行抛的 HTTPException 没有 code" + for node in ast.walk(tree): + if not _called(node, "JSONResponse"): + continue + content = next((k.value for k in node.keywords if k.arg == "content"), node.args[0] if node.args else None) + keys = {k.value for k in getattr(content, "keys", []) if isinstance(k, ast.Constant)} + assert "code" in keys, f"{rel} 第 {node.lineno} 行回的 JSONResponse 没有 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"] diff --git a/backend/tests/test_public_url.py b/backend/tests/test_public_url.py new file mode 100644 index 00000000..c13667dc --- /dev/null +++ b/backend/tests/test_public_url.py @@ -0,0 +1,169 @@ +"""对外访问地址只有一个说法(04 细案 B4,core/public_url.py)。 + +以前有两个:环境变量 AGENTLAB_PUBLIC_URL 只管单点登录的回调和来源核对,设置里 notify 一组的 public_base_url 只管通知里的 +链接——只配了环境变量的部署,通知里不带链接。现在全站经 public_base_url() 取: + +- 环境变量优先,其次设置,都没有是 None; +- 设置的值缓存在进程里(中间件按请求核对来源,不能每次查库),改设置的接口写完就刷新,不用重启; +- 环境变量设了:设置页显示「由环境变量指定」(GET /api/settings 的 public_url 一组),改成别的值回 409、带 code; +- 通知、定时任务 Webhook 的链接,单点登录的回调地址,非 GET 请求的 Origin 核对都用它。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete + +from app.api.settings import NOTIFY_GROUP +from app.core import public_url +from app.core.config import Settings, settings +from app.db.base import SessionLocal +from app.db.models import Setting +from app.main import app as main_app +from tests.fakes import auth_env + +ENV_URL = "https://agentlab.example.com" +SET_URL = "https://intra.example.net/agentlab" + + +@pytest.fixture(autouse=True) +async def clean(monkeypatch): + monkeypatch.setattr(settings, "public_url", "") + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key == NOTIFY_GROUP)) + await session.commit() + public_url.remember(None) + yield + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key == NOTIFY_GROUP)) + await session.commit() + public_url.remember(None) + + +def client() -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=main_app), base_url="http://test") + + +async def put_address(c: AsyncClient, value: str | None): + return await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": value}}}) + + +# ---------------------------------------------------------------- 取值的先后 + + +def test_env_wins_over_the_setting(monkeypatch): + public_url.remember(SET_URL) + assert public_url.public_base_url() == SET_URL and not public_url.env_locked() + monkeypatch.setattr(settings, "public_url", ENV_URL) + assert public_url.public_base_url() == ENV_URL and public_url.env_locked() + public_url.remember(None) + assert public_url.public_base_url() == ENV_URL, "设置清空了也还是环境变量的" + monkeypatch.setattr(settings, "public_url", "") + assert public_url.public_base_url() is None, "两处都没有:None(通知里不附链接,回调按请求的 Host 拼)" + + +def test_remember_normalizes_and_drops_broken_values(): + public_url.remember(" https://agentlab.example.com/ ") + assert public_url.public_base_url() == "https://agentlab.example.com" + for broken in ("agentlab.example.com", "ftp://files.example.com", "https://a.example.com/?x=1", 8080, ""): + public_url.remember(broken) + assert public_url.public_base_url() is None, broken + + +def test_env_value_is_normalized_and_checked_at_startup(monkeypatch): + monkeypatch.setenv("AGENTLAB_PUBLIC_URL", " https://agentlab.example.com/ ") + assert Settings().public_url == "https://agentlab.example.com" + monkeypatch.setenv("AGENTLAB_PUBLIC_URL", "") + assert Settings().public_url == "" + # 写错了启动时就报出来:以前拼出 agentlab.example.com/api/auth/oidc/callback 这样的回调,到登录时才发现 + for broken in ("agentlab.example.com", "https://agentlab.example.com/?from=env"): + monkeypatch.setenv("AGENTLAB_PUBLIC_URL", broken) + with pytest.raises(ValueError, match="AGENTLAB_PUBLIC_URL"): + Settings() + + +async def test_refresh_reads_the_stored_setting(): + async with SessionLocal() as session: + session.add(Setting(key=NOTIFY_GROUP, value={"public_base_url": SET_URL + "/"})) + await session.commit() + assert public_url.public_base_url() is None, "缓存还没对齐" + assert await public_url.refresh() == SET_URL + assert public_url.public_base_url() == SET_URL + + +# ---------------------------------------------------------------- 设置接口 + + +async def test_saving_the_setting_refreshes_the_cache_without_restart(): + async with client() as c: + assert (await put_address(c, SET_URL + "/")).status_code == 200 + assert public_url.public_base_url() == SET_URL + body = (await c.get("/api/settings")).json() + assert body["public_url"] == {"value": SET_URL, "env_locked": False} + assert (await put_address(c, None)).status_code == 200 + assert public_url.public_base_url() is None + + +async def test_env_locks_the_setting(monkeypatch): + monkeypatch.setattr(settings, "public_url", ENV_URL) + async with client() as c: + body = (await c.get("/api/settings")).json() + assert body["public_url"] == {"value": ENV_URL, "env_locked": True} + assert body[NOTIFY_GROUP] == {"public_base_url": None}, "notify 一组照旧是库里存的那一份" + r = await put_address(c, SET_URL) + assert r.status_code == 409, r.text + assert r.json()["code"] == "public_url_env_locked" and r.json()["field"] == "notify.public_base_url" + assert "AGENTLAB_PUBLIC_URL" in r.json()["detail"] + # 没改这一项(整组原样写回)不算改:不挡同一请求里别的设置 + assert (await put_address(c, None)).status_code == 200 + # 只读的 public_url 一组写不进库 + r = await c.put("/api/settings", json={"values": {"public_url": {"value": SET_URL, "env_locked": False}}}) + assert r.status_code == 200 + assert (await c.get("/api/settings")).json()["public_url"] == {"value": ENV_URL, "env_locked": True} + async with SessionLocal() as session: + assert await session.get(Setting, "public_url") is None + + +# ---------------------------------------------------------------- 用到它的地方 + + +async def test_notifications_link_with_the_env_address_alone(monkeypatch): + """只配了环境变量的部署,通知里也带链接(以前只认设置里的那一项)。""" + from app.notify import service + + monkeypatch.setattr(settings, "public_url", ENV_URL) + async with SessionLocal() as session: + assert await service._base_url(session) == ENV_URL + monkeypatch.setattr(settings, "public_url", "") + async with SessionLocal() as session: + session.add(Setting(key=NOTIFY_GROUP, value={"public_base_url": SET_URL})) + await session.commit() + async with SessionLocal() as session: + assert await service._base_url(session) == SET_URL + + +async def test_oidc_callback_uses_the_one_address(monkeypatch): + from starlette.requests import Request + + from app.api.auth import _redirect_uri + + request = Request({"type": "http", "method": "GET", "path": "/api/auth/oidc/start", "headers": [], + "scheme": "http", "server": ("10.0.0.5", 8000), "query_string": b""}) + assert _redirect_uri(request) == "http://10.0.0.5:8000/api/auth/oidc/callback", "都没有:按请求的地址拼" + public_url.remember(SET_URL) + assert _redirect_uri(request) == f"{SET_URL}/api/auth/oidc/callback", "只在设置里填了的也按它拼" + monkeypatch.setattr(settings, "public_url", ENV_URL) + assert _redirect_uri(request) == f"{ENV_URL}/api/auth/oidc/callback" + + +async def test_origin_check_accepts_the_one_address(): + await auth_env.reset() + try: + c = await auth_env.turn_on_local() + r = await c.post("/api/groups", json={"name": "ops"}, headers={"Origin": "https://intra.example.net"}) + assert r.status_code == 403 and r.json()["code"] == "csrf_origin" + public_url.remember(SET_URL) + r = await c.post("/api/groups", json={"name": "ops"}, headers={"Origin": "https://intra.example.net"}) + assert r.status_code == 201, r.text + finally: + await auth_env.reset() diff --git a/backend/tests/test_web_security.py b/backend/tests/test_web_security.py new file mode 100644 index 00000000..cc74f582 --- /dev/null +++ b/backend/tests/test_web_security.py @@ -0,0 +1,235 @@ +"""安全响应头(04 细案 C1):app/web_security.py 的纯 ASGI 中间件,加上 mount_web 给页面算内联脚本的哈希。 + +要守住的: +- 页面、/api 的响应(连同登录检查自己回的 401)都带 nosniff、Referrer-Policy 和 CSP frame-ancestors; +- frame-ancestors 缺省 'self',只有 'self' 时另加 X-Frame-Options: SAMEORIGIN;配了 AGENTLAB_FRAME_ANCESTORS 白名单时 + 以 CSP 为准、不发 XFO(XFO 写不了白名单,两个都发的话老浏览器按 XFO 把白名单里的门户也拦了); +- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(路由第 3 波才加,头的规则先守住); +- 严格的 script-src 只发 Content-Security-Policy-Report-Only,只在页面(index.html)上发,哈希就是构建产物里内联 + 脚本的 sha256;接口返回的 HTML(导出的报告自带 meta CSP)不带它; +- 接口自己设过的同名头不覆盖、不重复;WebSocket 不经过它; +- 白名单写错了启动时就报错。 +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +import httpx +import pytest +from fastapi import APIRouter, FastAPI, WebSocket +from fastapi.responses import HTMLResponse, Response +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.core.config import BACKEND_DIR, PROJECT_DIR, Settings +from app.main import app as main_app +from app.web_security import SecurityHeaders +from tests.fakes import auth_env + +THEME = "\n try { document.documentElement.setAttribute('data-theme', 'light') } catch (e) {}\n " +INDEX = ("
" + "" + "") +HASH = base64.b64encode(hashlib.sha256(THEME.encode()).digest()).decode() +REPORT = ("" + "") + + +@pytest.fixture +def dist(tmp_path: Path) -> Path: + root = tmp_path / "dist" + (root / "assets").mkdir(parents=True) + (root / "index.html").write_text(INDEX) + (root / "assets" / "index-AbC123.js").write_text("console.log('bundle')") + return root + + +def _app(dist: Path | None = None, **config) -> FastAPI: + app = FastAPI() + api = APIRouter(prefix="/api") + + @api.get("/ping") + async def ping() -> dict[str, str]: + return {"pong": "1"} + + @api.get("/report.html") + async def report() -> HTMLResponse: + return HTMLResponse(REPORT) + + @api.get("/download") + async def download() -> Response: + # 文件下载自己设了 nosniff(api/files.py):不能再加一份 + return Response(b"x", headers={"X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer"}) + + @api.websocket("/echo") + async def echo(ws: WebSocket) -> None: + await ws.accept() + await ws.send_text(await ws.receive_text()) + await ws.close() + + app.include_router(api) + if dist is not None: + from app.web import mount_web + + mount_web(app, dist) + app.add_middleware(SecurityHeaders, **config) + return app + + +async def _get(app: FastAPI, path: str) -> httpx.Response: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client: + return await client.get(path) + + +def _single(r: httpx.Response, name: str) -> str | None: + values = r.headers.get_list(name) + assert len(values) <= 1, f"{name} 发了 {len(values)} 份:{values}" + return values[0] if values else None + + +async def test_api_and_page_carry_the_defaults(dist: Path) -> None: + app = _app(dist, frame_ancestors=[], embed_origins=[]) + for path in ("/api/ping", "/api/nope", "/", "/studio", "/assets/index-AbC123.js"): + r = await _get(app, path) + assert _single(r, "x-content-type-options") == "nosniff", path + assert _single(r, "referrer-policy") == "strict-origin-when-cross-origin", path + assert _single(r, "content-security-policy") == "frame-ancestors 'self'", path + assert _single(r, "x-frame-options") == "SAMEORIGIN", path + + +async def test_report_only_script_policy_is_only_on_the_page(dist: Path) -> None: + app = _app(dist, frame_ancestors=[], embed_origins=[]) + for path in ("/", "/studio", "/runs/abc/evidence"): + r = await _get(app, path) + assert r.text == INDEX + assert _single(r, "content-security-policy-report-only") == f"script-src 'self' 'sha256-{HASH}'", path + # 脚本文件、接口不带;接口返回的 HTML(导出的报告)自带 meta CSP,不加一份对不上哈希的上报策略,正文原样 + for path in ("/assets/index-AbC123.js", "/api/ping", "/api/report.html"): + r = await _get(app, path) + assert "content-security-policy-report-only" not in r.headers, path + r = await _get(app, "/api/report.html") + assert r.text == REPORT + + +async def test_whitelist_wins_and_drops_x_frame_options() -> None: + app = _app(frame_ancestors=["https://portal.example.com", "https://*.corp.example"], embed_origins=[]) + r = await _get(app, "/api/ping") + assert _single(r, "content-security-policy") == \ + "frame-ancestors 'self' https://portal.example.com https://*.corp.example" + assert "x-frame-options" not in r.headers, "有白名单时以 CSP 为准:XFO 写不了白名单" + + +async def test_share_page_uses_the_embed_origins(dist: Path) -> None: + app = _app(dist, frame_ancestors=["https://portal.example.com"], embed_origins=["https://bi.example.org"]) + for path in ("/s", "/s/", "/s/anything"): + r = await _get(app, path) + assert _single(r, "content-security-policy") == "frame-ancestors 'self' https://bi.example.org", path + assert _single(r, "referrer-policy") == "no-referrer", path + assert "x-frame-options" not in r.headers, path + # 只认 /s 这一段:/settings、/studio 照整站的规则 + for path in ("/settings", "/studio", "/share"): + r = await _get(app, path) + assert _single(r, "content-security-policy") == "frame-ancestors 'self' https://portal.example.com", path + assert _single(r, "referrer-policy") == "strict-origin-when-cross-origin", path + # 没配嵌入白名单:分享页只能被本站嵌,同样另加 XFO + r = await _get(_app(dist, frame_ancestors=[], embed_origins=[]), "/s") + assert _single(r, "content-security-policy") == "frame-ancestors 'self'" + assert _single(r, "x-frame-options") == "SAMEORIGIN" + + +async def test_headers_already_set_are_kept() -> None: + r = await _get(_app(frame_ancestors=[], embed_origins=[]), "/api/download") + assert _single(r, "x-content-type-options") == "nosniff" + assert _single(r, "referrer-policy") == "no-referrer" + assert _single(r, "content-security-policy") == "frame-ancestors 'self'" + + +def test_websocket_is_not_touched() -> None: + with TestClient(_app(frame_ancestors=[], embed_origins=[])) as client: + with client.websocket_connect("/api/echo") as ws: + ws.send_text("hi") + assert ws.receive_text() == "hi" + + +async def test_main_app_and_its_own_refusals_carry_the_headers() -> None: + """真实的 app.main:加在最外层,登录检查自己回的 401、CORS 预检也带。""" + app = main_app + r = await _get(app, "/api/health") + assert _single(r, "content-security-policy") == "frame-ancestors 'self'" + assert _single(r, "x-frame-options") == "SAMEORIGIN" and _single(r, "x-content-type-options") == "nosniff" + await auth_env.reset() + try: + await auth_env.turn_on_local() + r = await _get(app, "/api/workflows") + assert r.status_code == 401 and r.json()["code"] == "auth_required" + assert _single(r, "x-content-type-options") == "nosniff" + assert _single(r, "content-security-policy") == "frame-ancestors 'self'" + finally: + await auth_env.reset() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client: + r = await client.options("/api/health", headers={"Origin": "http://localhost:5273", + "Access-Control-Request-Method": "GET"}) + assert r.status_code == 200 and _single(r, "x-content-type-options") == "nosniff" + + +def test_origin_lists_parse_and_bad_entries_fail_at_startup(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AGENTLAB_FRAME_ANCESTORS", raising=False) + monkeypatch.delenv("AGENTLAB_EMBED_ORIGINS", raising=False) + assert Settings().frame_ancestors == [] and Settings().embed_origins == [] + monkeypatch.setenv("AGENTLAB_FRAME_ANCESTORS", " https://portal.example.com/ , https://*.corp.example,'self'") + monkeypatch.setenv("AGENTLAB_EMBED_ORIGINS", '["http://bi.example.org:8080"]') + got = Settings() + assert got.frame_ancestors == ["https://portal.example.com", "https://*.corp.example"], "末尾的 / 去掉,'self' 本来就有" + assert got.embed_origins == ["http://bi.example.org:8080"] + monkeypatch.delenv("AGENTLAB_EMBED_ORIGINS") + # 只收「协议://主机[:端口]」:路径、通配一切、没写协议、'none' 这类写法启动时就报出来,别到嵌入失败才发现 + for bad in ("*", "portal.example.com", "https://portal.example.com/app", "ftp://portal.example.com", "'none'", + "https://", "https://portal.example.com?x=1"): + monkeypatch.setenv("AGENTLAB_FRAME_ANCESTORS", bad) + with pytest.raises(ValidationError, match="AGENTLAB_FRAME_ANCESTORS"): + Settings() + + +def test_deploy_env_documents_both_lists() -> None: + text = (PROJECT_DIR / "deploy" / "agentlab.env").read_text() + assert "# AGENTLAB_FRAME_ANCESTORS=" in text and "# AGENTLAB_EMBED_ORIGINS=" in text + + +_PROBE = r""" +import asyncio, json, httpx +from app.main import app + +async def main(): + t = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=t, base_url="http://t") as c: + out = {} + for p in ("/", "/api/health"): + r = await c.get(p) + out[p] = {k: r.headers.get(k) for k in ("content-security-policy", "x-frame-options", + "content-security-policy-report-only")} + print(json.dumps(out)) + +asyncio.run(main()) +""" + + +def test_main_app_reads_the_whitelist_and_hashes_the_page(dist: Path, tmp_path: Path) -> None: + """真实的 app.main:环境变量里的白名单生效、不发 XFO;托管页面时 index.html 带上报策略。settings、app 都是模块级 + 单例,起一个子进程导入。""" + env = {**os.environ, "AGENTLAB_DATA_DIR": str(tmp_path / "data"), "AGENTLAB_WEB_DIST": str(dist), + "AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", "AGENTLAB_FRAME_ANCESTORS": "https://portal.example.com"} + proc = subprocess.run([sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, capture_output=True, text=True, + timeout=120) + assert proc.returncode == 0, proc.stderr[-2000:] + out = json.loads(proc.stdout.strip().splitlines()[-1]) + for path in ("/", "/api/health"): + assert out[path]["content-security-policy"] == "frame-ancestors 'self' https://portal.example.com", path + assert out[path]["x-frame-options"] is None, path + assert out["/"]["content-security-policy-report-only"] == f"script-src 'self' 'sha256-{HASH}'" + assert out["/api/health"]["content-security-policy-report-only"] is None diff --git a/deploy/agentlab.env b/deploy/agentlab.env index 15b615d8..d121040b 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -23,7 +23,9 @@ BIND_ADDR=0.0.0.0 # 不能再改登录方式。local:第一次打开登录页时建第一个管理员(只在还没有管理员时可用);oidc 的身份提供方地址、 # 客户端、管理员映射在「设置 → 身份」里配。忘了密码:docker exec -it agentlab python -m app.auth reset-admin # AGENTLAB_AUTH_MODE=local -# 对外的地址(在反向代理后面时填):OIDC 的回调地址按它拼,非 GET 请求的 Origin 核对也认它 +# 对外访问地址(在反向代理后面时填):通知和定时任务 Webhook 里的链接、OIDC 的回调地址都按它拼,非 GET 请求的 +# Origin 核对也认它。设了这一项就以它为准,界面上「设置 → 通用」里的「对外访问地址」显示「由环境变量指定」、不能改; +# 不设时用界面上填的那一项。写错了(没有 http:// 或 https://、带 ? 或 #)服务启动时报错 # AGENTLAB_PUBLIC_URL=https://agentlab.example.internal # 会话:闲置多少小时、最长多少天过期 # AGENTLAB_AUTH_SESSION_IDLE_HOURS=12 @@ -31,6 +33,17 @@ BIND_ADDR=0.0.0.0 # 会话 cookie 是否带 Secure:留空按请求是不是 https 决定;反向代理终结了 https 时填 true # AGENTLAB_AUTH_COOKIE_SECURE= +# ---- 嵌入(可选)---- +# 默认只有 AgentLab 自己能把页面放进 iframe(响应头 Content-Security-Policy: frame-ancestors 'self',另加 +# X-Frame-Options: SAMEORIGIN),别的站点嵌进去会被浏览器拦下,防点击劫持。HSTS 不在这里加,交给终结 https 的反向代理。 +# 同站的门户(和 AgentLab 在同一个可注册域名下,比如 portal.example.internal 和 agentlab.example.internal)要嵌整站时, +# 把门户的地址写在下面,逗号分隔,只写 协议://主机[:端口],主机可以写 *.example.internal。填了之后只发 +# frame-ancestors、不再发 X-Frame-Options。登录用的 cookie 只随同站的请求带上,跨站的门户嵌整站登录不了。 +# 写错了(带路径、没写协议、只写 *)服务启动时报错。 +# AGENTLAB_FRAME_ANCESTORS=https://portal.example.internal +# 分享页(/s)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项。分享链接还没有上线,现在填了不起作用 +# AGENTLAB_EMBED_ORIGINS=https://bi.example.internal + # ---- 数据 ---- # 数据目录:数据库、执行断点、上传的文件、沙箱工作区,还有加密密钥 .secret_key 都在这里。 # 要备份的就是这个目录(丢了 .secret_key,存进去的 API Key 就解不开了)。 diff --git a/frontend/src/lib/publicUrl.ts b/frontend/src/lib/publicUrl.ts new file mode 100644 index 00000000..781500a9 --- /dev/null +++ b/frontend/src/lib/publicUrl.ts @@ -0,0 +1,31 @@ +/** + * 对外访问地址:别人打开 AgentLab 用的那个地址。服务端全站只认一个(backend/app/core/public_url.py):环境变量 + * AGENTLAB_PUBLIC_URL 优先,其次设置里 notify 一组的 public_base_url(界面上在「设置 → 通用」改,定时任务的 Webhook 一节 + * 有快捷入口)。通知和 Webhook 里的链接、单点登录的回调、来源核对都按它。 + * + * GET /api/settings 另给只读的 public_url 一组 {value, env_locked}:环境变量指定了的话显示它、写明「由环境变量指定」、 + * 不让改(改了服务端回 409)。老服务端没有这一组时按设置里存的那一项、可以改。 + */ + +export interface PublicAddress { + /** 生效的地址;null 是没设置(通知里不附链接) */ + value: string | null + /** 环境变量指定的:设置里的那一项不生效、不能改 */ + env: boolean +} + +export function publicAddressOf(settings: Record{T.linkBaseEnv}
} {editing && (
+