同一条销售查询,两个用户为什么看到不同结果?

上一篇,我给同一张发票明细表加了日期、客户、地区、商品、税率和金额等 8 类筛选条件,没有再为每个条件写一套查询接口。

但如果这张表真的接进业务系统,还有一个问题比筛选器更早:谁能看到哪些数据?

这次我仍然使用 WideWorldImporters OLTP 的发票明细,提交同一个 QueryModel、同一份 DSL、同一个日期范围,只替换调用者的权限上下文。

实际结果如下:

用户权限 可见销售区域 发票明细数 含税金额
东南区用户 Southeast 6,497 5,844,653.75
新英格兰区用户 New England 1,620 1,448,738.78

两次请求里没有任何一个地方由 AI 填写"我的权限区域"。

两次生成的 SQL 结构也相同,最后都被强制追加:

sql 复制代码
and d2.sales_territory = ?

区别只在受信权限上下文绑定的参数:一次是 Southeast,一次是 New England

这篇不先讲 RBAC、ABAC 或权限治理方法论,只回答一个具体问题:Foggy 接进已有业务系统后,怎样让页面、查询 API 和 AI 继续遵守原来的数据范围?

同一份 DSL 里没有权限参数

两个用户提交的都是下面这份请求:

json 复制代码
{
  "limit": 1,
  "columns": [
    "count(invoiceLineId) as invoiceLineCount",
    "sum(totalIncludingTax) as totalIncludingTax"
  ],
  "slice": [
    {
      "field": "invoiceDate$id",
      "op": "[)",
      "value": ["2016-01-01", "2016-06-01"]
    }
  ]
}

这里的日期是业务筛选条件。用户可以改成某个月,也可以继续增加商品、客户或金额范围。

销售区域却不是普通查询参数。

如果把权限也设计成:

json 复制代码
{"field": "customer$salesTerritory", "value": "Southeast"}

那它只是一个筛选器。调用者可以删除它,也可以把值换成另一个区域。AI 同样可能在拼装请求时漏掉它。

业务条件回答"用户想查什么",系统权限回答"这个用户最多能查到什么"。两者必须从不同入口进入查询。

权限条件在 QM 内部强制生效

我为本轮验证建立了一个权限专用 QM:

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

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

    modelPermissions: {
        mode: 'resolver',
        resolver: (context) => {
            let territory;

            if (context.authorization == 'Bearer demo-territory-southeast') {
                territory = 'Southeast';
            } else if (context.authorization == 'Bearer demo-territory-new-england') {
                territory = 'New England';
            } else {
                return { allow: false };
            }

            return {
                allow: true,
                rowPredicates: [
                    context.predicate.eq(
                        invoiceLines.customer$salesTerritory,
                        territory
                    )
                ]
            };
        }
    }
};

这里的两个 token 只是 disposable 测试身份,便于把证据完整复现出来。真实项目不会在 QM 里维护用户清单,而是由业务系统、身份服务或可信网关校验登录态,再把 opaque Authorization 交给权限解析器。

当前 resolver 能按动作决定是否允许模型访问,并返回类型化的行条件。它拿到的是宿主传入的权限上下文,不是 AI 在 DSL 中声明的角色或区域。

用户主动筛另一个区域会怎样

为了确认这个条件不是"默认筛选",我让东南区用户主动提交:

json 复制代码
{
  "field": "customer$salesTerritory",
  "op": "=",
  "value": "New England"
}

如果普通 DSL 能覆盖系统权限,这次就会越权看到 New England。

实际生成的 WHERE 同时保留了两条条件:

sql 复制代码
and d2.sales_territory = ?
and d2.sales_territory = ?

参数依次为:

text 复制代码
New England
Southeast

结果是 0 行。

用户可以继续收窄自己的范围,但不能把 Southeast 改成 New England,也不能通过删除 slice 去掉它。即使请求完全不带区域条件,resolver 仍然会追加 Southeast

这才是"系统权限先于页面和 AI 生效",而不是提示 AI 每次记得补一条 WHERE。

没有身份时不是返回全量

本轮还执行了两个反例:

  • 不带 Authorization
  • 带一个未配置的 token。

两种情况都拒绝模型执行,返回:

text 复制代码
The requested model operation is not available.

它没有在解析失败时退回 29,458 条全量发票明细。

模型 describe 也受相同入口控制:无权限时返回 MODEL_ACCESS_DENIED,合法上下文才能取得字段元数据。这一点很重要,因为"不能执行查询,但仍把敏感模型和字段完整暴露出来"也不是一个完整的权限边界。

这和 Runtime 登录不是一回事

权限接入时最容易混淆四件事:

层次 它回答的问题
Runtime 认证 这个调用方能不能访问 Runtime API
模型权限 能不能 describe、validate、execute 这个 QM
行权限 在这个 QM 中最多能看到哪些业务记录
字段权限 哪些列允许被描述、选择或返回

namespace 也不等于用户权限。它用于隔离和定位一组模型、数据源与资源,不能替代"销售 A 只能看自己区域"这样的业务规则。

