From 38d908299473bc72ffb679e79a52d3e2138e3d4a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 18:25:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=80=85=E7=9A=84=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E8=BF=90=E8=A1=8C=E8=AE=B0=E5=BD=95=E5=8F=AA=E7=BB=99?= =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E7=9A=84=E8=BF=90=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E8=BF=90=E8=A1=8C=E7=BB=8F=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=AF=BB=EF=BC=88=E5=A4=8D=E6=A0=B8=20M3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核发现:使用者(开登录后的 user 角色)发起的口径对账运行带 MASK_RESULTS、记在本人名下,run_events_readable 只看这两条, 于是他能经 /api/runs/{id}、/events、/graph 读到对账运行的原始图和事件,里面原样是口径取数节点的 SQL 模板。这一波正是以 「口径的 SQL 可能写着遮罩值」为由不让使用者在问数据里看口径、use_caliber,这条读法绕开了证据面板按遮罩换 SQL 的那一层。 做法:api/deps.run_events_readable 给 user 角色再加一条 origin_kind == "chat"——原始记录、事件、运行时的图、实时流只给他 自己的、查询结果在工具边界遮过的问数据运行(原来的说明里就写着「实际上满足的就是它自己的问数据运行」,这次照实收紧)。 对账运行他仍然能经账本和证据接口读(can_read_run 不变,那里展示的 SQL 按遮罩换过),对账的发起也不变。改了 api/deps.py 的这一个函数(没分给哪个包)和 ledger/reconcile 的模块说明。 测试:开账号密码登录,使用者对自己的问数据答案经接口发起对账,对账运行的 /api/runs/{id}、/events、/graph 都回 403,证据 接口 200;他自己的问数据运行这三样照旧 200。去掉这一条回跑,新用例失败;test_auth_access、test_personal_apps 照旧过。 --- backend/app/api/deps.py | 15 +++++++----- backend/app/ledger/reconcile.py | 4 ++- backend/tests/test_ledger_reconcile.py | 34 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 89023f31..21b4fbda 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -9,7 +9,7 @@ - can_read_run / can_read_artifact:运行、工件的读权限(user 角色只能读有授权的应用视图运行、自己参与的实例); - ensure_role / ensure_can_publish:接口内部按请求内容判定的权限(MCP 的 stdio、受管级发布、版本退役,Q8); - restricted():这次请求是不是开登录后的 user 角色(按请求内容收窄的接口用:问数据只能发起探索运行、工件按白名单给); -- run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的); +- run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的问数据运行); - ensure_conversation_access(session, conversation_id):接受 conversation_id 的接口核对对话归属(对话按人隔离); - run_scope / evidence_scope / chat_scope:main.py 挂在运行、证据、问数据几组路由上的依赖,按 run_id 判断能不能读。 run_scope 同样管运行的 WebSocket,握手时按运行判; @@ -213,15 +213,18 @@ async def run_events_readable(principal: Principal, run: Any) -> bool: """能不能读这次运行的原始记录、事件和实时流(GET /api/runs/{id}、/events、/graph,WebSocket /stream)。 关登录:能。builder 及以上:这次运行它能读就能(can_read_run:别人的个人应用刷新、个人蓝图试运行读不了)。 - user:这次运行它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。两个条件缺一不可: - 没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的应用实例、 - 有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。实际上满足两条的就是它自己开登录后发起的 - 问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。""" + user:问数据的运行(origin_kind=chat),这次它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。 + 三个条件缺一不可:没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的 + 应用实例、有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。服务端替他派生的运行也带着边界遮罩,但图 + 不是他的助手搭的:口径对账(ledger/reconcile)的图里原样是口径取数节点的 SQL 模板(口径的 SQL 可能写着遮罩值,所以 + 问数据里使用者不看口径),同样经证据接口读(展示的 SQL 里写着的遮罩值换成「已遮罩」,同应用视图运行)。实际上满足 + 三条的就是它自己开登录后发起的问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。""" if principal.mode == "off": return True if principal.at_least("builder"): return await can_read_run(principal, run) - if run is None or not principal.username or not masked_at_boundary(run): + if run is None or not principal.username or getattr(run, "origin_kind", None) != "chat" \ + or not masked_at_boundary(run): return False return await can_read_run(principal, run) diff --git a/backend/app/ledger/reconcile.py b/backend/app/ledger/reconcile.py index 722573bb..89cf56de 100644 --- a/backend/app/ledger/reconcile.py +++ b/backend/app/ledger/reconcile.py @@ -25,7 +25,9 @@ 不同时不猜,照实拒绝)。维度列是遮罩列、或者这次的结果里合并键不唯一时照实拒绝:前者对齐要用到遮住的值,后者对不准 是哪一行。被引用的列是遮罩列的同样不对账。 -只读:派生图只查库、不写;使用者发起时带上 MASK_RESULTS / READ_ONLY(同问数据),搭建者也带 READ_ONLY。 +只读:派生图只查库、不写;使用者发起时带上 MASK_RESULTS / READ_ONLY(同问数据),搭建者也带 READ_ONLY。对账运行记在 +使用者名下,但它的原始记录、事件、运行时的图不给使用者(api/deps.run_events_readable 只给问数据的运行):图里原样是口径 +取数节点的 SQL 模板,他经账本和证据接口看结果(展示的 SQL 按遮罩换过)。 """ from __future__ import annotations diff --git a/backend/tests/test_ledger_reconcile.py b/backend/tests/test_ledger_reconcile.py index bf2baf06..a14b5175 100644 --- a/backend/tests/test_ledger_reconcile.py +++ b/backend/tests/test_ledger_reconcile.py @@ -214,3 +214,37 @@ async def test_users_reconcile_their_own_answers_read_only_and_masked(client, mo assert run.options[MASK_RESULTS_OPTION] is True and run.options[READ_ONLY_OPTION] is True done = await S.wait(run.id) assert done.status == "succeeded", done.error + + +async def test_users_read_their_reconcile_runs_only_through_the_evidence(client, monkeypatch): + """使用者发起的对账运行带 MASK_RESULTS、记在他名下,但派生图里有口径取数节点的 SQL 模板(口径的 SQL 可能写着遮罩值, + 所以问数据里使用者不看口径、不能 use_caliber):运行的原始记录、事件、运行时的图(/api/runs/{id}、/events、/graph)只给 + 他自己的问数据运行,对账运行经证据接口读(那里按遮罩渲染)。""" + from app.tools.datasource import MASK_RESULTS_OPTION + from tests.fakes import auth_env + + wf = await S.publish() + row = await S.explore(monkeypatch, S.WITH_REFUNDS, origin="chat") + # 当作开登录后使用者 carol 问的:问数据运行在工具边界遮 + async with SessionLocal() as session: + source = await session.get(Run, row.id) + source.started_by, source.options = "carol", {MASK_RESULTS_OPTION: True} + await session.commit() + [action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"] + await auth_env.reset() + try: + admin = await auth_env.turn_on_local() + await auth_env.make_user("carol", "user") + assert (await admin.put("/api/auth/settings", json={"user_can_chat": True})).status_code == 200 + carol = await auth_env.login("carol") + res = await carol.post(f"/api/ledger/answers/{row.id}/reconcile", json={ + "query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True}) + assert res.status_code == 201, res.text + done = await S.wait(res.json()["run_id"]) + assert done.status == "succeeded" and done.started_by == "carol" and done.options[MASK_RESULTS_OPTION] is True + for rest in ("", "/events", "/graph"): + assert (await carol.get(f"/api/runs/{done.id}{rest}")).status_code == 403, rest + assert (await carol.get(f"/api/runs/{row.id}{rest}")).status_code == 200, f"自己的问数据运行照旧:{rest}" + assert (await carol.get(f"/api/runs/{done.id}/evidence")).status_code == 200 + finally: + await auth_env.reset()