SCIM 自动开通、组同步与离职核销:把目录变化送达每个应用
离职工单已经关闭,身份目录里的用户也显示停用,目标 SaaS 却仍能打开;另一名员工改了邮箱,同步任务把他当作新人,再创建一个没有历史审计的账号。团队看到同步控制台连续绿色,便以为生命周期已经闭环,实际上绿色可能只代表一次 HTTP 请求返回 2xx,既没有证明目标对象唯一,也没有证明组权限收回,更没有触及已经签发的应用会话。
SCIM 2.0 解决的是跨系统供应与目录状态同步:client 把权威身份源的变化转换成 User、Group、查询、PATCH 和 DELETE,service provider 解释请求并保存自己的资源。它不是登录协议,也不是会话撤销协议。要让自动化可信,必须同时持有源对象不可变 ID、externalId、目标 id、映射版本、操作结果与最终读回;离职时还要沿应用自己的会话、令牌和数据保留机制继续核销,不能把 active=false 当成万能退出按钮。
从 User、Group 和三类标识建立对象模型
SCIM 资源是带 schema URI 的 JSON 对象。核心 User 包含 userName、active、姓名、邮箱等属性,核心 Group 通过 members 引用用户或组;企业扩展还可表达员工编号、成本中心、部门和经理。schemas 不是装饰字段,它决定属性属于哪个定义。服务端公开的 /Schemas 会说明属性类型、是否多值、是否必填、大小写精确性、可变性、返回行为和唯一性,客户端不能只看字段名字猜含义。
身份匹配最容易混淆三个 ID。源系统不可变 ID 由权威目录持有,是人员改名后仍稳定的连接键;externalId 由 provisioning client 写入目标资源,用来保存源侧关联,但 RFC 7643 并不要求服务端在所有场景全局强制唯一;id 由 service provider 分配,只能在创建响应或查询结果中取得。可靠映射表至少以“connector + source tenant + source immutable ID”为键,保存目标 tenant、resource type、target id 与最近一次成功版本。
userName 常有唯一性语义,但大小写、租户级还是全局级唯一取决于目标实现。邮箱会改名、复用,也可能一人多个,因此不能在没有冲突流程时充当永久主键。active=false 表示用户资源的行政状态变为非活动,资源通常仍可查询;它既不等于硬删除,也不保证目标应用已经撤销 Cookie、access token、refresh token 或 API key。
先发现能力再发送写请求
SCIM 2.0 的稳定发现面包括 /ServiceProviderConfig、/ResourceTypes 和 /Schemas。ServiceProviderConfig 声明 PATCH、Bulk、filter、sort、ETag 与认证 scheme 等能力;ResourceTypes 把资源名、endpoint、核心 schema 和扩展关联起来;Schemas 给出属性契约。厂商页面写“支持 SCIM”并不意味着支持 Bulk、嵌套组、任意 filter 或相同停用语义,接入程序应把发现响应作为连接版本的一部分保存摘要。
set -euo pipefail
export SCIM_BASE='https://tenant.example.test/scim/v2'
export SCIM_TOKEN='<short-lived-test-token>'
for endpoint in ServiceProviderConfig ResourceTypes Schemas; do
curl --fail-with-body --silent --show-error \
-H 'Accept: application/scim+json' \
-H "Authorization: Bearer ${SCIM_TOKEN}" \
"${SCIM_BASE}/${endpoint}" > "${endpoint}.json"
done
jq '{patch,bulk,filter,etag,authenticationSchemes}' \
ServiceProviderConfig.json
jq '.Resources[] | {name,endpoint,schema,schemaExtensions}' \
ResourceTypes.json预期结果是 HTTP 200、application/scim+json 语义正确,资源端点与 schema URI 能互相对应。随后才决定使用 PATCH 还是 PUT、offset 分页还是 cursor 分页、是否发送 ETag。cursor 分页由 RFC 9865 定义,cursor 对客户端不透明;后续请求除 cursor 外应保持查询参数一致。若任一端不支持,就保留双方都能执行的分页方式,而不是手工解析 cursor。
认证不由 SCIM 定义专用 scheme。常见实现使用 OAuth bearer token、mTLS 或平台生成的连接凭据;无论采用哪一种,都应为目标租户和连接单独授权,并在首次写请求前验证 token 只能访问预期 base URL 与资源。把 token 放进 URL、shell 参数、仓库或失败 payload,会让一次同步错误升级为整租户目录写权限泄露。
租户接入从最小权限 canary 开始
在身份平台创建供应连接时,先固定源租户、目标租户、SCIM base URL、认证凭据、同步对象、作用组、连接键与属性映射版本。目标应用需要提供隔离测试租户或低权限测试群体;生产连接不应在第一次连通时扫描并修改全目录。对于一个同时服务多个客户的 SCIM endpoint,token 必须绑定 tenant,服务端不能仅根据请求体中的 externalId 推断租户。
连接测试应使用一个确定不存在的随机 userName 做过滤查询。无匹配的正确语义是 ListResponse、HTTP 200、totalResults: 0 和空 Resources,不是 404。这个请求同时验证 base URL、认证、Users endpoint、filter 语法和响应 schema,比只请求根路径更有价值。
probe="scim-probe-$(openssl rand -hex 8)@invalid.example"
curl --fail-with-body --silent --show-error --get \
-H 'Accept: application/scim+json' \
-H "Authorization: Bearer ${SCIM_TOKEN}" \
--data-urlencode "filter=userName eq \"${probe}\"" \
"${SCIM_BASE}/Users" |
jq -e '.totalResults == 0 and (.Resources | length) == 0'随后创建一个不属于任何高权限组的 canary 用户,保存响应中的 id 与 meta.version,再读回比较服务端解释后的状态。服务端可能规范化大小写、丢弃不支持字段或补默认值,不能用“请求发送成功”替代读回。只有 canary 的创建、更新、停用、重新启用和清理都符合业务语义,才逐步扩大分配群体。
正向实验串起创建、更新和读回
下面的脚本展示最小创建链。测试数据使用保留域名,token 只放环境变量;响应保存到临时目录并在退出时清理。执行者应拥有测试租户的用户写权限,但不授予应用管理员组写权限。
set -euo pipefail
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
suffix="$(openssl rand -hex 8)"
test_user="scim-canary-${suffix}@invalid.example"
external_id="canary-${suffix}"
jq -n \
--arg user "$test_user" \
--arg external "$external_id" \
'{
schemas:["urn:ietf:params:scim:schemas:core:2.0:User"],
externalId:$external,
userName:$user,
active:true,
name:{givenName:"SCIM",familyName:"Canary"}
}' > "$tmp/create.json"
curl --fail-with-body --silent --show-error \
-H 'Content-Type: application/scim+json' \
-H 'Accept: application/scim+json' \
-H "Authorization: Bearer ${SCIM_TOKEN}" \
--data-binary @"$tmp/create.json" \
"${SCIM_BASE}/Users" > "$tmp/created.json"
test_id="$(jq -er '.id' "$tmp/created.json")"
curl --fail-with-body --silent --show-error \
-H 'Accept: application/scim+json' \
-H "Authorization: Bearer ${SCIM_TOKEN}" \
"${SCIM_BASE}/Users/${test_id}" > "$tmp/read-back.json"
jq -e --arg id "$test_id" --arg user "$test_user" \
'.id == $id and .userName == $user and .active == true' \
"$tmp/read-back.json"
echo "PASS target-id=${test_id}"创建成功后,客户端持久化 source immutable ID -> target id 映射。后续事件优先用 target id 精确更新;映射缺失时才用经过转义的 joining filter 查询,并要求结果唯一。组同步也应先把源成员映射为目标 User id,再更新 Group.members,不能把邮箱字符串直接当引用。高权限组要有独立审批和反向读回,避免一次映射规则改动给整个部门授予管理员权限。
反向实验验证鉴权、过滤和重复处理
稳定的反例能证明服务端没有“尽量接受”危险输入。无认证访问 /Users 应返回 401;错误或跨租户 token 应返回 401 或 403;非法 filter 应返回 HTTP 400 和结构化 SCIM Error,常见 scimType 为 invalidFilter,绝不能忽略 filter 后返回全目录。写只读 id、删除 required 属性、使用错误 schema URI 和提交过期 ETag 也应得到可区分错误。
# 无认证,不能匿名返回目录
curl --silent --show-error --output /dev/null \
--write-out 'anonymous=%{http_code}\n' \
-H 'Accept: application/scim+json' \
"${SCIM_BASE}/Users"
# 非法 filter,不能降级为全量查询
curl --silent --show-error --get \
-H 'Accept: application/scim+json' \
-H "Authorization: Bearer ${SCIM_TOKEN}" \
--data-urlencode 'filter=userName definitely "x"' \
"${SCIM_BASE}/Users" |
jq '{schemas,status,scimType,detail}'重复创建实验使用同一 joining key 再次 POST。目标可以返回 409,也可以由连接器先查到现存对象并转为更新,但最终不变量是只有一个目标资源,且映射仍指向它。若网络在服务端提交后、客户端收到响应前中断,盲目重试 POST 会制造重复账号;恢复逻辑必须先按 externalId 或稳定 joining key 查询,再决定读回、更新还是进入人工冲突队列。
对 PATCH 的反测要重放相同操作。replace active=false 重放后仍应是 false;向集合 add 同一成员时,目标实现可能去重,也可能要求客户端先查询,因此必须把真实行为写入 connector contract。任何会在每次重试继续追加成员、制造新账号或扩大权限的操作,都不能直接交给无界重试。
幂等状态机处理重试、并发和撤销
供应任务不是一串无状态 HTTP 调用。每个源事件需要保存目标意图、幂等键、当前阶段、尝试次数、下次重试时间、目标 ID 和最后读回摘要。推荐状态为 observed -> matched -> applied -> verified;可重试错误进入 retry_wait,唯一键冲突和多匹配进入 quarantined,永久 schema 错误进入 rejected。只有读回符合意图才进入 verified。
ProvisioningRecord {
connectorId
sourceTenantId
sourceObjectImmutableId
targetTenantId
targetResourceId
desiredVersion
mappingVersion
operationKey
state
attemptCount
lastHttpStatus
lastScimType
readBackDigest
}
operationKey = hash(connectorId + sourceObjectImmutableId + desiredVersion)并发更新可使用服务端 meta.version 与 If-Match,前提是发现面声明并实测 ETag。收到 412 时,应重新读取最新对象、重算差异后再提交,不能覆盖另一个连接器刚写入的组成员。429 按 Retry-After 有界退避,并对租户公平排队;401 不应无限重试,应触发 token 状态检查;400 schema 错误必须隔离并暴露映射版本。
撤销也要幂等。重复执行 active=false、移出组和删除应用角色应收敛到同一状态;目标对象已经不存在时,核对映射和退出策略后可视为目标状态已满足,但要保留 404 的语义,避免把错误 base URL 当成“删除完成”。硬删除通常是后续、受政策控制的动作,不应用来替代可恢复的停用。
账号停用与会话撤销是两条链
离职事件到达后,第一条链修改目录与授权状态:将 User 设为 active=false,移出业务组和高权限组,撤回应用角色或许可,读回确认目标状态。第二条链处理已经存在的访问能力:调用应用的会话撤销或全局登出接口,撤销 refresh token、长期 API token 和个人访问密钥,必要时终止设备会话。SCIM RFC 7644 定义资源操作,但不保证应用如何解释停用,也不定义会话撤销。
因此离职验证至少包含四个独立结果:目标 User 可查询且 active=false;目标 Group 不再含该成员;新的交互登录和新 token 签发被拒绝;离职前已有 Cookie、access token 与 refresh token 按组织策略失效。只验证前两项,会留下现存会话;只撤会话而不修改目录,下一次同步或登录可能重新获得访问。
应用若无法立即撤销短期 access token,应记录其最晚自然失效点,并立即撤销可换取新 token 的 refresh token。网关或资源端若维护独立会话,也必须纳入清单。账号数据保留、业务记录归属和硬删除由隐私与审计政策决定,不能因为 SCIM DELETE 返回 204 就推断所有业务数据已清除。
离职完成证据应关联同一个人员事件,但不保存完整个人 payload:source immutable ID 的不可逆摘要、target id、停用读回、组差异、应用会话撤销请求 ID、令牌版本或凭据 ID、最终登录反测结果足以支持审计。重返员工则建立显式 rehire 流程,确认复用旧目标账号还是新建账号,不能简单把所有旧角色和会话恢复。
故障证据从 HTTP 状态回到对象差异
排障先看 connector/job ID、源与目标租户、operation、endpoint、HTTP status、SCIM scimType 和 correlation ID,再看 source immutable ID、externalId、target id、mapping 版本、joining property 与读回差异。401 通常指向缺失、过期或错误 token;403 说明凭据有效但资源授权不足;404 可能是资源不存在,也可能是 base URL 或租户路由错误;409 常见于唯一键冲突;412 是版本冲突;429 是容量节流,不是对象非法。
“用户改名后重复创建”应先比较源 immutable ID 是否稳定、旧映射是否丢失、filter 是否仍用邮箱以及目标唯一性如何执行。“组成员不断抖动”要比较两个连接器是否同时写同一 Group、分页是否漏读、嵌套组是否被扁平化、成员 PATCH 是否使用目标 ID。“停用后还能登录”则必须先区分目录读回为 true、应用未消费 active、登录身份来自另一连接,还是旧会话仍有效。
普通日志不记录 bearer token、Authorization header、密码、完整请求响应或姓名邮箱电话等人员属性。可以记录 token ID/版本与到期状态、base URL 的受控标识、schema/mapping 版本、字段名、值的不可逆摘要、HTTP 与 SCIM 错误。失败 payload 进入隔离证据库时,应加密、限制访问、设置保留时间并记录读取审计。
容量与成本由扫描、组和重试共同决定
全量对账的成本近似于租户数、对象数、分页请求和属性体积的乘积;大组的成员集合还会放大序列化、差异计算与 PATCH 负载。增量事件降低日常成本,但不能替代周期性 reconciliation,因为 webhook 丢失、映射升级和人工修改都会制造漂移。设计时同时保留增量队列与分片全量对账,并让高权限对象拥有更短的发现延迟和更强告警。
分页处理必须保存稳定进度。offset 分页在数据持续变化时可能重复或漏过对象,cursor 能由服务端维持遍历状态,但可能占用服务端资源并过期;两者都要在中断后安全恢复。Bulk 是可选能力,大批请求虽然减少网络往返,却扩大单次失败边界和敏感 payload 体积。应通过目标端发现结果、失败隔离能力和基线压测选取批量大小,而不是默认把整租户塞进一次请求。
队列监控至少观察待处理年龄、每租户吞吐、2xx/4xx/5xx/429、重试次数、冲突队列、读回不一致、重复对象、组差异和离职核销延迟。容量保护要保证一个大租户或坏映射不会阻塞其他租户:按租户限流与公平调度,设置有界重试和死信队列,并为停用事件预留优先通道。成本评估还要包含连接器维护、token 轮换、属性治理、隐私审查、人工冲突处理和审计存储,而不仅是 API 调用量。
Token 和敏感属性要最小化流动
SCIM bearer token 往往能批量读写目录,是高影响凭据。每个目标租户或应用使用独立 token,权限只覆盖需要的 User/Group 动作;凭据通过密钥管理设施注入连接器,不写入配置仓库、命令历史和诊断包。轮换时先创建新 token、让 canary 连接成功并完成最小读写,再切换工作任务并撤销旧 token;若平台只允许单 token,则使用维护窗口和可回退的短暂停机步骤。
属性同样遵循最小化。同步登录与授权所需的稳定标识、姓名和必要组,不因 schema 支持就发送电话、家庭地址、经理链、成本中心或员工编号。RFC 中 password 是 writeOnly 且 returned=never,除非目标系统确实只能这样初始化,否则不通过 SCIM 分发密码。高权限组名本身也可能暴露攻击目标,应限制日志与报表访问。
token 泄露时,立即停止对应 connector、撤销 token、发行替代凭据并审计泄露窗口内所有目录写操作;随后从源权威状态执行对账,检查新增账号、属性篡改和高权限组成员。仅替换 token 不能撤销攻击者已经创建的应用会话或个人 API key,这些对象仍要沿应用安全接口核销。
迁移和退出必须保住身份关联
更换连接器、IdP 或目标应用版本时,先导出不含 token 的连接配置、schema 发现摘要、属性映射、源到目标 ID 映射和一组脱敏 golden request/response。新连接在影子模式下读取同一源事件,只计算预期差异而不写;差异稳定后选择低权限 canary 执行创建、改名、组变更、停用、重返和删除。尤其要比较新旧连接对大小写、空值、multi-valued 属性、PATCH path、嵌套组和分页的处理。
双写会引入竞争,只有目标端具备清晰所有权和版本控制时才短期使用。更稳妥的切换是暂停旧写入、记录水位、让新连接从水位继续,再执行全量 reconciliation。迁移不能重新生成 externalId 或丢弃 target id 映射,否则每个旧用户都可能被误认成新对象。采用 cursor 分页前也应保留兼容路径,直到 client 与 service provider 都证明支持。
退出连接时,先阻止新分配,清空或隔离待处理队列,完成最后一次源目标对账,再按业务策略停用或删除由该连接拥有的对象。随后撤销 SCIM token、删除网络许可和连接配置,验证旧 token 返回 401/403、旧任务不能写入、目标高权限组无残留成员、离职账号的新登录与既有会话都符合撤销策略。审计中保留 connector ID、映射版本、对象计数与差异摘要,不保留可用 token 和完整人员数据。
实现协议细节时,应直接核对 RFC 7642 的概念模型、RFC 7643 schema 和 RFC 7644 协议动作;接入具体产品时再读取该产品的 SCIM profile,因为 filter、组、Bulk、分页、停用和删除语义可能只实现标准子集。退出完成的判据不是“连接已删除”,而是目录对象、组授权、队列、凭据、登录入口、现存会话与审计映射都能给出一致的最终状态。
