先看结论与判断条件
- 撤权首先是读取门禁切换,其次才是物理清理,不能在漫长删除期间继续返回旧向量结果。
- 每份原文及所有派生节点绑定 documentId、subjectId、permissionEpoch、sourceRevision 和 policyVersion。
- 清理对象不只包括向量,还包括分块文本、摘要、检索缓存、查询历史、临时文件、日志字段和任务队列。
- 索引、检索与摘要任务提交前再次读取当前 permissionEpoch,拒绝旧代际写回和迟到回调。
- 密钥失效可以缩短残留数据可读窗口,但不能替代明文缓存、数据库记录和备份策略中的实际清理。
- 完成回执要列出派生图覆盖、逐类计数、失败重试和候选版本,不能把一次 delete 返回成功当作清理完成。
先把撤权拆成阻断、清理和证明三个阶段
用户撤销某份文档的访问权限时,最先要改变的是读取资格,而不是等待数据库逐表删除。权限服务应原子递增 permissionEpoch,写入 revoked 状态,并让检索入口在组装查询前检查当前主体、文档和代际。旧索引文件即使暂时仍在磁盘,也不能再成为候选结果;否则清理耗时越长,越容易继续泄露已撤销内容。
第二阶段沿派生图执行清理。原文可能被复制到应用私有目录、解析缓存、OCR 临时文件、分块表、向量分片、摘要表、关键字索引、查询缓存和模型会话。只删除文档主表会留下可检索或可还原的副本。每个派生节点必须能回答来自哪个 sourceDocumentId、哪一版 sourceRevision、哪个 permissionEpoch,以及由哪个任务生成。
第三阶段产生可审核的完成回执。回执列出计划节点、实际删除、已失效密钥引用、无法立即删除的介质、排队重试和仍受保留策略约束的最小记录。它证明系统执行了哪些动作,不等于绝对证明所有物理介质已不可恢复。缓存、系统备份和第三方 SDK 的实际行为要按各自边界核验。
| 阶段 | 关键写入 | 检索状态 | 完成条件 |
|---|---|---|---|
| 开始撤权 | epoch 递增并标记 revoked | 立即拒绝 | 状态事务提交 |
| 派生清理 | 逐节点删除或加密失效 | 持续拒绝 | 任务无遗漏 |
| 迟到任务收敛 | 旧 epoch 写回被拒绝 | 持续拒绝 | 活动任务归零 |
| 回执完成 | 记录计数和失败项 | 持续拒绝 | 无未解释节点 |
| 重新授权 | 创建新 epoch 和新任务 | 仅新代际可用 | 不得复活旧缓存 |
用派生数据图覆盖原文到答案缓存的全部路径
端侧 RAG 的数据链通常从文档 URI 或文件副本开始,经解析器产生文本与版面信息,再切成 chunk,计算 embedding,写入向量索引,并生成摘要、关键词或重排特征。查询时又会产生候选集合、相似度缓存、拼接后的上下文和答案缓存。任何节点仍携带足以重建内容的信息,都属于撤权范围。
派生图不应依靠文件名约定推断。每个节点保存稳定 nodeId、parentNodeIds、documentId、subjectId、permissionEpoch、dataClass 和 storageLocator;多文档摘要或共享索引分片要记录所有父节点。若一个分片混合了已撤权与仍授权内容,清理任务必须重建分片,不能粗暴删除影响其他用户,也不能因为共享而保留撤权向量。
图中还要包含间接副本。诊断事件可能记录原文片段,崩溃附件可能带查询上下文,模型会话可能保存拼接 prompt,第三方 SDK 可能收集文件名或用户输入。OWASP 关于日志敏感数据的风险提示可用于建立检查表,但项目仍要逐 SDK、逐字段核对实际收集和传输,不能假设应用私有存储内的数据天然安全。
| 节点类型 | 可能含有的数据 | 撤权动作 | 核对证据 |
|---|---|---|---|
| source_copy | 原文件或规范化文本 | 删除文件与元数据 | 路径和哈希回执 |
| chunk | 局部正文和位置 | 按 documentId 删除 | 行数与父节点 |
| embedding | 语义表示和标签 | 删除向量并重建分片 | 索引版本 |
| summary | 压缩后的敏感事实 | 删除摘要和引用 | 来源集合 |
| query_cache | 候选、上下文或答案 | 按 epoch 失效并清除 | 缓存键清单 |
| diagnostic | 文件名、片段或错误输入 | 脱敏或按策略删除 | 字段级审计 |
permissionEpoch 必须贯穿索引、检索和展示
布尔 authorized 无法区分先授权、撤销、再授权的多个生命周期。permissionEpoch 在每次授权边界变化时单调递增,所有派生节点和任务都绑定创建时看到的 epoch。读取条件不只是 documentId 存在,还要同时满足 subjectId、当前 authorized 状态、节点 epoch 等于当前 epoch,以及 sourceRevision 仍在允许范围。
检索应在多个位置复核。查询规划时过滤无权文档,向量候选返回后再次核对节点标签,拼接上下文前确认父节点仍有效,答案提交前检查会话 generation 和权限快照。多次检查不是为了掩盖不一致,而是应对撤权与长耗时任务并发;每次失败都返回 permission_changed,并丢弃当前候选。
重新授权不能复用旧 epoch 的索引,即使文档内容哈希相同。用户可能更改了允许用途、账号、目录范围或数据处理选择。新授权创建新 epoch,按当前政策重新导入并产生新节点;旧节点继续处于 revoked 清理流程。这样可以避免撤权后仅翻转布尔值就让残留缓存重新可见。
| 执行点 | 输入代际 | 当前代际来源 | 不匹配终态 |
|---|---|---|---|
| 导入任务 | 任务创建 epoch | 权限事务表 | 取消解析与写入 |
| 向量写入 | chunk epoch | 当前文档授权 | 拒绝提交分片 |
| 查询规划 | 会话权限快照 | 权限服务 | 排除文档 |
| 上下文组装 | 候选节点 epoch | 批量当前状态 | 丢弃整次候选 |
| 答案缓存 | 请求 epoch 集合 | 当前文档集合 | 禁止缓存 |
| 页面展示 | 响应 generation | 页面当前请求 | 忽略迟到结果 |
先取消新工作,再处理正在运行的迟到任务
撤权事务提交后,调度器应停止为旧 epoch 领取新任务,并向正在运行的解析、embedding、摘要和压缩任务发送取消信号。取消只是优化,不能作为正确性前提,因为线程可能已经越过检查点,底层库也可能不支持即时中断。真正的门禁位于每次持久化提交前:任务重新读取当前 epoch,不一致就放弃输出。
队列消息至少携带 documentId、subjectId、permissionEpoch、sourceRevision、taskId 和 outputNodeIds。消费者不能仅凭 taskId 去重,因为撤权后新授权可能产生新的合法任务。写入事务把当前权限检查、节点唯一键和输出提交放在同一一致性边界;如果存储不支持跨组件事务,就使用准备态、提交标记和补偿清理,并保留每一步回执。
迟到任务最容易复活已经删除的数据。比如向量计算完成后写回旧分片,摘要回调重新放入内存缓存,或 WorkManager 重试恢复过期任务。清理完成前要枚举活动任务,完成后还要维持旧 epoch 的拒绝墓碑,直到所有可能重试窗口结束。墓碑只保存身份和代际,不保存文档内容。
| 任务状态 | 撤权动作 | 提交前检查 | 回执 |
|---|---|---|---|
| queued | 取消或标记不可领取 | epoch 必须当前 | cancelled_before_start |
| running | 请求取消 | 输出写入前复核 | discarded_stale_output |
| committing | 由事务裁决 | 权限与输出同事务 | committed 或 rejected |
| retry_wait | 永久拒绝旧 epoch | 墓碑匹配 | retry_suppressed |
| completed | 纳入派生图清理 | 节点父关系完整 | deleted 或 unresolved |
密钥失效是辅助边界,不能替代派生数据清理
可以为文档或权限域使用数据加密密钥,并用 Android Keystore 保护包裹密钥或执行受限密钥操作。撤权时删除可用密钥引用,能使仍在介质上的密文更难被正常解密,尤其适合无法瞬时重写的大文件。但 Keystore 只约束密钥使用,不会自动删除已经解密的 chunk、向量、摘要、内存页和日志。
密钥层级要避免一把全局密钥覆盖所有文档,否则撤销单份文档时无法独立失效;也不能为每个临时对象创建无界密钥导致管理失控。工程上常用主密钥包裹文档数据密钥,派生节点记录 keyRef 和 permissionEpoch。具体方案取决于设备能力、备份、迁移和恢复需求,不能从通用平台文档推导出某项目已启用硬件保护。
加密擦除也有适用限制。明文在生成 embedding 前会进入内存,某些向量本身可能仍泄露语义,系统备份可能保留旧密文,截图或导出文件则超出本地索引控制范围。因此回执要区分物理删除、逻辑不可检索、密钥失效和受保留策略约束,不用一个 cleaned 布尔值遮蔽不同证据等级。
用派生图校验器阻止遗漏节点和旧任务写回
下面的 Python 校验器读取公开安全的清理计划 JSON,逐个检查节点的 documentId、permissionEpoch、父节点和清理状态。它要求计划 epoch 与撤权记录一致,任何节点仍标记 active、父关系缺失或任务持有旧代际都会以非零状态退出。示例只处理元数据,不接触真实文档、向量或密钥。
图遍历从 source 节点开始,确认所有声明的派生节点都可达;若存在孤儿节点,说明来源链不完整,不能宣布完成。对 shared_shard 之类共享节点,计划应先生成只含授权成员的新版本,再把旧版本标记 replaced。代码并不执行删除,目的是让发布或测试流水线拒绝一份不完整的清理回执。
输入验证故意采用 fail closed:缺少 revocation、epoch 不是正整数、节点状态未知或任务仍可能提交时全部失败。生产系统还要校验签名、事务版本和存储实际计数。校验器能证明清单内部一致,不能证明磁盘块不可恢复,也不能替代对 SDK、备份和云同步路径的独立审查。
import json
import sys
from pathlib import Path
ALLOWED_STATES = {"deleted", "key_revoked", "replaced"}
def stop(message):
print(f"cleanup receipt rejected: {message}", file=sys.stderr)
raise SystemExit(2)
def load_plan(path_text):
path = Path(path_text)
if not path.is_file():
stop("plan file is missing")
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
stop(f"cannot read plan: {exc}")
def validate(plan):
revocation = plan.get("revocation", {})
document_id = revocation.get("documentId")
epoch = revocation.get("permissionEpoch")
if not document_id or not isinstance(epoch, int) or epoch < 1:
stop("invalid revocation identity")
nodes = plan.get("nodes", [])
if not nodes:
raise SystemExit("cleanup receipt rejected: derivation graph is empty")
by_id = {node.get("nodeId"): node for node in nodes}
if None in by_id or len(by_id) != len(nodes):
stop("node ids are missing or duplicated")
for node in nodes:
if node.get("documentId") != document_id:
stop(f"foreign document node: {node.get('nodeId')}")
if node.get("permissionEpoch") != epoch:
stop(f"epoch mismatch: {node.get('nodeId')}")
if node.get("cleanupState") not in ALLOWED_STATES:
stop(f"unfinished node: {node.get('nodeId')}")
for parent in node.get("parentNodeIds", []):
if parent not in by_id:
stop(f"missing parent {parent}")
for task in plan.get("tasks", []):
if task.get("permissionEpoch") <= epoch and task.get("canCommit"):
stop(f"stale task can still commit: {task.get('taskId')}")
print("cleanup receipt is internally consistent")
if __name__ == "__main__":
if len(sys.argv) != 2:
stop("usage: validate_cleanup.py plan.json")
validate(load_plan(sys.argv[1]))测试要证明撤权瞬间、清理过程和重新授权都不串线
基本用例先导入文档并确认存在原文、chunk、embedding、摘要和缓存节点,然后在查询与索引任务运行时触发撤权。门禁应立即让新查询返回 permission_changed,活动任务即使完成计算也无法提交。测试随后等待清理器收敛,核对每类节点计数、共享分片替换和旧 epoch 墓碑。
竞争用例要控制事件顺序,而不是依赖随机压力。分别覆盖向量写入前撤权、写入事务中撤权、候选返回后撤权、答案缓存前撤权、应用被杀后任务重试,以及撤权后马上重新授权。每个事件记录 requestGeneration、permissionEpoch、taskId 和 candidateHash,才能判断迟到结果究竟来自哪一代。
隐私测试还要搜索日志、崩溃附件、临时目录、剪贴板、导出路径和第三方 SDK 事件。Data safety 声明反映应用与 SDK 实际收集和共享的数据,但声明本身不会清除数据;测试发现的实际路径要反馈到数据地图和商店披露。没有对应回执时,应把该路径标记为未覆盖。
| 用例 | 控制点 | 必须观察 | 失败信号 |
|---|---|---|---|
| 查询并发撤权 | 候选返回前后 | 旧候选不进上下文 | 页面仍显示旧答案 |
| 索引任务迟到 | embedding 提交前 | 旧 epoch 写入被拒绝 | 索引节点复活 |
| 进程被杀 | 清理中断后重启 | 从回执断点继续 | 状态回到 active |
| 快速重新授权 | 新旧任务交错 | 仅新 epoch 可见 | 旧缓存被复用 |
| 共享分片 | 部分文档撤权 | 重建后成员正确 | 误删或残留 |
| 诊断路径 | 错误与崩溃上报 | 无正文和敏感片段 | 日志仍含内容 |
发布门禁需要清理回执、数据地图和明确限制
发布前的数据地图要把每类输入、派生节点、存储位置、密钥、任务和 SDK 接收者关联起来,并为每条边指定撤权动作。OWASP MASVS 的存储、隐私、密码学、平台交互等控制域适合作为审查索引,但不能代替具体业务分类、用户同意、法律保留和第三方合同审查。
门禁至少检查 permissionEpoch 一致性、旧任务写回拒绝、派生图可达性、每类删除计数、重试队列、墓碑保留和日志脱敏。候选的数据结构、解析器、向量库、缓存键、SDK 或备份设置变化后,旧回执不再充分,应基于新版本重跑。NIST SSDF 可支持变更与验证证据流程,但不提供某个实现的清理结论。
站内关于端侧 RAG 索引完整性的文章可继续核对索引文件来源与版本;准备评估商业 App 的 AI 数据边界和加固范围时,可通过页面行动按钮进入御盾中央平台提交数据地图、撤权序列与候选包。提交只启动评估,清理是否完整仍以同一候选的节点回执和设备测试为准。
- 撤权事务先递增 permissionEpoch 并立即阻断新检索。
- 原文、分块、向量、摘要、缓存、日志和任务都进入派生图。
- 每个节点保存 documentId、subjectId、sourceRevision 与 permissionEpoch。
- 所有后台任务在提交输出前重新读取当前权限代际。
- 密钥失效与物理删除、逻辑隔离和保留策略分别记账。
- 共享分片重建后核对成员,不误删仍授权文档。
- 撤权后重新授权创建新 epoch,不复活旧缓存。
- 候选变化后重跑并发、重启、SDK 和诊断路径测试。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android Keystore 可约束密钥用途,并可在设备支持时使用更强的密钥保护。 | Android Keystore 说明密钥材料、用途限制和设备保护相关的平台能力。 | Keystore 不清除解密后的原文、向量、摘要、缓存或日志,也不证明项目已采用硬件保护。 |
| 应用的数据披露需要包含自身代码和第三方 SDK 的实际收集与共享。 | Declare Android app data use 说明开发者对应用及集成 SDK 的 Data safety 信息负责。 | 商店声明不是运行时权限门禁、数据最小化或清理机制。 |
| 生产日志可能暴露令牌、个人信息和内部诊断数据。 | OWASP MASWE-0001 描述敏感数据写入应用日志的风险和缓解方向。 | 该页面处于 Beta,本文只采用稳定的风险与最小化原则,项目日志仍需字段级验证。 |
| 移动端应最小化敏感数据访问,并纳入第三方 SDK 的数据行为。 | OWASP MASVS-PRIVACY-1 要求应用最小化敏感数据访问。 | 该控制不能替具体业务的数据分类、用户同意记录、保留策略和合规审查。 |
| 移动安全验收需要按不同控制域分别取证。 | OWASP MASVS overview 给出存储、密码学、认证、网络、平台、代码、韧性和隐私等控制组。 | 控制分类不提供端侧 RAG 清理实现,也不形成任何具体产品的验收结论。 |
| 安全发布应保留需求、变更、构建和验证证据。 | NIST SP 800-218 SSDF 把安全开发与供应链风险管理组织为可执行实践。 | SSDF 不规定 permissionEpoch 或向量索引清理算法,具体结果仍要项目回执。 |
| 撤权后先阻断检索、再异步清理,可以减少清理窗口中的继续暴露。 | 工程判断:读取门禁是即时一致性边界,物理清理通常跨多个存储和任务,无法假定同事务完成。 | 具体延迟与覆盖率需要在目标存储、设备和任务系统上测量,本文不提供性能数字。 |
| 御盾项目中的派生图覆盖和撤权清理效果当前不能由通用资料推出。 | 项目证据尚未接入:需要候选哈希、节点清单、epoch 事件、设备矩阵和真实完成回执。 | 证据齐备前不得声称已清除全部副本、阻断攻击、通过兼容测试或产生客户效果。 |
工程常见问题
撤销权限后只删除向量数据库中的记录可以吗?
不够。原文副本、chunk、摘要、关键词索引、查询缓存、临时文件、日志和后台任务都可能保留内容或重新生成向量,必须按派生图处理。
为什么要先递增 permissionEpoch,再慢慢删除文件?
物理清理跨多个介质和任务,无法保证瞬时完成。先切换代际能让检索立即拒绝旧节点,并让迟到任务在提交前确定性失败。
删除 Keystore 中的密钥是否等于数据已经清理?
不等于。它可让相应密文失去正常解密路径,但已经解密的内存、缓存、向量、摘要、日志和备份仍需单独清理和验证。
用户重新授权同一文档时可以复用旧索引吗?
不应直接复用。新授权可能对应不同账号、用途和政策,应创建新 permissionEpoch,按当前数据边界重新导入,旧节点继续清理。
后台任务收到取消信号后还要检查 epoch 吗?
必须检查。取消可能迟到或底层计算不可中断,只有输出提交前重新读取当前 epoch,才能阻止旧任务把数据写回索引或缓存。
怎样证明撤权清理已经完成?
用同一候选的派生图、逐类节点计数、旧任务拒绝、共享分片替换、日志与 SDK 检查以及设备回归形成回执,同时明确备份和物理介质边界。