OncallAgent 的权限模型从一个朴素但明确的规则开始:当前已认证用户的 ID 同时充当 tenant scope,直到未来引入独立的组织 tenant。这个决定贯穿 bearer session、FastAPI 依赖、SQLite 查询、Milvus filter、后台任务与工具审计。它不是在记录写入后再补的一列,而是每次 list、get、create、update 和 delete 都必须显式传递的访问条件。
📷 [图片 token=BvK4bjFYJoSbh1xlgptcKIyinCc(未能下载,见飞书原文)]
认证回答“调用者是谁”,授权回答“这个调用者能否访问目标资源”。OncallAgent 对两者使用不同错误语义:token 缺失、未知或被撤销返回统一 401;有效用户访问其他 owner 的会话、文档、索引任务或诊断对象返回统一 403。对 Agent 系统而言,这个区分尤其重要,因为越权请求必须在模型、工具或向量搜索启动前被拒绝,不能依靠生成后的内容过滤补救。
📷 [图片 token=UBAwbMKDnobpAcxweiRcdiuEnrb(未能下载,见飞书原文)]
浏览器保存 bearer token 并提供路由守卫,但安全根仍在后端。前端状态可以被清除、绕过或手工修改,真正的数据隔离来自 Depends(_current_user) 解析的 UserRecord、owner-scoped Repository 方法和 tenant-scoped Milvus 表达式。阅读本主题时,应沿着身份、业务对象和向量对象三条链同时核对。
📷 [图片 token=De6WbNcx3oVHXzx7cv8cBTIYn7f(未能下载,见飞书原文)]
学习目标
追踪注册、登录、当前用户查询、登出和 session 撤销的完整生命周期。
理解密码哈希、token 随机值与 token 哈希分别保存在哪里。
识别 401 与 403 的运行时触发点,以及跨 tenant 请求为何在 Agent 前被拒绝。
掌握 SQLite owner 条件、Milvus tenant 条件与前端清理状态的互补关系。
能审查新增 Repository 或路由是否遗漏 owner/tenant scope。
功能入口与完整调用链
注册从 apps/frontend/src/authClient.ts 的 register 开始,发送共享 RegisterRequest 到 POST /auth/register。apps/backend/src/super_ai/api/app.py 的 register 路由调用 AuthService.register;服务把邮箱去空白并转小写,校验显示名、邮箱格式和至少八位密码,用 PasswordHash.recommended 生成密码哈希,再调用 AuthRepository.create_user。成功后生成 32 字节 URL-safe 随机 token,将 SHA-256 token hash 与 session 写入 SQLite,HTTP 响应只返回用户资料、原始 access token 和 bearer 类型。
📷 [图片 token=KgrObw9e8oXxKxxC6iLcbASQnhI(未能下载,见飞书原文)]
登录链路调用 AuthService.login。已知邮箱用保存的 password hash 校验;未知邮箱仍会对固定的 Argon2id dummy hash 执行一次验证,再返回与密码错误相同的 AUTH_INVALID_CREDENTIALS。这个实现减少了通过错误文案和显著不同代码路径枚举账号的风险。数据库不保存原始密码,也不保存原始 bearer token。
📷 [图片 token=WF7CbG5U7oa5fdx6JBvcKcdOnJs(未能下载,见飞书原文)]
受保护请求使用 FastAPI 的 HTTPBearer(auto_error=False)。_bearer_token 检查 scheme,_current_user 调用 AuthService.authenticate_token:先 hash 请求 token,查询 AuthSessionModel.token_hash,检查 revoked_at,读取 user,并更新 session 的 last_seen_at。随后路由将 user.id 传入下游。登出调用 revoke_session 写入撤销时间;同一个 token 此后由 AUTH_SESSION_REVOKED 拒绝。
📷 [图片 token=Hd9AbMTSlo8BcexkqR1c49TKnRh(未能下载,见飞书原文)]
注册或登录
→ AuthService
→ SQLiteAuthRepository
→ users + auth_sessions
→ 返回一次原始 bearer token
受保护请求
→ _bearer_token
→ _current_user
→ SHA-256(token) 查询 session
→ UserRecord.id 作为 owner_user_id 与 tenant_id
→ Repository / Milvus / Agent 工具
📷 [图片 token=WQpcbKhvrokYIdxvwdncoIBmnuc(未能下载,见飞书原文)]
以流式聊天为例,stream_chat_message 收到当前用户后,先调用 repositories.chat.get_session(owner_user_id=user.id, session_id=...)。另一个用户的 session ID 不能匹配 owner 条件,路由抛出 AUTH_FORBIDDEN;ChatStreamingService 和 Agent runner 都不会被创建执行。知识文档、索引任务、诊断、后台任务、反馈与 MCP 连接采用相同模式。
📷 [图片 token=PH1ubgqRjoOw7uxV1THceIUZnkd(未能下载,见飞书原文)]
核心源码地图
| 源码位置 | 关键符号 | 职责 |
|---|---|---|
packages/api-contracts/src/auth.ts | RegisterRequest、LoginRequest、AuthUser、AuthTokenResponse | 定义前后端共享认证 DTO,不暴露密码哈希或 session 内部字段。 |
apps/backend/src/super_ai/auth/service.py | AuthService、normalize_email、hash_token | 校验凭证、哈希密码、签发和验证可撤销 session。 |
apps/backend/src/super_ai/auth/repositories.py | AuthRepository、UserRecord、AuthSessionRecord | 隔离认证业务逻辑与 SQLAlchemy 存储细节。 |
apps/backend/src/super_ai/auth/sqlite.py | SQLiteAuthRepository | 持久化用户、token hash、last seen 与 revoked 状态。 |
apps/backend/src/super_ai/api/app.py | _current_user、_bearer_token、认证路由 | 建立统一认证依赖,并把 user ID 传入每个受保护处理器。 |
apps/backend/src/super_ai/memory/models.py | UserModel、AuthSessionModel、各类 owner_user_id | 定义持久化身份与 owner-scoped 业务模型及索引。 |
apps/backend/src/super_ai/memory/sqlite.py | SQLiteChatMemoryRepository、_require_document、_find_diagnostic_task | 在查询和变更中同时匹配业务 ID 与 owner_user_id。 |
apps/backend/src/super_ai/memory/vector_scope.py | build_vector_chunk_metadata、build_milvus_tenant_filter | 生成向量权限 metadata 与 Milvus tenant/知识库过滤表达式。 |
apps/frontend/src/stores/auth.ts | useAuthStore、createAuthRouteAccess | 恢复当前用户、管理登录态,并在登出或失效时清理受保护 store。 |
apps/frontend/src/router/index.ts | createAppRouter | 为工作区路由提供前端访问守卫,但不替代后端授权。 |
📷 [图片 token=BEhqbfGARoXTjQxcm5ycWDVenie(未能下载,见飞书原文)]
代码调用流程图
认证解决“你是谁”,owner scope 解决“你能访问什么”。两者必须连续发生,不能只检查 bearer token 就直接读取业务对象。
📷 [图片 token=Kqigb7CQuoWwJDxU34Gcqe4FnLb(未能下载,见飞书原文)]

