AI核心能力提升课程:
云博士带小白:自然语言AI编程做A股选股产品
https://www.bilibili.com/cheese/play/ss142425888
AI神经网络全谱系架构全景精讲
https://www.bilibili.com/cheese/play/ss743886858
哈佛博后带你玩转深度学习实战
https://www.bilibili.com/cheese/play/ss512662943
如何避免不同用户的信息混在一起:设计会话标识与数据隔离
一次普通的订单查询,为什么可能变成数据泄露?
假设你正在开发一个电商客服助手。用户甲询问“我的订单到哪里了”,助手查询订单后回答:“订单 A1001 已发货,预计明天送达。”随后,用户乙打开自己的聊天窗口,也问了同样的问题,助手却返回了甲的订单信息。
问题不一定出在模型本身。更常见的原因是:系统没有明确区分不同用户的会话,或者虽然区分了聊天记录,却没有在查询订单时校验用户身份。
这类错误通常发生在几个环节:
- 多个用户共用同一个会话标识,导致历史消息被错误复用。
- 服务端把上一次请求的用户信息保存在共享变量中,并发请求相互覆盖。
- 系统根据客户端传入的
user_id查询数据,却没有验证这个用户是否有权读取对应订单。 - 会话记录隔离了,但缓存、数据库查询或检索结果没有隔离。
- 用户退出登录后,旧会话仍能被其他登录身份继续使用。
要解决这些问题,不能只给每个聊天窗口生成一个随机 ID,而应该建立完整的身份与数据隔离规则。
本文用一个虚构的电商订单助手,搭建一套可独立运行的 Python 最小示例,说明如何设计用户标识、会话标识、权限校验和会话存储,并通过正常、边界、失败及越权测试检查隔离是否有效。
一、先分清用户、会话和请求
在设计数据结构之前,需要先明确三个标识分别代表什么。
| 标识 | 示例 | 用途 | 生命周期 |
|---|---|---|---|
user_id | user_100 | 表示系统识别并认证的用户主体 | 通常跨多个会话存在 |
conversation_id | conv_a81f... | 表示某个用户的一段对话 | 创建新会话时生成,可按业务规则结束 |
request_id | req_9c20... | 标识一次请求,方便日志追踪和排查 | 每次请求不同 |
它们不能互相替代。
user_id解决“是谁”,conversation_id解决“正在进行哪段对话”,request_id解决“这是哪一次请求”。
例如,同一个用户可以有两个独立会话:
user_100 ├── conv_aaa:查询订单 └── conv_bbb:咨询退货政策另一个用户即使发出完全相同的问题,也应该使用自己的会话:
user_200 └── conv_ccc:查询订单需要特别注意:会话 ID 不是身份凭证。即使conversation_id足够随机,也不能因为请求方知道这个 ID,就允许它访问对应数据。
1. 身份由认证层确定,而不是由请求参数自证
如果客户端发送:
{ "user_id": "user_100", "conversation_id": "conv_aaa", "question": "我的订单到哪里了?" }服务端不能直接相信请求中的user_id。攻击者可以把它改成user_200,尝试读取另一个用户的信息。
真实系统中,user_id应由服务端从已经验证的登录会话、令牌或其他可信认证上下文中取得。客户端提供的用户 ID 最多作为业务参数,不能单独证明用户身份。
为了让后续示例能够独立运行,本文会用一个固定的演示身份模拟认证层。它只用于理解隔离规则,不代表真实登录系统,也不能直接替代生产环境的认证机制。
2. 会话归属必须由服务端验证
每个会话都应该有明确的所有者:
conversation_id → owner_user_id当用户访问会话时,服务端至少需要检查:
会话存在 AND 会话所有者 == 当前已认证用户任一条件不满足,都不能读取或继续使用该会话的历史消息。
3. 读取历史消息和读取业务数据是两道不同的权限检查
假设用户甲拥有会话conv_aaa,但订单A1001属于用户甲,订单B2002属于用户乙。
即使用户甲合法访问自己的会话,也不意味着他可以读取任意订单。系统必须分别检查:
- 用户甲能否访问
conv_aaa。 - 用户甲能否读取
A1001或B2002。
第一道检查保护会话历史,第二道检查保护业务数据。只做好其中一道是不够的。
二、设计最小隔离方案
本例选择 Python 3 和 SQLite。SQLite 是轻量级关系数据库,适合演示数据模型、归属校验和查询约束,不需要部署独立数据库服务。
适用环境:
- Python 3.10 或更高版本。
- 使用 Python 标准库,不需要安装第三方依赖。
- 数据库使用内存模式,程序退出后演示数据即消失。
- 用户身份通过参数模拟,真实身份认证不在本例范围内。
1. 设计数据结构
我们只需要三张表:
| 表名 | 关键字段 | 作用 |
|---|---|---|
conversations | conversation_id、owner_user_id | 保存会话及所有者 |
messages | conversation_id、role、content | 保存对话消息 |
orders | order_id、owner_user_id、status | 保存订单及订单所有者 |
其中最重要的关系是:
conversations.owner_user_id └── 决定谁可以访问会话 messages.conversation_id └── 决定消息属于哪段会话 orders.owner_user_id └── 决定谁可以访问订单消息表不必在每条消息中重复保存用户 ID,因为消息通过conversation_id关联到会话,会话再记录所有者。但查询消息前仍然必须检查会话归属,不能只拿到会话 ID 就直接返回历史内容。
2. 明确访问规则
本例采用以下规则:
- 创建会话时,由服务端生成新的会话 ID,并绑定当前已认证用户。
- 读取历史记录时,先验证会话归属,再读取消息。
- 写入消息时,同样验证会话归属,不能只在读取时做权限检查。
- 查询订单时,查询条件必须同时包含订单 ID 和当前用户 ID。
- 找不到会话、用户身份缺失或权限不符时,拒绝操作。
- 不把会话 ID 的随机性当作权限控制。
这种设计遵循默认拒绝和每次请求都验证权限的原则。OWASP 的授权安全建议也强调,认证成功不等于拥有所有资源的访问权,随机化资源标识不能代替授权检查。[1]
三、完整实现:一个带隔离检查的订单助手存储层
这个最小实现不接入大模型,而是先把最容易导致信息串线的底层问题做好:会话创建、消息读写和订单查询。
这不是刻意省略智能体核心,而是把身份与数据隔离独立出来验证。无论上层使用哪一种模型或 Agent 框架,底层都需要类似的约束。
1. 文件清单
| 文件 | 用途 |
|---|---|
app.py | 初始化演示数据库、实现会话与订单访问控制,并运行测试 |
全部依赖来自 Python 标准库:
sqlite3:操作 SQLite 数据库。secrets:生成不可预测的会话标识。unittest:运行自动化验收测试。
Python 的secrets模块适合生成难以猜测的随机令牌;不过,难以猜测只是降低被猜中的风险,不代表获得该令牌的人就自动拥有资源访问权限。[2]
2.app.py完整代码
将以下内容完整保存为app.py。
import secretsimport sqlite3import unittestclass AccessDenied(Exception): """请求方没有访问资源的权限。"""class NotFound(Exception): """资源不存在,或对当前用户不可见。"""class OrderAssistantStore: """ 最小演示实现。 authenticated_user_id 必须由可信认证层提供。 本示例的测试会直接传入虚构用户 ID, 不能将这种方式当作生产环境的登录认证。 """ def __init__(self): self.db = sqlite3.connect(":memory:") self.db.row_factory = sqlite3.Row self._init_db() def _init_db(self): self.db.executescript(""" PRAGMA foreign_keys = ON; CREATE TABLE conversations ( conversation_id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP这里的 SQL 使用参数绑定,而不是把用户输入拼接进 SQL 字符串。Python 的sqlite3文档明确建议使用占位符绑定参数,以降低 SQL 注入风险。[3]
3. 运行测试
在保存了app.py的目录中,使用终端执行以下命令。下面以 Bash 为例:
python --version python -m py_compile app.py python app.py不需要安装第三方依赖,也不需要配置密钥或模型端点。
测试使用unittest的断言检查实际返回行为。预期结果是六项测试全部通过;这并不意味着真实登录、网络接口或生产数据库已经通过验证。
四、从代码中提炼出可复用的隔离流程
这段实现可以概括为以下处理链路:
#chatgpt-mermaid-_r_1t1_{font-family:-apple-system-body,ui-sans-serif,-apple-system,system-ui,“Segoe UI”,Helvetica,“Apple Color Emoji”,Arial,sans-serif,“Segoe UI Emoji”,“Segoe UI Symbol”;font-size:16px;fill:rgb(13, 13, 13);}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#chatgpt-mermaid-_r_1t1_ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#chatgpt-mermaid-_r_1t1_ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#chatgpt-mermaid-_r_1t1_ .error-icon{fill:rgb(243, 243, 243);}#chatgpt-mermaid-_r_1t1_ .error-text{fill:rgb(13, 13, 13);stroke:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ .edge-thickness-normal{stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .edge-thickness-thick{stroke-width:3.5px;}#chatgpt-mermaid-_r_1t1_ .edge-pattern-solid{stroke-dasharray:0;}#chatgpt-mermaid-_r_1t1_ .edge-thickness-invisible{stroke-width:0;fill:none;}#chatgpt-mermaid-_r_1t1_ .edge-pattern-dashed{stroke-dasharray:3;}#chatgpt-mermaid-_r_1t1_ .edge-pattern-dotted{stroke-dasharray:2;}#chatgpt-mermaid-_r_1t1_ .marker{fill:rgb(143, 143, 143);stroke:rgb(143, 143, 143);}#chatgpt-mermaid-_r_1t1_ .marker.cross{stroke:rgb(143, 143, 143);}#chatgpt-mermaid-_r_1t1_ svg{font-family:-apple-system-body,ui-sans-serif,-apple-system,system-ui,“Segoe UI”,Helvetica,“Apple Color Emoji”,Arial,sans-serif,“Segoe UI Emoji”,“Segoe UI Symbol”;font-size:16px;}#chatgpt-mermaid-_r_1t1_ p{margin:0;}#chatgpt-mermaid-_r_1t1_ .label{font-family:-apple-system-body,ui-sans-serif,-apple-system,system-ui,“Segoe UI”,Helvetica,“Apple Color Emoji”,Arial,sans-serif,“Segoe UI Emoji”,“Segoe UI Symbol”;color:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ .cluster-label text{fill:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ .cluster-label span{color:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ .cluster-label span p{background-color:transparent;}#chatgpt-mermaid-_r_1t1_ .label text,#chatgpt-mermaid-_r_1t1_ span{fill:rgb(13, 13, 13);color:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ .node rect,#chatgpt-mermaid-_r_1t1_ .node circle,#chatgpt-mermaid-_r_1t1_ .node ellipse,#chatgpt-mermaid-_r_1t1_ .node polygon,#chatgpt-mermaid-_r_1t1_ .node path{fill:rgb(222, 234, 251);stroke:rgb(83, 154, 248);stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .rough-node .label text,#chatgpt-mermaid-_r_1t1_ .node .label text,#chatgpt-mermaid-_r_1t1_ .image-shape .label,#chatgpt-mermaid-_r_1t1_ .icon-shape .label{text-anchor:middle;}#chatgpt-mermaid-_r_1t1_ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .rough-node .label,#chatgpt-mermaid-_r_1t1_ .node .label,#chatgpt-mermaid-_r_1t1_ .image-shape .label,#chatgpt-mermaid-_r_1t1_ .icon-shape .label{text-align:center;}#chatgpt-mermaid-_r_1t1_ .node.clickable{cursor:pointer;}#chatgpt-mermaid-_r_1t1_ .root .anchor path{fill:rgb(143, 143, 143)!important;stroke-width:0;stroke:rgb(143, 143, 143);}#chatgpt-mermaid-_r_1t1_ .arrowheadPath{fill:rgb(143, 143, 143);}#chatgpt-mermaid-_r_1t1_ .edgePath .path{stroke:rgb(143, 143, 143);stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .flowchart-link{stroke:rgb(143, 143, 143);fill:none;}#chatgpt-mermaid-_r_1t1_ .edgeLabel{background-color:rgb(252, 252, 252);text-align:center;}#chatgpt-mermaid-_r_1t1_ .edgeLabel p{background-color:rgb(252, 252, 252);}#chatgpt-mermaid-_r_1t1_ .edgeLabel rect{opacity:0.5;background-color:rgb(252, 252, 252);fill:rgb(252, 252, 252);}#chatgpt-mermaid-_r_1t1_ .labelBkg{background-color:rgba(252, 252, 252, 0.5);}#chatgpt-mermaid-_r_1t1_ .cluster rect{fill:rgb(243, 243, 243);stroke:rgba(0, 0, 0, 0.1);stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .cluster text{fill:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ .cluster span{color:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:-apple-system-body,ui-sans-serif,-apple-system,system-ui,“Segoe UI”,Helvetica,“Apple Color Emoji”,Arial,sans-serif,“Segoe UI Emoji”,“Segoe UI Symbol”;font-size:12px;background:rgb(243, 243, 243);border:1px solid rgba(0, 0, 0, 0.1);border-radius:2px;pointer-events:none;z-index:100;}#chatgpt-mermaid-_r_1t1_ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:rgb(13, 13, 13);}#chatgpt-mermaid-_r_1t1_ rect.text{fill:none;stroke-width:0;}#chatgpt-mermaid-_r_1t1_ .icon-shape,#chatgpt-mermaid-_r_1t1_ .image-shape{background-color:rgb(252, 252, 252);text-align:center;}#chatgpt-mermaid-_r_1t1_ .icon-shape p,#chatgpt-mermaid-_r_1t1_ .image-shape p{background-color:rgb(252, 252, 252);padding:2px;}#chatgpt-mermaid-_r_1t1_ .icon-shape .label rect,#chatgpt-mermaid-_r_1t1_ .image-shape .label rect{opacity:0.5;background-color:rgb(252, 252, 252);fill:rgb(252, 252, 252);}#chatgpt-mermaid-_r_1t1_ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#chatgpt-mermaid-_r_1t1_ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#chatgpt-mermaid-_r_1t1_ .node .neo-node{stroke:rgb(83, 154, 248);}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node rect,#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].cluster rect,#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node polygon{stroke:url(#chatgpt-mermaid-_r_1t1_-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].swimlane.cluster rect{filter:none;}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node path{stroke:url(#chatgpt-mermaid-_r_1t1_-gradient);stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node .neo-line path{stroke:rgb(83, 154, 248);filter:none;}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node circle{stroke:url(#chatgpt-mermaid-_r_1t1_-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].node circle .state-start{fill:#000000;}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].icon-shape .icon{fill:url(#chatgpt-mermaid-_r_1t1_-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#chatgpt-mermaid-_r_1t1_ [data-look=“neo”].icon-shape .icon-neo path{stroke:url(#chatgpt-mermaid-_r_1t1_-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#chatgpt-mermaid-_r_1t1_ .node text{font-size:14px;font-weight:600;letter-spacing:normal;fill:rgb(0, 79, 153);}#chatgpt-mermaid-_r_1t1_ .edgeLabels text{font-size:13px;font-weight:600;letter-spacing:-0.08px;fill:rgb(0, 79, 153);}#chatgpt-mermaid-_r_1t1_ .node tspan[font-weight=“normal”],#chatgpt-mermaid-_r_1t1_ .edgeLabels tspan[font-weight=“normal”]{font-weight:600;}#chatgpt-mermaid-_r_1t1_ .edgeLabel .label rect{opacity:1;rx:13px;ry:13px;fill:rgb(245, 250, 255);stroke:rgb(206, 219, 229);stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .node rect,#chatgpt-mermaid-_r_1t1_ .node circle,#chatgpt-mermaid-_r_1t1_ .node ellipse,#chatgpt-mermaid-_r_1t1_ .node polygon,#chatgpt-mermaid-_r_1t1_ .node path{fill:rgb(229, 243, 255);stroke:rgba(0, 0, 0, 0.1);stroke-width:1px;}#chatgpt-mermaid-_r_1t1_ .node rect{rx:16px;ry:16px;}#chatgpt-mermaid-_r_1t1_ .node.mermaid-decision .label-container{fill:rgb(245, 250, 255);stroke:rgb(206, 219, 229);stroke-dasharray:2,2;}#chatgpt-mermaid-_r_1t1_ .edgePaths .flowchart-link{stroke:rgb(143, 143, 143);stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;}#chatgpt-mermaid-_r_1t1_ .marker{fill:rgb(143, 143, 143);stroke:rgb(143, 143, 143);}#chatgpt-mermaid-_r_1t1_ :root{–mermaid-font-family:-apple-system-body,ui-sans-serif,-apple-system,system-ui,“Segoe UI”,Helvetica,“Apple Color Emoji”,Arial,sans-serif,“Segoe UI Emoji”,“Segoe UI Symbol”;}收到请求取得可信认证身份身份有效?拒绝请求读取目标资源资源归属符合身份?拒绝访问执行读取或写入返回允许访问的数据否是否是
这个流程里有几个重要细节。
第一,先取得身份,再检查资源。不要把请求正文中的user_id直接当成可信身份。
第二,读取和写入都需要检查。如果只保护历史消息读取,却允许其他用户向该会话写入消息,后续生成回答时仍然可能混入错误的上下文。
第三,授权失败时不能降级成不受限制的查询。例如,权限服务不可用时,不应为了让聊天继续工作而直接返回全部历史记录。安全相关判断失败时,应停止受保护的操作。
第四,会话归属与订单归属分别检查。用户拥有某段聊天历史,并不意味着聊天中提到的所有订单都属于他。
五、接入 Agent 或大模型时,还要隔离哪些数据?
上面的代码只保护了 SQLite 中的会话和订单。真正的智能体系统往往还有历史摘要、缓存、向量检索结果和工具调用记录。如果这些环节继续共享用户上下文,底层数据库即使做了隔离,最终回答仍然可能泄露其他用户的信息。
建议把下面这张表作为接入模型前的检查清单。
| 数据或组件 | 必须落实的约束 | 常见错误 |
|---|---|---|
| 对话历史 | 先验证会话归属,再读取消息 | 直接按conversation_id返回全部消息 |
| 模型上下文 | 只拼接当前用户有权访问的消息和资料 | 复用上一个请求的完整上下文 |
| 会话摘要 | 摘要继承原会话的访问权限 | 把多个用户的历史合并成一个摘要 |
| 缓存 | 缓存键包含正确的用户或租户边界,并校验权限 | 只按问题文本缓存答案 |
| 向量检索 | 在检索层限制可访问的数据范围 | 先检索全部文档,再只靠模型判断是否能回答 |
| 工具调用 | 工具从可信身份上下文取得用户范围 | 让模型自行决定查询哪个用户的订单 |
| 日志与追踪 | 限制敏感内容的采集、查看和保留 | 将完整聊天记录、令牌或个人资料写入普通日志 |
一个特别容易忽略的问题:缓存串线
假设用户甲问:
我的订单 A1001 到哪里了?
系统将回答缓存为:
key: "我的订单 A1001 到哪里了?" value: "订单 A1001 已发货,预计明天送达。"用户乙问同一句话时,如果系统只按问题文本命中缓存,就可能把甲的答案直接返回给乙。
一种更稳妥的设计是把缓存键与用户或租户范围绑定,例如:
cache_key = tenant_id + user_id + normalized_question不过,这并不是适用于所有缓存的万能规则。对于公开知识库,可以使用不同的缓存策略;对于订单、账户资料等私有数据,则需要严格限制缓存作用域,并在必要时重新验证资源权限。即使缓存键包含用户 ID,也不能代替身份认证与授权检查。
模型不能替代权限判断
不要在提示词里只写一句:
你只能回答当前用户自己的订单。
这句话可以作为模型行为约束,但它不能阻止后端把其他用户的数据送进模型。
更可靠的顺序是:
- 服务端确认当前用户身份。
- 数据层只返回该用户有权读取的记录。
- 应用层构建当前会话的上下文。
- 模型根据授权范围内的数据生成回答。
- 工具执行敏感操作时,再次检查权限。
模型负责理解与生成,服务端负责访问控制。两者承担的职责不能混为一谈。
六、如何验证数据隔离真的有效?
不能只验证“用户甲能看到自己的消息”。隔离测试的关键是主动尝试跨边界访问,并观察系统是否拒绝。
下面的测试使用代码中的虚构数据:
user_100拥有订单A1001。user_200拥有订单B2002。- 两个用户分别拥有自己的会话。
| 测试目的 | 输入或操作 | 预期结果 | 判定方法 |
|---|---|---|---|
| 正常读取 | user_100读取自己的会话和订单A1001 | 返回自己的历史和订单状态 | 历史条数为 2,订单状态为“已发货” |
| 同一用户多会话 | 同一用户创建两个会话,分别写入不同内容 | 两段历史互不混合 | 一个会话 2 条消息,另一个 1 条 |
| 越权读取会话 | user_200读取user_100的会话 ID | 拒绝访问 | 抛出AccessDenied,不返回历史消息 |
| 越权写入会话 | user_200向user_100的会话追加消息 | 拒绝写入 | 再读取原会话,消息数仍为 2 |
| 越权读取订单 | user_200查询A1001 | 返回统一的不可访问结果 | 抛出NotFound,不返回订单内容 |
| 不存在的会话 | 读取不存在的会话 ID | 拒绝访问 | 抛出NotFound |
| 空消息输入 | 向自己的会话写入空白字符串 | 拒绝写入 | 抛出ValueError |
进一步增加两个真实系统必需的检查
上表覆盖了最小实现中的主要路径,但如果要将设计用于 Web API 或多租户系统,还需要补充以下测试。
测试一:篡改请求中的user_id。
模拟已经登录的用户甲,故意在请求正文里填写用户乙的 ID。正确实现应始终以认证层提供的身份作为授权依据,而不是根据请求正文切换身份。
测试二:并发请求交错。
让用户甲、用户乙同时发起请求,并在读取历史、查询订单、调用模型等步骤之间制造交错。检查是否存在共享的可变全局变量、复用错误的请求上下文,或者在异步任务中丢失了认证身份的情况。
这两个测试不能仅通过当前的单进程单元测试来证明。它们需要在实际 Web 框架、认证中间件、并发执行环境及真实存储配置中进一步验证。
七、常见故障怎么定位?
故障一:历史记录正常,但模型回答了别人的内容
优先检查模型实际收到的上下文,而不是先修改提示词。
确认每次请求的历史消息、摘要、检索片段和缓存结果是否都来自当前授权范围。排查时可以记录request_id、经过脱敏的user_id、conversation_id和检索来源标识,但不要为了排查而把完整个人资料或认证令牌写入普通日志。
故障二:新建会话后,仍然出现上一段对话的内容
检查是否存在跨会话复用的状态:
- 应用进程中的全局对话历史。
- 按用户而非会话保存的临时上下文。
- 未更新会话标识的前端请求。
- 共享缓存中的旧摘要或模型回答。
新会话应有独立的历史边界。是否继承旧会话的摘要,需要作为明确的业务功能设计,不能通过无意中的状态复用实现。
故障三:用户拿不到自己的数据
检查创建会话时写入的owner_user_id是否与认证层返回的身份一致。还要检查会话是否在不同账号、租户或登录状态之间被错误复用。
不要为了“修复”问题而删除所有权限检查。应定位身份映射或资源归属哪里不一致。
故障四:随机会话 ID 被当成了授权机制
随机 ID 的作用是降低被猜中的可能性,不是证明请求方有权访问资源。
如果系统允许任何持有会话 ID 的人直接读取历史,那么一旦 ID 从浏览器日志、共享链接、分析平台或其他渠道泄露,数据仍可能暴露。正确做法是每次请求都进行服务端授权校验。
八、从最小示例走向生产环境
本文的实现只展示了最核心的控制逻辑。要用于真实业务,还应明确以下边界:
- 认证:使用经过验证的登录身份,不接受客户端自报的身份作为授权依据。
- 多租户:如果用户属于不同组织,数据查询还应验证租户归属,不能只检查个人用户 ID。
- 数据库:生产数据库需要合适的索引、事务策略、备份与访问控制。对于复杂并发场景,应评估连接管理与事务边界。
- 会话生命周期:设计会话过期、退出登录、账号禁用和权限撤销后的处理方式。
- 缓存与检索:将权限边界落实到缓存键、检索过滤、摘要和工具数据源。
- 日志:记录必要的安全事件,限制敏感数据的采集和访问。
- 失败策略:身份信息缺失、权限服务异常或数据归属无法确认时,拒绝受保护操作,而不是放宽限制。
另一个重要原则是:不要只依赖某一个入口处的权限检查。一个订单查询工具可能同时被聊天接口、后台任务和其他服务调用,因此授权规则应靠近实际资源操作,并确保每条调用路径都受到保护。[1]
验证状态
- 已执行的本地检查:在隔离目录中使用 Python 执行
python -m py_compile app.py和python app.py。 - 执行结果:语法检查通过,6 项
unittest测试通过,覆盖正常读取、跨用户读取、跨用户写入、订单越权、无效输入和同一用户多会话。 - 尚未验证:真实身份认证、HTTP API、多进程并发、生产数据库权限、缓存隔离、向量检索过滤及模型服务集成。
- 适用边界:本例用于验证隔离规则的最小闭环,不是完整的生产认证系统,也不构成完整安全审计。
参考资料
- OWASP,Authorization Cheat Sheet:关于默认拒绝、逐请求授权校验,以及不能将难以猜测的资源 ID 当作权限控制的建议。 https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- Python 官方文档,secrets — Generate secure random numbers for managing secrets:关于生成安全随机令牌的标准库接口与使用场景。 https://docs.python.org/3/library/secrets.html
- Python 官方文档,sqlite3 — DB-API 2.0 interface for SQLite databases:关于 SQL 参数绑定以及避免通过字符串拼接构造 SQL 查询的说明。 https://docs.python.org/3/library/sqlite3.html
真正可靠的会话隔离,不是给每段聊天分配一个独特的 ID 就结束了,而是让身份、会话、历史消息、业务数据、缓存和检索结果始终遵循同一套访问边界。会话 ID 用于定位会话,认证身份用于识别请求方,授权规则决定请求方到底能访问什么。