上一篇把 Data Copilot 的全貌讲了一遍:自然语言问业务数据,中间走 LangGraph,真正碰库之前过闸门。 评论区有人问得挺直接------闸门到底拦什么?Prompt 里写「只能 SELECT」,算不算安全?
这篇把执行层拆开。Chat-to-SQL 演示里最容易忽略的,往往是这条看起来完全正常的 SQL:
sql
SELECT * FROM fact_orders WHERE status = 'paid'
语法没问题,意图也对。但如果当前用户只被授权 tenant_id IN (10, 20),这条语句一旦原样进业务库,就是越权。
模型不知道有这层。用户也看不到后面发生了什么。 所以我没把「模型今天表现好」当成 SQL 安全边界。行业里爱说 fail-closed,翻译成人话就是:不确定,就拒绝。
仓库在这: github.com/yanqiuping1...
这篇只讲闸门:
- 为啥不能把执行权交给模型
- 几层检查分别拦什么,配「差点出事」的例子
- 当时几笔不想事后后悔的取舍
提示词注入、评测集、sqlglot 方言坑,后面单独拆,这篇不硬塞。
1. 生成 SQL 和执行 SQL,必须是两件事
玩具级 Chat-to-SQL 的骨架我见过不少:Schema 塞进 Prompt,模型吐 SQL,应用层直接跑。 演示很爽。接到公司库上,我会下意识退半步------不是不信模型会写 SELECT,是不敢把「会不会写坏」当成唯一防线。
NL2SQL 上生产,事故成本通常不是答错一个数,而是:
- 模型顺手写了
DELETE/UPDATE(它真的会,尤其是用户话里带了「清掉」「改成」); - 编了张不该碰的表;
- 漏了行级条件,把别的租户、别的学校查出来;
LIMIT忘了写,一次拖几十万行,前端和库一起喘。
Prompt 里当然会写「只能单条只读 SELECT」。召回文本也会洗一洗疑似注入指令。 这些有用,但它们挡的是模型的行为倾向,不是库门口的执行权。
我这边主链路很死板:
生成 SQL → validate_sql → apply_policy → execute_sql
校验不过,去纠正或直接失败;过了才允许碰只读业务库。模型没有「跳过网关」的通道。
2. 几层闸门,分别差点怎样
不是为了叠层数好看。每一层都对应过一种「看起来能过」的写法。
第一层:业务库只读断言(正则,先挡脏的)
业务库禁止 DML / DDL / 权限语句 / 多语句。空 SQL、中间带分号,直接拒。
有人会说:正则土。土,但便宜,而且能挡住一种 AST 只看「最外层是不是 SELECT」时容易手软的写法:
sql
WITH x AS (INSERT INTO t VALUES (1)) SELECT 1
外层像查询。里面已经在写库。 单测里这条走的是 BUSINESS_DML_FORBIDDEN,不是「解析成功就算了」。
治理库(问数自己的用户、元数据、审计)策略不一样:允许 INSERT / UPDATE,禁止物理 DELETE 和运行时 DDL。 两套库两套规矩,别混。业务侧只读是底线。
第二层:sqlglot 语法树(结构,不靠碰运气)
过了正则,再用 sqlglot 按方言 parse。解析失败:PARSE_ERROR,拒绝。 最外层必须是 SELECT 或 WITH ... SELECT,否则 NOT_SELECT。
为啥不继续用正则判断「是不是查询」?因为表名、LIMIT、列引用后面都要改树。 字符串替换 LIMIT,遇到子查询、方言差异、注释,迟早出丑。sqlglot 的坑以后单写,这里先用它该用的能力:把语句当成结构看。
第三层:物理表白名单(空兜底,一张都不能查)
从 AST 抽表名,CTE 别名不算物理表 。 WITH punch AS (SELECT ... FROM sport_activity_qzs_time) SELECT ... FROM punch 里,punch 是临时结果,不能拿去对白名单。 这层我一开始差点写反:要么合法 CTE 被误杀,要么模型随口起个「表名」就混过去。
白名单从治理库的表元数据 / 指标相关表加载,进程内缓存。 代码里的兜底是空集合,不是「开发时写死的那几张常用表」。
这是故意的。元数据没配好、缓存没刷新,结果应该是谁也查不了,而不是默默能查全库。 产品经理有时会觉得「不友好」。可一旦兜底放行,那才是真事故。
不在名单里的表:TABLE_NOT_ALLOWED。SELECT * FROM secret_table 走不到执行器。
第四层:列------编造的拒,敏感的也拒
两件事,别混:
- 列不存在 :模型爱把业务黑话写成字段,比如库里是
name/in_year,它写成student_name/enrollment_year。网关对照元数据,COLUMN_NOT_FOUND,后面还有纠正节点,不是把错 SQL 丢给数据库报一堆看不懂的错。 - 列被 deny :权限里标了禁止字段(测试里用过
secret_col),AST 扫到引用就COLUMN_DENIED。Prompt 里也会提示「禁止字段」,但提示只是给模型看的,拦人的是这层。
SELECT * 和输出别名有些边角,sqlglot 方言篇再展开。原则不变:敏感列不能靠「模型别选」来保证。
第五层:LIMIT(默认 100,写大了也压回去)
没写 LIMIT,网关给最外层补上。 写了但超过 sql_max_rows(默认 100,系统参数可改,上限夹在 1~10000),压到上限。
探查工具更狠:probe SQL 的 LIMIT 再封顶到 10。Agent 为了看取值分布,不该一次拉一张宽表。
执行器还有第二道:fetchmany(max_rows + 1),多出来就 TOO_MANY_ROWS。 网关改写失败、方言把 LIMIT 吃掉,执行器还挡得住一次拖爆。
LIMIT 背后的产品判断(谁改、改完前端会不会炸)以后单写。这里只需记住:默认 100 不是随便拍的数,是「先别把库和浏览器一起打死」。
第六层:行级范围(模型没写 WHERE,我也注入)
还是那条「看起来正常」的订单查询。DataScope 开着、当前用户有 tenant → tenant_id 绑定、授权值是 10 和 20 时,网关会在执行前改成类似:
sql
SELECT COUNT(*) AS cnt FROM fact_orders
WHERE tenant_id IN (:scope_tenant_0, :scope_tenant_1)
参数从授权来,不从模型来。模型就算把 tenant_id = 99 写进 WHERE,字面量不在 grant 里会 SCOPE_VIOLATION。
学校账号还有更老的一条硬规矩:SQL 文本里必须出现 sch_id,否则 MISSING_SCH_ID。 没配表级授权、默认拒绝打开:NO_DATA_SCOPE,直接不能问数。
行级怎么配、超管怎么剥条件,C 系列再讲。这篇只要一个结论:越权不能等应用层查完再滤。滤漏了,数据已经出库了。
第七层:执行器再走一遍只读
execute_readonly 入口再次 assert_business_readonly_sql。 校验节点和执行节点之间如果以后被人加了「快捷路径」,执行器自己还是只读。
纵深重复看起来笨。安全上,笨比巧可靠。
3. 一张表看完
| 层 | 主要拦什么 | 典型失败码 | 差点怎样 |
|---|---|---|---|
| 只读断言 | 写库、改表、多语句 | BUSINESS_DML_FORBIDDEN |
CTE 里塞 INSERT |
| 语法树 | 非 SELECT、解析失败 | NOT_SELECT / PARSE_ERROR |
正则当查询,结构已经不是查询 |
| 表白名单 | 不该碰的物理表 | TABLE_NOT_ALLOWED |
兜底名单写满常用表,配漏就放行 |
| 列校验 | 编造列、敏感列 | COLUMN_NOT_FOUND / COLUMN_DENIED |
错列丢给数据库;敏感列靠自觉 |
| LIMIT | 一次拖爆 | 改写或 TOO_MANY_ROWS |
模型写 LIMIT 10000 |
| DataScope | 行级越权 | SCOPE_VIOLATION / NO_DATA_SCOPE |
语法正确但没租户条件 |
| 执行器 | 二次只读 + 行数帽 | 同上 | 有人绕过校验节点 |
Prompt 清洗、不可信定界,挡的是另一类问题(用户 / 召回里塞「忽略以上指令」)。 那层有用,但打不穿执行层就不该指望它打穿。下篇专门说。
4. 两段真代码,对应上面两句最硬的话
校验入口 在 backend/app/sql/guard.py。顺序就是:只读断言 → parse → 必须是查询 → 表白名单 → 敏感列 → 学校 sch_id → 强制 LIMIT。
python
# 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, settings=settings)
if not _is_readonly_query(parsed):
raise SqlGuardError("NOT_SELECT", "仅允许 SELECT 查询")
tables = _extract_tables(parsed) # CTE 别名已排除
unknown = tables - allowed
if unknown:
raise SqlGuardError("TABLE_NOT_ALLOWED", f"表不在白名单: {', '.join(sorted(unknown))}")
if policy is not None and policy.denied_columns:
validate_denied_columns_sql(stripped, policy.denied_columns, ...)
if outer.args.get("limit") is None:
parsed = outer.limit(max_rows) # 没写就补;写大了同样压回 max_rows
return render_sql(parsed, sql_ctx=sql_ctx, settings=settings)
行级注入 在 backend/app/policy/scope_injector.py。模型没写绑定列时,补 AND col IN (:scope_...);写了字面量,则核对是否在授权里。
python
# backend/app/policy/scope_injector.py(节选)
# 模型没写 tenant_id 时,执行前补上授权 IN,参数不来自模型
in_sql = f"{col_ref} IN ({', '.join(placeholders)})"
# 已有字面量则 validate_scope_literals:99 不在 [10, 20] 就 SCOPE_VIOLATION
LangGraph 里这两步是显式节点:validate_sql 过了才 apply_policy,再 execute_sql。 不是藏在某个「助手函数」里碰运气调用。
5. 几笔取舍,我现在还认
正则 + 语法树,两层都留。 正则脏、快,擅长「语句里出现了 INSERT」。树干净,擅长「这张表是不是 CTE」。只留一层,都会在对方擅长的地方漏。
表白名单兜底为空,而不是写死业务表。 开源仓库要给别人接自己的库。写死我自己的表名,别人 clone 下来要么查错,要么误以为「没配也能查」。空集合更难看,但更诚实。
LIMIT 默认 100,允许运维改,不允许模型说了算。 问数页要的是能看的结果,不是一次导出全量。全量走报表 / 下载,别走对话。
行级条件由网关注入,不要求模型「记得写 WHERE」。 Prompt 里会提示数据范围,那是为了让 SQL 更像人写的、少走纠正。真正的边界是参数化 IN。模型今天抽风,授权值也不会变成它编的那个。
这些取舍对不对,欢迎拍砖。项目还在长。
6. 这篇先到这
记住三句就够:
- NL2SQL / Chat-to-SQL 的 SQL 安全,边界在执行层,不在模型自觉;
- 闸门要能讲出「差点出事」的例子,否则只是清单;
- 不确定就拒绝,比默认放行难做,但更敢接到业务库旁边。
开源仓库在下面,本地可以跑通(Compose + Fixture,不必先配云钥匙)。说明看 docs/DEMO.md / AGENTS.md。
GitHub: github.com/yanqiuping1...