Files
ThearchyHelios e82d7fa55d 集成接口补齐错误码,开登录时收起 /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,测试失败。
2026-10-16 10:19:50 +08:00

155 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from typing import Any
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
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
_MAX_INLINE = 2 * 1024 * 1024
@router.get("/{artifact_id}")
async def get_artifact(artifact_id: str) -> dict[str, Any]:
"""按内容哈希取回一个工件。取回时会复验哈希——内容不可变是这层的全部承诺。
开了登录:builder 及以上照旧拿原文,只被别人的个人应用刷新、个人蓝图试运行引用的除外(只给拥有者,管理员也一样,
auth/access.artifact_readable)。user 角色只能取它能读的运行引用的工件(can_read_artifact),而且对它来说遮罩
就是边界:只给白名单里的几种,按派生遮罩处理后再给(_masked_for_user),别的种类一律不给。"""
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 CodedHTTPException(403, "没有权限查看这件工件", ARTIFACT_FORBIDDEN)
try:
content = artifact_store.load(artifact_id)
except ValueError as e:
# 哈希对不上:明确报给调用方,这正是工件库存在的意义
raise CodedHTTPException(409, str(e), ARTIFACT_TAMPERED) from e
if content is None:
raise CodedHTTPException(404, "工件不存在,可能已被删除", ARTIFACT_NOT_FOUND)
if restricted:
content = await _masked_for_user(principal, artifact_id, content)
return {"id": artifact_id, "content": content}
#: user 角色能取的工件种类(白名单)。别的种类——节点的整份产出(node_output,含遮罩列的查询行)、口径卡取值的完整原值
#: (metric_input)、工具快照、双盲复算和反方审稿的记录、质询答复、知识库快照——遮罩管不全,一律不给
_REPORT = "report_doc"
_METRIC_SET = "metric_set"
_RECEIPT = "action_receipt"
async def _masked_for_user(principal: Any, artifact_id: str, content: Any) -> Any:
"""user 角色看到的样子,按工件登记的种类分:
- 报告文档:写作时就按遮罩写好的(遮罩列的值在正文、目录里本来就是「已遮罩」),照给;
- 查询快照:照核查 agent 的工具边界那一套遮(engine/masking.py 的 mask_query_result:遮罩列、按 SQL 认出取自遮罩列的
列、按值认的遮罩值);端点快照遮登记的遮罩列,响应原文去掉;
- 指标集:和证据面板同一个判断(api/evidence.py 的 _metric_masks),取自遮罩列的指标的值、输入、代入式写「已遮罩」;
- 动作回执:响应体按能力登记的遮罩字段遮(同确认卡上的预览);
- 别的种类:不给。"""
from app.capabilities.endpoints import SNAPSHOT_KIND
from app.data import catalog as data_catalog
from app.db.base import SessionLocal
from app.db.models import Artifact
async with SessionLocal() as session:
rows = list((await session.execute(
select(Artifact.kind, Artifact.run_id, Artifact.node_id).where(Artifact.id == artifact_id))).all())
kinds = {kind for kind, _, _ in rows}
if kinds & {data_catalog.QUERY_SNAPSHOT_KIND, SNAPSHOT_KIND} and isinstance(content, dict):
return await _masked_snapshot(content, endpoint=SNAPSHOT_KIND in kinds)
if _REPORT in kinds:
return content
if _METRIC_SET in kinds and isinstance(content, dict):
return await _masked_metrics(principal, artifact_id, content, rows)
if _RECEIPT in kinds and isinstance(content, dict):
return await _masked_receipt(content, rows)
raise CodedHTTPException(403, "这类工件的原文只对搭建者及以上的角色开放", ARTIFACT_KIND_FORBIDDEN)
async def _masked_snapshot(content: dict[str, Any], *, endpoint: bool) -> dict[str, Any]:
from app.data.engine import masked_columns
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.engine.masking import mask_query_result
source = None
if content.get("source") and not endpoint:
async with SessionLocal() as session:
source = (await session.execute(select(DataSource).where(DataSource.name == str(content["source"])))
).scalar_one_or_none()
hidden = list(dict.fromkeys([*(str(c) for c in content.get("mask_columns") or []),
*(masked_columns(source.options) if source is not None else [])]))
done = mask_query_result(content, hidden, source.kind if source is not None else None,
schema=source.schema_cache if source is not None else None)
view = dict(done.payload)
if endpoint:
view.pop("response", None)
return view
async def _masked_metrics(principal: Any, artifact_id: str, card: dict[str, Any],
rows: list[Any]) -> dict[str, Any]:
"""指标集按它所在的那次运行认遮罩(证据面板的指标步骤同一套):取自遮罩列的输入写「已遮罩」、代入式里那几处同样换掉,
指标本身不给值的值和渲染出来的字一并换掉。工件按内容寻址、可能被好几次运行引用,按这个人能读的那一次认。"""
from app.api.deps import can_read_run
from app.api.evidence import MASKED, _held_word, _Masks, _metric_masks, _sealed, _withheld
from app.engine import recompute
owner = None
for kind, run_id, node_id in rows:
if kind == _METRIC_SET and run_id and await can_read_run(principal, run_id):
owner = (run_id, node_id or "")
break
if owner is None:
raise CodedHTTPException(403, "没有权限查看这件工件", ARTIFACT_FORBIDDEN)
sealed = await _sealed(owner[0])
masks = _Masks(sealed)
metrics: list[Any] = []
for metric in card.get("metrics") or []:
if not isinstance(metric, dict):
metrics.append(metric)
continue
held, word = await _metric_masks(metric, owner[1], artifact_id, sealed, masks, card=card)
shown = dict(metric)
shown["inputs"] = [_withheld(item, held[str(item.get("path"))])
if isinstance(item, dict) and str(item.get("path")) in held else item
for item in metric.get("inputs") or []]
if withheld := {path: _held_word(found) for path, found in held.items() if found.kind != "table"}:
shown["substituted"] = recompute.substituted_without(metric, withheld)
if metric.get("value") is not None and (word is not None or metric.get("masked")):
shown["value"] = shown["rendered"] = word or MASKED
metrics.append(shown)
return {**card, "metrics": metrics}
async def _masked_receipt(content: dict[str, Any], rows: list[Any]) -> dict[str, Any]:
"""动作回执:响应体按写入能力登记的遮罩字段遮。遮罩字段取现在的登记和发起时的治理快照两份的并集:发起之后管理员才
加上的遮罩,原文同样不给。"""
from app.capabilities.registry import masked_paths
from app.db.base import SessionLocal
from app.db.models import ActionRecord, Capability, Run
from app.process.actions import masked
pointers: list[str] = []
async with SessionLocal() as session:
for kind, run_id, node_id in rows:
if kind != _RECEIPT or not run_id:
continue
names = set((await session.execute(select(ActionRecord.capability).where(
ActionRecord.run_id == run_id, ActionRecord.node_id == node_id))).scalars())
run = await session.get(Run, run_id)
snapshot = ((run.options or {}).get("capabilities") or {}) if run is not None else {}
for name in names:
cap = await session.get(Capability, name)
pointers += masked_paths(cap) if cap is not None else []
pointers += [str(p) for p in (snapshot.get(name) or {}).get("masked") or []]
return {**content, "body": masked(content.get("body"), list(dict.fromkeys(pointers)))}