账户与访问权限同步协议
账户与访问权限同步协议
Section titled “账户与访问权限同步协议”本文是 IAM 与业务应用(CRM、OA 等)之间「账户与访问权限同步」的线协议参考,供接入方后端实现。
适用文档:CRM 后端接入、OA 后端接入、CRM 账户同步本地验收。
IAM 是应用准入的权威源。应用不需要自己判断谁能登录,只需要:
- 接收 IAM 主动投递的开通 / 撤权事件(
POST到应用配置的接收地址); - 在需要时主动向 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,建议按「停用 → 轮换 → 更新接收方 → 启用」的顺序操作。
2. 签名方案
Section titled “2. 签名方案”所有请求(IAM 投递事件、应用查询快照)携带以下请求头:
content-type: application/jsonx-iam-key-id: <Key ID>x-iam-timestamp: <Unix 秒级时间戳>x-iam-request-id: <本次请求唯一 ID,不超过 128 字符>x-iam-signature: <HMAC-SHA256 十六进制小写>2.1 规范串
Section titled “2.1 规范串”签名对象是把下面 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。
2.2 校验要求
Section titled “2.2 校验要求”接收方必须逐项校验,任一不通过即拒绝:
x-iam-timestamp必须是纯数字,且与服务端时间偏差不超过 300 秒;x-iam-signature必须是 64 位十六进制字符串;x-iam-request-id非空且不超过 128 字符;- 签名比较必须使用定时安全比较,不能用普通字符串相等;
- Key ID 未知或已吊销时返回 401。
2.3 冒烟测试向量
Section titled “2.3 冒烟测试向量”可直接用于验证签名实现是否符合协议:
| 项目 | 值 |
|---|---|
| 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 |
3. 事件投递:IAM → 应用
Section titled “3. 事件投递:IAM → 应用”IAM 向应用在管理端登记的接收地址发起 POST,请求头即第 2 节的签名头。默认约定路径为 /api/internal/iam/provisioning/users,实际以登记值为准(签名用的 PATH 也取自该地址的 pathname)。
3.1 请求体
Section titled “3.1 请求体”{ "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。
3.2 离职与封禁通知
Section titled “3.2 离职与封禁通知”企业微信回调同步人员状态、全量通讯录同步,以及管理员封禁 / 解封,都会触发应用准入重算。企微同步的人员写入与 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,确认不能恢复访问。
3.3 接收方要求
Section titled “3.3 接收方要求”- 必须校验签名与时间戳,不能只依赖 IP 或网络隔离。
- 必须按
revision做单调性判断:忽略小于等于已处理版本的事件,否则一个迟到的旧版本事件会把已撤销的访问重新打开。 - 必须幂等:同一
eventId重复投递不能产生重复账号或重复角色。 - 处理成功返回 2xx;正文与状态码用于投递侧判定结果。
3.4 重试与对账
Section titled “3.4 重试与对账”| 项目 | 取值 |
|---|---|
| 单次超时 | 15 秒 |
| 最大尝试 | 8 次 |
| 退避间隔 | 5 分钟、15 分钟、1 小时、6 小时(达到上限后重复最后一档) |
| 重试条件 | 网络错误、429、5xx |
| 不重试 | 其余 4xx 视为不可恢复;409 记为冲突状态,供人工处理 |
除事件驱动投递外,IAM 还有每 5 分钟一次的维护任务,恢复未完成的人员 / 应用重算任务、重新计算准入并处理到期的投递。达到最大尝试次数或进入不可恢复失败 / 冲突状态后,需要管理员排障后手动重试。因此接收方必须能容忍同一状态被重复投递。
4. 快照查询:应用 → IAM
Section titled “4. 快照查询:应用 → IAM”首次登录、或应用自身状态与 IAM 不一致时,应用可主动拉取某个用户的当前准入快照,避免依赖历史事件。
POST /api/internal/provisioning/snapshotcontent-type: application/jsonx-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。
4.1 成功响应
Section titled “4.1 成功响应”响应体是事件负载去掉 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 单调性 + 幂等)。
4.2 错误码
Section titled “4.2 错误码”| 状态码 | 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 |
4.3 防重放
Section titled “4.3 防重放”IAM 用 verification 表记录已消费的 requestId(键为 Key ID 与 requestId 的哈希),保留 10 分钟。因此:
- 每次查询必须使用新的
requestId,重试时也不能复用; - 命中已消费记录返回
409 REPLAY,这不是签名错误,不要通过重试同一请求来「修复」。
5. 应用侧实现清单
Section titled “5. 应用侧实现清单”- 接收地址为外部可达的 HTTPS 地址(仅本机调试允许
http://localhost),不含账号密码、查询串与 fragment。 - 实现了第 2 节的规范串与 HMAC 计算,并通过 2.3 的测试向量。
- 校验时间戳偏差 300 秒、64 位十六进制签名、定时安全比较。
- 按
revision忽略旧版本事件,按eventId幂等。 - 撤权事件会真正回收访问,且旧版本事件无法恢复访问。
- 快照查询每次使用新的
requestId,正确处理409与403。 - 日志不记录 HMAC Secret、完整签名与投递正文中的个人信息。
6. 相关实现
Section titled “6. 相关实现”- 契约与常量:
packages/contracts/src/provisioning.ts - 签名与验签:
packages/auth/src/provisioning-hmac.ts - 准入计算与投递:
packages/auth/src/provisioning.ts - 快照 HTTP 处理器:
apps/server/src/provisioning-snapshot.ts
7. 撤权时效与会话边界
Section titled “7. 撤权时效与会话边界”IAM 在授权码兑换和刷新令牌时复核账号及应用准入。显式用户授权变更会清理该用户在当前应用的已存储 OAuth 凭据;部门授权变更会清理当前应用的已存储 OAuth 凭据,其他应用不受影响。禁用应用时同样清理该应用的凭据。重新启用或恢复授权后需要重新登录授权。
清理数据库凭据不能让仅离线验签的 JWT 自动失效,也不能直接删除 CRM/OA 自己的登录会话。接收方必须在撤权事件后禁用本地访问并回收会话,按 revision 拒绝旧事件;有即时失效要求的资源操作还需在线确认当前权限。IAM 不把 ID Token 当作业务资源访问凭据。本仓库的测试不代表下游实现已完成验收。