AI 问数也要做行级权限:让 Codex 给销售查询加上权限

副标题:总部、区域经理和跨区域人员使用同一条 DSL,为什么会得到不同的销售结果?

最近看到很多关于 AI 问数准确性、指标口径和自然语言查询的文章,但关于数据权限如何真正落到查询模型中的内容相对少一些。

我看到过一些问数产品,公开介绍更多集中在菜单权限、功能权限等方面,可能是我还没有遇到更专业的介绍,也可能部分产品主要面向领导驾驶舱和聚合指标场景,权限细节没有展开。

所以下面以 Codex 为例,分享一种我们目前正在实践的行级数据权限配置方式,抛砖引玉。

上一篇文章讨论了业务系统、BI 和 AI 问数如何共享同一批事实、指标、时间口径、数据粒度、状态和权限。那篇文章把权限作为三个入口都必须遵守的边界,但没有继续展开:权限规则到底怎样进入查询模型,最后又怎样影响 SQL 和合计结果?

这篇就从一个具体的销售查询开始。

先把场景说清楚

这次使用前文已经出现过的 WWI OLTP 发票明细查询模型:

  • 查询模型:wwi_oltp_invoice_lines
  • TM:WwiOltpInvoiceLineModel
  • 销售金额:totalIncludingTax
  • 权限字段:customer$salesTerritory

customer salesTerritory是TM/QM中的语义字段。customer表示客户维度,salesTerritory 是 TM/QM 中的语义字段。customer 表示客户维度, salesTerritory是TM/QM中的语义字段。customer表示客户维度, 后面是该维度的属性,salesTerritory 表示客户所属的销售区域。它对应 WWI 数据里的销售区域字段,不是用户自己在 DSL 中填写的权限参数。

给这个查询模型增加下面的行级权限:

身份 可见范围 合计范围
总部 全部销售区域 全部销售区域
区域经理 自己负责的销售区域 自己负责的销售区域
跨区域支持人员 被授权的多个销售区域 被授权的多个销售区域
身份缺失或无效 拒绝查询 不产生结果

增加"跨区域支持人员",是为了展示多值权限范围:他可以看几个被授权的区域,但不能看全部区域。

为什么用代码表达权限

不同于传统的配置式权限方案,本篇采用编码的形式表达权限规则。

在传统开发中,使用配置确实可以减少重复开发量。但要维护一套真正可用的数据权限配置系统,需要处理组织树、动态数据范围、多租户、策略版本、生效时间、缓存和审计等问题,系统本身往往并不简单。

AI 让编码式权限的使用门槛降低了。你只需要用业务语言告诉 Codex"总部看全部、区域经理看自己的区域、跨区域人员看被授权的区域",Codex 就可以协助检查 TM、生成 QM/FSScript、补充验证请求,并根据结果继续调整配置。

这并不是说配置式权限不可用。如果企业已经有完整的数据权限系统,完全可以在数据模型代码中,通过自定义函数或 Spring Bean 连接现有权限系统,获取用户身份、授权区域和策略结果;也可以通过 FSScript 的 get/post 调用已有权限接口。数据模型只负责把返回的可信范围转换成行级条件,最终仍由查询引擎执行。

我给 Codex 的第一句话

真实使用时,我不会一开始就把所有实现细节写成一份需求说明,通常会先这样说:

sql 复制代码
帮我给现在这个 WWI 发票明细查询加上销售区域权限。
总部能看全部,区域经理只能看自己的区域,跨区域支持人员只能看分配给他的几个区域。没有身份或身份不对,就不要返回数据。
你先看看 TM 里有没有现成的销售区域字段,直接在 QM 里处理,别让我在 DSL 里传权限。
身份怎么拿先用 FSScript 伪代码,注释里说明以后可以通过 get/post 调业务系统,或者接 Spring Bean。
最后用同一条查询,给我看看总部、区域经理和跨区域人员生成的 SQL 有什么不同。

如果 Codex 继续追问身份来源,再补一句:

sql 复制代码
先不要接真实权限系统,先把 resolver 和 SQL 逻辑写完整,身份解析部分保留伪代码。

这比直接把一整套接口定义、验收标准和内部实现约束都塞进第一条提示词更接近日常工作方式。Codex 先检查已有模型,再根据上下文补全实现。

FSScript 里的权限解析

下面是权限 QM 中的伪代码。resolveIdentity 不是固定内置函数,这里只展示接入位置:

fsscript 复制代码
const invoiceLines = loadTableModel('WwiOltpInvoiceLineModel');

export const queryModel = {
  name: 'wwi_oltp_invoice_lines_territory_authorized',
  model: invoiceLines,

  modelPermissions: {
    mode: 'resolver',
    resolver: (context) => {
      /*
       * 伪代码:把 Authorization 交给可信身份来源。
       *
       * 方案一:通过 get/post 调业务系统,
       * 将授权码换成:
       * { level, territory, territories }。
       *
       * 方案二:调用可解码并校验授权码的 Java 类或 Spring Bean。
       */
      const identity = resolveIdentity(context.authorization);

      if (!identity) {
        return { allow: false };
      }

      // 总部:不增加销售区域过滤条件
      if (identity.level === 'HQ') {
        return { allow: true };
      }

      // 区域经理:只能看自己负责的一个区域
      if (
        identity.level === 'REGION_MANAGER' &&
        identity.territory
      ) {
        return {
          allow: true,
          rowPredicates: [
            context.predicate.eq(
              invoiceLines.customer$salesTerritory,
              identity.territory
            )
          ]
        };
      }

      // 跨区域支持人员:只能看被授权的多个区域
      if (
        identity.level === 'TERRITORY_SUPPORT' &&
        identity.territories &&
        identity.territories.length > 0
      ) {
        return {
          allow: true,
          rowPredicates: [
            context.predicate.in(
              invoiceLines.customer$salesTerritory,
              identity.territories
            )
          ]
        };
      }

      // 未知身份、空范围或不完整身份默认拒绝
      return { allow: false };
    }
  }
};

