NL2SQL 最怕越权:我在执行层叠了好几层 SQL 闸门

上一篇把 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...

这篇只讲闸门:

  1. 为啥不能把执行权交给模型
  2. 几层检查分别拦什么,配「差点出事」的例子
  3. 当时几笔不想事后后悔的取舍

提示词注入、评测集、sqlglot 方言坑,后面单独拆,这篇不硬塞。


1. 生成 SQL 和执行 SQL,必须是两件事

玩具级 Chat-to-SQL 的骨架我见过不少:Schema 塞进 Prompt,模型吐 SQL,应用层直接跑。 演示很爽。接到公司库上,我会下意识退半步------不是不信模型会写 SELECT,是不敢把「会不会写坏」当成唯一防线。

NL2SQL 上生产,事故成本通常不是答错一个数,而是:

  • 模型顺手写了 DELETE / UPDATE(它真的会,尤其是用户话里带了「清掉」「改成」);
  • 编了张不该碰的表;
  • 漏了行级条件,把别的租户、别的学校查出来;
  • LIMIT 忘了写,一次拖几十万行,前端和库一起喘。

Prompt 里当然会写「只能单条只读 SELECT」。召回文本也会洗一洗疑似注入指令。 这些有用,但它们挡的是模型的行为倾向,不是库门口的执行权。

我这边主链路很死板:

生成 SQL → validate_sqlapply_policyexecute_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,拒绝。 最外层必须是 SELECTWITH ... SELECT,否则 NOT_SELECT

为啥不继续用正则判断「是不是查询」?因为表名、LIMIT、列引用后面都要改树。 字符串替换 LIMIT,遇到子查询、方言差异、注释,迟早出丑。sqlglot 的坑以后单写,这里先用它该用的能力:把语句当成结构看。

第三层:物理表白名单(空兜底,一张都不能查)

从 AST 抽表名,CTE 别名不算物理表WITH punch AS (SELECT ... FROM sport_activity_qzs_time) SELECT ... FROM punch 里,punch 是临时结果,不能拿去对白名单。 这层我一开始差点写反:要么合法 CTE 被误杀,要么模型随口起个「表名」就混过去。

白名单从治理库的表元数据 / 指标相关表加载,进程内缓存。 代码里的兜底是空集合,不是「开发时写死的那几张常用表」。

这是故意的。元数据没配好、缓存没刷新,结果应该是谁也查不了,而不是默默能查全库。 产品经理有时会觉得「不友好」。可一旦兜底放行,那才是真事故。

不在名单里的表:TABLE_NOT_ALLOWEDSELECT * 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...

相关推荐
foggyprojects2 小时前
当 AI 输出销售额时,如何让它解释这个数字是怎么算出来的?
后端
一只叫煤球的猫2 小时前
开个新坑,从头开始完整拆解 Spring AI 2.0 的源码
后端·面试·aigc
自进化Agent智能体2 小时前
Hermes Cron 定时任务 —— 让 Agent 自动工作
后端
我不是码神663 小时前
Windows 下 Codex CLI 报“拒绝访问 (os error 5)”:先查入口,再查配置
后端·chatgpt
她的男孩3 小时前
我用 LLM 把后台 CRUD 效率提升 10 倍:AI 代码生成器的架构与落地实践
java·后端·架构
程序员cxuan3 小时前
本地跑一个 Qwen 3.8,你将拥有一个 Opus 4.6
人工智能·后端·程序员
凤山老林4 小时前
高保真集成测试:Spring Boot 结合 Testcontainers 的工程落地
spring boot·后端·集成测试
步行cgn4 小时前
MyBatis <trim> 标签完全解析:动态 SQL 的终极武器
后端
XPoet4 小时前
AI 编程工程化:实战——从 0 到 1 搭建 AI 编程工作流
前端·后端·ai编程