Skip to content

账户与访问权限同步协议

本文是 IAM 与业务应用(CRM、OA 等)之间「账户与访问权限同步」的线协议参考,供接入方后端实现。

适用文档:CRM 后端接入、OA 后端接入、CRM 账户同步本地验收。

IAM 是应用准入的权威源。应用不需要自己判断谁能登录,只需要:

  1. 接收 IAM 主动投递的开通 / 撤权事件(POST 到应用配置的接收地址);
  2. 在需要时主动向 IAM 查询某个用户的当前准入快照(POST /api/internal/provisioning/snapshot)。

两条方向共用同一套 HMAC 签名方案。

每个 OAuth Client 在 IAM 管理端「应用详情 → 访问控制 → 账户与访问权限同步」单独配置接收地址,首次启用时生成一对凭据:

项目 说明
Key ID 与具体 OAuth Client 绑定,随请求头明文发送
HMAC Secret 仅生成时显示一次;IAM 侧用 BETTER_AUTH_SECRET 派生的密钥加密存储

凭据与 Client ID 强绑定:用 A 应用的凭据查询 B 应用的快照返回 403 CLIENT_MISMATCH。轮换凭据会立即吊销旧 Key ID,建议按「停用 → 轮换 → 更新接收方 → 启用」的顺序操作。

所有请求(IAM 投递事件、应用查询快照)携带以下请求头:

content-type: application/json
x-iam-key-id: <Key ID>
x-iam-timestamp: <Unix 秒级时间戳>
x-iam-request-id: <本次请求唯一 ID,不超过 128 字符>
x-iam-signature: <HMAC-SHA256 十六进制小写>

签名对象是把下面 5 行用 \n 连接、结尾不加换行的字符串:

{METHOD}
{PATH}
{TIMESTAMP}
{REQUEST_ID}
{BODY_SHA256_HEX}
占位符 取值规则
METHOD HTTP 方法,大写,当前固定为 POST
PATH 目标 URL 的 pathname,不含域名和查询串
TIMESTAMP 与 x-iam-timestamp 完全一致的字符串
REQUEST_ID 与 x-iam-request-id 完全一致的字符串
BODY_SHA256_HEX 原始请求体的 SHA-256 十六进制小写摘要

签名值:

signature = HMAC-SHA256(secret, canonical) 的十六进制小写

注意是「先对正文取 SHA-256,再把摘要放进规范串」,不是直接对正文做 HMAC。

接收方必须逐项校验,任一不通过即拒绝:

  • x-iam-timestamp 必须是纯数字,且与服务端时间偏差不超过 300 秒;
  • x-iam-signature 必须是 64 位十六进制字符串;
  • x-iam-request-id 非空且不超过 128 字符;
  • 签名比较必须使用定时安全比较,不能用普通字符串相等;
  • Key ID 未知或已吊销时返回 401。

可直接用于验证签名实现是否符合协议:

项目 值
method POST
path /api/internal/iam/provisioning/users
timestamp 1700000000
requestId 11111111-1111-1111-1111-111111111111
body {"action":"upsert","clientId":"oa-client"}
secret test-hmac-secret-value
body SHA256 48ab415eea4c05fa4f70c9951ead8acc7037c2a96b89a208cbfdecf305b54a95
signature 9e49bb64aaeb63d3ee84218b512d0c385350d8c9c3bf974ddfa90c5d125002eb

IAM 向应用在管理端登记的接收地址发起 POST,请求头即第 2 节的签名头。默认约定路径为 /api/internal/iam/provisioning/users,实际以登记值为准(签名用的 PATH 也取自该地址的 pathname)。

{
"eventId": "3f1c…",
"revision": 12,
"occurredAt": "2026-09-20T08:30:00.000Z",
"action": "upsert",
"clientId": "crm-client-id",
"user": {
"subject": "iam-user-id",
"wecomUserId": "LiuYi",
"name": "张三",
"email": "zhangsan@example.com",
"departmentId": "100",
"position": "销售专员",
"departmentIds": ["100", "101"],
"departmentAncestorIds": ["1"]
},
"access": {
"allowed": true,
"appRole": "sales",
"reason": "department_allowed"
}
}
字段 说明
eventId 事件唯一 ID,可用于幂等去重
revision 该用户在该应用上的准入版本号,单调递增
occurredAt 事件生成时间(ISO 8601)
action upsert 授予/更新准入;revoke 撤销准入
user.email 仅当 emailVerified=true 且邮箱可投递时才有值,其余为 null
user.position 企业微信职位,可能为 null(表示已清空)
user.departmentIds 直接所属的企业微信部门 ID 列表
user.departmentAncestorIds 直接所属部门的全部上级部门 ID 列表
access.allowed 是否允许访问;revoke 事件固定为 false
access.appRole IAM 侧应用角色,仅作参考;业务权限由应用自己的规则决定
access.reason 判定原因,便于排障

