上生产如何防越权:把 SQL 执行权从大模型手里拿回来

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,不能签发执行权。图上没有「助手模式跳过网关」的边。

执行面

生成面

召回与口径

生成 SQL 字符串

校验

注入授权

只读执行

读图:箭头只有一条,从字符串进入校验。生成面结束的地方,执行面才开始。Prompt 侧仍会做不可信定界、召回清洗,那是降低模型被带跑的概率,不是执行许可。提示词注入打不穿执行层,下一篇 B2 专门写。

节点顺序也是设计,不是随便排的:

  1. 先校验,再注入。 白名单、只读、LIMIT 先过;注入只补范围,不放开新表。
  2. 注入发生在出库前。 模型没写租户条件,系统补上参数化 IN;写了不在授权里的字面量,直接拒。
  3. 执行器不信任上游。 改写后的字符串再走一遍只读和行数帽。图以后会改,这条契约不能改。

校验失败走纠正或直接失败,不会降级成「先查出来再说」。

二、网关按失败模式分层

叠很多检查,容易写成清单。有用的分法是:每一层对应一种「看起来能过、实际上已经出事」的语句。少一层,就会在那一类上漏。

模型产出的 SQL

只读断言

sqlglot 语法树

表白名单 列 LIMIT

行级范围注入

执行器二次校验

只读业务库

从上到下,越靠近数据库越不信任上一层。任意一步不确定,整条链路停,到不了业务库。

1. 语句形态:业务库只读

业务库禁止 DML、DDL、权限语句、多语句;只允许 SELECTWITH ... 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 里这两步是显式节点,不是藏在某个助手函数里碰运气调用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值