副标题:总部、区域经理和跨区域人员使用同一条 DSL,为什么会得到不同的销售结果?
最近看到很多关于 AI 问数准确性、指标口径和自然语言查询的文章,但关于数据权限如何真正落到查询模型中的内容相对少一些。
我看到过一些问数产品,公开介绍更多集中在菜单权限、功能权限等方面,可能是我还没有遇到更专业的介绍,也可能部分产品主要面向领导驾驶舱和聚合指标场景,权限细节没有展开。
所以下面以 Codex 为例,分享一种我们目前正在实践的行级数据权限配置方式,抛砖引玉。
上一篇文章讨论了业务系统、BI 和 AI 问数如何共享同一批事实、指标、时间口径、数据粒度、状态和权限。那篇文章把权限作为三个入口都必须遵守的边界,但没有继续展开:权限规则到底怎样进入查询模型,最后又怎样影响 SQL 和合计结果?
这篇就从一个具体的销售查询开始。
先把场景说清楚
这次使用前文已经出现过的 WWI OLTP 发票明细查询模型:
- 查询模型:wwi_oltp_invoice_lines
- TM:WwiOltpInvoiceLineModel
- 销售金额:totalIncludingTax
- 权限字段:customer$salesTerritory
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 };
}
}
};
这里有三个关键点:
- Authorization 是可信上下文,不是用户在 DSL 中声明的角色;
- 总部、单区域、多区域分别对应不加条件、eq 和 in;
- 任何无法确认身份或数据范围的情况都默认拒绝。
同一个 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 降低了编码式权限的使用门槛,但真正的数据边界仍然必须由可信身份来源和查询引擎共同保证。