补全资料时,若本人企微通讯录已登记邮箱或企业邮箱,输入须与其中之一一致(去除首尾空白、忽略大小写),仍须完成邮件验证。待验证地址独立保存在 verification 表,不写入 user.email、不占用正式邮箱唯一性;验证成功后才原子写入正式邮箱、标记已验证并完成资料。待验证期间统一保持 mustUpdateProfile=true,企微和非企微账号均不得恢复准入;验证成功才清除此标记。管理员要求重新完善资料时,同事务删除待验证记录,撤销旧链接。历史已完善资料但未验证的邮箱允许直接重发,通讯录有邮箱时仍校验一致性;新流程已过期或管理员重置的账号须重新填写。最新一次提交或重发会使旧链接失效,确认时由数据库唯一约束防止同邮箱并发绑定。升级前排队的负载若带有当前未验证或已变更的邮箱,会先重新对账生成新版本,不直接投递旧邮箱。

action 由 IAM 计算:用户被禁用、企业微信状态非 active 或不在应用授权范围内时投递 revoke,否则投递 upsert。

企业微信回调同步人员状态、全量通讯录同步,以及管理员封禁 / 解封,都会触发应用准入重算。企微同步的人员写入与 provisioning_user_reconcile_job 任务在同一事务内提交;即时重算失败或进程中断后,每 5 分钟的维护任务会恢复处理。管理员封禁 / 解封成功后,先等待人员任务落库,再安排后台重算;Better Auth 的账号写入与任务写入不是同一事务,两者之间中断仍由全量定时对账兜底。

access.reason 是稳定协议字段,以下取值的语义保持兼容;删除、重命名或改变含义前须提前通知接收方并协调升级。接收方应以 access.allowed 控制准入,以原因区分处置;未知原因应保留原值用于排障,不能因此放行。

access.reason 优先表达人员限制,再表达应用授权:

原因 含义
user_departed 本地人员或企微档案已离职
user_banned 管理员封禁仍在有效期内
wecom_inactive 本地人员状态不可用,或已绑定企微的账号被禁用、未激活或缺少企微档案
app_disabled 应用已停用
其他原因 沿用授权范围判定,例如 explicit_denied、not_allowed

未绑定企微的邮箱账号不要求企业微信通讯录档案;准入仍受应用授权、显式拒绝、账号停用、离职、封禁和应用停用约束。已绑定企微的账号继续要求有效且在职的通讯录档案。邮箱是否可下发仍按前述验证规则判定,CRM 内部业务角色仍由 CRM 管理。

企微 UserID 变更时迁移原用户的企微关联、登录 account、档案和部门关系,IAM subject 保持不变,不因改名标记离职;新 UserID 已关联其他身份时拒绝自动合并并记录同步失败。解封不会覆盖离职状态,也不会恢复被撤销的旧登录凭据。

日常应用管理已移除“禁用此客户端”入口,准入统一通过访问控制管理。撤销全员访问需同时移除人员和部门授权。数据库中的历史 disabled 状态仍保留兼容,不会自动重新启用历史停用应用。

底层兼容行为中,停用 OAuth 应用会产生 app_disabled 撤权;只要同步连接仍启用,就会继续推送。依照本协议,接收方处理撤权会禁止该应用账号准入并撤销本地会话。CRM 是否将“停用应用”定义为同时关闭全部本地访问,仍需双方业务确认,联调验收须明确这一点。

只有配置并启用同步连接的应用会收到通知。没有历史开通状态的用户不会生成首次 revoke;存量 CRM/OA 账号接入前,应完成 iam_subject 绑定并建立同步基线,不能假设已有 Cookie 的用户都已被纳入通知范围。

接收系统收到 revoke 后,必须按 user.subject 找到本地账号,在事务内保存禁止准入状态和新 revision,并撤销该用户所有设备的本地 Token / Session。每次受保护的业务请求都应检查本地准入状态;使用缓存时,撤权也必须清除相关缓存。重新授权后要求重新登录,不能恢复旧凭据。

IAM 显示“通知成功”只表示接收方返回 2xx,不能证明业务系统已经使旧 Cookie / Token 失效。验收必须保留离职前的 Cookie / Token,模拟离职后再次调用业务接口,确认被拒绝;再投递旧版本 upsert,确认不能恢复访问。

  • 必须校验签名与时间戳,不能只依赖 IP 或网络隔离。
  • 必须按 revision 做单调性判断:忽略小于等于已处理版本的事件,否则一个迟到的旧版本事件会把已撤销的访问重新打开。
  • 必须幂等:同一 eventId 重复投递不能产生重复账号或重复角色。
  • 处理成功返回 2xx;正文与状态码用于投递侧判定结果。
