Files
AgentLab/backend/tests/test_querycore_user_access.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

1126 lines
62 KiB
Python
Raw 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.
"""K14:普通使用者按口径取数的统一授权、服务端补上的工具绑定、只列有授权的口径清单、服务端准备的那条执行路。
这份文件盯住这几条:
1. **授权在 QueryCore 一处兜底。** compose 这一档(问数、固定请求路由、自动路由、父 Run 的工具调用)在**固定 workflow +
version 之后、起 Run 之前**核资产授权(apps/access.caliber_role:同一张 AppGrant + 应用当前发布版精确固定着这一版):
use 及以上才放行,没有授权当场 403 `querycore_not_authorized`,**一条运行都不起**;builder / admin / 关登录由调用方
显式放行;actor 缺失(认不出是谁)按普通使用者算,照拒。
2. **行级范围照授权的那一档。** use 只看自己提交的记录,observe 看全部(records/access.scope_of_run:口径里的记录表数据源)。
3. **工具是服务端绑上的、模型看得见。** 有授权口径可用时 chat/launch 把 `caliber_query` 补进 Agent 的 tools(用户不用在
画布上画);auth/chat_policy 的白名单允许这个只读工具(模型清单里列得出来),能不能执行仍由 QueryCore 按授权判。
4. **口径清单只列有授权的版本。** look/calibers 对普通使用者只列 `usable_calibers` 里那些版本(builder 不变)。
5. **服务端准备的执行有显式绑定、白名单、遮罩、只读与父 Run 证据。** `execute_prepared` 搭最小的父图、经 `user_chat_run`
起一次探索父 Run,实际从 `caliber_query` 工具走(派生的子运行就是那次固定执行),交回与 `execute` 同形的 Outcome。
数据全是合成的(临时 SQLite 库、合成标量指标),不连真实业务库;这些指标不是任何真实业务口径。
"""
from __future__ import annotations
import asyncio
import json
import uuid
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.api import deps
from app.api.deps import Principal
from app.apps.access import usable_calibers
from app.auth import chat_policy
from app.chat.launch import bind_caliber_tool, user_chat_run
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.runner import run_manager
from app.ledger import nlchange, querycore
from app.look.calibers import CaliberKind, permitted_versions
from app.main import app
from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION
from tests import ledger_samples as S
from tests.fakes import auth_env
from tests.publication_samples import add_record, add_version, confirm, make_workflow, publish
from tests.test_caliber_query_tool import grant_caliber, records_caliber_graph, records_source
#: 口径卡的节点 id(源头口径卡)
CARD = "card"
#: 取数窗口:start(含)/ end(不含)
WINDOW = "AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'"
#: 这次入口取值:2026-09-01 ≤ paid_at < 2026-09-08,只算 paid:100 + 50.5 + 20
INPUTS = {"start": "2026-09-01", "end": "2026-09-08"}
WINDOW_SUM = 170.5
def upload_graph(*, include_open: bool = False) -> dict[str, Any]:
"""入口 → 一条取数(上传表格)→ 口径卡 → 报告 → 出口。这张表是 ledger_samples 的虚构店铺库。
include_open 再加一档状态(v2 的口径):换个算法就换了取数(等价于「图上改了东西」),让两版各有自己的清单与结果。"""
status = "status IN ('paid', 'open')" if include_open else "status = 'paid'"
return S.chain(
S.node("start", "input", fields=[{"name": "start", "type": "string", "required": True},
{"name": "end", "type": "string", "required": True}]),
S.node("sales", "tool", tool=S.tool(),
args={"sql": f"SELECT SUM(amount) AS gmv, count(*) AS orders FROM orders "
f"WHERE {status} {WINDOW}"}),
S.node(CARD, "metrics", caliber="合成周销售", caliber_version="v1", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "expression": "cell(nodes.sales, 0, 'gmv')"},
{"id": "orders", "name": "单数", "unit": "单", "expression": "cell(nodes.sales, 0, 'orders')"},
]),
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
def agent_graph(tools: tuple[str, ...] = ()) -> dict[str, Any]:
"""一张普通使用者搭得出来的问数图:入口 → Agent(绑数据源查询)→ 出口。"""
return S.chain(
S.node("start", "input", fields=[{"name": "question"}]),
S.node("ask", "agent", prompt="{{ input.question }}", tools=list(tools) or [S.tool()], max_steps=4),
S.node("out", "output", fields=[{"name": "回答", "value": "{{ nodes.ask.text }}"}]),
)
def values_of(outcome: querycore.Outcome) -> dict[str, Any]:
return {m["id"]: m["value"] for m in outcome.as_dict()["metrics"]}
async def execute(asset: dict, *, metric_ids: list[str] | None = None, inputs: dict[str, Any] | None = None,
**kw: Any) -> querycore.Outcome:
"""走服务入口跑一次(agent 之外的那条路):不给 actor 时按当前请求的身份算(HTTP 入口就是这样)。"""
async with SessionLocal() as session:
return await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=metric_ids or ["gmv"],
inputs=INPUTS if inputs is None else inputs, **kw)
def as_user(name: str = "uma", role: str = "user") -> Any:
"""这次请求的人是这个人(HTTP 中间件写的就是它);返回 token,用完 reset。"""
return deps.set_principal(Principal(username=name, role=role, verified=True, mode="local"))
async def runs_matching(*, workflow_id: str | None = None, started_by: str | None = None) -> int:
"""库里符合这几个条件的运行条数:用例按**自己**的口径或身份数,别的用例、别的 worker 留下的行串不进来。"""
stmt = select(Run.id)
if workflow_id is not None:
stmt = stmt.where(Run.workflow_id == workflow_id)
if started_by is not None:
stmt = stmt.where(Run.started_by == started_by)
async with SessionLocal() as session:
return len(list((await session.execute(stmt)).scalars()))
async def add_record_row(source_id: str, actor: str) -> None:
"""往这张记录表里追加一行(谁提交的):行级范围是 mine 时别人看不见它。"""
from app.records import store
from app.records.tables import columns_of, path_of, table_of_source
async with SessionLocal() as session:
table = await table_of_source(session, source_id)
store.append(path_of(table), columns_of(table), {"门店": "二店", "数量": 2}, version=table.version,
run_id=None, actor=actor, action_key=uuid.uuid4().hex)
async def app_of_source(source_id: str) -> str:
from app.records.tables import table_of_source
async with SessionLocal() as session:
table = await table_of_source(session, source_id)
return str(table.app_id)
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "user_access_orders.db")
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
# ==========================================================================
# 1、授权:use 能用、observe 看全部、没有授权当场拒
# ==========================================================================
async def test_use_sees_only_their_own_rows_and_observe_sees_all(client):
"""有授权(use)才跑得成,且只看得到自己提交的记录;换成 observe 就看全部——两处都是同一份授权判出来的。"""
from app.db.models_apps import AppGrant
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
source_id, name = await records_source(grants={"uma": "use"})
await add_record_row(source_id, "别人") # 别人的一行:use 看不见,observe 看得见
asset = await make_workflow(graph=records_caliber_graph(name))
await publish(admin, asset)
await grant_caliber(asset["workflow_id"], 1, "uma", "use")
token = as_user("uma")
try:
use = await execute(asset, metric_ids=["n"], inputs={}, actor="uma")
finally:
deps.reset_principal(token)
assert use.ok, use.problems
assert values_of(use) == {"n": 1}, "use 档只看自己提交的那一行"
async with SessionLocal() as session:
session.add(AppGrant(app_id=await app_of_source(source_id), principal_kind="user", principal="uma",
role="observe"))
await session.commit()
token = as_user("uma")
try:
observe = await execute(asset, metric_ids=["n"], inputs={}, actor="uma")
finally:
deps.reset_principal(token)
assert observe.ok, observe.problems
assert values_of(observe) == {"n": 2}, "observe 档看全部"
finally:
await admin.aclose()
await auth_env.reset()
async def test_an_unauthorized_caliber_is_refused_before_any_run(client):
"""没有授权:上传表格那一档口径也当场拒(403 querycore_not_authorized),**一条运行都不起**。"""
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
before = await runs_matching(workflow_id=asset["workflow_id"])
token = as_user("uma")
try:
with pytest.raises(querycore.QueryCoreRefused) as caught:
await execute(asset, actor=None)
finally:
deps.reset_principal(token)
assert caught.value.code == querycore.NOT_AUTHORIZED and caught.value.status_code == 403
assert caught.value.problems[0]["version"] == 1 and caught.value.problems[0]["workflow_id"] == asset["workflow_id"]
assert await runs_matching(workflow_id=asset["workflow_id"]) == before, "起 Run 之前就拒:这次没留下运行"
# 给它一份授权(应用当前发布版固定着这一版 + 至少 use)之后同样这条路就通了
await grant_caliber(asset["workflow_id"], 1, "uma", "use")
token = as_user("uma")
try:
allowed = await execute(asset)
finally:
deps.reset_principal(token)
assert allowed.ok and values_of(allowed) == {"gmv": WINDOW_SUM}
finally:
await admin.aclose()
await auth_env.reset()
async def test_a_fixed_request_cannot_bypass_the_asset_grant(client):
"""固定请求路由(K12)交回的那条固定请求照样过同一道授权:换一个入口绕不过去,也不回退旧 Agent 路径。"""
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
request = {"workflow_id": asset["workflow_id"], "card_id": CARD, "metric_ids": ["gmv"], "inputs": INPUTS}
async with SessionLocal() as session:
routed = await nlchange.route(session, request=request)
assert routed.mode == nlchange.ROUTE_EXECUTE and routed.request["version"] == 1
call = {key: value for key, value in routed.request.items() if key != "purpose"}
before = await runs_matching(workflow_id=asset["workflow_id"])
token = as_user("uma")
try:
async with SessionLocal() as session:
with pytest.raises(querycore.QueryCoreRefused) as caught:
await querycore.execute(session, purpose=str(routed.request["purpose"]), **call)
assert caught.value.code == querycore.NOT_AUTHORIZED
assert await runs_matching(workflow_id=asset["workflow_id"]) == before, "这条路上也没有起任何运行"
await grant_caliber(asset["workflow_id"], 1, "uma", "use")
outcome = await querycore.execute(session, purpose=str(routed.request["purpose"]), **call)
finally:
deps.reset_principal(token)
assert outcome.ok and values_of(outcome) == {"gmv": WINDOW_SUM}
finally:
await admin.aclose()
await auth_env.reset()
async def test_a_missing_actor_is_refused_when_login_is_on(client):
"""开了登录却认不出这次是谁:按普通使用者算——资产授权查不到主体就是没有授权,照拒(不拿「认不出」当放行)。"""
admin = await auth_env.turn_on_local()
try:
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
token = deps.set_principal(None)
try:
with pytest.raises(querycore.QueryCoreRefused) as caught:
await execute(asset)
finally:
deps.reset_principal(token)
assert caught.value.code == querycore.NOT_AUTHORIZED
finally:
await admin.aclose()
await auth_env.reset()
async def test_builder_and_admin_are_explicitly_allowed(client):
"""builder / admin 由调用方显式放行:它们不按资产授权收窄(那几档不写进资产授权函数),没有授权也跑得成。"""
admin = await auth_env.turn_on_local()
try:
builder = f"b{uuid.uuid4().hex[:8]}" # 这两个身份自己的一份:别的用例留下的授权串不进来
await auth_env.make_user(builder, "builder")
top_name = f"a{uuid.uuid4().hex[:8]}"
await auth_env.make_user(top_name, "admin")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
async with SessionLocal() as session:
# 按**这一版**看:别的工作流上「给所有人」的授权留在库里(测试会话共用一个库),不影响这一版没有授权
assert (asset["workflow_id"], 1) not in await usable_calibers(session, builder)
built = await execute(asset, actor=builder)
assert built.ok and values_of(built) == {"gmv": WINDOW_SUM}
token = as_user(top_name, role="admin")
try:
top = await execute(asset)
finally:
deps.reset_principal(token)
assert top.ok and values_of(top) == {"gmv": WINDOW_SUM}
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 2、工具是服务端绑的、白名单允许它
# ==========================================================================
async def test_the_agent_gets_the_caliber_tool_only_when_there_is_one_to_use(client):
"""有授权口径可用时把 caliber_query 补进 Agent 的 tools(改的是副本,用户的图不动);一份也没有、或者不是普通使用者就不补。
「一份也没有」用**停用**的身份来立:测试会话共用一个库,别的用例给「所有人」的授权(AppGrant.principal_kind=everyone)
会留在库里、命中任何名字——停用的身份在资产授权那一层就是没有主体(apps/access._subject),不受那些授权影响。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}" # 这个身份自己的一份
await auth_env.make_user(who, "user")
gone = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(gone, "user", disabled=True)
await auth_env.make_user("bob", "builder")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
graph = agent_graph()
async with SessionLocal() as session:
# 一份也没有(停用的人):原样交回(不给模型一个一调就被拒的工具)
assert await bind_caliber_tool(session, graph, gone) == graph
# builder:它本来就能自己在画布上绑,不替它补
assert await bind_caliber_tool(session, graph, "bob") == graph
await grant_caliber(asset["workflow_id"], 1, who, "use")
bound = await bind_caliber_tool(session, graph, who)
tools = bound["nodes"][1]["data"]["config"]["tools"]
assert tools == [S.tool(), querycore.CALIBER_QUERY], tools
assert graph["nodes"][1]["data"]["config"]["tools"] == [S.tool()], "补的是副本,调用方的图一个字不动"
assert chat_policy.chat_graph_problem(bound) is None, "白名单允许这个只读工具(模型清单里列得出来)"
assert querycore.CALIBER_QUERY in chat_policy.CHAT_BUILTIN_TOOLS
from app.api.copilot import _tool_catalog
listing = _tool_catalog([], builtins=chat_policy.CHAT_BUILTIN_TOOLS)
assert querycore.CALIBER_QUERY in listing, "使用者的提示词里列得出口径查询:模型看得到它"
async with SessionLocal() as session:
memory_scope, options, running = await user_chat_run(session, graph, who)
assert memory_scope == f"chat:{who}" and options == {MASK_RESULTS_OPTION: True, READ_ONLY_OPTION: True}
assert querycore.CALIBER_QUERY in running["nodes"][1]["data"]["config"]["tools"]
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 3、口径清单只列有授权的版本
# ==========================================================================
async def test_look_lists_only_authorized_versions_for_a_plain_user(client):
"""look/calibers:普通使用者只列有授权的那一版(别的连解析都不做);builder 照旧列全部。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}" # 这个身份自己的一份:别的工作留下的授权串不进来
await auth_env.make_user(who, "user")
mine = await make_workflow(graph=upload_graph())
await publish(admin, mine)
other = await make_workflow(graph=upload_graph())
await publish(admin, other)
await grant_caliber(mine["workflow_id"], 1, who, "use")
person = Principal(username=who, role="user", verified=True, mode="local")
async with SessionLocal() as session:
permitted = await permitted_versions(session, person)
assert (mine["workflow_id"], 1) in permitted, "有授权的那一版在清单里"
seen = await CaliberKind.load(session, actor=person)
assert seen.items, "有授权的那一版要列出来"
assert mine["workflow_id"] in {item.workflow_id for item in seen.items}
assert all((item.workflow_id, item.version) in permitted for item in seen.items)
assert other["workflow_id"] not in {item.workflow_id for item in seen.items}
# 搭建者不受这一层收窄(行为不变)
builder = Principal(username="bob", role="builder", mode="local")
assert await permitted_versions(session, builder) is None
everything = await CaliberKind.load(session, actor=builder)
assert {mine["workflow_id"], other["workflow_id"]} <= {item.workflow_id for item in everything.items}
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 4、服务端准备的那条路:显式绑定、白名单、遮罩/只读、父 Run 证据
# ==========================================================================
async def test_the_prepared_path_runs_through_the_tool_and_keeps_the_parent_evidence(client):
"""execute_prepared:最小父图(入口 → caliber_query → 出口)、经 user_chat_run 起探索父 Run、真的从工具走一次。
交回的 Outcome 与 execute 同形:run_id 是父 Run(显式绑定、遮罩/只读、每条 query artifact 的证据都在它上面),
execution_run_id 是它派生出来真正跑那几条 SQL 的子运行。"""
from app.engine.evidence_reader import read_sealed
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
await grant_caliber(asset["workflow_id"], 1, "uma", "use")
async with SessionLocal() as session:
outcome = await querycore.execute_prepared(
session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["gmv", "orders"],
inputs=INPUTS, actor="uma", origin={"conversation_id": "c1", "turn_id": "t1"})
assert outcome.ok, outcome.problems
assert values_of(outcome) == {"gmv": WINDOW_SUM, "orders": 3}
assert outcome.version == 1 and outcome.purpose == querycore.DEFAULT_PURPOSE
assert outcome.execution_run_id and outcome.execution_run_id != outcome.run_id
async with SessionLocal() as session:
parent = await session.get(Run, outcome.run_id)
child = await session.get(Run, outcome.execution_run_id)
assert parent.run_class == "exploratory" and parent.origin_kind == "chat"
assert parent.started_by == "uma" and parent.origin["conversation_id"] == "c1"
# 遮罩 + 只读:和普通问数一样的两条边界;预算也记在父 Run 上(子运行照它走)
assert parent.options[MASK_RESULTS_OPTION] is True and parent.options[READ_ONLY_OPTION] is True
assert parent.options[querycore.BUDGET_OPTION]["max_queries"]
assert parent.memory_scope == "chat:uma"
# 显式绑定:父图上那个节点就是口径查询,参数写死在图上(不是模型挑的)
[bound] = [n for n in parent.graph["nodes"] if n["data"]["config"].get("tool") == querycore.CALIBER_QUERY]
assert bound["data"]["config"]["args"] == {"workflow": asset["workflow_id"], "card": CARD,
"metric_ids": ["gmv", "orders"], "inputs": INPUTS}
# 派生的子运行:明确的父子关系,跑的是那一次固定执行
assert child is not None and child.origin["parent_run_id"] == parent.id
assert child.origin["service"] == "querycore" and child.options[READ_ONLY_OPTION] is True
# 证据:父 Run 的台账里逐条记着这次口径查询的查询快照(工件、来源、执行身份)
sealed = await read_sealed(parent.id)
payload = sealed.payload_of([querycore.PREPARED_NODE])
assert payload and payload["kind"] == querycore.CALIBER_QUERY and payload["run_id"] == child.id
assert [q["execution_id"] for q in payload["queries"]] == [q["execution_id"] for q in outcome.queries]
assert {q["artifact"] for q in outcome.queries} <= set(sealed.queries)
# 普通使用者读得到这次运行(它自己发起、origin_kind=chat、在工具边界遮过)
assert await deps.run_events_readable(Principal(username="uma", role="user", verified=True, mode="local"),
parent)
finally:
await admin.aclose()
await auth_env.reset()
async def test_a_moved_recommended_version_is_reported_not_substituted(client):
"""点名的那一版固定并授权过,工具那条路固定的是**推荐版**:推荐版中途换过时照实记下来、不把另一版的数当这一版交出。
(这里让这个人两版都有授权,好让执行真的走到「跑的是另一版」那一步;只有一版授权时另一次执行会先在授权那道门被拒。)"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
first = await make_workflow(graph=upload_graph())
await publish(admin, first) # v1
second = await add_version(first, upload_graph(include_open=True), version=2)
await publish(admin, second, version=2) # v2 成了推荐版
await grant_caliber(first["workflow_id"], 1, who, "use")
await grant_caliber(first["workflow_id"], 2, who, "use")
async with SessionLocal() as session:
outcome = await querycore.execute_prepared(session, workflow_id=first["workflow_id"], card_id=CARD,
metric_ids=["gmv"], inputs=INPUTS, version=1, actor=who)
assert outcome.ok is False and outcome.version == 2
assert querycore.VERSION_MOVED in [p["code"] for p in outcome.problems]
assert values_of(outcome)["gmv"] != WINDOW_SUM, "跑的是另一版:值也不是点名那一版的"
finally:
await admin.aclose()
await auth_env.reset()
async def test_the_prepared_path_refuses_before_launching_and_masks_the_result(client, tmp_path):
"""没有授权:这条路也在起任何运行之前拒;有授权但列被遮罩:照实交回遮罩过的结果(值不交出去)。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}" # 这个身份自己的一份:别的用例留下的运行串不进来
await auth_env.make_user(who, "user")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
before = await runs_matching(started_by=who)
async with SessionLocal() as session:
with pytest.raises(querycore.QueryCoreRefused) as caught:
await querycore.execute_prepared(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=["gmv"], inputs=INPUTS, actor=who)
assert caught.value.code == querycore.NOT_AUTHORIZED and caught.value.status_code == 403
assert await runs_matching(started_by=who) == before, "没授权就连父 Run 都不起"
await grant_caliber(asset["workflow_id"], 1, who, "use")
await S.set_source(tmp_path / "user_access_orders.db", options={"mask_columns": ["gmv"]})
async with SessionLocal() as session:
masked = await querycore.execute_prepared(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=["gmv"], inputs=INPUTS, actor=who)
[metric] = masked.metrics
assert metric.get("masked") is True and metric["value"] is None
assert masked.ok is False and querycore.METRIC_MASKED in [p["code"] for p in masked.problems]
# 父 Run 上留给证据的那一份也只有遮罩后的说法,原值一个字都不在
from app.engine.evidence_reader import read_sealed
payload = (await read_sealed(masked.run_id)).payload_of([querycore.PREPARED_NODE])
assert payload["metrics"][0]["masked"] is True and payload["metrics"][0]["value"] is None
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 5、正式那一档也要授权(安全复核 1)
# ==========================================================================
async def test_the_formal_path_also_needs_the_asset_grant(client):
"""正式运行**也**要这一版口径的资产授权:没有授权当场 403,起了任何运行都算漏;授权之后还要发布依据齐。"""
from app.apps import publications
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset) # 有清单、还没有验证记录与审阅
before = await runs_matching(workflow_id=asset["workflow_id"])
async with SessionLocal() as session:
with pytest.raises(querycore.QueryCoreRefused) as caught:
await querycore.execute_formal(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=["gmv"], inputs=INPUTS, actor=who)
assert caught.value.code == querycore.NOT_AUTHORIZED and caught.value.status_code == 403
assert caught.value.problems[0]["purpose"] == publications.PURPOSE_FORMAL
assert await runs_matching(workflow_id=asset["workflow_id"]) == before, "正式这一档也在起 Run 之前拒"
# 授权之后是**另一道**(发布依据):机器码是 judge 的原码,不是 not_authorized
await grant_caliber(asset["workflow_id"], 1, who, "use")
async with SessionLocal() as session:
with pytest.raises(querycore.QueryCoreRefused) as proof:
await querycore.execute_formal(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=["gmv"], inputs=INPUTS, actor=who)
assert proof.value.code == publications.RECORD_MISSING and proof.value.code != querycore.NOT_AUTHORIZED
assert await runs_matching(workflow_id=asset["workflow_id"]) == before
# 两道都过:发布依据补齐(独立核算 + 人工确认)之后正式跑得成
record = await add_record(asset, metric_ids=("gmv",))
await confirm(asset, record)
await publish(admin, asset)
async with SessionLocal() as session:
outcome = await querycore.execute_formal(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=["gmv"], inputs=INPUTS, actor=who)
assert outcome.ok and values_of(outcome) == {"gmv": WINDOW_SUM}
assert outcome.publication["identity"] == publications.PROVEN
finally:
await admin.aclose()
await auth_env.reset()
async def test_a_formal_parent_run_by_a_plain_user_is_refused_without_a_grant(client):
"""应用图里引用一份没授权给它的口径:正式父 Run 里那一次调用照样被拒(工具那一层也过同一道授权)。"""
from app.apps import publications
from tests.test_caliber_query_tool import gated_parent_graph
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
asset = await make_workflow(graph=upload_graph())
graph = gated_parent_graph(asset["workflow_id"], metric_ids=["gmv"])
run = await run_manager.start(graph=graph, input_payload={}, run_class="formal", started_by=who)
await _wait_for(run.id, ("interrupted",))
await confirm(asset, await add_record(asset, metric_ids=("gmv",))) # 依据齐(不给资产授权)
await publish(admin, asset)
await run_manager.resume(run.id, {"approved": True})
done = await _settled(run.id)
assert done.status == "failed" and querycore.NOT_AUTHORIZED in str(done.error or ""), done.error
assert "正式" in str(done.error or ""), "说法里说清这是正式那一档"
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 6、普通使用者读运行:图上、事件里不留口径的内部身份(安全复核 2)
# ==========================================================================
#: 事件里**不许**出现的键:口径身份、发布清单、selection、哈希、工件 id、参数材料
INTERNAL_EVENT_KEYS = ("args", "preview", "artifact", "query_artifact", "query_artifacts", "schema_artifact",
"evidence", "parameters", "origin", "workflow_id", "card_id", "version", "manifest",
"selection", "identity", "execution_hash", "graph_hash", "selection_hash", "closure_hash",
"vars")
def _cookie(client: Any) -> str:
return f"agentlab_session={client.cookies.get('agentlab_session')}"
async def _ws(path: str, cookie: str | None) -> list[dict]:
"""把一次 WebSocket 握手原样交给整个应用(中间件 + 路由级依赖 + 路由),记下发出去的帧。"""
inbox: Any = asyncio.Queue()
await inbox.put({"type": "websocket.connect"})
sent: list[dict] = []
async def receive():
return await inbox.get()
async def send(message):
sent.append(message)
if message["type"] == "websocket.close":
await inbox.put({"type": "websocket.disconnect", "code": 1000})
headers = [(b"host", b"test")] + ([(b"cookie", cookie.encode())] if cookie else [])
scope = {"type": "websocket", "asgi": {"version": "3.0"}, "scheme": "ws", "http_version": "1.1", "path": path,
"raw_path": path.encode(), "query_string": b"", "root_path": "", "headers": headers,
"client": ("127.0.0.1", 50000), "server": ("test", 80), "subprotocols": [], "state": {}}
await asyncio.wait_for(app(scope, receive, send), 10)
return sent
async def _wait_for(run_id: str, statuses: tuple[str, ...]) -> Any:
deadline = asyncio.get_running_loop().time() + 30.0
while asyncio.get_running_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row is not None and row.status in statuses:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def _settled(run_id: str) -> Any:
return await _wait_for(run_id, ("succeeded", "failed", "cancelled", "interrupted"))
async def _allow_chat(admin: Any) -> None:
r = await admin.put("/api/auth/settings", json={"user_can_chat": True})
assert r.status_code == 200, r.text
async def prepared_parent(client, *, who: str) -> tuple[dict, Any]:
"""一份合成口径 + 一次普通使用者的 prepared 执行:交回 (资产, Outcome)。调用方自己开登录、给授权。
client 是**发布用的**客户端(开登录之后发请求要带 CSRF 头,admin 那个就是)。"""
asset = await make_workflow(graph=upload_graph())
await publish(client, asset)
await grant_caliber(asset["workflow_id"], 1, who, "use")
async with SessionLocal() as session:
outcome = await querycore.execute_prepared(
session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["gmv", "orders"], inputs=INPUTS,
actor=who, origin={"conversation_id": "c1", "turn_id": "t1"})
assert outcome.ok, outcome.problems
return asset, outcome
async def test_a_plain_user_reads_the_run_without_the_internal_caliber(client):
"""普通使用者读得到自己这次运行,但运行图与事件里读不到口径的内部身份;指标值、状态、进度、别的节点都在。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
await _allow_chat(admin)
asset, outcome = await prepared_parent(admin, who=who)
person = await auth_env.login(who)
graph_body = (await person.get(f"/api/runs/{outcome.run_id}/graph")).json()
[node] = [n for n in graph_body["graph"]["nodes"] if n["id"] == querycore.PREPARED_NODE]
args = node["data"]["config"]["args"]
assert set(args) == {"caliber"}, args
assert asset["workflow_id"] not in json.dumps(graph_body, ensure_ascii=False)
assert graph_body["workflow_id"] is None and graph_body["version"] is None
events = (await person.get(f"/api/runs/{outcome.run_id}/events")).json()
dumped = json.dumps(events, ensure_ascii=False)
assert asset["workflow_id"] not in dumped and f'"{CARD}"' not in dumped, "事件里不许出现口径身份"
mine = [e for e in events if e["node_id"] == querycore.PREPARED_NODE]
assert mine, "口径查询节点的事件要在(进度看得见)"
for event in mine:
assert not [k for k in event["data"] if k in INTERNAL_EVENT_KEYS], event
ends = [e for e in mine if e["type"] == "tool.end"]
assert ends and ends[0]["data"]["tool"] == querycore.CALIBER_QUERY
assert ends[0]["data"]["duration_ms"] is not None, "花了多久留着"
assert [m["id"] for m in ends[0]["data"]["result"]["metrics"]] == ["gmv", "orders"], "指标值留着"
assert {m["value"] for m in ends[0]["data"]["result"]["metrics"]} == {WINDOW_SUM, 3}
assert ends[0]["data"]["result"]["ok"] is True and ends[0]["data"]["result"]["status"] == "succeeded"
# 别的节点的事件原样:不因为这一条口径查询就把普通聊天的事件清空
others = [e for e in events if e["node_id"] and e["node_id"] != querycore.PREPARED_NODE]
assert others, "别的节点的事件还在"
assert any("artifact" in e["data"] or "preview" in e["data"] for e in others), "别的节点照原样给(没被清空)"
# 搭建者读到的还是原件:图上参数、事件里的工件都在
raw = (await admin.get(f"/api/runs/{outcome.run_id}/graph")).json()
[raw_node] = [n for n in raw["graph"]["nodes"] if n["id"] == querycore.PREPARED_NODE]
assert raw_node["data"]["config"]["args"]["workflow"] == asset["workflow_id"]
raw_events = (await admin.get(f"/api/runs/{outcome.run_id}/events")).json()
assert asset["workflow_id"] in json.dumps(raw_events, ensure_ascii=False)
await person.aclose()
finally:
await admin.aclose()
await auth_env.reset()
async def test_the_websocket_stream_goes_through_the_same_projection(client):
"""实时流和事件接口是同一条路:WS 补的历史、推的实时事件都过同一份投影。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
await _allow_chat(admin)
asset, outcome = await prepared_parent(admin, who=who)
person = await auth_env.login(who)
frames = await _ws(f"/api/runs/{outcome.run_id}/stream", _cookie(person))
assert frames[0]["type"] == "websocket.accept"
texts = [f.get("text") or "" for f in frames if f["type"] == "websocket.send"]
assert any("stream.end" in text for text in texts), texts[-3:]
dumped = "\n".join(texts)
assert asset["workflow_id"] not in dumped and f'"{CARD}"' not in dumped, "实时流里也不许出现口径身份"
assert querycore.CALIBER_QUERY in dumped, "口径查询那一步在(进度看得见)"
results = [json.loads(text)["data"]["result"] for text in texts
if '"result"' in text and isinstance(json.loads(text).get("data", {}).get("result"), dict)]
assert results and results[0]["ok"] is True, "结果摘要里留着算成没算成"
assert [m["id"] for m in results[0]["metrics"]] == ["gmv", "orders"], "指标值留着"
# 实时那一段(_pump)收的是总线上推来的事件:过的是同一个投影
from app.api import runs as runs_api
from app.core.events import RunEventModel
live: list[dict] = []
class _Socket:
async def send_json(self, payload: dict[str, Any]) -> None:
live.append(payload)
async def close(self) -> None:
return None
async def _live():
yield RunEventModel(run_id=outcome.run_id, type="tool.end", node_id=querycore.PREPARED_NODE,
data={"tool": querycore.CALIBER_QUERY, "provided": "args",
"args": {"workflow": asset["workflow_id"]}, "preview": "{}"})
graph = querycore.prepared_graph(asset["workflow_id"], card_id=CARD, metric_ids=["gmv"], inputs=INPUTS)
token = deps.set_principal(Principal(username=who, role="user", verified=True, mode="local"))
try:
await runs_api._pump(_Socket(), _live(), outcome.run_id, runs_api.run_projection(graph))
finally:
deps.reset_principal(token)
assert live[0]["data"]["tool"] == querycore.CALIBER_QUERY and "args" not in live[0]["data"]
await person.aclose()
finally:
await admin.aclose()
await auth_env.reset()
def test_the_projection_only_applies_to_ordinary_readers():
"""谁拿投影:开登录后的使用者要,关登录 / 搭建者不要;补固定那条日志里的固定目标也不给。"""
from app.api import runs as runs_api
graph = querycore.prepared_graph("wf-1", card_id=CARD, metric_ids=["gmv"], inputs=INPUTS)
token = as_user("carol")
try:
projection = runs_api.run_projection(graph)
assert projection is not None
assert projection.graph(graph)["nodes"][1]["data"]["config"]["args"].get("workflow") is None
pin = {"type": "log", "node_id": None,
"data": {"code": "late_pin", "source": "门店报损", "rows": "mine", "caliber": "wf-1@1:card",
"workflow_id": "wf-1", "version": 1, "late": True, "pinned_by": "caliber_query"}}
safe = projection.event(pin)["data"]
assert safe["source"] == "门店报损" and safe["rows"] == "mine" and "caliber" not in safe
assert "workflow_id" not in safe and "version" not in safe
finally:
deps.reset_principal(token)
assert runs_api.run_projection(graph) is None, "关登录:照原样给"
# ==========================================================================
# 7、角色以身份为准:访问密钥 / 角色快照不一致时不放行(安全复核 3)
# ==========================================================================
async def test_a_key_capped_identity_is_not_raised_by_the_database_role(client):
"""访问密钥认出来的身份封顶在使用者:它的用户名在库里是管理员,也不能因此被抬成放行。"""
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("rootish", "admin")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
key_like = Principal(username="rootish", role="user", verified=True, mode="local", via="key")
token = deps.set_principal(key_like)
try:
async with SessionLocal() as session:
assert await chat_policy.restricted_actor(session, key_like) is True
# 形参给的是名字(工具那条路拿到的就是父 Run 上记的名字)时,请求那一档更严 → 取更严的
assert await chat_policy.restricted_actor(session, "rootish") is True
with pytest.raises(querycore.QueryCoreRefused) as caught:
await execute(asset)
finally:
deps.reset_principal(token)
assert caught.value.code == querycore.NOT_AUTHORIZED
# 反过来:身份说自己是搭建者、库里是使用者 —— **发起前那一道**以身份为准(和 deps.restricted 同一条);但运行里
# 记下来的只有**名字**,运行到「转抄口径」那一步时只有库里的角色可认(metrics 节点、工具路径都是这个规则),
# 所以这一次运行会在节点上失败。两条规则各自按它拿得到的那一份证据判,不互相冒充。
await auth_env.make_user("mismatch", "user")
builder_like = Principal(username="mismatch", role="builder", verified=True, mode="local")
async with SessionLocal() as session:
assert await chat_policy.restricted_actor(session, builder_like) is False, "身份说自己是搭建者:以身份为准"
# 只给名字(工具那条路拿到的就是名字)时只有库里的角色可认:库里是使用者 → 按使用者算,不因身份那一次放行
assert await chat_policy.restricted_actor(session, "mismatch") is True
outcome = await execute(asset, actor=builder_like)
assert outcome.status == "failed" and querycore.NOT_AUTHORIZED in str(
[p.get("message") for p in outcome.problems]), "运行里只剩名字:口径那一步按库里那一档判"
finally:
await admin.aclose()
await auth_env.reset()
async def test_a_disabled_person_stays_limited_by_name(client):
"""手上只有用户名时仍回库里认停用:停用的人按普通使用者算(拿不到授权就是拒)。"""
from app.db.models import User
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("gone", "builder")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
async with SessionLocal() as session:
assert await chat_policy.restricted_actor(session, "gone") is False
user = (await session.execute(select(User).where(User.username == "gone"))).scalar_one()
user.disabled = True
await session.commit()
assert await chat_policy.restricted_actor(session, "gone") is True, "停用的人不按角色放行"
with pytest.raises(querycore.QueryCoreRefused) as caught:
await execute(asset, actor="gone")
assert caught.value.code == querycore.NOT_AUTHORIZED
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 8、手搭的转抄卡(metrics.caliber_from):运行时按同一份资产授权核(安全复核 1)
# ==========================================================================
def chained_graph(workflow_id: str, *, card: str = CARD) -> dict[str, Any]:
"""一张自己搭的转抄卡图:拷一份源口径的取数节点,口径卡用 `caliber_from` 钉到那一版的卡。
普通使用者可以在画布上(或问数据里生成的图里)这么搭:节点类型、工具都在白名单里,**口径定义本身**是从别的工作流那一版
读进来的——所以这一道门只能在运行时判(`engine/nodes/metrics._require_own_caliber`)。"""
import copy
source = upload_graph()
fetch = copy.deepcopy(next(n for n in source["nodes"] if n["id"] == "sales"))
fields = next(n for n in source["nodes"] if n["id"] == "start")["data"]["config"]["fields"]
return S.chain(
S.node("start", "input", fields=copy.deepcopy(fields)),
fetch,
S.node(CARD, "metrics", caliber="转抄的合成周销售", caliber_version="v1",
caliber_from={"workflow_id": workflow_id, "workflow_version": 1, "node_id": card}),
S.node("out", "output", fields=[{"name": "答", "value": "运行结束"}]))
async def run_chained(who: str, workflow_id: str, *, card: str = CARD, **kw: Any) -> Any:
run = await run_manager.start(graph=chained_graph(workflow_id, card=card), input_payload=dict(INPUTS),
started_by=who, **kw)
return await _settled(run.id)
async def test_a_chained_card_is_refused_without_the_asset_grant(client):
"""没授权:转抄卡那一步在**读定义、算数之前**失败(机器码同一条),别人的口径定义一个字都读不进来。"""
from app.engine.evidence_reader import read_sealed
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
before = await runs_matching(started_by=who)
row = await run_chained(who, asset["workflow_id"])
assert row.status == "failed" and querycore.NOT_AUTHORIZED in str(row.error or ""), row.error
assert asset["workflow_id"] not in str(row.error or "") and CARD not in str(row.error or ""), row.error
sealed = await read_sealed(row.id)
assert [e for e in sealed.events if e[1] == "node.finished" and e[2] == CARD] == [], \
"在算数之前失败:口径卡节点一次都没跑完"
assert await runs_matching(started_by=who) == before + 1, "这次运行本身是发起过的(不是发起被拒)"
# 那一条失败事件过一遍投影(普通使用者读得到的运行事件):口径名与卡都不带出来,机器码留着
from app.api import runs as runs_api
from app.db.models import RunEvent, Workflow
async with SessionLocal() as session:
workflow = await session.get(Workflow, asset["workflow_id"])
rows = list((await session.execute(select(RunEvent).where(RunEvent.run_id == row.id))).scalars())
token = as_user(who)
try:
projection = runs_api.run_projection(row.graph)
failures = [projection.event({"type": e.type, "node_id": e.node_id, "data": e.data}) for e in rows
if e.type in runs_api.FAILURE_EVENTS]
finally:
deps.reset_principal(token)
assert failures, "这一次运行有失败事件"
dumped = json.dumps(failures, ensure_ascii=False)
assert workflow.name not in dumped, dumped
assert querycore.NOT_AUTHORIZED in dumped and "转抄的口径没有授权" not in dumped, dumped
# 给了授权就照常读那一版的定义、算出那一版的数
await grant_caliber(asset["workflow_id"], 1, who, "use")
ok = await run_chained(who, asset["workflow_id"])
assert ok.status == "succeeded", ok.error
payload = json.dumps((await read_sealed(ok.id)).payload_of([CARD]) or {}, default=str)
assert str(WINDOW_SUM) in payload, payload[:400]
finally:
await admin.aclose()
await auth_env.reset()
async def test_a_system_run_and_builders_are_not_judged_by_the_caliber_rule(client):
"""服务端自己发起的运行(不记发起人:按需比对、口径召回、目录变化后的重算)与 builder 照旧跑得完。
只有「认得出是普通使用者、又没拿到这一版的授权」才拒(上一条用例)。这一条盯住另外两档不被误伤:
召回那批后台运行就是不带发起人的(`ledger/recall.launch(actor=None)`)。"""
admin = await auth_env.turn_on_local()
try:
builder = f"b{uuid.uuid4().hex[:8]}"
await auth_env.make_user(builder, "builder")
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset)
token = deps.set_principal(None) # 没有请求身份、运行也不记发起人:服务端自己发起的
try:
run = await run_manager.start(graph=chained_graph(asset["workflow_id"]), input_payload=dict(INPUTS))
system = await _settled(run.id)
finally:
deps.reset_principal(token)
assert system.status == "succeeded", system.error
built = await run_chained(builder, asset["workflow_id"])
assert built.status == "succeeded", built.error
finally:
await admin.aclose()
await auth_env.reset()
async def test_the_chained_card_only_uses_the_pinned_version(client):
"""授权是按(工作流, 版本)给的:钉住的那一版有授权才跑得成;同一张卡改钉另一版、那一版没授权照样拒。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
first = await make_workflow(graph=upload_graph())
await publish(admin, first) # v1
second = await add_version(first, upload_graph(include_open=True), version=2)
await publish(admin, second, version=2) # v2 成了推荐版
await grant_caliber(first["workflow_id"], 1, who, "use") # 只给 v1
one = await run_chained(who, first["workflow_id"])
assert one.status == "succeeded", one.error
graph = chained_graph(first["workflow_id"])
next(n for n in graph["nodes"] if n["id"] == CARD)["data"]["config"]["caliber_from"]["workflow_version"] = 2
run = await run_manager.start(graph=graph, input_payload=dict(INPUTS), started_by=who)
other = await _settled(run.id)
assert other.status == "failed" and querycore.NOT_AUTHORIZED in str(other.error or "")
finally:
await admin.aclose()
await auth_env.reset()
# ==========================================================================
# 9、图与失败说法的投影:提示词、转抄卡映射、失败白话(安全复核 2)
# ==========================================================================
def _ask_graph(workflow_id: str) -> dict[str, Any]:
"""普通使用者的图:agent 绑口径查询(提示词里照抄了 look 交回的「取数写法」)+ 一张转抄卡。"""
call = json.dumps({"workflow": workflow_id, "card": CARD, "metric_ids": ["gmv"],
"inputs": {"start": "2026-09-01", "end": "2026-10-01"}}, ensure_ascii=False)
return S.chain(
S.node("start", "input", fields=[{"name": "question"}]),
S.node("ask", "agent", prompt=f"回答问题:照这一行调 {querycore.CALIBER_QUERY} 取数(不要自己写取数 SQL):{call}",
tools=[S.tool(), querycore.CALIBER_QUERY], max_steps=4),
S.node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.ask.text }}"}]),
S.node(CARD, "metrics", caliber_from={"workflow_id": workflow_id, "workflow_version": 1, "node_id": CARD,
"nodes": {"sales": "sales"}}, metrics=[]),
)
def test_the_projection_redacts_prompts_and_chained_maps():
"""图上与口径有关的内部写法:提示词里的取数写法(身份的值)、转抄卡的身份映射——键、说明、inputs 的值都留着。"""
from app.api import runs as runs_api
graph = _ask_graph("wf-1")
token = as_user("carol")
try:
projected = runs_api.run_projection(graph).graph(graph)
finally:
deps.reset_principal(token)
prompt = projected["nodes"][1]["data"]["config"]["prompt"]
assert "不要自己写取数 SQL" in prompt and '"metric_ids"' in prompt, "说明与要算的指标留着"
assert '"start": "2026-09-01"' in prompt, "要填的入口取值留着"
assert "wf-1" not in prompt, "口径身份那几个值换掉了"
assert f'"workflow": "(有授权的口径)"' in prompt and f'"card": "(有授权的口径)"' in prompt, prompt
assert projected["nodes"][3]["data"]["config"]["caliber_from"] == runs_api.CALIBER_FROM_NOTE
# 原件不动
assert graph["nodes"][1]["data"]["config"]["prompt"].count("wf-1") == 1
assert graph["nodes"][3]["data"]["config"]["caliber_from"]["workflow_version"] == 1
# builder 及以上拿到的是原件
assert runs_api.run_projection(graph) is None
def test_a_failure_says_it_without_the_caliber_identity():
"""失败说法的安全白话:机器码留着,口径名、版本、card 不带出来;不是口径那一档的原样给。"""
from app.api import runs as runs_api
message = ("工具 caliber_query 执行失败:没有权限:口径「周合成-abc」v1 没有授权给你使用"
"(querycore_not_authorized)。请检查节点中填写的参数")
assert runs_api.safe_failure(message) == f"{runs_api.CALIBER_FAILED_TEXT}(querycore_not_authorized)"
assert runs_api.safe_failure("模型调用失败:接入返回了 502") is None
assert runs_api.safe_failure(None) is None
# ==========================================================================
# 10、普通使用者读得到的运行:失败说法与账本视图(安全复核 2、3)
# ==========================================================================
async def test_a_plain_user_sees_a_safe_failure_on_the_run_and_its_events(client):
"""自己发起的问数据运行里口径查询失败:运行记录与事件里的说法都只留「没成功 + 机器码」。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
await _allow_chat(admin)
asset = await make_workflow(graph=upload_graph())
await publish(admin, asset) # 不给授权:口径查询在工具那一层被拒
graph = S.chain(
S.node("start", "input", fields=[{"name": "question"}]),
S.node("look", "tool", tool=querycore.CALIBER_QUERY,
args={"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["gmv"], "inputs": INPUTS}),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.look.ok }}"}]))
run = await run_manager.start(graph=graph, input_payload={}, run_class="exploratory", started_by=who,
origin_kind="chat", options={MASK_RESULTS_OPTION: True, READ_ONLY_OPTION: True})
row = await _settled(run.id)
assert row.status == "failed" and querycore.NOT_AUTHORIZED in str(row.error or "")
person = await auth_env.login(who)
detail = (await person.get(f"/api/runs/{run.id}")).json()
assert detail["error"] == f"按口径取数没有成功(querycore_not_authorized)", detail["error"]
events = (await person.get(f"/api/runs/{run.id}/events")).json()
dumped = json.dumps(events, ensure_ascii=False)
assert asset["workflow_id"] not in dumped and CARD not in dumped
[failed] = [e for e in events if e["type"] == "node.failed"]
assert failed["data"]["error"] == "按口径取数没有成功(querycore_not_authorized)"
[whole_run] = [e for e in events if e["type"] == "run.failed"]
assert whole_run["data"]["error"] == "按口径取数没有成功(querycore_not_authorized)", whole_run
assert whole_run["data"]["label"], "失败在哪一步(节点的叫法)留着"
# 搭建者看到的还是原话(口径名、卡、码都在)
raw = (await admin.get(f"/api/runs/{run.id}/events")).json()
[raw_failed] = [e for e in raw if e["type"] == "node.failed"]
assert asset["workflow_id"] in json.dumps(raw, ensure_ascii=False) or CARD in raw_failed["data"]["error"]
await person.aclose()
finally:
await admin.aclose()
await auth_env.reset()
async def matched_run(monkeypatch, who: str) -> str:
"""一次与「周销售」等价、封存后比对过的运行(发起人是这个使用者):账本视图用它。"""
from app.ledger import match as ledger_match
S.script(monkeypatch, text="本周销售额 [[v:Q1.r0.销售额]]。")
run = await run_manager.start(graph=S.explore_graph(S.EQUIVALENT), input_payload={}, run_class="exploratory",
origin_kind="canvas", started_by=who)
row = await S.wait(run.id) # 等封存(比对读的是封存范围,只等状态会偶发地读到 pending)
assert row.status == "succeeded" and row.manifest_seq is not None, row.error
await ledger_match.drain()
return row.id
async def test_the_ledger_view_drops_the_caliber_identity_for_a_plain_user(client, monkeypatch):
"""普通使用者的口径账本:内部身份(工作流 id / 版本 / 哈希 / 卡节点 id)不出去,人读的那一套一个字不动。"""
admin = await auth_env.turn_on_local()
try:
who = f"u{uuid.uuid4().hex[:8]}"
await auth_env.make_user(who, "user")
workflow_id = await S.publish()
run_id = await matched_run(monkeypatch, who)
person = await auth_env.login(who)
res = await person.get(f"/api/runs/{run_id}/ledger")
assert res.status_code == 200, res.text
body = res.json()
assert body["state"] == "matched"
dumped = json.dumps(body, ensure_ascii=False)
assert workflow_id not in dumped, "工作流 id 不出去(这一次的出具被拒:只说明,不给身份)"
for key in ("workflow_id", "graph_hash", "closure_hash", "selection_hash", "execution_hash"):
assert f'"{key}"' not in dumped, key
assert body["actions"] and body["actions"][0]["available"] is False, body["actions"]
# 人读的:口径名、级别叫法、指标标签与绑定、每条查询的结果、证据关系
[caliber] = body["calibers"]
assert caliber["workflow_name"] == "周销售" and caliber["level_text"] == "受管" and caliber["caliber"]
[label] = body["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["workflow_name"] == "周销售" and label["binding"]
assert "node_id" not in label and "version" not in label
[query] = body["queries"]
assert query["node_id"] and query["artifact"] and query["status"] == "proven", "证据关系留着"
assert "node_id" not in query["candidates"][0]
assert [a["kind"] for a in body["actions"]] == ["issue"] and body["whole"]["equivalent"] is True
# 搭建者拿到的是原件:工作流 id、版本、哈希、卡节点 id 都在
raw = (await admin.get(f"/api/runs/{run_id}/ledger")).json()
raw_dumped = json.dumps(raw, ensure_ascii=False)
assert workflow_id in raw_dumped and '"graph_hash"' in raw_dumped
assert "node_id" in raw["reports"]["write"]["labels"][next(iter(raw["reports"]["write"]["labels"]))]
# 有权出具的人(应用授权把这一版固定给它):出具那一项要带着身份去发请求——只有**确实给它做**的那一项带
await grant_caliber(workflow_id, 1, who, "use")
allowed = (await person.get(f"/api/runs/{run_id}/ledger")).json()
[action] = allowed["actions"]
assert action["available"] is True and action["workflow_id"] == workflow_id and action["version"] == 1
assert allowed["calibers"][0].get("workflow_id") is None, "别处照旧不给身份"
assert allowed["reports"]["write"]["labels"][next(iter(allowed["reports"]["write"]["labels"]))].get("version") is None
# 证据片段 / 报告导出读的是同一份标签(ledger/view.report_labels):同一条投影
from app.api.evidence import _sealed
from app.ledger.view import report_labels
sealed = await _sealed(run_id)
token = as_user(who)
try:
labels = (await report_labels(sealed, "write"))["labels"]
finally:
deps.reset_principal(token)
[label] = labels.values()
assert label["kind"] == "caliber" and label["workflow_name"] == "周销售"
assert "workflow_id" not in label and "node_id" not in label and "version" not in label
await person.aclose()
finally:
await admin.aclose()
await auth_env.reset()