Chat-to-SQL 演示里,Schema 进 Prompt、模型吐 SQL、应用层直接跑,链路很短。接到公司业务库,短就会变成事故:写库、碰不该碰的表、漏租户条件、一次拖几十万行。
上一篇把 Data Copilot 的 NL2SQL 全貌讲过了。本篇只回答一个架构问题:SQL 安全边界该放在哪?
结论先放在前面——边界不在「模型今天会不会只写 SELECT」,而在执行层。Prompt 改变的是生成倾向;库门口的许可必须是另一套控制面。不确定,就拒绝。这就是 Fail-closed。
开源仓库:https://github.com/yanqiuping110-cloud/xb-data-copilot-bot
一、先拆两个控制面,再谈闸门
很多方案把「只能 SELECT」写进系统提示词,就认为执行层安全了。这把两件不该绑在一起的事绑死了:
- 生成面:模型根据召回的表、指标、口径,产出一条字符串。它不知道当前用户的授权列表,也不对出库负责。
- 执行面:谁允许碰库、碰哪张表、带什么范围、最多返回多少行。这是系统的许可,不是模型的记忆。
玩具级 Chat-to-SQL 的问题,不是模型不够聪明,是这两面叠在同一个函数里:生成完立刻 execute。演示好看,是因为演示库没有租户、没有敏感列、没有人在意一次扫全表。
企业问数要把它们拆开。Data Copilot 写在 LangGraph 里的契约是:模型可以猜 SQL,不能签发执行权。图上没有「助手模式跳过网关」的边。
读图:箭头只有一条,从字符串进入校验。生成面结束的地方,执行面才开始。Prompt 侧仍会做不可信定界、召回清洗,那是降低模型被带跑的概率,不是执行许可。提示词注入打不穿执行层,下一篇 B2 专门写。
节点顺序也是设计,不是随便排的:
- 先校验,再注入。 白名单、只读、LIMIT 先过;注入只补范围,不放开新表。
- 注入发生在出库前。 模型没写租户条件,系统补上参数化
IN;写了不在授权里的字面量,直接拒。 - 执行器不信任上游。 改写后的字符串再走一遍只读和行数帽。图以后会改,这条契约不能改。
校验失败走纠正或直接失败,不会降级成「先查出来再说」。
二、网关按失败模式分层
叠很多检查,容易写成清单。有用的分法是:每一层对应一种「看起来能过、实际上已经出事」的语句。少一层,就会在那一类上漏。
从上到下,越靠近数据库越不信任上一层。任意一步不确定,整条链路停,到不了业务库。
1. 语句形态:业务库只读
业务库禁止 DML、DDL、权限语句、多语句;只允许 SELECT 或 WITH ... SELECT。治理库(用户、元数据、审计)允许 INSERT / UPDATE,禁止物理 DELETE 和运行时 DDL。
双库双策略,是为了避免一种常见混用:问数系统自己要写审计,于是给业务连接也开了写。业务侧只读是底线,跟模型无关。
这一层用正则先挡脏语句。土,但便宜。它专门对付 AST 只看「最外层像 SELECT」时会手软的写法:
WITH x AS (INSERT INTO t VALUES (1)) SELECT 1
外层是查询,里面已经在写库。单测要求这条必须 BUSINESS_DML_FORBIDDEN。正则在 parse 之前把写操作从字符串里揪出来;语法树后面负责表、列、LIMIT。两层都留,是因为它们漏的位置不一样。
只读断言在校验节点和执行器入口各做一次。纵深重复是故意的:以后若有人在两节点之间加快捷路径,执行器自己仍是只读。安全上,笨比巧可靠。
2. 语句结构:当成树,而不是字符串
过了正则,用 sqlglot 按方言 parse。解析失败 PARSE_ERROR;最外层不是查询 NOT_SELECT。
表名、LIMIT、列引用都要改树。用字符串替换 LIMIT,碰到子查询、注释、方言差异迟早出丑。sqlglot 的坑以后单写;这里用它该用的能力:把 SQL 当结构看。
物理表白名单从 AST 抽表名,CTE 别名不算物理表。WITH punch AS (SELECT ... FROM t1) SELECT ... FROM punch 里,punch 是临时结果。这层一开始很容易写反:别名当物理表,合法 CTE 被误杀;不当物理表,模型随口起名就混过白名单。
白名单来自治理库的表元数据 / 指标相关表。代码兜底是空集合,不是写死几张常用表。元数据没配好,结果应该是谁也查不了,而不是默默能查全库。开源项目要接别人的业务库,写死我自己的表名,要么误导要么误放。空集合难看,但 Fail-closed。不在名单里:TABLE_NOT_ALLOWED。
列分两件事,别混。编造列(库里是 name,模型写成 student_name)走 COLUMN_NOT_FOUND,后面还有纠正,避免把错 SQL 丢给数据库报一堆看不懂的错。权限 deny 列走 COLUMN_DENIED。Prompt 里会提示禁止字段,拦人的仍是这层。
LIMIT 是产品判断,不是 SQL 技巧。没写就补最外层;写了超过 sql_max_rows(默认 100,运维可改,夹在 1~10000)就压回。Agent 探查再封顶 10。对话问数要的是能看的结果,全量导出不走这条链路。模型不能否决行数帽。
3. 数据范围:授权在出库前完成
语法正确、意图也对的查询,仍可能越权。典型是漏了租户 / 学校条件。模型生成时通常看不到授权列表;即使用 Prompt 写了「必须带租户」,也不该把边界建立在「它今天记得写」。
DataScope 开着时,网关在 apply_policy 按表绑定注入参数化 IN。授权值来自治理库 grant,不来自模型。模型就算把 tenant_id = 99 写进 WHERE,字面量不在 grant 里会 SCOPE_VIOLATION。没配表级授权、默认拒绝打开:NO_DATA_SCOPE,整次问数失败。
JOIN 时只给有绑定的事实表补条件,不会给没有范围列的维度表乱注入。学校账号还有一条更老的硬规矩:SQL 文本必须出现 sch_id,否则 MISSING_SCH_ID。
Prompt 里拼【数据范围】【可见表】,是为了让生成更像人写的、少走纠正。真正的边界是出库前的改写。滤漏了,数据已经出库了——所以不能等应用层查完再滤。
4. 最后一刀:执行器不信任上游
execute_readonly 再次做只读断言,并用 fetchmany(max_rows + 1) 做行数帽。方言把 LIMIT 吃掉、网关改写漏了,执行器还挡得住一次拖爆,失败码 TOO_MANY_ROWS。
到这里,执行面才算闭合:生成面交出字符串,执行面自己决定许不许可、改不改写、跑不跑、跑多少。
校验入口的顺序,就是上面这套设计的落地(节选,路径 backend/app/sql/guard.py):
def validate_sql(sql: str, ctx: UserContext, *, max_rows: int, ...) -> str:
assert_business_readonly_sql(sql) # 写库 / 多语句先挡
parsed = parse_sql(stripped, sql_ctx=sql_ctx)
if not _is_readonly_query(parsed):
raise SqlGuardError("NOT_SELECT", "仅允许 SELECT 查询")
unknown = _extract_tables(parsed) - allowed # CTE 别名已排除
if unknown:
raise SqlGuardError("TABLE_NOT_ALLOWED", ...)
# 敏感列、sch_id、强制 LIMIT 都在这棵树上做完
return render_sql(parsed, sql_ctx=sql_ctx)
行级注入在 backend/app/policy/scope_injector.py,发生在校验之后、执行之前。LangGraph 里这两步是显式节点,不是藏在某个助手函数里碰运气调用。

495

被折叠的 条评论
为什么被折叠?