关键实现拆解
凭证和 session 的存储语义
UserModel.password_hash 是最多 512 字符的哈希字段。默认 PasswordHash.recommended 在当前测试中产生 Argon2 哈希;服务从不把密码或哈希放进 _user_payload。注册重复邮箱由数据库唯一约束触发 IntegrityError,服务转换成 BUSINESS_CONFLICT,同样不回显密码数据。
📷 [图片 token=C0XsbMTFfoF4eAxxYiWcqbIEnjd(未能下载,见飞书原文)]
session token 的原始值只在签发结果中返回给客户端,数据库保存 hash_token 产生的 SHA-256。AuthSessionModel 还保存 user 外键、创建、最后访问和撤销时间。SHA-256 在这里不是密码哈希算法,而是高熵随机 token 的查找标识;密码仍需使用抗暴力破解的专用哈希。登出不是删除浏览器字符串就结束,而是服务端持久化撤销状态。
📷 [图片 token=BLf5bcKVCoLiVQxOwlpcUuNFnxe(未能下载,见飞书原文)]
看什么:认证服务先把 bearer 转成稳定 hash 查询 session,再检查撤销状态;原始 token 不作为数据库查找值。
📷 [图片 token=KGNeb0wjaoB8PxxX0kacV366nOf(未能下载,见飞书原文)]
async def authenticate_token(self, token: str) -> UserRecord:
if not token.strip():
raise AuthError("AUTH_UNAUTHENTICATED", "Authentication is required.")
# 1. 数据库查询键是 token hash,原始 bearer 不持久化。
token_hash = hash_token(token)
session = await self._repository.find_session_by_token_hash(token_hash)
if session is None:
raise AuthError("AUTH_UNAUTHENTICATED", "Authentication is required.")
# 2. 被注销的 session 即使 token 正确也不能继续使用。
if session.revoked_at is not None:
raise AuthError("AUTH_SESSION_REVOKED", "The authentication session has been revoked.")
user = await self._repository.find_user_by_id(session.user_id)
if user is None:
raise AuthError("AUTH_UNAUTHENTICATED", "Authentication is required.")
await self._repository.touch_session(session.id, _utc_now())
return user
📷 [图片 token=Hc3WbF6pVoV6StxybPfc9p1onYb(未能下载,见飞书原文)]
片段证明认证是服务端可撤销状态,而不是只验证 bearer 字符串格式。未知 token 与缺 token 都隐藏为未认证;已撤销 session 有稳定错误码。当前模型没有过期时间判断,因此不能从这段代码推断自动过期、刷新 token 或设备管理已经实现。
📷 [图片 token=JjM9byylAoVhMsxufP9csKKqnhf(未能下载,见飞书原文)]
看什么:把“签发、使用、撤销”画成 session 状态机,可以避免把 localStorage 清理误当成服务端登出。
📷 [图片 token=LEdQbBPN7oFx7wxURNjcNQcFn8e(未能下载,见飞书原文)]

状态图强调撤销在数据库中生效,所以同一个 token 即使仍留在浏览器也会被拒绝。失败分支不会暴露 session 是否属于哪个用户;密码验证则走独立的 Argon2 边界,不能与 token hash 的用途混淆。
📷 [图片 token=IZQSbEk13oX0Lbxq4p7cKqg4nab(未能下载,见飞书原文)]
Owner scope 必须进入查询条件
apps/backend/src/super_ai/memory/repositories.py 的协议方法普遍要求 owner_user_id。例如 chat 的 get_session、document 的 get_document、diagnostic 的 get_task 都不能只收资源 ID。SQLite 实现把 owner 条件写进同一条 SELECT;_require_chat_session、_require_document 等 helper 在找不到组合键时抛出 TenantScopeError。父对象检查也发生在写入前,例如 append message 会先验证同 owner 的 chat session,创建索引任务会先验证同 owner、知识库与文档。
📷 [图片 token=TYRbbYCuKoR5Zfx1NWQc4cI2npf(未能下载,见飞书原文)]
这种模式避免了危险的“先按全局 ID 查记录,再在 Python 中比较 owner”。后者容易在异常、日志或并发路径中泄露对象存在性。当前 API 对很多资源把 owner-scoped 未命中映射成 403,因此已认证用户即使猜中其他 tenant 的 ID,也不能读取、更新或删除其内容。
📷 [图片 token=LxsHb4bqPoTGv7xamyKc8bBsnPh(未能下载,见飞书原文)]
看什么:聊天流入口在构造服务和 Agent runner 之前,用当前认证用户 ID 与 session ID 组成同一条 Repository 查询。
📷 [图片 token=KngJbsiCBogzLXxilO5cV9D9nTb(未能下载,见飞书原文)]
@app.post("/chat/sessions/{session_id}/messages:stream")
async def stream_chat_message(
request: Request,
session_id: str,
body: StreamChatMessageRequest,
user: Annotated[UserRecord, Depends(_current_user)],
) -> StreamingResponse:
repositories = _memory_repositories(request)
# 1. 查询条件同时携带当前 owner 和 session id。
session = await repositories.chat.get_session(
owner_user_id=user.id,
session_id=session_id,
)
# 2. 越权在 ChatStreamingService 和 Agent runner 之前结束。
if session is None:
raise ApiErrorException("AUTH_FORBIDDEN")
service = ChatStreamingService(
repositories=repositories,
agent_runner=_chat_agent_runner(request),
memory_service=_chat_memory_service(request),
)
📷 [图片 token=C839bhtZRoIUZaxqttRculHDnMh(未能下载,见飞书原文)]
代码证明跨 tenant 会话不会启动模型、知识检索或 MCP 工具,也不会先泄露会话标题。安全边界依赖 Repository 真正把 owner 写进 SQL;如果某个新实现只在 Python 返回后比较,就会削弱这种“未命中即拒绝”的统一语义。
📷 [图片 token=DFCnbSuUDot474xmVh6cMgrVnwL(未能下载,见飞书原文)]
看什么:请求身份如何一路成为数据条件,而不是停留在路由装饰器上。
📷 [图片 token=ZrY2bbSWto84HfxsyuRcDwognCc(未能下载,见飞书原文)]

