检索时授权
这是整个项目的核心主张:身份被编译成一个 SQL 谓词,关键词通道与向量通道都带着它执行。未授权的行不离开数据库,因此没有任何下游组件需要「记得过滤」。
一、先说结论
- 事后过滤既泄漏又损召回:数据进了应用内存就会流向重排、prompt、日志与 trace;被过滤掉的又正是索引已选中的 top-k。
- 授权谓词由纯函数编译:
(Principal, PolicyVersion) → SqlPredicate,所有身份属性都是绑定参数,绝不做字符串拼接。 - 授权与适用范围是两件事:租户、密级、部门、项目属于授权,计入安全门禁;region、有效期、文档状态属于适用范围,影响相关性而非访问权。
- 存在性也要保护:无权访问与不存在,对外必须表现一致。
- 决策表已全部生效:
policy abac/1,租户、密级、部门、项目四条规则都编译进了查询。demo 语料已经带上访问标签,评测门禁用 30 条授权负例和人工标注的可见集合检验这四条规则;本文明确区分设计目标与已验证范围。
二、三条不变量
| 不变量 | 含义 | 当前验证到哪一级 |
|---|---|---|
| 安全 | 未授权的 chunk 不离开 SQL 边界,不进入应用内存、重排、prompt、日志、trace、缓存与响应 | 完整决策表:评测门禁(30 条授权负例,逐 chunk 检查文档与版本,逐身份比较完整可见列表)、基于属性的测试对照独立参考实现、每条规则在三种策略上的集成测试、架构测试 |
| 召回 | 加上授权谓词后,授权子集内的排序质量不应低于在该子集上做精确检索 | 构造性成立:v0.1 不建 ANN 索引,所有已授权行都是候选 |
| 存在性 | 「你无权查看」与「它不存在」从外部看完全一致 | 部分:检索不返回过滤计数与被排除行的任何元数据;404 与统一拒答要等 M2–M3 |
把三条分开写有实际意义。安全与召回常被混为一谈:「在数据库里过滤」保证了安全,但并不自动保证召回——带 WHERE 的 HNSW 近似检索会在过滤条件很窄时返回远少于 top-k 的结果。v0.1 用精确检索把这个问题挡在门外,将来引入索引时必须专门测量。
三、决策表
一个 chunk 对某个身份可见,当且仅当下表每一行都成立(行之间是 AND),默认拒绝。
| # | 属性 | 身份侧 | 文档侧 | 规则 | 身份缺失该属性时 |
|---|---|---|---|---|---|
| 1 | 租户 | tenant_id(必需声明) | chunk.tenant_id | 相等 | token 被拒绝 |
| 2 | 密级 | clearance | classification | rank(classification) <= rank(clearance),顺序 public < internal < confidential < restricted | 视为 public |
| 3 | 部门 | department | allowed_departments | 数组为空,或部门在数组中 | 只能看到无部门限制的文档 |
| 4 | 项目 | projects[] | required_projects | 数组为空,或交集非空(any-of) | 只能看到无项目限制的文档 |
四行均已实现。推迟到 v0.2 的还有 groups、角色、显式 deny 与 chunk 级标签;如果将来加入 chunk 级标签,它只能收紧文档权限,绝不能放宽。
四、编译后的形态
当前生效的谓词(policy abac/1)。它的文本对所有身份都相同,只有绑定的值不同,所以任何 claim 的值都改变不了查询的形状:
c.tenant_id = :auth_tenant_id
AND v.classification_rank <= :auth_clearance_rank
AND (cardinality(v.allowed_departments) = 0 OR CAST(:auth_department AS text) = ANY(v.allowed_departments))
AND (cardinality(v.required_projects) = 0 OR v.required_projects && CAST(:auth_projects AS text[]))几处「默认拒绝」的细节:
- 缺失或无法识别的密级 claim 绑定为最低级——拼错的 claim 永远不会放宽权限;
- 缺失的部门绑定为
NULL,与NULL比较永远不成立;缺失的项目绑定为空数组,和任何数组都没有交集; - 项目列表作为一个数组字面量绑定,每个元素都加引号,值里的逗号、花括号、引号都只是数据。
关键词通道、向量通道和 chunk 列表嵌入的是同一个谓词对象;融合与去重只对 SQL 已放行的行排序。
标签变更不依赖模型服务
标签属于文档版本,随入库请求提交(classification、allowedDepartments、requiredProjects);不带标签的文档在租户内公开、不受限。只有内容、格式、标签三者都与当前版本相同,才算「未变更」。
只改标签时,系统新建一个版本行,把原版本的 chunk 整体接管过来,并在同一个事务里翻转指针——不切分、不算向量。这样收回权限不需要模型服务在线,并且对下一次查询生效。
policy_version 随每个结果返回,后续还会写入查询执行记录与审计事件(M2.5),因此「这条结果是在哪个策略下返回的」是可追溯的。
五、授权与适用范围为什么要分开
一个 US 的员工看不到 EU 的休假政策,通常不是因为「无权访问」,而是「不适用于他」。把两者混在一起会有三个后果:
- 安全指标被稀释——权限负例里混进大量适用性用例,「零泄漏」这个结论就不值钱了;
- 无法表达「2024 年的政策是什么」这类合理需求;
- 用例里的「不该返回的文档」含义含混,到底是越权还是不相关。
因此评测数据把二者拆成 unauthorized_documents(安全门禁)与 hard_negative_documents(质量指标),报告也分开统计。
适用范围现在已经实现,时间语义也有了定论(ADR-0005):
asOf只按有效期过滤文档的当前版本,默认是请求发生的时间;版本在valid_from <= asOf < valid_to时适用,缺失的一端视为不限。它不会选出历史版本,授权也始终按当前版本的标签判断。- 想回答「2025 年的政策是什么」,做法是把被取代的制度作为一篇独立文档发布,并给它封闭的有效期——而不是去翻同一篇文档的历史。
- region 默认取身份 token 里的值;没有标注 region 的文档适用于所有地区;没有 region 的身份不做地区过滤。
asOf和region可以由请求指定,因为它们不属于授权:换一个地区或日期提问,改变的是哪些内容相关,而不是这个身份能读什么。- 范围条件由独立的类型编译、使用自己的参数,和授权谓词是 SQL 里两个分开的条件。评测用的 chunk 列表可以去掉范围条件(
includeOutOfScope),但去不掉授权谓词。
为什么不直接支持「选取历史版本」:那需要定义多个版本并存时取哪个、按当时的标签还是现在的标签判断权限、旧版本保留多久,还要有评测用的参考答案。其中任何一条出错,内容就会在错误的标签下被返回——那是泄漏,不是相关性问题。
六、不泄漏存在性
- 看不到的文档返回 404,与不存在的文档一致;
- 响应与 trace 里不出现「已过滤 N 条」;
- 无权访问导致的空结果,与语料中确实没有答案,返回结构完全一致。
已知残余风险:时延侧信道。授权命中与未命中的耗时可能不同,v0.1 不做缓解,威胁模型会明确写出这一点。
七、怎么保证它不被绕过
| 防线 | 内容 |
|---|---|
| 架构测试 | 扫描主代码,只有 AuthorizedChunkQuery 的源码可以出现 from/join chunk;authorization 包不依赖 web、retrieval 与 JDBC |
| 基于属性的测试 | jqwik 随机生成身份与带标签的文档(包括会破坏朴素引号处理的值),列表、关键词、向量三条查询路径的结果必须与一份独立的参考实现完全相同;参考实现只存在于测试代码里。测试数据经 JDBC 数组写入,不走生产代码的数组字面量,引号 bug 无法自我抵消 |
| 集成测试 | 真实 pgvector 上覆盖决策表每条规则在三种策略上的表现、多条规则同时成立、恶意 claim 值、模型服务不可用时的纯标签变更、无效与权限不足的 token |
| 评测门禁 | 返回文档与人工标注的可见集合比较,而不是与 policy compiler 自己比较;有一条越权结果就让 CI 失败 |
| 变异验证 | 开发时故意改坏谓词(<= 改成 <、去掉部门规则、数组元素不加引号),基于属性的测试都在几次尝试内失败,确认测试确实有拦截能力 |
M2 接下来补上:审计事件、存在性保护与威胁模型。
八、常见误区
- 「在 SQL 里过滤就等于安全且高召回」:安全成立,召回不一定。近似向量索引先取近邻再按条件过滤,过滤越窄结果越少。
- 「返回 403 比 404 更诚实」:对未授权内容返回 403 等于承认该文档存在,这本身就是泄漏。
- 「权限可以放在重排或 prompt 阶段兜底」:那意味着未授权内容已经进入了这些组件,兜底的是事故而不是权限。
小结
授权写在查询里,是为了让「谁能看到什么」只有一处实现、一处验证。从租户隔离扩到完整决策表时,改动只发生在 PolicyCompiler 内部和标签的存储上,检索代码一行没动——这正是把授权编译成一个谓词对象的好处。接着看检索通道如何在这个谓词之上取回候选。