模型权限不会替代入口认证。如果浏览器可以随便伪造 Bearer demo-territory-southeast,这个演示 token 本身当然不安全。生产部署必须先校验登录态、签名、有效期和调用来源,再把可信权限上下文交给 Foggy。

已有业务系统可以怎样接

企业不一定需要先建设一套新的权限中心。现阶段可以按原系统的部署方式选择接入点。

方案一:独立 Runtime 解析 opaque Authorization

这就是本篇跑通的主链。

Runtime Query API 接收 Authorization,QM 的 modelPermissions.resolver 根据当前动作和身份结果决定:

  • 是否允许访问模型;
  • 应该强制追加哪些行条件。

它适合已经有统一网关、SSO、服务 token 或权限服务,希望 Foggy 独立部署的项目。

Foggy 当前已经支持 Authorization 透传、模型动作授权、typed row predicate 和 fail closed。生产部署仍需要接入企业实际使用的 token 校验、RBAC / 租户映射与审计,但这不影响权限解析和强制行条件已经可以进入查询主链。

这里不一定要为每种权限系统开发一个 Java 适配器。

FSScript 已经提供全局 get / post。resolver 可以把 context.authorization 原样交给企业已有的权限接口,再把接口结果转换成 Foggy 的模型动作和行条件:

javascript 复制代码
modelPermissions: {
    mode: 'resolver',
    resolver: (context) => {
        const remote = post({
            url: 'https://permission.example.internal/v1/model/resolve',
            headers: {
                Authorization: context.authorization
            },
            body: {
                namespace: context.namespace,
                model: context.model,
                action: context.action
            },
            responseType: 'map'
        });

        if (remote.allowed !== true) {
            return {
                allow: false,
                decisionId: remote.decisionId,
                policyVersion: remote.policyVersion
            };
        }

        return {
            allow: true,
            attributes: remote.attributes,
            rowPredicates: [
                context.predicate.in(
                    invoiceLines.customer$id,
                    remote.customerIds
                )
            ],
            decisionId: remote.decisionId,
            policyVersion: remote.policyVersion,
            expiresAt: remote.expiresAt
        };
    }
}

这样,企业可以修改 QM / FSScript 来适配已有权限 API,完成 validate 后刷新或发布模型,不需要修改、重新编译和发布 Java 应用。

开发或自托管 external bundle 还可以通过 watch=true 监听 .tm.qm.fsscript 变化,自动清理受影响脚本并推进 source revision。但"随写随发布"不等于普通用户可以在线改权限:模型作者属于可信计算基,生产环境仍应限制发布权限,并保留验证、版本和审计流程。

平台 HTTP 客户端已经处理了连接 / 请求超时、最大响应大小、非 2xx 失败、敏感内容不落日志,以及跨 origin 重定向时移除 Authorization 和 Cookie。外部权限接口超时、报错或 resolver 返回非法结构时,受保护模型拒绝操作,而不是退回全量数据。

token 应放在 HTTPS Authorization Header 中,不要为了方便拼进 GET query。query 参数可能进入代理、网关和访问日志,而 Header 才能沿用现有身份链路的脱敏与转发规则。

仍有一个成本必须正视:如果每次查询都同步调用权限服务,权限接口的延迟和可用性会进入查询主链。一次请求内相同 namespace + model + action 的决策会复用,但下一次 HTTP 查询会重新求值。生产设计需要为权限服务准备稳定 SLA,并谨慎设计带有效期的决策与缓存隔离。

方案二:Spring 内嵌时复用宿主 Bean

如果 Foggy 直接嵌入现有 Spring Boot 业务系统,QM 可以通过 accesses 调用宿主 Bean 或 helper,读取当前登录用户、组织、门店或客户范围:

javascript 复制代码
accesses: [{
    queryBuilder: (context) => {
        const user = getCurrentUser(context);
        context.query.and(sales.store$id, user.storeKey);
    }
}]

这种方式不需要把原系统的用户表复制进 Foggy。原业务系统仍然负责"他是谁"和"他属于哪些组织",QM 负责保证这些约束进入每次查询。

Spring Bean 不是内嵌部署的唯一选择。accessesqueryBuilder 本身就是 FSScript,也能从 context.authorization 取得 token,再使用相同的 get / post 调用外部权限接口。因此即使 Foggy 跟随 Spring Boot 一起部署,只要现有系统已经提供权限 API,也可以完全通过脚本完成适配,不必新增 Java Bean。

两种做法的选择很直接:已有可复用的本地登录上下文、复杂签名、mTLS 或宿主私有能力时,用 Bean;权限已经以稳定 HTTP API 提供时,用 FSScript get/post,适配代码可以随模型发布。

对于新模型,更推荐把远程权限调用集中在前面的 modelPermissions.resolver:它能同时处理 DESCRIBEVALIDATEEXECUTE 等动作,并把权限结果统一转换为 attributes 和 typed row predicates。accesses 仍可直接读取 Authorization 或调用外部接口来兼容既有行权限,但不要在多个 accesses 中重复请求权限系统,否则同一次查询可能得到多份不一致决策,也会放大网络开销。

现有 focused 测试已经验证:用户 slice、排序和权限 WHERE 会同时存在,普通 DSL 不会移除 accesses 注入的条件。本篇的双身份实数则来自前面的 Runtime resolver 主链,两类证据没有混写成同一个案例。

方案三:让数据库原生 RLS 兜底

如果业务身份可以可靠映射到数据库连接或 session context,也可以继续使用数据库 Row-Level Security。

Wide World Importers 本身就带有按 Sales Territory 限制客户范围的 RLS 示例。它的优势是无论查询来自 Foggy、报表还是其他客户端,数据库都执行最后一道限制。

它的代价也很实际:许多业务系统共用同一个数据库账号,真正的用户、角色和组织规则只存在于应用层。此时必须设计连接身份或 session context 的映射,不能因为数据库账号叫 readonly 就认为它已经具备业务行权限。

本轮没有为了文章创建数据库用户或改 RLS 配置,因此这里只把它作为 WWI 已有的数据库侧方案,不冒充本轮双身份执行证据。

方案四:可信业务网关先算好权限投影

Odoo、TMS、ERP 或已有 API 网关也可以先完成身份与权限计算,再调用内部查询引擎。

内部治理契约可以携带:

text 复制代码
systemSlice
fieldAccess
deniedColumns
policySnapshotId

例如宿主把"当前用户可见客户 ID 范围"转换成 systemSlice,把敏感金额列转换成 deniedColumns,同时保留本次策略快照用于审计。

关键边界是:这些字段只能由可信服务端生成,不能出现在普通 Runtime Query API 或 AI 可自由提交的 DSL 中。否则所谓系统权限又会退化成调用者自己填写的筛选条件。

Foggy 已经提供这些内部治理入口。本轮没有搭建一个真实 ERP 网关做端到端验证,所以它在本文中是接入方案,不是第四个成功案例。

一个 QM 能不能承载所有权限

同一份 QM 可以让页面、API 和 AI 共用字段、JOIN、指标和权限入口,但这不意味着所有用户必须看到完全相同的 schema。

如果只需要按组织、门店、区域或客户归属限制事实行,可以继续使用同一个 QM 的行权限。

如果不同角色的业务含义、可用指标和明细粒度已经明显不同,使用共享 TM / 指标定义下的多个 QM 往往更清楚。例如普通销售只开放客户汇总,财务角色才开放发票明细与利润字段。

这次只完成了行权限证据。我没有为了让文章看起来完整,虚构字段权限已经端到端跑通。

这次能确认什么,不能确认什么

已经确认:

  • 同一 QM、同一 DSL 能根据 opaque Authorization 注入不同的强制行条件。
  • 两个身份的结果与 WWI 按区域分组的只读基线一致。
  • 用户条件只能继续收窄,不能删除或放宽系统权限。
  • 无身份和未知身份 fail closed。
  • describe、validate、execute 已有动作化权限入口。

尚未确认:

  • 任意企业 SSO、JWT、RBAC 或多租户系统可以零配置接入。
  • 字段权限和完整审计已经完成本轮端到端验证。
  • 一个 demo token 可以代表生产认证安全。

对于有业务系统、有数据库、有 IT 或全栈人员,但还没有成熟 BI / AI 平台的企业,我更希望权限接入从这里开始:先复用原系统已经可信的一小部分用户与数据范围,跑通一个销售 QM,而不是等一套大而全权限平台全部建完才交付第一张表。

下一篇再继续向业务页面走:AI 查到一批客户以后,怎样打开成可筛选、分页、汇总,并能进入客户详情的表格,同时不丢掉这次已经绑定的权限上下文。

相关推荐
梅头脑1 小时前
Java镜像1.2GB→200MB,K8s Pod OOMKilled 137半夜报警——容器化的真实踩坑记录
后端
netCode1 小时前
IDEA使用 Alibaba Cloud Toolkit
后端
foggyprojects1 小时前
业务要给发票明细加 8 个筛选条件,我没有再写 8 个查询接口
后端
爱勇宝2 小时前
人到了一定年纪,才会看懂这些人性真相
前端·后端
阿标在干嘛2 小时前
从RESTful到GraphQL:政策快报平台接口设计的演进
后端·restful·graphql
万少3 小时前
用 TraeWork 给小孩做一个家庭工作台
前端·人工智能·后端
2601_963870183 小时前
【计算机毕业设计】基于Vue + Spring Boot的社区养老服务管理平台设计与实现
spring boot·后端·课程设计
前端开发张小七3 小时前
Java 学习笔记 · 第二课:面向对象核心(封装、继承、多态)及接口与异常
java·后端·程序员
颜进强3 小时前
Calude Code - 23 用 MCP 把 Jenkins 变成 AI 队友:一次对话完成自动化发布
前端·后端