SCIM 账号供给解决的是账号生命周期不同步的问题。很多团队做了单点登录,却仍然手工建号、手工停用,结果是离职员工的应用账号长期残留,调岗人员的角色权限没人回收。本文从协议调用、属性映射、离职回收三个环节给出可直接执行的配置与脚本,把「离职到权限清零」从几天压缩到分钟级。
适用场景
适用于已经统一了身份源、但业务系统账号仍靠人工开通的组织。典型信号有三个:入职当天新员工要等半天才能登录业务系统;离职后审计发现三个月前离职的人账号仍然处于启用状态;同一员工在不同系统里的部门、岗位属性互相矛盾。
- 应用数量超过十个,手工开户成为入职流程瓶颈
- 权限回收依赖人力资源部门邮件通知,执行情况无法核验
- 审计要求提供「离职账号 24 小时内停用」的可信证据
- 需要按部门动态组批量授权,人工维护容易漏人
只要满足其中两条,就说明账号供给已经需要自动化,继续靠流程约束人力,漏洞只会越来越多。
前置条件
- 身份源支持 SCIM 2.0 供给协议,且已开启应用供给功能
- 业务侧具备 SCIM 端点,或使用支持 SCIM 的中间件做协议转换
- 具备身份源管理员权限,可创建供给凭据(Bearer Token)
- 有一份可用于对账的离职清单,来自人力资源系统导出或数据库视图
- 已梳理清楚各系统角色与组织属性的对应关系,这一步最耗时也最容易被跳过
原理说明:SCIM 与 SSO 的分工
单点登录解决「能不能登录」,SCIM 解决「账号是否存在、属性是什么」。两者经常被混为一谈,导致只做认证不做供给的架构:登录走统一入口,账号却仍由各系统自行创建,于是出现账号永久存在、离职后只要密码没改仍可登录、属性不同步引发越权等问题。
SCIM 2.0 本质是一组 REST 资源:/Users 与 /Groups,标准动作包括 GET、POST、PUT、PATCH、DELETE,过滤采用 filter=userName eq “x” 形式,媒体类型为 application/scim+json。其中最关键的是 PATCH:几乎所有系统的用户停用都通过 PATCH 把 active 置为 false 实现,而不是 DELETE。原因是硬删除会让审计链路断裂,历史操作记录失去归属,多数业务系统也明确不支持硬删。
另一个设计要点是幂等。身份源会周期性全量同步,同一个用户可能被反复创建。SCIM 用 externalId 作为外部系统唯一键,供给端必须把它映射到稳定不变的值,例如工号。用邮箱做 externalId 是常见错误——员工改姓、换邮箱后会被当成新用户重复开户,随后出现权限归属混乱。
操作步骤一:接口鉴权与账号查询
先用一次精确查询确认链路通、权限对。注意 filter 必须做 URL 编码,直接把空格和引号写进 URL 会被服务端判为非法请求。
TOKEN='scim_2f9c4a7e5b8d4c1f9a03'
# 按 userName 精确查询单个用户,Accept 必须声明 SCIM 媒体类型
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/scim+json" \
"https://idp.example.com/scim/v2/Users?filter=userName%20eq%20%22zhangsan%40corp.example.com%22"
# 只取必要字段,避免大响应拖慢自动化流程
curl -s -H "Authorization: Bearer $TOKEN" \
"https://idp.example.com/scim/v2/Users?attributes=id,userName,active,externalId&count=5"
返回的 id 是系统内部主键,后续所有更新都用它,不要用 userName——改名后 userName 会变,id 不会。把 id 落库到本地映射表,是自动化脚本稳定运行的前提。
操作步骤二:开户与属性映射
创建用户的最小可用请求体如下。字段缺失会导致部分系统开户成功但属性为空,进而影响按属性授权的策略,因此建议一次性把必填项补齐。
curl -s -X POST "https://idp.example.com/scim/v2/Users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"externalId": "HR-20261025-0042",
"userName": "lisi@corp.example.com",
"name": {"givenName": "Si", "familyName": "Li"},
"emails": [{"value": "lisi@corp.example.com", "primary": true, "type": "work"}],
"active": true,
"title": "运维工程师",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"department": "技术中心",
"employeeNumber": "HR-20261025-0042"
}
}'
映射阶段要锁定三组关系:department 决定角色组,employeeNumber 决定唯一性,manager 决定审批链。三组属性任一缺失,下游都会退化成手工补录。属性映射表建议写进配置文件并纳入版本管理,不要散落在管理后台的界面里——出了偏差无法回溯是哪次改动引入的。
操作步骤三:离职即时停用
离职处理必须在秒级完成,且必须可核验。用 PATCH 置 active 为 false,同时在应用侧吊销已有会话,这一步经常被漏掉,导致标记停用后旧令牌在有效期内仍可访问。
# 1) 供给端停用账号(幂等操作,重复执行结果一致)
curl -s -X PATCH "https://idp.example.com/scim/v2/Users/$UID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "active", "value": false},
{"op": "replace", "path": "displayName", "value": "已离职"}]
}'
# 2) 应用侧吊销该用户全部会话,避免存量令牌继续可用
curl -s -X POST "https://app.example.com/api/admin/sessions/revoke" \
-H "Authorization: Bearer $APP_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"userName\": \"lisi@corp.example.com\"}"
两步都要做,且顺序不能反:先停用再吊销会话,避免吊销期间新会话又被创建出来。如果业务系统不支持会话吊销,退而求其次是把令牌有效期压到 15 分钟以内,并把关键操作改为二次校验,但要在审计材料里如实说明这条残余风险。
操作步骤四:回收结果对账
停用动作是否全部生效,不能靠人工抽查,要用离职清单做自动对账。下面的脚本读取离职表,逐个查询供给端状态,把仍然启用的账号列出来。
#!/usr/bin/env python3
# /opt/scim/verify_offboard.py
import csv
import json
import os
import urllib.parse
import urllib.request
BASE = "https://idp.example.com/scim/v2"
TOKEN = os.environ["SCIM_TOKEN"]
def scim(path):
req = urllib.request.Request(BASE + path, headers={
"Authorization": "Bearer " + TOKEN,
"Accept": "application/scim+json"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r)
def find(user_name):
q = "/Users?filter=" + urllib.parse.quote('userName eq "' + user_name + '"')
res = scim(q).get("Resources", [])
return res[0] if res else None
bad = []
with open("/srv/hr/left_employees.csv", encoding="utf-8") as f:
for row in csv.DictReader(f):
u = find(row["email"])
if u and u.get("active") is True:
bad.append((row["email"], u["id"], row.get("leave_date", "-")))
print("STILL_ACTIVE", len(bad))
for item in bad:
print("\t".join(map(str, item)))
脚本结果要接进告警通道。对账失败通常意味着两种情况:离职清单没更新,或供给任务在身份源侧被跳过(例如某个用户有一条错误记录导致整批同步中断)。两种都应按事故处理,而不是留到下周再查——残留账号每天都是一次可被利用的机会。
配置验证
验证必须覆盖四条,缺一条就会留下盲区。
# 1) filter 精确命中:返回 totalResults 应为 1
curl -s -H "Authorization: Bearer $TOKEN" \
"https://idp.example.com/scim/v2/Users?filter=userName%20eq%20%22lisi%40corp.example.com%22" \
| grep -o '"totalResults":[0-9]*'
# 2) 停用后状态核验
curl -s -H "Authorization: Bearer $TOKEN" \
"https://idp.example.com/scim/v2/Users/$UID?attributes=active"
# 3) 用旧令牌访问业务接口,期望 401
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $OLD_USER_TOKEN" "https://app.example.com/api/profile"
- 查询准确性:filter 精确命中 1 条,不做全量模糊匹配
- 幂等性:同一 externalId 重复创建返回 409 而非新建第二条账号
- 停用有效性:PATCH 后 active 为 false,且旧令牌请求返回 401
- 对账有效性:故意保留一个测试账号不停用,对账脚本必须能报出来
常见问题
Q1:filter 查询报 400 或返回空结果,问题出在哪?
八成是 URL 编码。空格必须写成 %20,双引号必须写成 %22,邮箱里的 @ 通常可以保留但建议一并编码。另外部分系统只支持有限的过滤运算符,eq 一般可用,sw 前缀匹配经常不支持,用之前先读该系统的能力声明接口,比反复试错更快。
Q2:账号已标记停用,为什么还能访问业务系统?
SCIM 停用只改变供给端状态,不影响业务侧已签发的会话凭证。令牌在有效期内仍然有效,必须由业务侧主动吊销。正确做法是供给事件触发会话吊销接口,并把令牌有效期作为兜底手段缩短。审计口径上,「离职后仍可访问」应按未完成回收处理,而不是「已停用」。
Q3:组同步导致越权怎么防?
根因通常是按名称映射。两个不同部门同名、部门改名后旧组未清理,都会让成员被错误加入高权限组。防法是给组加稳定的唯一标识(如部门编码),授权规则绑定标识而非名称,并在每次全量同步后比对组成员数量,出现突增立即告警。
总结
SCIM 账号供给的价值不在于自动化开户,而在于让账号状态成为可计算、可核验的对象。开户、调岗、离职三个动作都由属性驱动,回收结果由清单对账兜底,权限残留才不再依赖某个人的责任心。落地时优先做离职回收这一条链路,它风险最高、见效最快,也最容易拿到管理层的持续支持。