Skip to content

feat(api): read an authorized document and answer every refusal with the same 404 - #27

Merged
poppycoderr merged 3 commits into
mainfrom
feat/document-read-without-existence-leak
Oct 2, 2026
Merged

poppycoderr merged 3 commits into
mainfrom
feat/document-read-without-existence-leak

Conversation

@poppycoderr

Copy link
Copy Markdown
Owner

Background

Milestone item 2.6. The existence invariant says that "you may not see this" and "this does not exist" look the same from outside. Search already returned no counts of filtered rows. There was no way to read a single document, though, and that is the endpoint where a careless 403 would give existence away.

Contract

GET /api/v1/documents/{key}        query scope
  200  {key, title, versionNo}     the principal is authorized for the document
  404  identical in every other case:
         no such key · another tenant · clearance, department or project does not admit it
         · disabled · deleted · key not well formed

Design decisions

read(principal, key)
  key not well formed → 404, nothing recorded
  predicate = compile(principal)                         # the same predicate as search
  row = AuthorizedChunkQuery.document(key, predicate)    # the same join: active version of an active document
  audit document.read, decision = row present ? allow : deny
  row present ? 200 : 404
  • One path for visibility. The read goes through AuthorizedChunkQuery and the compiled predicate. A document is readable exactly when its chunks are retrievable, and there is no second rule that could drift from search.
  • Scope is not applied. The endpoint answers whether the principal may read the document, not whether it applies to a region or a date.
  • deny carries no reason. The audit event records that the key was not visible to the principal and nothing else. The audit log therefore does not become a list of which hidden documents exist.
  • Only validated keys are stored. The key becomes the audit event's resource id, so a malformed key is rejected before anything is written.

How it is verified

  • Document reads. One test reads a confidential document, a document restricted to another department, a disabled one, a deleted one, one in another tenant, a malformed key and a nonexistent key. Every response must equal the nonexistent case in status, body and header names, apart from the key the caller sent.
  • Search. A keyword search that only hidden documents could answer must equal a search that nothing answers, once the execution id and trace id are removed. The test uses the keyword channel on purpose: a vector search always returns the nearest visible chunks, whatever the query, so it says nothing about hidden content, but two of its responses are never identical.
  • Audit. Reads are recorded as allow or deny with empty attributes, and a malformed key leaves no event.

Timing differences between these cases are not mitigated. The authorization document lists this as a residual risk, and the threat model will cover it.


背景

里程碑的 2.6 项。存在性不变量的要求是:从外部看,「你无权查看」和「它不存在」表现一致。检索此前已经不返回被过滤的数量。但系统还没有读取单篇文档的接口,而这正是一个随手返回 403 就会暴露存在性的地方。

契约

接口的行为见英文部分的代码块:已授权时返回 {key, title, versionNo};其他所有情况返回完全相同的 404。

设计决定

读取流程见英文部分的伪代码。

  • 可见性只有一条路径。 读取走的是 AuthorizedChunkQuery 和编译好的谓词。一篇文档可读,当且仅当它的 chunk 可以被检索到;不存在第二套可能和检索不一致的规则。
  • 不应用适用范围。 这个接口回答的是「这个身份能不能读这篇文档」,而不是它是否适用于某个地区或日期。
  • deny 不带原因。 审计事件只记录这个 key 对该身份不可见,不记录别的。这样审计日志不会变成一份「哪些隐藏文档存在」的清单。
  • 只存储校验过的 key。 key 会成为审计事件的资源 id,所以格式不合法的 key 在写入任何内容之前就被拒绝。

如何验证

  • 文档读取。 一个测试分别读取机密文档、仅限其他部门的文档、已停用的文档、已删除的文档、其他租户的文档、格式不合法的 key,以及不存在的 key。每个响应的状态码、响应体和响应头名称,都必须与「不存在」的情况相同,只有调用方传入的 key 不同。
  • 检索。 一次只有隐藏文档能回答的关键词检索,去掉执行 id 和 trace id 之后,必须与一次没有任何内容能回答的检索完全相同。测试特意用了关键词通道:向量检索无论查询是什么,都会返回距离最近的可见 chunk,所以它不会透露隐藏内容,但它的两次响应永远不会完全相同。
  • 审计。 读取被记录为 allow 或 deny,属性为空;格式不合法的 key 不留下任何事件。

这几种情况之间的耗时差异没有做处理。授权文档把它列为残余风险,威胁模型会覆盖这一点。

@poppycoderr
poppycoderr merged commit e2558bb into main Oct 2, 2026
6 checks passed
@poppycoderr
poppycoderr deleted the feat/document-read-without-existence-leak branch October 2, 2026 17:29
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