检索时授权
这是整个项目的核心主张:身份被编译成一个 SQL 谓词,关键词通道与向量通道都带着它执行。未授权的行不离开数据库,因此没有任何下游组件需要「记得过滤」。
一、先说结论
- 事后过滤既泄漏又损召回:数据进了应用内存就会流向重排、prompt、日志与 trace;被过滤掉的又正是索引已选中的 top-k。
- 授权谓词由纯函数编译:
(Principal, PolicyVersion) → SqlPredicate,所有身份属性都是绑定参数,绝不做字符串拼接。 - 授权与适用范围是两件事:租户、密级、部门、项目属于授权,计入安全门禁;region、有效期、文档状态属于适用范围,影响相关性而非访问权。
- 存在性也要保护:无权访问与不存在,对外必须表现一致。
- 决策表已全部生效:
policy abac/1,租户、密级、部门、项目四条规则都编译进了查询。demo 语料已经带上访问标签,评测门禁用 30 条授权负例和人工标注的可见集合检验这四条规则;本文明确区分设计目标与已验证范围。
二、三条不变量
| 不变量 | 含义 | 当前验证到哪一级 |
|---|---|---|
| 安全 | 未授权的 chunk 不离开 SQL 边界,不进入应用内存、重排、prompt、日志、trace、缓存与响应 | 完整决策表:评测门禁(30 条授权负例,逐 chunk 检查文档与版本,逐身份比较完整可见列表)、基于属性的测试对照独立参考实现、每条规则在三种策略上的集成测试、架构测试 |
| 召回 | 加上授权谓词后,授权子集内的排序质量不应低于在该子集上做精确检索 | 构造性成立:v0.1 不建 ANN 索引,所有已授权行都是候选 |
| 存在性 | 「你无权查看」与「它不存在」从外部看完全一致 | 检索与文档读取已验证:读取机密、仅限其他部门、已停用、已删除、其他租户、格式不合法、不存在的 key,得到的是逐字节相同的 404(只有 key 不同);只有隐藏文档能回答的关键词检索,与没有任何内容能回答的检索响应相同。回答链路上一致的拒答要等 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 的这几项都已完成:审计事件、存在性保护与威胁模型(见下)。
审计:同步写入,写失败就拒绝
每次检索在返回结果之前,会在同一个事务里写入一条执行记录和一条审计事件;写入失败,检索返回 503,一条结果也不给。管理操作(提交入库、停用、删除)与它的审计事件在同一个事务里,审计写不进去,变更就回滚。
审计属性在结构上就是白名单:每种动作有自己的写入方法和带类型的参数,JSON 在 SQL 里用 jsonb_build_object 组装,代码里没有可以随意塞内容的 map。所以查询文本、chunk 文本、文档标题没有进入审计表的途径——有一个测试专门用辨识度很高的查询和标题去检索,然后把两张表的每一行当文本读出来确认。
每个请求在认证之前就拿到 trace id。合法的 W3C traceparent 提供这个 id,格式不对的会被丢弃并重新生成,调用方没法借这个请求头往日志和审计行里注入内容。
Trace:白名单在导出的位置强制执行
每个请求是一条 OpenTelemetry trace,它的 trace id 就是 X-Trace-Id 返回的、写进审计行和执行记录的那个 id。一次查询的 span 如下:
http post /api/v1/query
└── query.answer
├── retrieval.search
│ ├── policy.compile
│ ├── retrieval.sparse
│ ├── retrieval.dense
│ │ └── model.embed
│ ├── retrieval.fusion
│ └── rerank
│ └── model.rerank
├── context.build
├── generation
└── citation.validatespan 只能带白名单里的属性:策略、k、检索配置哈希、策略版本、各阶段的候选数与结果数、模型名、回答状态、降级原因,以及失败阶段的异常类名。
关键在于白名单在哪里执行。应用自己创建的 span 好管,但并不是每个 span 都由应用创建:HTTP 埋点会自动附上请求路径(里面有文档 key),被记录的异常会带上消息,而模型服务拒绝请求时,消息里引用的正是发过去的文本。所以白名单不放在创建 span 的地方,而是放在 span 离开进程的地方:每个 span 导出器都被包一层,白名单以外的属性、所有 span 事件和状态描述在导出前被丢弃。
对应的测试在问题、文档标题、文档正文和模型回复里各放一个标记词,然后跑回答、四种策略的检索、一次错误消息会引用输入的重排失败、文档读取和几个被拒绝的请求;导出的任何 span 只要含有标记词、白名单以外的属性、事件或状态描述,测试就失败。把这层包装去掉,测试会因为 http.url 带出文档 key 而失败。
日志:不打印异常消息
日志有同样的问题,而且更隐蔽:模型服务拒绝一次请求时,异常消息里引用的就是发过去的问题或文档段落,log.warn("...: {}", e.toString()) 这样一行就把它写进了日志。所以项目里的日志只用异常类名、HTTP 状态码和 SQL 状态码描述一次失败,从不输出异常消息。
对应的测试让 embedding、重排和对话模型的调用都以“消息里引用了输入”的错误失败,再把审计表弄坏、发几个非法请求,然后读取捕获到的全部日志:问题、文档标题、文档正文、prompt 只要出现一个就失败。这个测试第一版其实是无效的——测试替身抛出的异常消息里并不带请求内容,把日志改回打印异常它照样通过;按真实客户端的行为改掉替身之后,它才真的能抓住问题。
在 Docker Compose 环境里,每行日志是一个带 traceId 的 JSON 对象。加上 COMPOSE_PROFILES=observability 启动,会多起一个带 Grafana 的 collector,dashboard 上能看到各阶段的 p95 延迟、降级原因、回答状态和可以点开的 trace。
威胁模型:没有检查验证的,一律算残余风险
docs/security/threat-model.md 的写法只有一条规则:一项措施只有被某个具名的测试或检查验证过,才列为控制措施;否则列为残余风险。它直说了几件不好听的事:
- 身份只是 demo:签名密钥公开在仓库里,任何人都能签发任意身份。这个项目展示的是「身份已被验证的前提下」怎么做授权。
- 每个租户只有一种管理角色:能入库的人也能调低密级,目前只有审计,没有防护。
- 谓词由应用执行:拿到应用数据库凭据的人可以读到一切,PostgreSQL 行级安全可以作为第二层。
- 耗时侧信道、审计行防篡改、限流、日志内容,都没有任何检查覆盖。
八、常见误区
- 「在 SQL 里过滤就等于安全且高召回」:安全成立,召回不一定。近似向量索引先取近邻再按条件过滤,过滤越窄结果越少。
- 「返回 403 比 404 更诚实」:对未授权内容返回 403 等于承认该文档存在,这本身就是泄漏。
- 「权限可以放在重排或 prompt 阶段兜底」:那意味着未授权内容已经进入了这些组件,兜底的是事故而不是权限。
小结
授权写在查询里,是为了让「谁能看到什么」只有一处实现、一处验证。从租户隔离扩到完整决策表时,改动只发生在 PolicyCompiler 内部和标签的存储上,检索代码一行没动——这正是把授权编译成一个谓词对象的好处。接着看检索通道如何在这个谓词之上取回候选。