主题
鉴权与授权双层体系
本章目标:
- 分清"鉴权(你是谁)"与"授权(你能干什么)"两层,以及它们各自在哪段代码
- 走通 Keycloak OAuth2 登录流:authorization code → token → 自签 HS256 JWT → HttpOnly cookie
- 掌握授权的两个子层: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.py 的 KeycloakService 封装 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/login503(config.py:42-44),不崩进程(graceful)。- verify 不验过期靠 cookie:
decode_access_tokenHS256 解码,过期判断由 cookie 生命周期 + 前端处理(参见openspec/changes/fix-jwt-expiry-auto-relogin)。
授权子层 1:acdm-backend 项目三级 + 资源链式反查
acdm-backend/app/auth/dependencies.py 三个 FastAPI 依赖,挂在 router 的 Depends 上:
| 依赖 | 用于 | 通过条件 | 失败码 | Source |
|---|---|---|---|---|
require_project_access | GET 项目/子资源 | 项目存活 ∧(admin ∨ 成员) | 40401(掩盖存在性) | dependencies.py:45-70 |
require_project_owner | PUT/DELETE 项目、增删成员 | 项目存活 ∧(admin ∨ owner) | 非成员 40401 / 普通成员 40301 | dependencies.py:73-101 |
require_admin | /admin/* | user.is_admin | HTTP 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.pyhandler 转 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} filter | acdm_auth.py:13 |
| run | 折叠进 threads(SDK 0.3.x _On) | acdm_auth.py:17 |
| store | 按 namespace 隔离,user.identity in namespace tuple | acdm_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 503 | acdm-backend/app/config.py:42-44 |
SECRET_KEY | 空 | HS256 签发密钥(❌ 禁入库) | acdm-backend/app/config.py:54-56 |
ACCESS_TOKEN_EXPIRE_MINUTES | 1440(24h) | JWT 过期 | acdm-backend/app/config.py:58-59 |
COOKIE_SECURE | True(prod) | cookie Secure 标记 | acdm-backend/app/config.py:61-63 |
ACDM_PROJECT_ACCESS_ENABLED | true | false=回退 Phase 1 无授权 | acdm-backend/app/auth/dependencies.py:38 |
ACDM_THREAD_AUTH_ENABLED | true | false=引擎无 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 JWTacdm-backend/app/auth/dependencies.py:1-111— 项目三级授权依赖(本章主源)acdm-backend/app/auth/resource_deps.py— 资源级链式反查防越权acdm-backend/app/auth/verify.py:1-28— forward_auth verifydeer-flow/backend/packages/harness/deerflow/auth/acdm_auth.py:1-75— 引擎 owner_sub SQL 硬隔离acdm-backend/app/config.py:33-63— Keycloak/JWT/cookie 配置项
Related Pages
| Page | Relationship |
|---|---|
| 请求生命周期与服务拓扑 | 本章鉴权层是该章 forward_auth 链路的实现 |
| 控制面与引擎的耦合契约 | 本章 owner_sub 注入/校验在该章作为耦合契约展开 |
| 项目管理与资源授权 | 本章三级依赖在该章被项目业务路由使用 |
| 审计、SSE 与后台任务 | 本章 admin 独立 API 在该章 admin 治理中展开 |
| 环境变量、凭据与降级开关 | 本章两个降级开关在该章速查表中 |