身份解析只建立 tenant scope,具体对象授权仍由每次查询完成。匿名请求在进入用户 Repository 前返回 401;已认证但 owner 不匹配返回 403,这两类失败不能合并成同一种前端恢复动作。
📷 [图片 token=Y8Nzbwgjro8MD9xqHlNc440unzb(未能下载,见飞书原文)]
向量隔离是双层的
Milvus chunk 在标量字段和 JSON metadata 中都包含 ownerUserId、tenantId、knowledgeBaseId、documentId 和 chunkId。build_milvus_tenant_filter 生成 tenant 相等且 knowledge base 位于授权集合的表达式;MilvusVectorStore.search_chunks 和 list_chunks 都调用它。授权后的知识库 ID 集合为空时直接返回空结果,完全不连接或查询 Milvus,从结构上阻止无范围搜索。
📷 [图片 token=MQBUbYrIYoIhszxVcCkcxi5Lnfd(未能下载,见飞书原文)]
删除也必须带范围。delete_document_chunks 要求 tenant、知识库和文档 ID 都非空,再构造三条件过滤器。文档 API 先确认 owner-scoped document 存在,才调用向量删除和 SQLite 软删除。向量库不是权限真相来源;允许的知识库集合来自当前认证用户和业务边界,Milvus 只执行已经明确的范围。
📷 [图片 token=Jpv4bkFfRoQERnxV5X3cTAV1nog(未能下载,见飞书原文)]
看什么:过滤器把 tenant 相等与允许知识库集合合成一个表达式,输入值在拼接前转义。
📷 [图片 token=Em5xbRTO3oB9y4xs309cdJs3nPg(未能下载,见飞书原文)]
def build_milvus_tenant_filter(*, tenant_id: str, knowledge_base_ids: Sequence[str]) -> str:
# 1. 每个知识库 ID 先进行 Milvus 字符串转义。
quoted_kb_ids = ",".join(f'"{_escape_milvus_string(item)}"' for item in knowledge_base_ids)
escaped_tenant_id = _escape_milvus_string(tenant_id)
# 2. tenant 与授权知识库必须同时满足。
return f'tenantId == "{escaped_tenant_id}" && knowledgeBaseId in [{quoted_kb_ids}]'
📷 [图片 token=KfBXbyH4YoCw0xxzL4ocsf8Ynab(未能下载,见飞书原文)]
片段证明 Milvus 查询不是仅按向量相似度运行,而是携带显式范围表达式。它本身不决定哪些知识库可访问,也不处理空集合;调用边界必须先授权并短路空范围,返回结果后检索工具还会再核对 owner、tenant 与 metadata。
📷 [图片 token=FO78bJvm6oh5OlxAtYacWZeqnjf(未能下载,见飞书原文)]
看什么:双层隔离不是重复字段堆叠,而是“业务授权生成允许集、向量边界执行过滤、结果再校验”的纵深防御。
📷 [图片 token=FkZvbrR3UoEqWxxNlCtczB4lnme(未能下载,见飞书原文)]

