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.tsregister 开始,发送共享 RegisterRequestPOST /auth/registerapps/backend/src/super_ai/api/app.pyregister 路由调用 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_FORBIDDENChatStreamingService 和 Agent runner 都不会被创建执行。知识文档、索引任务、诊断、后台任务、反馈与 MCP 连接采用相同模式。

📷 [图片 token=PH1ubgqRjoOw7uxV1THceIUZnkd(未能下载,见飞书原文)]

核心源码地图

源码位置关键符号职责
packages/api-contracts/src/auth.tsRegisterRequestLoginRequestAuthUserAuthTokenResponse定义前后端共享认证 DTO,不暴露密码哈希或 session 内部字段。
apps/backend/src/super_ai/auth/service.pyAuthServicenormalize_emailhash_token校验凭证、哈希密码、签发和验证可撤销 session。
apps/backend/src/super_ai/auth/repositories.pyAuthRepositoryUserRecordAuthSessionRecord隔离认证业务逻辑与 SQLAlchemy 存储细节。
apps/backend/src/super_ai/auth/sqlite.pySQLiteAuthRepository持久化用户、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.pyUserModelAuthSessionModel、各类 owner_user_id定义持久化身份与 owner-scoped 业务模型及索引。
apps/backend/src/super_ai/memory/sqlite.pySQLiteChatMemoryRepository_require_document_find_diagnostic_task在查询和变更中同时匹配业务 ID 与 owner_user_id。
apps/backend/src/super_ai/memory/vector_scope.pybuild_vector_chunk_metadatabuild_milvus_tenant_filter生成向量权限 metadata 与 Milvus tenant/知识库过滤表达式。
apps/frontend/src/stores/auth.tsuseAuthStorecreateAuthRouteAccess恢复当前用户、管理登录态,并在登出或失效时清理受保护 store。
apps/frontend/src/router/index.tscreateAppRouter为工作区路由提供前端访问守卫,但不替代后端授权。

📷 [图片 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 中都包含 ownerUserIdtenantIdknowledgeBaseIddocumentIdchunkIdbuild_milvus_tenant_filter 生成 tenant 相等且 knowledge base 位于授权集合的表达式;MilvusVectorStore.search_chunkslist_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(未能下载,见飞书原文)]

路由守卫等待初始化完成后,再判断 requiresAuthpublicOnly。这解决刷新页面时的竞态和视觉跳转。安全审查仍应假定攻击者可以直接调用 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(未能下载,见飞书原文)]

阅读顺序与小结

  1. packages/api-contracts/src/auth.ts 认识公开的身份数据形状。

  2. 阅读 apps/backend/src/super_ai/auth/service.pyapps/backend/src/super_ai/auth/sqlite.py,区分密码哈希、原始 token 和 token hash。

  3. apps/backend/src/super_ai/api/app.py 追踪 _current_user 和一个具体受保护路由。

  4. apps/backend/src/super_ai/memory/sqlite.py 检查 owner 条件是否进入 SELECT、UPDATE 与父对象验证。

  5. 最后阅读 apps/backend/src/super_ai/memory/vector_scope.py 与 Milvus 实现,确认 tenant 和授权知识库范围同时进入向量过滤条件。

OncallAgent 的 tenant 隔离不是一处中间件,而是一条贯穿认证、路由、Repository、向量、Agent 与前端状态的传递链。最可靠的审查方法是选择一个资源 ID,确认每一步都同时携带当前用户 ID,并确认空授权范围不会退化成全量操作。只要有一层把 owner 当成可选参数,AI 工具和高召回检索就会放大那一处缺口。

📷 [图片 token=CJDhbHvjRoxSV3xT8EbcftybnDf(未能下载,见飞书原文)]