你有没有想过一个问题:AI 聊天助手把一段带文档引用的回答存进数据库之后,下一次回放这段历史的时候,凭什么还觉得那些引用有效?
很多人第一反应是"这有什么好想的,当时查出来的,当时就是对的"。但这个说法只对了一瞬间——对的是写入的那一刻,而聊天历史是拿来读的,写入和读取之间隔着的不是空白,是时间。时间会改变租户的授权状态,会关掉某个开关,会把一份本来可见的文档撤出搜索范围。数据库行本身感知不到这些变化,它只知道老实躺着,把三天前捞上来的那段话原封不动地递给今天来读的人。
说人话就是——你们都把权限检查做在了查询的时候,这当然没错;但回放是一条被遗忘的通道,它绕过了查询时的那道闸门,直接从持久层把旧结果端出来了。你以为是同一个系统在服务用户,实际上是一段焊死在仓库里的历史在复活,带着它出生那天的一切上下文,包括那些早就该失效的权限。
这就是 Rodrigo Diego 那篇短文真正让我惦记的地方。他提了个设计原则,叫"持久化即非许可"——persistence is not permission。话糙理不糙。凡是存下来的东西,都有一个共同特征:它冻结的是过去某一刻的状态。你可以给每一行数据都盖上"写入时有效"的戳,但你没法让数据库在授权漂移的那一刻自动追上来改这一行。它不会,它也不该会——那是应用层的活儿。
所以真正的问题变成了:应用层在回放的时候,有没有重新问一遍"这条记录现在还有没有资格被这个人看到"?
有意思的是,作者的第一层门控做得挺绝。租户的 copilot 功能被禁用时,历史记录 API 直接返回 404,并且连"查一下这个会话存不存在"这种最基础的查询都不执行。注意这里,404 而不是 403,不是随便挑的。403 的潜台词是"我知道这东西存在,但你不许看"——光这一句话,就已经在向调用者确认了你探测的目标是真实存在的。404 的意思是:不管你有没有权限,我这儿都不存在你要找的东西。无效请求和越权请求在响应上长一个样,让探测者无从分辨哪个是"没有"、哪个是"不让看"。
这个选择很老派,像前代工程师处理反枚举问题时的惯用手法。但它在这里特别成立,因为聊天历史这种东西天然适合装死——用户误删了、系统清掉了、根本没生成过,都说得通,谁也不欠谁一个"为什么访问不到"。与其小心翼翼地解释边界,不如干干净净地让整个路由消失。当然,你得明白 404 的装死不是安全机制本身,它是一种姿态:不给探测者递话头。真正的闸门还是在权限校验那一层,404 只是决定"要不要让这扇门看起来存在过"。
往下再拆一层,我注意到作者还放了第二层更细的门控。当 copilot 功能在跑,但租户的文档搜索能力已经被禁用的时候,历史记录本身还是返回的——毕竟那只是聊天文本,用户说过的话不算机密;但附在消息上的文档来源卡片会被整个置成 null。这个处置的粒度我很喜欢。它不是一刀切把所有东西都捂死,而是把"对话历史上确实发生过这么一次检索"这件事跟"当时引用了哪些文档"剥离开。对话是租户自己的资产,来源却是从某个可能已被收回授权的角落里捞出来的,两者在权限生命周期上根本不是同一件事,本来就该分开判死刑。
你可以把这个想成:用户问了一句话,当时 AI 查了文档库给了回答,来源卡片标着是某份内部手册的第三章。一个月后文档库的权限收紧了,可那条回答还留在历史记录里。回放的时候如果照样把那张来源卡片焊回消息上,你等于给了用户一条线索:让他知道存在过一份手册、而且手册里有一章恰好回答过这个问题。这比直接给正文还危险——正文只是文本,来源卡片是在指路。
有个更朴素的说法,特适合当测试用例来想:如果权限已经撤销了,你在回放界面上看到的应该是一段"似乎没引用过任何东西"的话。这个细节做得不到位的话,等于自家聊天记录在帮用户猜哪扇门背后还藏着东西。
作者的代码里还顺带修了一个序列化校验的坑,我读到这段的时候想了一会儿才意识到它跟权限有啥关系。大意是:持久化的来源卡片在 JSONB 里的规范化格式跟 Go 结构体的严格校验不对付,格式上一点点出入就可能导致整个会话记录写入失败——或者更隐蔽地,回放时反序列化报错,一条好好的历史直接整个报废。
这个坑跟 RAG 的关联其实藏在更底层的地方。在线检索的时候,格式宽容是常态,管道里经过的每一个环节都会自己消化掉一些小偏差,没人会对着一份 JSON 的 key 顺序较劲。但数据一旦落库,你就换了一个世界——数据库不管你是人是机器,只管按表结构收;回放代码反序列化的时候,校验是严格的,以前在管道里被默默容忍的小毛病,在这个环节会变成硬错误。宽容的管道产出的数据,被严格的门卫拦在了门外。这就是我说的,持久化会把临时的宽容变成永久的严格。
你要说这跟授权有啥关系,其实没啥直接的因果。但它解释了为什么"回放"这个动作从来不是简单地把东西捞出来——你要保证捞出来的东西既没有被权限挡住,也没有被格式卡死。两条都断在半路上,那这条历史就算真丢了。
聊到这儿,得坦白一下。我一开始看到"回放时要重新检查授权"这个主张时,心里想的是:这不就是读路径上再做一次权限判断吗,有什么值得写一整篇的。真正扎到我的其实是那个前提——为什么这条路径会存在?因为大多数人的实现里,聊天历史就只是一张带外键的表,回放 HTTP 接口取出来就直接拼上下文了。压根没有人把它当成一次独立的、需要重新评估的读取。大家默认"当时写进去的是合法的,所以读出来也合法"。
这个默认在普通 CRUD 里通常站得住。发布过的文章、提交过的订单,写入时合法基本等于永远合法。但 RAG 上下文是唯一的例外,也是唯一的雷:它的内容里嵌着指向外部文档的引用,而外部文档的授权状态是可变的。其他领域的数据是自己就是本体,而 RAG 历史里的数据是"引用了本体的二手货"。一手的文档被撤了权限,二手的引用还留在会话里,继续给用户指路。
所以整个问题的核心不是权限系统写得够不够严密,而是你有没有意识到:上下文这个容器里的人称代词隔了一段时间之后会变得不成立。"那天 AI 有权限读这份手册所以引用了它",这句话里的"有权限"是一个发生在过去的动作,而你回放的时候,动作的当事人已经在时间对面了。要重新问一遍的还是那个问题——现在呢?
作者提出了检查的时机,而且不是拿到 API 请求的时候才查,是查到"历史记录列表"之前、在还没有执行任何查询的时候就先短路。这是有意为之的:能少暴露一个数据库行的存在就少暴露一次。反正权限判断永远比数据访问更便宜,也永远比数据访问更该先发生。
这个类比到这儿其实有点撑不住了。我是说"旧通行证"那个比喻——拿着一张旧工牌试图进已经换了门禁系统的办公区。通行证失效,人就进不去,很直观。但它撑不住的地方在于:现实里的门禁只有一重门,而回放路径上的门有好多扇,有些是权限闸门、有些是格式校验、有些是存在性装死的姿态。每一扇门都有自己独立的脾气。这不是一张卡刷一次就能全通的事。
写到这里我想起来,其实还有个细节也一直在拐角处等着——作者在处理 404 时对"调用者会不会已经知道会话存在"这件事的拿捏。文章里说 404 是为了"避免向调用者确认会话或数据的存在"。但拆穿说,这个保护能防的是没有其他信息渠道的调用者。如果请求是从前端发出去的,前端可能早就渲染过会话标题了,用户知道它存在。这时候 404 更像一个仪式性的防备,防的是调用者之外的、可能在嗅探 API 的第三方。要不要这么防,是产品决策。但有一点是确定的:装死永远比坦白便宜。
再往深一点看,作者说"数据库行本身无法感知权限漂移"。这句话才是全文真正的脊柱。太多权限相关的事故,最后追根溯源都栽在这上面——我们总是有意无意地把数据库当成了一个"会记住谁有权限"的地方,实际上它只是一块记性很好的硬盘,只记得住写入那一刻的真相。你可以给它配触发器、配物化视图、配各种试图让它"自动更新"的机制,但每多一层这种自动,就要多一层维护它的工程师。
这次的问题其实可以一句话收住:回放 RAG 历史的时候如果不重新检查授权,你就等于默认了一件根本不会静止的事——权限。别再劝自己"当时查过就行"了。你回放的是历史没错,但历史里每一处指向外部世界的引用都已经过期了。要不要把它当成刚查的一样重新验一遍,这不该是个问题。至于接下来更麻烦的那一层——回放该不该重新检索当前文档来判断旧答案是不是已经过时——那是另一个坑,比授权深得多,这条留到下次再展开。
