架构梳理 · 可视化文档

Aida 用户身份与权限体系全景

范围:aida-agent(server) + aida-desktop(client) · 生成于 2026-06-26

3 套身份空间 5 步权限过滤管线 双令牌 + 端点绑定 Hybrid 本机授权 34 条核验问题

核心判断 · Verdict

「user」不是一个概念,而是三套并存身份空间users 表与 resolveAssignmentSubjectId 单点桥接后,经 TenantContext 跨链路传播;权限是一条「域准入 → 角色 → 资源门」的顺序过滤管线

整体设计稳健,真正的风险集中在两处:server 侧 api-my跨域角色提升(水平越权),与 desktop 侧 standalone 写路径未绑定原生对话框(任意写入面)

严重度图例: ● 高危 ● 中危 ● 低危 事实(file:line 证据) · 判断/建议 · 假设 已分列

全景结构图 · 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 后端实证的租户 上游外部 id
external_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["返回可见资源"]
高危关联 第 ② 步 domainResolverMiddlewareheader 改写 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 只进不出

低×4

revokeSessionGrants 无调用、logout 不撤 grant、tombstone 不删——靠 TTL 被动兜底。

下一步 · Next Move

  1. 1修 apimy-1(水平越权):对 X-Aida-Domain header 与 query domainId 做一致性校验,或粒度路由不依赖被改写的 ctx.roles
  2. 2修 localexec-3(任意写入面):用一次性令牌把 standalone finalPath 与本次 pick-save-path 选择绑定。
  3. 3补授权生命周期回收:接通 revokeSessionGrants,session/logout 时撤销 grant + selected-file 快照锁。
  4. 4批量收口正确性中危(userdata-1 / chan-2 / chan-5)与清理类(死代码、文档漂移、双导出)。

本文档由源码实读 + 多 agent 对抗核验生成;每条问题均有 file:line 证据。需要单条修复方案或 server↔desktop 完整时序图可继续。