Skip to content

鉴权与授权双层体系

本章目标:

  1. 分清"鉴权(你是谁)"与"授权(你能干什么)"两层,以及它们各自在哪段代码
  2. 走通 Keycloak OAuth2 登录流:authorization code → token → 自签 HS256 JWT → HttpOnly cookie
  3. 掌握授权的两个子层:acdm-backend 项目三级权限 + 引擎 Aegra owner_sub 隔离,以及 admin 为何走独立 API

TL;DR

鉴权层:Keycloak OAuth2 Authorization Code flow,acdm-backend 用 code 换 token、查 userinfo,自签一个 HS256 JWT 写进 HttpOnly acdm_session cookie;Caddy forward_auth → /auth/verify 在每个受保护请求前校验。授权层分两个独立子层:① acdm-backend 用 require_project_access/require_project_owner/require_admin 做项目级三级 + 资源级链式反查;② 引擎用 acdm_auth.py(LangGraph SDK Auth 扩展点)按 owner_sub 在 Aegra 的 threads/runs/store 上做 SQL 层硬隔离。admin 跨用户访问无法 bypass SQL 硬过滤,改走独立 admin API。

Overview(为什么鉴权与授权要拆成两层、授权又拆两子层)

把"你是谁"和"你能干什么"分开,是因为它们的变更频率和实现位置不同:鉴权对接外部 Keycloak,逻辑稳定且只该有一处;授权是业务规则,随产品演进频繁变,且分布在两个进程——acdm-backend 管业务资源(项目/会议/文件),引擎管 Agent 会话(thread/run/store)。

授权再拆两子层的根因是故障域和数据归属不同:

  • 项目资源在 acdm 库,授权由 acdm-backend 的 FastAPI 依赖在请求入口判;
  • Agent 会话在 Aegra 库,授权必须下沉到 Aegra 的 SQL 查询层(user_id 硬过滤),否则一个 handler bug 就可能让 A 看到 B 的对话。

正因为 Aegra 的隔离是 SQL 层硬过滤,admin 想跨用户看所有对话就无法在 handler 里 bypass——这解释了为什么 a-cdm 专门为 admin 做了一个独立 API,而不是在 acdm_auth 里给 admin 开后门。

鉴权层:Keycloak OAuth2 + 自签 JWT

