Skip to content

feat(authorization): enforce clearance, department and project labels in every chunk query - #20

Merged
poppycoderr merged 2 commits into
mainfrom
feat/label-authorization
Oct 1, 2026
Merged

poppycoderr merged 2 commits into
mainfrom
feat/label-authorization

Conversation

@poppycoderr

@poppycoderr poppycoderr commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Background

Until now the compiled predicate carried only the tenant. This PR implements the rest of the decision table from docs/architecture/authorization.md: clearance, department and project. It covers milestone items 2.1, 2.2 and 2.4 in one change, so that labels are never stored without being enforced.

The predicate

 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[]))
  • Constant text. The predicate text is the same for every principal, and only the bound parameters vary. No claim value can change the shape of the query.
  • Fails closed.
    • A missing or unknown clearance claim binds the lowest rank.
    • A missing department binds NULL, which never compares true.
    • Missing projects bind an empty array, which overlaps nothing.
  • One predicate for all paths. The sparse channel, the dense channel and the chunk listing embed the same predicate object, as before. Fusion and deduplication still only reorder rows that SQL admitted.
  • Version. policy_version is now abac/1.

Labels

POST /api/v1/ingestion-jobs
  documents[].classification       public | internal | confidential | restricted   (default public)
  documents[].allowedDepartments   []  = no department restriction
  documents[].requiredProjects     []  = no project restriction; otherwise any one of them suffices

unchanged ⇔ content hash, format and labels all match the active version
labels changed, content unchanged
  → new version row takes over the existing chunks, pointer flips in the same transaction
  → no chunking, no embedding call

Migration V5 adds the label columns to document_version and to the queued job documents. classification_rank is a generated column, so the level order is defined in one place. Existing versions become public and unrestricted, which is how they behaved before.

A label-only change does not call the model service. Revoking access therefore works during a model outage and applies to the next query.

Region and validity are scope, not authorization. They arrive with the scope filters in 2.3, after the time-semantics decision.

How it is verified

AuthorizationPropertyIT (jqwik, real PostgreSQL)
  for random corpora and principals, including values that break naive quoting
    expected = reference evaluator written from the decision table, in test code only
    listing, sparse query and dense query must each return exactly `expected`
  • Property test. Test rows are written through JDBC arrays, not through the production array literal, so a quoting bug cannot cancel itself out. The test fails within a few tries when <= becomes <, when the department rule is dropped, or when array elements are left unquoted.
  • Integration tests. They cover each rule on all three strategies, the conjunction of rules, hostile claim values, and the label-only change with the model service unavailable.
  • New dependency. jqwik is a new test dependency. The authorization rules require property-based tests, and jqwik provides generators and shrinking on the JUnit Platform the project already uses.

Evaluation impact

None, as expected. The demo corpus has no access labels yet, so inside a tenant every principal still sees the same documents as before. The CI evaluation run of this PR (Linux x86_64, policy abac/1) returns ranked results identical to the published M1b report in all 280 case and strategy pairs, with zero security violations. Labelled documents, the matching visibility labels and authorization-negative cases follow in the next PR. Only then does the evaluation security gate exercise these rules.


背景

此前编译出的谓词里只有租户条件。本 PR 实现了 docs/architecture/authorization.md 决策表的其余部分:密级、部门和项目。它把里程碑的 2.1、2.2、2.4 三项合在一次改动里完成,这样标签不会出现「已存储但未执行」的状态。

谓词

SQL 的变化见英文部分的 diff。

  • 文本固定。 谓词文本对所有身份都相同,只有绑定参数不同。任何 claim 的值都改变不了查询的形状。
  • 默认拒绝。
    • 缺失或无法识别的密级 claim 绑定为最低级。
    • 缺失的部门绑定为 NULL,比较永远不成立。
    • 缺失的项目绑定为空数组,和任何数组都没有交集。
  • 所有路径共用一个谓词。 关键词通道、向量通道和 chunk 列表仍然嵌入同一个谓词对象。融合和去重依旧只对 SQL 放行的行重新排序。
  • 版本。 policy_version 现在是 abac/1。

标签

入库请求的字段和幂等规则见英文部分的代码块。

迁移脚本 V5 给 document_version 和排队中的任务文档都加上了标签列。classification_rank 是生成列,级别顺序只在一处定义。已有版本变为公开、不受限,和它们之前的行为一致。

只改标签不会调用模型服务。因此模型服务宕机时仍然可以收回访问权限,并且对下一次查询生效。

region 和有效期属于适用范围,不属于授权。它们会在时间语义确定之后,随 2.3 的范围过滤一起加入。

如何验证

基于属性的测试的思路见英文部分的伪代码。

  • 基于属性的测试。 测试数据通过 JDBC 数组写入,不走生产代码里的数组字面量,所以引号处理上的 bug 不会自己抵消掉。把 <= 改成 <、去掉部门规则、或者不给数组元素加引号,这个测试都会在几次尝试内失败。
  • 集成测试。 覆盖每条规则在三种策略上的表现、多条规则同时成立、恶意的 claim 值,以及模型服务不可用时的纯标签变更。
  • 新依赖。 jqwik 是新增的测试依赖。授权规则要求有基于属性的测试,而 jqwik 在项目已经使用的 JUnit Platform 上提供了生成器和收缩(shrinking)。

对评测的影响

没有影响,这符合预期:demo 语料还没有访问标签,所以在同一个租户内,每个身份能看到的文档和之前一样。本 PR 的 CI 评测(Linux x86_64,policy abac/1)与已发布的 M1b 报告相比,全部 280 个「用例 × 策略」组合的排序结果完全一致,越权结果为 0。带标签的文档、对应的可见性标注和授权负例会在下一个 PR 里加入,到那时评测的安全门禁才会真正检验这些规则。

@poppycoderr
poppycoderr merged commit a05bcae into main Oct 1, 2026
6 checks passed
@poppycoderr
poppycoderr deleted the feat/label-authorization branch October 1, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant