架构梳理 · 可视化文档
Aida 用户身份与权限体系全景
范围:aida-agent(server) + aida-desktop(client) · 生成于 2026-06-26
核心判断 · Verdict
「user」不是一个概念,而是三套并存身份空间在 users 表与 resolveAssignmentSubjectId 单点桥接后,经 TenantContext 跨链路传播;权限是一条「域准入 → 角色 → 资源门」的顺序过滤管线。
整体设计稳健,真正的风险集中在两处:server 侧 api-my 的跨域角色提升(水平越权),与 desktop 侧 standalone 写路径未绑定原生对话框(任意写入面)。
全景结构图 · Structure Map
读图坐标系:身份从哪来 → 在哪汇合 → 怎么传播 → 被谁消费。
flowchart TB
subgraph SRC["身份来源 · 3 套身份空间"]
direction LR
B["Business 终端用户
Backend Token"]
I["Internal 后台用户
账号 + 密码"]
C["Channel / IM 用户
飞书 union_id"]
end
B -->|"/api/auth/exchange"| AJ["Aida JWT"]
I -->|"/api/auth/internal-login"| IJ["Internal JWT"]
C -->|"webhook 命中 channel_user_binding"| RT["运行时身份"]
AJ --> TC
IJ --> TC
RT --> TC
TC["TenantContext
tenantId · userId · empId · roles · department · backendToken"]
TC -->|"AsyncLocalStorage 全链路传播"| CONS["下游消费"]
CONS --> MY["api-my:投影『当前用户可见面』"]
CONS --> BK["backendToken:调后端业务工具"]
TC -.->|"resolveAssignmentSubjectId 单点桥"| U["users 表 (identity_type)"]
U --> ASG["user_domain_assignments
用户 ↔ 域 ↔ 角色"]
MY -.->|"读"| ASG
关键认知:channel_user_binding.user_id 存的是「运行时 userId」而非 users.id,所以 IM 绑定不直接关联内部用户/域分配,只在 resolveAssignmentSubjectId 单点对齐。
① 三套身份空间对比
| 身份空间 | 来源凭证 | tenantId | 运行时 userId | backendToken |
|---|---|---|---|---|
| Business 终端用户 |
Backend Token → Aida JWT | 后端实证的租户 | 上游外部 idexternal_user_id |
有 |
| Internal 后台用户 |
用户名 + 密码 → Internal JWT | internal:team保留命名空间 |
users.id 本身 |
无(恒空) |
| Channel IM 用户 |
飞书 union_id → 绑定表 | 绑定行里的 tenantId | 绑定行里的运行时 userId | 取缓存,无则重登 |
汇合点 1 · users 表
统一身份表,identity_type = business | internal 区分。
汇合点 2 · 单点桥
resolveAssignmentSubjectId:运行时 userId → users.id,fail-closed。
汇合点 3 · 绑定表
channel_user_binding:IM 平台 id → 运行时 userId。
② 请求身份生命周期
一次 /api/* 请求如何「取得身份 → 装进上下文 → 传播 → 消费」。
flowchart LR R["请求带 token"] --> AM["authMiddleware
3 分支识别"] AM --> A1["admin static token"] AM --> A2["internal JWT"] AM --> A3["主 Aida JWT(验签)"] A3 --> TP["tokenProvider.resolve
取缓存 backendToken"] TP -->|"缓存 miss"| E401["AuthError → 401
需重新 exchange"] TP --> DEP["fetchUserDepartment
(5min 缓存)"] DEP --> CTX["createTenantContext"] CTX --> ALS["withTenantContext
AsyncLocalStorage"] ALS --> DS["orchestrator / tool-client / scheduler"]
③ 权限过滤管线(api-my 可见面)
某用户「能看到哪些 Agent / Capability / Skill / Workspace」是一条顺序短路的过滤链。
flowchart TD S["authMiddleware:取 ctx.roles"] --> D["domainResolverMiddleware
X-Aida-Domain 改写 ctx.roles"] D --> SUB["resolveAssignmentSubjectId
运行时 userId → users.id(fail-closed)"] SUB -->|"null"| F1["403"] SUB --> GA["getActiveAssignment(subjectId, query.domainId)"] GA -->|"无 assignment"| F2["403 Domain access denied"] GA --> CER["computeEffectiveRoles
assignment.roles 空 → 回退 ctx.roles"] CER --> FILT{"逐类资源过滤"} FILT --> M1["① 域成员过滤"] M1 --> M2["② 租户覆盖(仅 capability)"] M2 --> M3["③ 域授权角色门"] M3 --> M4["④ 全局默认角色门"] M4 --> M5["⑤ 部门门(仅 agent)"] M5 --> OUT["返回可见资源"]
domainResolverMiddleware 按 header 改写 ctx.roles,而过滤用 query domainId——两个 domain 源无一致性校验,叠加「assignment.roles 空回退 ctx.roles」即构成 apimy-1 跨域角色提升。
④ 资源放行决策矩阵(evaluateResourceAccess)
单个资源是否对当前用户可见,按下表自上而下短路。
| 优先级 | 条件 | 结果 | 说明 |
|---|---|---|---|
| 0 | tenantOverride.enabled === false |
deny | 租户级显式关停,最高优先 |
| 1 | 角色来源取:租户覆盖 > 域授权 > 全局默认 |
选定 requiredRoles | 三级覆盖,就近优先 |
| 2 | requiredRoles 为空 |
allow | 无要求即放行(no_requirement) |
| 3 | effectiveRoles ∩ requiredRoles 非空 |
allow | 角色交集命中则放行,否则 deny |
⑤ Desktop 双令牌与凭证隔离
桌面端是 Business 身份的客户端,扮演「可信凭证持有方」。
flowchart LR
subgraph MAIN["Electron Main(可信)"]
V["Credential Vault
Backend Token(safeStorage 加密 0600)"]
EX["exchange → Aida JWT"]
V --> EX
end
subgraph REN["Renderer(不可信)"]
AJ["Aida JWT(模块级内存)"]
end
EX -->|"IPC get-access-token:只下发短时 JWT"| AJ
AJ -->|"Authorization: Bearer"| API["agent API"]
V -.->|"绝不进"| X["config / renderer / IPC / 日志"]
凭证只在 Main
Backend Token 加密落 vault,renderer 永远拿不到。
端点绑定
token 绑 endpoint identity = origin + basePath,换端点即弃旧 token。
登录硬化
loopback 回调 + 32B state 常量时间比对 + origin allowlist + 单次使用 + 5min TTL。
⑥ Hybrid 本机授权模型(desktop)
server 仍是唯一 agent loop;要碰本机文件时下发 domain=desktop 的 delegated tool_call,desktop 决定「这一轮能碰什么」。
flowchart TB
SRV["server agent loop(唯一 loop)"] -->|"SSE delegated tool_call"| REN["renderer(转发)"]
REN -->|"IPC dispatch-tool-call"| LE["LocalExecutorService(Main)"]
LE --> G1["trusted root + skill 同意态"]
LE --> G2["Selected File Grant(opaque fileId)"]
LE --> G3["Transient Grant(snapshot + 30min TTL + revoke)"]
LE --> G4["approval-gate(本机确认 + 10min TTL)"]
LE --> G5["path-policy(容器化 / 拒 symlink 越界)"]
G1 --> EXn["执行本机 primitive"]
G2 --> EXn
G3 --> EXn
G4 --> EXn
G5 --> EXn
EXn --> LED["egress-ledger(脱敏出口审计)"]
| 授权类型 | server 可见 | 生命周期 | 现状 |
|---|---|---|---|
| Selected File Grant | opaque fileId |
仅 sessionId 绑定 | 无快照锁 / 无 TTL |
| Transient Grant | opaque grantId |
snapshot + 30min TTL + revoke | 完整 |
| Output Target Grant | opaque outputTargetId |
— | 两端预留,未落地 |
| approval-gate | —(本机确认) | 10min TTL | 完整 |
⑦ 风险摘要(已对抗核验)
两轮 workflow 共确认 34 条真问题(server 18 + desktop 16),另驳回 15 条误报。下列为重点。
Server 侧(aida-agent)
apimy-1 · 跨域角色提升
高header 域角色污染 query 域评估 → 列出本应被拦截的资源。水平越权。
auth-2 · 未验签 claims 兜底签 JWT
中后端模糊失败时用 decodeJwt(未验签)身份签发 30d Aida JWT。
apimy-2 · archived 域仍可枚举
中粒度路由不校验 domain.status,与 /domains、/domain-view 读路径不一致。
userdata-1 · embedding 被覆盖 NULL
中embedder 临时不可用时加固偏好会清空已有向量,双列不一致。
chan-2 · 孤儿绑定
中channel_user_binding 无 FK,删用户后 IM 绑定残留且仍可命中。
chan-5 · team 无白名单
中服务端不校验 team 取值 → internal 用户落到孤立 internal:team 命名空间。
Desktop 侧(aida-desktop)
localexec-3 · 任意路径写盘
中standalone save_dialog 的 finalPath 未与原生对话框绑定,renderer 可传任意绝对路径(仍受确认门)。
localexec-1 · grant 无快照锁
中Selected File Grant 无 TTL/revoke/snapshot,文件被替换后读到新内容。
dlogin-3 · exchange 无超时
中换 JWT 热路径无 AbortController,server hang 时挂住调用方(含 chat 窗口)。
系统性 · grant 只进不出
低×4revokeSessionGrants 无调用、logout 不撤 grant、tombstone 不删——靠 TTL 被动兜底。
下一步 · Next Move
- 1修 apimy-1(水平越权):对
X-Aida-Domainheader 与 querydomainId做一致性校验,或粒度路由不依赖被改写的ctx.roles。 - 2修 localexec-3(任意写入面):用一次性令牌把 standalone finalPath 与本次 pick-save-path 选择绑定。
- 3补授权生命周期回收:接通
revokeSessionGrants,session/logout 时撤销 grant + selected-file 快照锁。 - 4批量收口正确性中危(userdata-1 / chan-2 / chan-5)与清理类(死代码、文档漂移、双导出)。
本文档由源码实读 + 多 agent 对抗核验生成;每条问题均有 file:line 证据。需要单条修复方案或 server↔desktop 完整时序图可继续。