acdm-backend/app/auth/keycloak.pyKeycloakService 封装 OIDC 四个端点(keycloak.py:26-40):authorization / token / userinfo / logout,都按 {base_url}/realms/{realm}/protocol/openid-connect/* 拼。

关键点:

  • 自签 JWT 而非透传 Keycloak token:acdm-backend 用 SECRET_KEY(HS256,config.py:54-56)签自己的短 JWT,payload {sub, is_admin, exp}。这样下游(verify / proxy / 引擎)只需一个对称密钥就能解,不必每次回 Keycloak。
  • HttpOnly cookie:acdm_session 是 HttpOnly,JS 读不到,降低 XSS 窃 token 风险;COOKIE_SECURE 生产 True(config.py:61-63)。
  • KEYCLOAK_CLIENT_SECRET 空 → /auth/login 503(config.py:42-44),不崩进程(graceful)。
  • verify 不验过期靠 cookie:decode_access_token HS256 解码,过期判断由 cookie 生命周期 + 前端处理(参见 openspec/changes/fix-jwt-expiry-auto-relogin)。

授权子层 1:acdm-backend 项目三级 + 资源链式反查

acdm-backend/app/auth/dependencies.py 三个 FastAPI 依赖,挂在 router 的 Depends 上:

依赖用于通过条件失败码Source
require_project_accessGET 项目/子资源项目存活 ∧(admin ∨ 成员)40401(掩盖存在性)dependencies.py:45-70
require_project_ownerPUT/DELETE 项目、增删成员项目存活 ∧(admin ∨ owner)非成员 40401 / 普通成员 40301dependencies.py:73-101
require_admin/admin/*user.is_adminHTTP 403(Phase 1 原契约)dependencies.py:104-110

设计细节:

  • 掩盖存在性:非成员访问别人的项目返 40401 "Not found" 而非 403,不泄露"该项目存在"(dependencies.py:54)。
  • 降级:access_enabled()ACDM_PROJECT_ACCESS_ENABLED,false 时所有成员校验直接 return project(回退 Phase 1,dependencies.py:33-42:60-61)。
  • 错误契约:授权失败走 BusinessException(code, message),由 main.py handler 转 HTTP 400 + {code,data:null,message},与全局业务异常一致(dependencies.py:6-7);唯独 require_admin 保留 Phase 1 的 HTTP 403。

require_project_owner 的判定树:

资源级授权拆到 acdm-backend/app/auth/resource_deps.py,做链式反查防越权:访问 file → 反查 file.meeting_id → meeting.calendar_id → calendar.project_id,再对 project 做成员校验;任一层软删(deleted_at)即 40401。这样攻击者拿到一个 file_id 也无法绕过项目成员检查。

授权子层 2:引擎 Aegra owner_sub 硬隔离

引擎侧授权在 deer-flow/backend/packages/harness/deerflow/auth/acdm_auth.py,用 LangGraph SDK 官方 Auth 扩展点(acdm_auth.py:31-35),由 aegra.json / langgraph.json"auth" 字段加载,server-agnostic(LangGraph 0.7 + Aegra 0.9 兼容)。规则(acdm_auth.py:10-21):

操作规则Source
@auth.authenticate从 Caddy 注入的 X-Auth-User-Id/X-Auth-User-Role 提取身份(兼容 bytes/str key)acdm_auth.py:57-75
创建 thread服务端强制注入 metadata.owner_sub = ctx.user.identity,覆盖客户端传入(防伪造)acdm_auth.py:11-12
读/改/删/搜 thread始终返 {owner_sub: identity} filteracdm_auth.py:13
run折叠进 threads(SDK 0.3.x _On)acdm_auth.py:17
store按 namespace 隔离,user.identity in namespace tupleacdm_auth.py:18

关键设计:_is_admin bypass 已在 MR2 移除(acdm_auth.py:47-49)。因为 Aegra 0.9.4 在 SQL 层用 user_id 硬过滤,handler 即使返 None(不加 filter)也无法让 admin 看到别人的 thread——SQL 已经把行过滤掉了。所以 admin 跨用户访问改走 acdm-backend 的独立 admin API /api/acdm/admin/threads/all(acdm_auth.py:13-16),is_admin 字段仍在 authenticate() 返回值里保留给那个独立 API 做 require_admin gate。

降级:ACDM_THREAD_AUTH_ENABLED=false 时所有 handler 立即 return None,等价 Phase 1 无隔离(acdm_auth.py:20-21:38-44)。错误抛 Auth.exceptions.HTTPException 交 server 包成 HTTP 错误,不走 acdm-backend 的 BusinessException 契约(两进程独立,前端按 HTTP status 判,acdm_auth.py:23-24)。

Configuration(本章相关)

env默认影响Source
KEYCLOAK_CLIENT_SECRET空则 /auth/login 503acdm-backend/app/config.py:42-44
SECRET_KEYHS256 签发密钥(❌ 禁入库)acdm-backend/app/config.py:54-56
ACCESS_TOKEN_EXPIRE_MINUTES1440(24h)JWT 过期acdm-backend/app/config.py:58-59
COOKIE_SECURETrue(prod)cookie Secure 标记acdm-backend/app/config.py:61-63
ACDM_PROJECT_ACCESS_ENABLEDtruefalse=回退 Phase 1 无授权acdm-backend/app/auth/dependencies.py:38
ACDM_THREAD_AUTH_ENABLEDtruefalse=引擎无 owner_sub 隔离acdm_auth.py:40

Common Pitfalls / 实战 Tips

  • 40401 不一定是"不存在":可能是"你不是成员"被掩盖。排查越权别只看字面 message。
  • admin 看不到别人对话不是 bug:Aegra SQL 硬过滤所致,跨用户必须走 /api/acdm/admin/threads/all(acdm_auth.py:13-16)。
  • 两套错误契约不要混:acdm-backend 授权失败是 BusinessException→HTTP 400;引擎 auth 失败是 HTTP status(401/403/404)。前端对两者判断方式不同。
  • owner_sub 由服务端强制注入:客户端传任何 metadata.owner_sub 都会被覆盖(acdm_auth.py:11-12),不要试图在前端"设置 owner"。

References

  • acdm-backend/app/auth/keycloak.py:1-143 — Keycloak OIDC + 自签 HS256 JWT
  • acdm-backend/app/auth/dependencies.py:1-111 — 项目三级授权依赖(本章主源)
  • acdm-backend/app/auth/resource_deps.py — 资源级链式反查防越权
  • acdm-backend/app/auth/verify.py:1-28 — forward_auth verify
  • deer-flow/backend/packages/harness/deerflow/auth/acdm_auth.py:1-75 — 引擎 owner_sub SQL 硬隔离
  • acdm-backend/app/config.py:33-63 — Keycloak/JWT/cookie 配置项
PageRelationship
请求生命周期与服务拓扑本章鉴权层是该章 forward_auth 链路的实现
控制面与引擎的耦合契约本章 owner_sub 注入/校验在该章作为耦合契约展开
项目管理与资源授权本章三级依赖在该章被项目业务路由使用
审计、SSE 与后台任务本章 admin 独立 API 在该章 admin 治理中展开
环境变量、凭据与降级开关本章两个降级开关在该章速查表中

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析