项目 取值
单次超时 15 秒
最大尝试 8 次
退避间隔 5 分钟、15 分钟、1 小时、6 小时(达到上限后重复最后一档)
重试条件 网络错误、429、5xx
不重试 其余 4xx 视为不可恢复;409 记为冲突状态,供人工处理

除事件驱动投递外,IAM 还有每 5 分钟一次的维护任务,恢复未完成的人员 / 应用重算任务、重新计算准入并处理到期的投递。达到最大尝试次数或进入不可恢复失败 / 冲突状态后,需要管理员排障后手动重试。因此接收方必须能容忍同一状态被重复投递。

首次登录、或应用自身状态与 IAM 不一致时,应用可主动拉取某个用户的当前准入快照,避免依赖历史事件。

POST /api/internal/provisioning/snapshot
content-type: application/json
x-iam-key-id: <Key ID>
x-iam-timestamp: <Unix 秒>
x-iam-request-id: <唯一 ID>
x-iam-signature: <HMAC-SHA256 十六进制小写>
{"clientId":"crm-client-id","subject":"iam-user-id"}

签名规范串使用 PATH = /api/internal/provisioning/snapshot。正文上限 8 KiB。

响应体是事件负载去掉 eventId / occurredAt、加上 revision:

{
"revision": 12,
"action": "upsert",
"clientId": "crm-client-id",
"user": { "subject": "iam-user-id", "…": "字段与第 3.1 节一致" },
"access": { "allowed": true, "appRole": "sales", "reason": "department_allowed" }
}

应用应按与事件相同的规则应用该负载(action + revision 单调性 + 幂等)。

状态码 error 含义
400 INVALID_PAYLOAD 正文不是合法 JSON,或 clientId / subject 缺失
401 UNAUTHORIZED 缺少签名头、Key ID 未知、时间戳超差或签名不符
403 CLIENT_MISMATCH clientId 与该凭据绑定的客户端不一致
403 CONNECTION_DISABLED HMAC 验签通过,但同步连接已停用;不返回快照
404 CLIENT_NOT_FOUND 未找到该开通客户端
404 SUBJECT_NOT_FOUND 未找到该 subject 对应的 IAM 用户
404 SNAPSHOT_UNAVAILABLE 该用户当前没有可用的开通状态
409 REPLAY 同一 requestId 已被使用过
413 PAYLOAD_TOO_LARGE 正文超过 8 KiB

IAM 用 verification 表记录已消费的 requestId(键为 Key ID 与 requestId 的哈希),保留 10 分钟。因此:

  • 每次查询必须使用新的 requestId,重试时也不能复用;
  • 命中已消费记录返回 409 REPLAY,这不是签名错误,不要通过重试同一请求来「修复」。
  • 接收地址为外部可达的 HTTPS 地址(仅本机调试允许 http://localhost),不含账号密码、查询串与 fragment。
  • 实现了第 2 节的规范串与 HMAC 计算,并通过 2.3 的测试向量。
  • 校验时间戳偏差 300 秒、64 位十六进制签名、定时安全比较。
  • 按 revision 忽略旧版本事件,按 eventId 幂等。
  • 撤权事件会真正回收访问,且旧版本事件无法恢复访问。
  • 快照查询每次使用新的 requestId,正确处理 409 与 403。
  • 日志不记录 HMAC Secret、完整签名与投递正文中的个人信息。
  • 契约与常量:packages/contracts/src/provisioning.ts
  • 签名与验签:packages/auth/src/provisioning-hmac.ts
  • 准入计算与投递:packages/auth/src/provisioning.ts
  • 快照 HTTP 处理器:apps/server/src/provisioning-snapshot.ts

IAM 在授权码兑换和刷新令牌时复核账号及应用准入。显式用户授权变更会清理该用户在当前应用的已存储 OAuth 凭据;部门授权变更会清理当前应用的已存储 OAuth 凭据,其他应用不受影响。禁用应用时同样清理该应用的凭据。重新启用或恢复授权后需要重新登录授权。

清理数据库凭据不能让仅离线验签的 JWT 自动失效,也不能直接删除 CRM/OA 自己的登录会话。接收方必须在撤权事件后禁用本地访问并回收会话,按 revision 拒绝旧事件;有即时失效要求的资源操作还需在线确认当前权限。IAM 不把 ID Token 当作业务资源访问凭据。本仓库的测试不代表下游实现已完成验收。