这里有三个关键点:

  1. Authorization 是可信上下文,不是用户在 DSL 中声明的角色;
  2. 总部、单区域、多区域分别对应不加条件、eq 和 in;
  3. 任何无法确认身份或数据范围的情况都默认拒绝。

同一个 DSL,最终 SQL 不同

用户只提出一个业务问题:

yaml 复制代码
查询 2026 年 1 月含税销售额最高的 10 个销售区域

假设当前用户是 Southeast 区域经理,身份服务返回:

json 复制代码
{
  "level": "REGION_MANAGER",
  "territory": "Southeast"
}

用户 DSL 单独表达的是日期、聚合、排序和条数:

sql 复制代码
SELECT sales_territory,
       SUM(total_including_tax) AS total_amount
FROM wwi_oltp_invoice_lines
WHERE invoice_date >= '2026-01-01'
  AND invoice_date < '2026-02-01'
GROUP BY sales_territory
ORDER BY total_amount DESC
LIMIT 10;

QM resolver 注入行权限后,最终执行逻辑等价于:

sql 复制代码
SELECT sales_territory,
       SUM(total_including_tax) AS total_amount
FROM wwi_oltp_invoice_lines
WHERE invoice_date >= '2026-01-01'
  AND invoice_date < '2026-02-01'
  AND sales_territory = 'Southeast'
GROUP BY sales_territory
ORDER BY total_amount DESC
LIMIT 10;

如果是跨区域支持人员,条件会变成:

sql 复制代码
AND sales_territory IN ('Southeast', 'New England')

总部则不注入销售区域条件。

上面使用的是逻辑模型字段,方便说明合并关系。真实 Runtime 生成 SQL 时,会根据 TM 展开客户、城市和州省之间的关联,并使用实际表别名。

最重要的是,权限条件发生在聚合之前。SUM、COUNT、排名等结果,都是权限过滤后的结果,不能先算全量合计,再对明细结果做过滤。

用户不能通过 DSL 放大权限

如果 Southeast 区域经理主动在 DSL 中筛选 New England,用户条件和系统权限条件会同时存在:

sql 复制代码
AND sales_territory = 'New England'
AND sales_territory = 'Southeast'

结果是 0 行。

用户可以继续缩小自己的查询范围,但不能通过删除筛选条件、修改区域名称或让 AI 漏掉一个参数来扩大系统授权范围。

怎么验证

至少验证下面四种情况:

身份 验证重点
总部 明细和合计覆盖全部销售区域
区域经理 只返回自己的销售区域
跨区域支持人员 只返回授权的多个销售区域
无效身份 查询被拒绝,不退回全量

还需要用一条越界 DSL 做边界测试,确认普通查询条件无法覆盖 resolver 返回的行条件。

这次 Codex 实际完成了什么

Codex 可以协助完成:

  • 检查 TM 中是否已经存在可用的销售区域字段;
  • 生成带 modelPermissions.resolver 的 QM;
  • 将身份结果转换成 typed row predicate;
  • 生成同一 DSL 下不同身份的验证请求;
  • 根据 SQL 和结果继续修正配置。

但 Codex 不是权限系统。生产环境中的登录、SSO、授权码签发、签名校验、组织关系和审计,仍然由企业自己的系统负责。

结尾

数据权限不应该停留在提示词里"请不要越权"。更可靠的方式是:

复制代码
可信身份
  → 权限 resolver
  → 行级 predicate
  → 业务 DSL 合并
  → 聚合前过滤
  → 返回受权限约束的结果

这篇只展示最常用的行级权限。当前 WWI 示例使用销售区域作为权限字段,生产系统可以把它替换为真实的组织、租户或客户归属字段,整体思路不变。

Codex 降低了编码式权限的使用门槛,但真正的数据边界仍然必须由可信身份来源和查询引擎共同保证。

相关推荐
千里码aicood1 小时前
flask基于数字人的储粮知识问答原型系统研究与实现
后端·python·flask
考虑考虑1 小时前
arthas使用
java·运维·后端
李昊哲小课2 小时前
Spring Boot 4 旅游主题实战教程 阶段三:数据持久化
spring boot·后端·mybatis·旅游
AI 编程助手GPT2 小时前
Bun 1.4 正式发布:从 Zig 改写为 Rust,内置浏览器、图片处理和并行测试
开发语言·人工智能·后端·ai·chatgpt
IT_陈寒2 小时前
Redis大KEY删除慢到手抖,这几个方法让我少熬一夜
前端·人工智能·后端
步行cgn2 小时前
传统 SSM 与 Spring Boot 开发对比:从配置地狱到约定优于配置
java·spring boot·后端
JavaGuide4 小时前
一个 SKILL.md 拿下 2.3 万 Star:让 Codex/Claude Code 少说废话、先给答案
前端·后端
陈随易12 小时前
pm2 替代品,nodejs&bun 线上部署必备工具
前端·后端·程序员
丘山一郎12 小时前
Spring 中的IOC控制反转 和DI依赖注入
java·后端·spring