📷 [图片 token=HnZNbWAqgoYJxBxsPa5c3zjEnfg(未能下载,见飞书原文)]
三条分支分别表达权限拒绝、安全空结果和受限查询。任何为了提高召回而在空范围时搜索全 collection 的实现都会破坏隔离;同样,客户端传入 knowledge base ID 不能成为授权来源。
📷 [图片 token=LYuFb1yY6oWm3VxVA7zcA5imn6I(未能下载,见飞书原文)]
前端状态清理不是授权,但仍然必要
AUTH_TOKEN_STORAGE_KEY 当前使用浏览器 localStorage。useAuthStore.initialize 在存在 token 时调用 /auth/me 恢复 user;失败则移除 token,并 reset chat、knowledge 与 aiops store。logout 即使后端请求失败,也在 finally 中清除本地 token、用户和受保护状态。这能避免上一位用户的数据残留在同一浏览器界面,但它不能替代后端 owner 过滤。
📷 [图片 token=YVqxbMZlkohSDixl0x3cXjCxnid(未能下载,见飞书原文)]
路由守卫等待初始化完成后,再判断 requiresAuth 与 publicOnly。这解决刷新页面时的竞态和视觉跳转。安全审查仍应假定攻击者可以直接调用 API,因为 localStorage、Pinia 和 Vue Router 都处于调用者控制范围。
📷 [图片 token=CLmlbr2keock1nxmQFRcghHdnuP(未能下载,见飞书原文)]
看什么:注销把服务端请求放在 try,本地 token 和三个受保护 store 的清理放在 finally。
📷 [图片 token=SyavbDZo4o7PTCxylh4cjXc5nxd(未能下载,见飞书原文)]
logout: (): Promise<void> =>
run(async () => {
try {
// 1. 尝试让服务端持久化撤销当前 session。
await client.logout();
} finally {
// 2. 即使网络失败,也不让旧用户数据继续留在界面。
storage().removeItem(AUTH_TOKEN_STORAGE_KEY);
user.value = null;
useChatStore().reset();
useKnowledgeStore().reset();
useAiopsStore().reset();
}
}),
📷 [图片 token=TPekb3zjTosmtSxVdpqceqHbnDf(未能下载,见飞书原文)]
代码证明前端在不可用或网络失败时仍会消除可见残留,这是共享浏览器场景的重要隐私边界。它不证明 token 已在服务端撤销:若 logout 请求没有到达后端,旧 token 仍可能有效,因此真正权限判断必须始终在每次 API 请求中重新执行。
📷 [图片 token=WqzIbRMmSoDSpvx9fpfcrcSSnMa(未能下载,见飞书原文)]
后台任务和审计同样属于 tenant 数据
异步执行容易成为权限传递的断点。OncallAgent 创建文档索引或 AIOps 诊断时,把当前用户 ID 同时写入业务任务与 BackgroundJobModel.owner_user_id。worker handler 从持久 job 取出资源 ID 后,仍以 owner 调用索引或诊断服务;查询、取消和重试后台任务也要求同一个 owner。这样浏览器请求结束后,权限上下文不会退化为一个无主的全局任务。
📷 [图片 token=SGHobpW1MoVs7Rxkf2Oc0zGLnDd(未能下载,见飞书原文)]
工具审计、诊断步骤、证据、报告证据链接和 checkpoint 也保存 owner。它们不仅是“附属日志”,而是可能含用户查询、外部工具摘要和诊断推理的受保护业务数据。读取某个诊断证据链时,不能只验证最外层 task;Repository helper 会把 owner、task ID 与具体 step、report 或 evidence ID 同时放入条件,防止使用另一个任务下的子对象 ID 拼接越权请求。
📷 [图片 token=G2sMbubLXoUa0Ix9trscJQ0en3e(未能下载,见飞书原文)]
看什么:后台 job 在入队时就保存 owner,之后查询资源也要求同一 owner,而不是只靠 worker 内存里的请求上下文。
📷 [图片 token=EBFNba4kSo5XM6xjuo1cHT3DnVg(未能下载,见飞书原文)]
async def enqueue(
self,
*,
owner_user_id: str,
job_id: str,
kind: str,
resource_type: str,
resource_id: str,
payload: JsonDict | None = None,
max_attempts: int = 3,
timeout_seconds: int = 900,
retry_of_job_id: str | None = None,
available_at: datetime | None = None,
) -> BackgroundJobRecord:
now = utc_now()
# 1. owner 与资源标识一起成为持久 job 的业务事实。
row = BackgroundJobModel(
id=job_id,
owner_user_id=owner_user_id,
kind=kind,
resource_type=resource_type,
resource_id=resource_id,
status="queued",
payload=payload or {},
📷 [图片 token=W8WMbbdH7oesSLxmXJOcYzu5nuh(未能下载,见飞书原文)]
片段证明请求结束后 owner 仍随 job 持久化,worker 可以把它继续传给索引或诊断服务。风险边界在 payload:即使记录有 owner,也不能把凭据、完整工具参数或用户正文随意持久化;列表、详情、取消、重试和事件读取仍必须分别按 owner 过滤。
📷 [图片 token=FXaIbvNgUoUxLIxjWNsc8kMxnDe(未能下载,见飞书原文)]
看什么:异步链路中 owner 需要跨越业务任务、durable job、worker、工具审计和证据子对象,任何一跳缺失都会形成权限断点。
📷 [图片 token=HIGTb7nuConFaqxmKH9c47FCngb(未能下载,见飞书原文)]

📷 [图片 token=ZIarbDBz7omlRpxCqhNcrKrHnxh(未能下载,见飞书原文)]
图中 owner 不是为了展示,而是每次读写查询的组成部分。durable runtime 的全局 worker 可以领取任务,但它不能因此获得跨 tenant 的业务读取能力;资源服务仍用 job.owner_user_id 重新进入 Repository 边界。
📷 [图片 token=RiZDbaOWGoUe6nxODYyc5csYn3b(未能下载,见飞书原文)]
从列表、详情到变更的统一审查
权限缺陷经常只修详情接口,却漏掉列表、统计或批量操作。审查一个新实体时,应按 create、list、get、update、delete 五类方法逐一确认:create 的 owner 必须来自当前用户而非请求 body;list 必须先按 owner 过滤再分页或排序;get 与 update 需要在同一查询中匹配 ID 和 owner;delete 需要同样范围并检查关联清理;重试、复制、导出和 search 也应视为独立读写入口。
📷 [图片 token=M7RgbUR22o3cslx6o3Bc6gbznqb(未能下载,见飞书原文)]
关联对象还需要父子范围一致。例如创建 document index task 前,_require_document 同时匹配 owner、knowledge base 和 document;追加 chat message 前,_require_chat_session 确认父会话;添加诊断 report 或 evidence 前,helper 确认 task 同 owner。只在子记录写 owner,而不验证父对象,会留下跨 tenant 关联污染。
📷 [图片 token=JAlQbuQVloSS4Yx9bYyc7AtPnSZ(未能下载,见飞书原文)]
看什么:文档详情查询把 document、owner 和 knowledge base 放在同一个 SQL WHERE 中,并可继续叠加软删除条件。
📷 [图片 token=WN6Wb0Dw7ovX91x7kWQcTrHWnRb(未能下载,见飞书原文)]
async def get_document(
self,
*,
owner_user_id: str,
knowledge_base_id: str,
document_id: str,
include_deleted: bool = False,
) -> KnowledgeDocumentRecord | None:
# 1. ID、owner 与父知识库在同一查询中匹配。
stmt = select(KnowledgeDocumentModel).where(
KnowledgeDocumentModel.id == document_id,
KnowledgeDocumentModel.owner_user_id == owner_user_id,
KnowledgeDocumentModel.knowledge_base_id == knowledge_base_id,
)
# 2. 软删除可见性在 scoped 查询上继续收窄。
stmt = _apply_deleted_filter(stmt, include_deleted)
async with self._session_factory() as session:
row = (await session.scalars(stmt)).one_or_none()
return _knowledge_document_record(row) if row is not None else None
📷 [图片 token=ZqLGb2cO7oQCp1xa5HncWHH5nH9(未能下载,见飞书原文)]
这段实现避免先查全局 document 再比较 owner,也避免把相同 document ID 错挂到另一个知识库。审查 update、delete、retry 或导出时必须重复检查同样的组合条件;一个安全的 get 方法不能自动证明其他入口安全。
📷 [图片 token=MfV0byA6CotjXnxSu0Yca0vRn5f(未能下载,见飞书原文)]
错误语义与对象存在性
已认证用户访问另一个 owner 的资源时,owner-scoped 查询得到空,API 返回统一 403。响应不会告诉调用者该 ID 是否真实存在、属于谁或资源类型详情。对于未认证调用者,处理器在进入 Repository 前就返回 401。这个顺序既保持统一契约,也减少通过状态码和文案枚举资源的机会。
📷 [图片 token=QLKRbvuyiogZBIxbxWjcURpGnhg(未能下载,见飞书原文)]
内部 TenantScopeError 的消息可能包含资源 ID,适合在受控服务边界定位问题,但不应直接作为客户端响应。API 使用 AUTH_FORBIDDEN 的安全默认消息。观测日志也只应记录请求路径、错误 code 或异常类别;如果把 Repository 异常原文和用户输入一起写日志,就会绕过 HTTP 层的隐藏策略。
📷 [图片 token=LmZrbDRNooFDOHxNQbjcBgD9nPc(未能下载,见飞书原文)]
看什么:用判定图区分“没有认证上下文”和“有身份但 scoped 查询无结果”,两者发生在不同层。
📷 [图片 token=VTLAbcnhgo4VFCxQuJ1c37VznNh(未能下载,见飞书原文)]

📷 [图片 token=NqXZbING4oVUgbx7bmScS9zZnJg(未能下载,见飞书原文)]
这个分支保证已认证攻击者不能从 404、资源标题或异常原文枚举其他 tenant 对象。内部诊断仍可记录安全 code 与异常类别,但把含资源 ID 的 TenantScopeError 原文直接返回客户端会破坏隐藏策略。
📷 [图片 token=I7cdbbgahoR4NXxf9Mvcyeetnib(未能下载,见飞书原文)]
浏览器 token 的现实边界
当前 localStorage 方案支持刷新恢复和 bearer API,但脚本若能在同源页面执行,就可能读取 token。因此前端仍需避免不可信 HTML 注入,内容展示组件必须保持安全渲染;这不是后端 tenant 过滤能够补救的风险。另一方面,服务端 session 可撤销意味着 token 泄露后的处置不只依赖等待过期,用户登出后相同 token 会被拒绝。
📷 [图片 token=U5m9bMltgonqPlxhh4ucByY2n7f(未能下载,见飞书原文)]
当前模型没有 session 过期时间字段,AuthSessionModel 记录 created、last seen 与 revoked。不能把不存在的自动过期描述成已实现能力。若未来增加有效期,需要同步数据迁移、认证服务判断、统一错误、前端恢复和测试,而不能只在浏览器设置一个计时器。
📷 [图片 token=KhM1bJrJWo1CFVxWRhOc9CHRnyb(未能下载,见飞书原文)]
认证相关的安全评审还应检查响应缓存与页面切换。当前客户端每次受保护请求都从存储读取 token,服务端每次都重新验证 session,没有把某次 /auth/me 成功当作后续请求的永久通行证。用户切换后,旧页面中的异步响应也不应重新写回已清空 store;前端状态测试与后端 owner 查询共同限制这一竞态的影响。
📷 [图片 token=OlelboDFwoZn4zxLaKzcl7eCn9g(未能下载,见飞书原文)]
看什么:认证客户端每次请求都从当前 storage 读取 token,并仅在存在时添加 bearer header。
📷 [图片 token=YHdFbhIubogDj9xWOwKcPCTDnrg(未能下载,见飞书原文)]
const token = storage.getItem(AUTH_TOKEN_STORAGE_KEY);
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
if (init.body !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
// 1. 每次请求读取当前 token,而不是缓存一次 /auth/me 结论。
if (token !== null) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetchImpl(`${baseUrl}${path}`, {
...init,
headers
});
if (!response.ok) {
// 2. 服务端仍会逐次验证 session 与撤销状态。
throw new AuthClientError(await parseApiError(response));
}
📷 [图片 token=CPjLbpMFQo5dKoxk0fbcSvGrnMe(未能下载,见飞书原文)]
代码证明前端能在 token 被清除后停止发送旧 bearer,却也暴露 localStorage 的现实风险:同源脚本可以读取同一个值。防 XSS、安全渲染和依赖治理属于客户端边界;owner-scoped 后端查询与服务端撤销则限制 token 被盗后的数据范围和处置路径。
📷 [图片 token=JmfhbjU6no7Ot1x5bF0cl5oSnNh(未能下载,见飞书原文)]
数据、契约与状态
共享 AuthUser 只包含 id、email、displayName 和 createdAt。AuthTokenResponse 增加 accessToken 与固定的 bearer tokenType;LogoutResponse 只表达 revoked 为真。后端 _auth_result_payload 与这些字段一一对应,密码哈希、session ID、token hash、last seen 和 revokedAt 都留在服务端。
📷 [图片 token=FnfLbQvJHoncytx664BcHsMvnZf(未能下载,见飞书原文)]
认证状态至少有三层:浏览器有没有 token、服务端 session 是否存在且未撤销、业务资源是否属于当前 user。第一层缺失会触发前端跳转;第二层失败返回 401;第三层失败返回 403。不能用“token 格式合法”推导“session 有效”,也不能用“session 有效”推导“资源可访问”。
📷 [图片 token=LvNEbX8U5obZNtxgGcOccfcznKc(未能下载,见飞书原文)]
业务数据中的 owner_user_id 与向量数据中的 tenantId 当前都取 UserRecord.id。主规格 openspec/specs/authorization-and-tenant-isolation/spec.md 明确这是现阶段约束而非永久组织模型。未来若引入组织 tenant,不能简单把一个字段改名;需要重新审查成员关系、owner 与 tenant 的区别、迁移、共享资源与 Milvus filter。
📷 [图片 token=Aas9btISpoMKo2xatjucVFYCnqg(未能下载,见飞书原文)]
权限、安全与失败边界
匿名请求没有 tenant scope,因此不得访问用户专属 Repository。HTTPBearer 配置 auto_error=False 后,项目自己用 AUTH_UNAUTHENTICATED 生成统一 envelope。未知 token 和未知邮箱不会暴露额外细节;撤销 token 使用明确的 session-revoked 代码,仍是 401。
📷 [图片 token=SyuIb7cOJovH10xu2gacc9B4n8f(未能下载,见飞书原文)]
所有 Agent 和工具装配都必须发生在授权后。聊天会话权限在 Agent runner 前确认,知识检索工具只接收当前用户可访问知识库,MCP 只装配当前用户启用且真实发现的连接,工具审计带 owner。AIOps 的任务、步骤、证据、报告与 checkpoint 也通过同一个 owner 传递。任何“为了提高召回”而移除 tenant filter 的做法都会突破系统最重要的数据边界。
📷 [图片 token=GsoPb85Vjo3ApJx1W3JcElKunMf(未能下载,见飞书原文)]
日志不得记录 Authorization、bearer token、密码、完整用户消息或工具参数。apps/backend/src/super_ai/observability.py 的敏感键匹配与递归 _redact 是一道通用防线,具体调用点还应只传 ID、状态、耗时和错误类别。跨 tenant 拒绝也不应把目标对象内容或 owner 信息写进响应。
📷 [图片 token=WPRMbSVXVoZeQQxnCNxcpiljncT(未能下载,见飞书原文)]
阅读顺序与小结
从
packages/api-contracts/src/auth.ts认识公开的身份数据形状。阅读
apps/backend/src/super_ai/auth/service.py与apps/backend/src/super_ai/auth/sqlite.py,区分密码哈希、原始 token 和 token hash。在
apps/backend/src/super_ai/api/app.py追踪_current_user和一个具体受保护路由。在
apps/backend/src/super_ai/memory/sqlite.py检查 owner 条件是否进入 SELECT、UPDATE 与父对象验证。最后阅读
apps/backend/src/super_ai/memory/vector_scope.py与 Milvus 实现,确认 tenant 和授权知识库范围同时进入向量过滤条件。
OncallAgent 的 tenant 隔离不是一处中间件,而是一条贯穿认证、路由、Repository、向量、Agent 与前端状态的传递链。最可靠的审查方法是选择一个资源 ID,确认每一步都同时携带当前用户 ID,并确认空授权范围不会退化成全量操作。只要有一层把 owner 当成可选参数,AI 工具和高召回检索就会放大那一处缺口。
📷 [图片 token=CJDhbHvjRoxSV3xT8EbcftybnDf(未能下载,见飞书原文)]