2025 年初,某中型 SaaS 公司的一名高级研发在三月底办完离职手续。HR 在系统里走完流程的同一天,便把离职单传给了 IT,要求在最后工作日结束时关闭所有访问。IT 工程师在 Active Directory(AD)里将这名员工的账号设置为 disabled,邮件也停了,VPN 证书吊销,在内部工单里敲下了 “done”。三周之后,安全团队在一次例行的 GitHub 组织审计中发现:这名员工的 GitHub 账号仍然活跃,上周还向一个私有仓库 push 过一次代码;顺藤摸瓜又发现他的 AWS IAM 用户仍然持有 AdministratorAccess,Jira 里他还在某个 epic 的 assignee 列表中,Salesforce 里他上周甚至登录过一次查看客户资料。原因并不复杂:AD 之外的这些 SaaS 应用当初是各团队自行申请开通的,账号散落在不同的 admin console,没有任何自动化联动。这是一起典型的僵尸账号(zombie account)安全事件,也是 SOC 2 CC6.2 和 CC6.3 控制项的直接失分项——审计师只需要抽一个离职员工样本就能发现问题。
这起事件背后的系统性根因,是身份的生命周期管理(lifecycle management)在绝大多数组织里仍然依赖人工流程。本文从这个背景出发,完整讲透 SCIM(System for Cross-domain Identity Management)协议的资源模型、操作语义、推拉模式差异、主流 IdP 的实现分歧,以及一个 SCIM 服务端在工程上真正要面对的幂等、冲突、软删除、孤儿账户、审计和限流问题。阅读完本文,一位工程师应当能够在自己的应用里实现一个符合 RFC 7644 的 SCIM 2.0 服务端,并把它接入 Okta 或 Microsoft Entra ID 的 provisioning 流程。
一、账号手动管理的系统性问题
1.1 散落的身份:SaaS 平均 80+ 应用
Okta 在 2023 年发布的 Businesses at Work 报告中给出一个被行业广泛引用的数字:一家中型企业平均使用 89 个 SaaS 应用,而金融与科技行业的头部公司能超过 200 个。这个数字的可怕之处不在于应用数量本身,而在于每一个应用都拥有自己独立的用户目录(user directory)、自己的权限模型、自己的 admin console。一名工程师入职时,需要分别在 GitHub、AWS、Jira、Confluence、Slack、Notion、Figma、Datadog、Sentry、PagerDuty、1Password 等至少十几个系统中被开通账号,每一次开通都是一次人工操作,每一次操作都可能出错或遗漏。
1.2 JML 流程:人工的脆弱链路
身份行业用 JML(Joiners-Movers-Leavers)来概括账号生命周期的三种主要事件:入职者需要被开通对应角色的所有应用访问;转岗者(mover)需要在新的 BU 或团队中获得新权限、同时撤销旧权限——这一步是实践中最容易被忽略的,因为”给权限”有人催,“撤权限”没有人盯;离职者(leaver)需要在最后工作日结束时立刻停用所有访问。传统做法是 HR 系统产生一张工单,人工路由到若干 IT 和业务系统管理员,每个人点一遍按钮。这套流程存在三个致命缺陷:时效依赖人工响应速度、执行依赖操作员记忆、结果无法被验证。
1.3 没有可审计的访问变更记录
SOC 2、ISO 27001、HIPAA 和 PCI DSS 都要求组织能够对”谁在什么时候授予了谁对什么资源的什么权限”做出可追溯的回答。手动开通天然无法满足这一条——一位管理员在某个 SaaS 的 admin UI 上点了一下”add user”,这个动作在那个 SaaS 的日志里或许有记录,但在组织级别就是一条空白。当审计师要求提供”过去一个季度所有权限变更的清单”时,没有 IdP 和 provisioning 自动化的组织通常只能靠人工导出各系统日志再合并,代价极高且往往不完整。
1.4 过度特权与孤儿账户
手动发权限时,管理员为了省事往往选择最大权限——给开发者直接开 AWS AdministratorAccess、给新员工直接拉进所有 Slack channel、给外包直接赋予 Jira Project Admin。这种过度特权(over-privilege)在攻击发生时放大爆炸半径。另一面是孤儿账户(orphan account):指那些在 IdP 里已经不存在、但在某个下游应用里仍然存在并可能可以登录的账号。孤儿账户是僵尸账号的兄弟,是攻击者用社工或泄露凭据渗透进组织的最常见入口之一。Verizon 的 DBIR 报告连续多年指出,凭据相关的入侵占所有 breach 的 50% 左右,其中相当比例涉及被遗忘的前员工账号。
自动化是解决这些问题的唯一可行路径,而 SCIM 就是这条路径上事实上的标准协议。
二、SCIM 2.0 的资源模型
2.1 协议概览与 RFC 族
SCIM 2.0 由三份 RFC 共同构成:RFC 7642 给出概念模型与用例,RFC 7643 定义资源模型与核心 Schema,RFC 7644 定义基于 HTTP 的协议。协议的设计目标是一句话:“在多个域之间标准化地做身份的 CRUD”。它不是认证协议(那是 SAML/OIDC 的事),不是授权协议(那是 OAuth 的事),它只做一件事:把一个身份以及它归属的组,在源系统(通常是 IdP)和目标系统(service provider,你的应用)之间同步。
SCIM 使用 JSON 作为默认载荷格式,Content-Type 是
application/scim+json(尽管多数实现也接受
application/json)。它通过 REST 风格的 HTTP
动词表达操作,用 schemas 字段标记资源所遵循的
schema,天然支持扩展。
2.2 User 资源的核心字段
核心 User schema 的 URN 是
urn:ietf:params:scim:schemas:core:2.0:User。下面是一个典型的
User 资源:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "2819c223-7f76-453a-919d-413861904646",
"externalId": "EMP-00231",
"userName": "zhang.san@example.com",
"name": {
"formatted": "张三",
"familyName": "张",
"givenName": "三"
},
"displayName": "张三",
"emails": [
{ "value": "zhang.san@example.com", "type": "work", "primary": true }
],
"phoneNumbers": [
{ "value": "+86-138-0000-0001", "type": "work" }
],
"active": true,
"groups": [
{ "value": "e9e30dba-f08f-4109-8486-d5c6a331660a", "display": "engineering" }
],
"meta": {
"resourceType": "User",
"created": "2026-04-21T08:00:00Z",
"lastModified": "2026-04-21T08:00:00Z",
"version": "W/\"a330bc54f0671c9\"",
"location": "https://scim.example.com/v2/Users/2819c223-7f76-453a-919d-413861904646"
}
}其中 userName
是必填且在服务端必须唯一的字段,通常是邮箱或企业账号;id
由服务端生成、不可变;externalId
由客户端(IdP)提供、用作 IdP 一侧的主键——在 IdP 和 SP
之间建立稳定对应关系的关键字段;active
是停用开关,SCIM 推荐的软删除方式是
active=false;meta.version 承载
ETag 用于并发控制;groups 字段是 read-only
的反向映射,写入组成员关系要通过 Group 资源完成。
2.3 Group 资源与成员关系
Group schema 的 URN 是
urn:ietf:params:scim:schemas:core:2.0:Group,字段远比
User 简单:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"id": "e9e30dba-f08f-4109-8486-d5c6a331660a",
"displayName": "engineering",
"members": [
{
"value": "2819c223-7f76-453a-919d-413861904646",
"$ref": "https://scim.example.com/v2/Users/2819c223-...",
"type": "User",
"display": "张三"
}
],
"meta": { "resourceType": "Group", "location": "..." }
}Group 的成员关系在实现中是一个反复踩坑的点:当 IdP
推一个新的组过来,它可能选择一次 PUT 把整个 members
数组覆盖,也可能选择 PATCH 用
add/remove
逐个调整。服务端必须把这两种风格都实现正确,否则当成员数量超过几百人时,PUT
会因为 payload 太大或 rate limit 爆掉。
2.4 Enterprise User Schema 扩展
核心 User schema 里没有组织结构相关的字段,这些放在扩展
schema 里,URN 是
urn:ietf:params:scim:schemas:extension:enterprise:2.0:User。使用时把这个
URN 放到外层 schemas
数组里,然后在同一层级用它作为 key:
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
],
"userName": "zhang.san@example.com",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"employeeNumber": "EMP-00231",
"costCenter": "CN-R&D-001",
"organization": "Example Corp",
"division": "Product",
"department": "Platform Engineering",
"manager": {
"value": "26118915-6090-4610-87e4-49d8ca9f808d",
"displayName": "李四"
}
}
}这份扩展数据通常直接来自 HR
系统(Workday、BambooHR),是驱动下游应用做”基于属性的访问控制”(ABAC)的重要输入。举例:你的自研应用可以把
department=Platform Engineering
自动映射为”平台工程组的基础权限”,把 manager
字段反向用于审批链路。
2.5 Schema 发现:三个只读端点
SCIM 服务端必须暴露三个发现端点,让客户端能够在运行时了解这个 SP 支持什么:
GET /ServiceProviderConfig:声明本服务端支持的特性,如patch.supported、bulk.supported、filter.supported、etag.supported、authenticationSchemes。GET /Schemas:列出本服务端支持的所有 schema 定义(每个属性的类型、可变性等元数据)。GET /ResourceTypes:列出本服务端暴露的 endpoint,如/Users、/Groups和它们对应的 schema。
一个最小的 ServiceProviderConfig 示例:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"documentationUri": "https://example.com/docs/scim",
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": 200 },
"changePassword": { "supported": false },
"sort": { "supported": true },
"etag": { "supported": true },
"authenticationSchemes": [
{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Authentication scheme using the OAuth Bearer Token Standard",
"primary": true
}
]
}这三个端点不仅是协议合规要求,也是许多 IdP 的 provisioning 配置向导(例如 Okta 在启用 “Import New Users and Profile Updates” 时)依赖的探测入口。
2.6 属性的九个元属性
每个 SCIM 属性在 schema 里都带有一组元属性,直接决定了服务端的校验和响应逻辑:
type:string / boolean / decimal / integer / dateTime / reference / complex。multiValued:单值还是多值(如 emails、groups 是多值)。required:是否必填。caseExact:字符串匹配时是否大小写敏感。mutability:readOnly(服务端生成,客户端不能改,如id)/readWrite(默认)/immutable(创建时可写,之后不可改)/writeOnly(只写入,返回时隐藏,如password)。returned:always(总是返回)/never(永不返回,如password)/default(默认返回)/request(只在客户端显式 attributes 参数请求时才返回)。uniqueness:none/server(本服务端唯一,如userName) /global(全球唯一,SCIM 规范里很少用)。canonicalValues:枚举值限制(例如 email 的type只能是work/home/other)。referenceTypes:reference 类型指向的资源类型列表。
工程实现中最容易出错的是
mutability——新手常把 userName
实现为
readWrite,结果客户端改名后服务端允许了更新,但下游系统(例如
GitHub)却因为用户名唯一性冲突而失败。推荐把
userName 设为
immutable,让客户端走 “删除+新建”
或”冻结后重建”的路径。
三、SCIM 协议操作详解
3.1 POST /Users:创建
最小可工作的创建请求如下:
POST /scim/v2/Users HTTP/1.1
Host: scim.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/scim+json
Accept: application/scim+json
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "zhang.san@example.com",
"name": { "givenName": "三", "familyName": "张" },
"emails": [{ "value": "zhang.san@example.com", "primary": true, "type": "work" }],
"externalId": "EMP-00231",
"active": true
}
成功时服务端返回 201 Created,Location
头指向新资源,Body 是完整的资源表示:
HTTP/1.1 201 Created
Location: https://scim.example.com/v2/Users/2819c223-7f76-453a-919d-413861904646
ETag: W/"a330bc54f0671c9"
Content-Type: application/scim+json
{ "id": "2819c223-...", "userName": "zhang.san@example.com", ...,
"meta": { "resourceType": "User", "created": "...", "version": "W/\"a330bc54f0671c9\"" } }
常见失败:400 Bad Request(schema
校验失败)、409 Conflict(userName
重复)、413 Payload Too Large、429 Too Many Requests。SCIM
对错误响应有专门的 schema
urn:ietf:params:scim:api:messages:2.0:Error,应当用它承载错误细节,而不是随意编一个
JSON:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "User with userName zhang.san@example.com already exists."
}3.2 GET /Users/{id}:读取
GET /scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1
Authorization: Bearer ...
客户端可以通过 attributes
参数限定返回字段,通过 excludedAttributes
排除字段,例如
?attributes=userName,emails,active
只读这几个字段。服务端应当尊重这两个参数以减少带宽,并且在字段是
returned=request 时也只有被显式要求才返回。
3.3 PUT /Users/{id}:全量替换
PUT 的语义是”用请求体完全替换服务端现存的资源”。客户端必须发送完整的资源表示,服务端对请求体中未出现的可写字段执行清空或重置。这是 PUT 最大的陷阱——当 IdP 实现 PUT 时没有把所有字段都带上,服务端的”帮用户保留原值”就是 bug,严格遵守 PUT 语义的”清空未出现字段”才是对的。实践中 PUT 很少被 IdP 用来做部分更新,大多数 IdP 都会用 PATCH。
3.4 PATCH /Users/{id}:增量更新
PATCH 的 payload 采用
urn:ietf:params:scim:api:messages:2.0:PatchOp
schema,Operations 数组里每一项是一个
{op, path, value}:
PATCH /scim/v2/Users/2819c223-... HTTP/1.1
Authorization: Bearer ...
Content-Type: application/scim+json
If-Match: W/"a330bc54f0671c9"
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "replace", "path": "active", "value": false },
{ "op": "replace", "path": "name.familyName", "value": "李" },
{ "op": "add", "path": "emails",
"value": [{ "value": "zhang.san@alt.example.com", "type": "home" }] },
{ "op": "remove", "path": "emails[type eq \"home\" and value eq \"old@example.com\"]" },
{ "op": "replace",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
"value": "Security Engineering" }
]
}
几个要点:op 有 add /
replace / remove
三种;path
可以是属性名、嵌套属性(name.familyName),或者带过滤器的多值属性(emails[type eq "work"].value);当
path 省略时,value
必须是一个对象,其顶层 key 就是要操作的属性。对多值属性的
add 语义是”追加,若已有同 value
则替换”,remove
对多值属性必须带过滤器否则整个字段会被清空。SCIM 的 PATCH
语义和 JSON Patch(RFC 6902)相似但不同,不要混用两份
spec。
响应可以是 200 OK 带完整资源,或
204 No Content(如果客户端传了
Prefer: return=minimal 头)。
3.5 DELETE /Users/{id}:硬删除
DELETE /scim/v2/Users/{id} 成功时返回
204 No Content。在合规场景下,不推荐使用
DELETE,应当改用
PATCH active=false——原因在第五节会展开:软删除保留了审计证据,硬删除会造成”这个账号存在过”这件事本身从数据库消失。
3.6 GET /Users?filter=…:过滤查询
SCIM filter 语法是一个受限的表达式语言,支持比较操作符
eq ne co sw ew gt ge lt le pr,逻辑操作符
and or not,以及括号和属性路径。几个例子:
userName eq "zhang.san@example.com"
name.familyName sw "张"
emails[type eq "work" and value co "@example.com"]
active eq true and meta.lastModified gt "2026-04-01T00:00:00Z"
not (emails co "@test.com")
配合 startIndex 和 count
实现分页(注意 SCIM 的 startIndex 从 1 开始,不是 0):
GET /scim/v2/Users?filter=active+eq+true&startIndex=1&count=50
响应使用
urn:ietf:params:scim:api:messages:2.0:ListResponse:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 1234,
"startIndex": 1,
"itemsPerPage": 50,
"Resources": [ { "...User...": "..." } ]
}3.7 POST /Bulk:批量操作
Bulk 允许客户端在一次请求中提交多个子操作,服务端要么原子执行、要么尽力而为:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"],
"failOnErrors": 1,
"Operations": [
{ "method": "POST", "path": "/Users", "bulkId": "a1",
"data": { "userName": "u1@example.com", "schemas": ["...User"] } },
{ "method": "PATCH", "path": "/Users/2819c223-...", "bulkId": "a2",
"data": { "Operations": [ { "op": "replace", "path": "active", "value": false } ] } }
]
}Bulk 的价值是在 IdP 首次”全量导入”时减少
RTT,但对服务端来说实现成本不低,特别是要处理
bulkId 引用(一个 Operation
可以引用同批次上一个尚未执行的操作的结果
id,例如”先创建用户,再把他加进组”)。许多 SP 选择在
ServiceProviderConfig 里声明
bulk.supported=false,让 IdP 退化为串行。
3.8 ETag 与并发控制
当同时有多个 IdP(或 IdP + HR 系统)对同一个 SCIM
服务端写入时,必须用乐观并发控制防止丢失更新。SCIM 通过
meta.version 字段承载 ETag:
PATCH /scim/v2/Users/2819c223-... HTTP/1.1
If-Match: W/"a330bc54f0671c9"
...
如果服务端检测到当前版本已经不是
a330bc54f0671c9,返回
412 Precondition Failed,客户端应当 GET
最新资源再合并后重试。要支持 ETag,必须在
ServiceProviderConfig.etag.supported=true
中显式声明。
四、Push 与 Pull 两种同步模式
4.1 Push:IdP 作为源,主动推到 SP
Push 模式是目前最主流的模式:IdP(Okta、Entra ID)在 HR
事件发生或在 IdP 内部发生属性变更时,立刻对下游 SP 发起 SCIM
API 调用。这个模式的好处是实时性接近秒级——一个员工在 HR
被标记为离职,不到一分钟所有 SP 的账号就被
active=false;坏处是 SP 必须暴露一个公网可达的
SCIM 端点,运维上增加了一块攻击面,必须配合 Bearer Token
轮换、IP 白名单和严格的审计。
4.2 Pull:SP 主动从 IdP 拉
Pull 模式相反,SP 周期性调用 IdP 暴露的 SCIM API
拉取变更。Entra ID 和 Okta 都提供自身的 SCIM
出站接口,但这种用法远不如 Push 流行。Pull
的使用场景包括:SP 位于隔离网络无法暴露公网端点、SP
的写入接口是批处理不适合高频调用、需要做跨 IdP
的联邦汇聚。Pull 的增量同步通常基于
meta.lastModified gt <上次拉取时间>
过滤器:
GET /scim/v2/Users?filter=meta.lastModified+gt+"2026-04-21T00:00:00Z"
&sortBy=meta.lastModified&sortOrder=ascending
&startIndex=1&count=200
注意时钟漂移问题:应当以服务端返回的最大
lastModified
为下次查询的下界,而不是客户端本地时钟,并且要留一个安全边际(例如减去
1 秒)以容忍毫秒级精度丢失和并发写入。
4.3 事件驱动:Webhook 与 SSE
SCIM 本身不定义事件推送,但越来越多的 IdP 在 SCIM 之上叠加 Webhook 或 Server-Sent Events,把”用户被创建”、“组成员变更”作为事件实时投递给 SP 自己的事件总线。这本质上是在 SCIM 的 RESTful 单体交互之外,补充了一条流式通道。实践里可以把 Push SCIM 作为”写路径”、把 Webhook 作为”事件通知”,两条链路互为备份:Push 失败时 Webhook 仍然能够唤起 SP 自身的 reconcile。
4.4 增量同步与 reconcile
无论 Push 还是 Pull,都不能假设单次 API 调用是可靠的。网络抖动、服务端 5xx、客户端 bug 都可能让某一次变更没被成功应用。生产级别的 SCIM 集成必须定期跑一次 reconcile(对账):以 IdP 为 source of truth,全量拉一遍 IdP 的用户清单,与 SP 侧当前账号集合做 diff,发现两边不一致时自动修正。通常每天凌晨跑一次全量 reconcile,既能修正零散丢失的事件,也能发现孤儿账户。
五、主流 IdP 的 SCIM 支持差异
SCIM 是标准,但 IdP 的具体实现各有差异。下表概括几个主流 IdP 的关键区别:
┌──────────────────┬──────────────┬──────────────┬────────────────┬──────────────┐
│ IdP │ CRUD 覆盖 │ Push Groups │ Custom Attrs │ Provisioning │
│ │ │ │ │ Logs │
├──────────────────┼──────────────┼──────────────┼────────────────┼──────────────┤
│ Okta │ 完整 │ 支持 │ 支持 │ 完整审计 │
│ Microsoft Entra │ 完整 │ 有限(需配置)│ attribute map │ 完整审计 │
│ OneLogin │ 完整 │ 支持 │ 支持 │ 一般 │
│ Google Workspace │ 有限 │ 不原生 │ 通过 Dir API │ 一般 │
│ Keycloak │ 需插件 │ 需插件 │ 需插件 │ 依赖插件 │
└──────────────────┴──────────────┴──────────────┴────────────────┴──────────────┘
5.1 Okta
Okta 的 SCIM 客户端实现是业内最完整的,支持 POST/PUT/PATCH/DELETE 全动词、支持 filter、分页、Push Groups、以及”profile sourcing”(用 SP 返回的属性反向回写到 Okta profile)。接入一个新应用时,你需要在 Okta 后台 “Add Application → Generic SCIM 2.0 App” 中配置 Base URL 和 Bearer Token,然后在 Provisioning tab 里勾选 “Create Users / Update User Attributes / Deactivate Users / Sync Password” 四个开关,再做属性映射。调试工具是 Okta System Log 里的 “Application provisioning” 事件,每次 SCIM 调用的 request/response 都能看到。
5.2 Microsoft Entra ID(原 Azure AD)
Entra ID 的 SCIM 体验相对更”向导化”——它把 attribute
mapping 放到一个图形界面里,支持表达式函数。Entra
的典型坑点有两个:一是默认的 userPrincipalName 映射常常和 SP
侧的 userName 格式不一致(例如 SP 期望纯邮箱而
Entra 推过来是 ImmutableId),二是 Entra 对 PATCH
的实现有时会把单值属性也放进多值 Operations 里,SP
的解析必须宽容。Entra 提供 Provisioning Logs 和 Audit Logs
两条审计链路,排错时应优先看 Provisioning Logs。
5.3 OneLogin、Google Workspace、Keycloak
OneLogin 的 SCIM 2.0 支持成熟,API Parameters 接近 Okta。Google Workspace 没有一等公民的 SCIM 服务端,其 provisioning 通常基于 SAML Just-in-Time + Directory API 组合,和 SCIM 的语义有一定 gap,需要在 SP 侧额外处理。Keycloak 作为开源 IdP,原生不提供 SCIM 出站同步,必须依赖社区的 scim-for-keycloak 插件;其生命周期事件驱动能力不如商业 IdP 成熟,自建 Keycloak 时要评估是否接受这一限制,或者在 Keycloak 之上再加一层自研的事件驱动 provisioning 组件。
5.4 一个 Okta 风格的 provisioning 配置示例
下面这段 YAML 不是 Okta 的原生配置格式(Okta 用的是 admin console),而是在 Terraform 或内部运维平台里常见的抽象,可以直观看到接入一个 SCIM SP 需要提供哪些参数:
application:
name: internal-platform
scim:
version: "2.0"
base_url: https://scim.example.com/v2
auth:
scheme: oauth_bearer
token_secret_ref: vault:secret/okta/scim/internal-platform#bearer
features:
create_users: true
update_user_attributes: true
deactivate_users: true
push_groups: true
import_users: false
attribute_mapping:
userName: "${user.email}"
name.givenName: "${user.firstName}"
name.familyName: "${user.lastName}"
externalId: "${user.employeeNumber}"
active: "${user.status == 'ACTIVE'}"
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department":
"${user.department}"
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager":
"${user.manager.employeeNumber}"
unique_id_field: externalId
rate_limit:
max_rps: 10
burst: 20六、工程坑点
6.1 幂等性:SCIM 调用必须可重放
IdP 在网络抖动或 429
后会自动重试。同一次逻辑的”创建用户”事件,SP 很可能收到两次
POST。如果服务端天真地
INSERT INTO users (...),第二次会因为主键冲突爆
500——这会让 IdP
认为同步失败并进入重试循环。正确的幂等实现应当把
externalId 或 userName
作为逻辑主键:
// 伪代码:幂等创建
existing, err := repo.FindByExternalID(ctx, req.ExternalID)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if existing != nil {
// 幂等:返回已存在的资源,或按 IdP 期望返回 409
return existing, nil
}
return repo.Create(ctx, req)PATCH 操作的幂等保证更细致:replace
自然幂等,add
对多值属性在规范里是”存在则更新”同样幂等,但
remove
针对不存在的元素应返回成功(no-op)而不是
404,否则重试会把本来能成功的操作打成失败。
6.2 冲突处理:match-and-link
当 IdP 第一次推一个用户过来,SP 里可能已经存在一个相同邮箱的”历史账号”(手动创建的、或早期通过 SAML JIT 创建的)。此时有三种策略:
- reject:返回 409,让 IdP 管理员人工处理。最保守但会阻塞首次同步。
- merge:把现有账号的
externalId字段设为 IdP 传来的值,从此两者绑定。需要明确的业务规则决定谁的属性生效。 - create-new:强制创建一个新账号,旧账号保留或停用。会产生”同一人两个账号”的混乱。
推荐 2,并且提供一个离线脚本让管理员审查合并结果。match
的字段优先级通常是:externalId >
userName > primary email。
6.3 软删除 vs 硬删除
PATCH active=false
是软删除,账号的历史数据、权限快照、登录日志都保留,审计时可以回答”这个账号在过去某个时间点有什么权限”。DELETE /Users/{id}
是硬删除,规范并未强制要求物理删除数据库行,允许实现只是”标记为不可访问”。工程建议:
- 默认所有 DELETE 都在内部转成
active=false + deleted_at=now(),数据库行保留至少审计周期。 - 暴露一个单独的”truly delete”管理接口给合规需求(例如 GDPR 的 right to be forgotten),走人工审批。
- 对于 GDPR 下被真正擦除的行,保留一个脱敏的审计存根(例如只保留 hashed userName 和时间戳),以便合规审计能看到”存在过”。
6.4 孤儿账户的发现与清理
孤儿账户产生的路径有两种:一是 SCIM 断连期间 IdP 删除了用户但事件没到 SP;二是 SP 侧手工创建的账号从未进入 IdP。发现孤儿账户的核心手段是 reconcile——每日把 IdP 的全量用户清单(通过 IdP 的 SCIM 出站接口或 Directory API)与 SP 本地账号做 diff:
idp_users = {u["externalId"]: u for u in fetch_all_from_idp()}
sp_users = {u.external_id: u for u in db.query(User).filter_by(deleted_at=None).all()}
missing_in_idp = sp_users.keys() - idp_users.keys()
for eid in missing_in_idp:
orphan = sp_users[eid]
logger.warning("orphan account detected", extra={"user_id": orphan.id, "external_id": eid})
orphan.active = False
orphan.deactivated_reason = "orphan_detected_by_reconcile"
db.commit()孤儿清理应当是”先停用 + 通知”,而不是直接删除——防止因为临时的 IdP 故障引发大面积误杀。
6.5 审计日志
每一次 SCIM 操作都必须产生一条审计记录。最低粒度字段:
{
"timestamp": "2026-04-21T08:12:34.567Z",
"actor": {
"type": "scim_client",
"client_id": "okta-prod",
"source_ip": "52.84.0.12",
"bearer_token_id": "tok_7a2f..."
},
"action": "user.patch",
"target": { "type": "User", "id": "2819c223-..." },
"request": { "operations": [ { "op": "replace", "path": "active", "value": false } ] },
"response": { "status": 200 },
"correlation_id": "req_01HW..."
}审计日志必须是 append-only、带 correlation_id(通常取自
IdP 的 X-Okta-Request-Id 或
Request-ID
头部并级联),并且要能抵抗管理员修改。通常落库的同时 tee
到一个集中式
SIEM(Splunk、Datadog、Elasticsearch),retention
周期不短于合规要求(SOC 2 通常 1 年,HIPAA 6 年)。
6.6 错误处理:429 / 422 / 409
- 429 Too Many Requests:必须携带
Retry-After头,Okta 和 Entra 都会尊重它做 exponential backoff。服务端内部应当区分”每租户限流”和”全局限流”,对前者的 429 不会影响其他租户。 - 422 Unprocessable Entity:请求 JSON
合法但业务校验失败(如邮箱格式非法、department
不存在),scimType 应当选
invalidValue、invalidSyntax、mutability等具体值。 - 409 Conflict:主要用于
userName/externalId唯一性冲突,scimType 必须是uniqueness。
所有错误必须返回合规的 Error schema,客户端会按 scimType 决定是否重试;返回一个纯字符串或自定义 JSON 会让重试逻辑短路成”无条件重试”,酿成雪崩。
6.7 Bearer Token 的轮换策略
SCIM 标准的认证方式是 OAuth Bearer Token(固定长效 token)。这东西一旦泄漏,攻击者就能冒充 IdP 对你所有租户做 CRUD。落地建议:
- 每个 IdP / 每个租户一套独立的 token,不要共用。
- 定期轮换(90 天),并支持 dual-token 过渡期——新旧两个 token 在 30 天重叠窗口都有效,避免运维切换期的空窗。
- token 通过 Vault / AWS Secrets Manager / Azure Key Vault 保管,运行时才拉到内存,不写磁盘。
- 服务端对 token 做内部 key id 映射,审计日志里记录
bearer_token_id而不是 token 本身。 - 对异常 IP、异常请求模式触发 token 冻结并报警。
对安全要求更高的场景,可以要求 IdP 换用 mTLS 或 OAuth 2.0 Private Key JWT。Okta 和 Entra 都支持后者,Bearer Token 只是最低标准。
七、构建一个 SCIM 服务端
7.1 Go 实现 /Users 端点骨架
下面是一个最小可用的 SCIM Users 端点骨架,使用标准库
net/http 和一个简化的 repository 接口:
package scim
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
const (
UserSchema = "urn:ietf:params:scim:schemas:core:2.0:User"
EnterpriseSchema = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
ErrorSchema = "urn:ietf:params:scim:api:messages:2.0:Error"
PatchOpSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
ListResponseSchema = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
)
type Meta struct {
ResourceType string `json:"resourceType"`
Created time.Time `json:"created"`
LastModified time.Time `json:"lastModified"`
Version string `json:"version"`
Location string `json:"location"`
}
type User struct {
Schemas []string `json:"schemas"`
ID string `json:"id"`
ExternalID string `json:"externalId,omitempty"`
UserName string `json:"userName"`
Active bool `json:"active"`
Emails []Email `json:"emails,omitempty"`
Name *Name `json:"name,omitempty"`
Enterprise *EnterpriseUserExt `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User,omitempty"`
Meta Meta `json:"meta"`
}
type Email struct {
Value string `json:"value"`
Type string `json:"type,omitempty"`
Primary bool `json:"primary,omitempty"`
}
type Name struct {
GivenName string `json:"givenName,omitempty"`
FamilyName string `json:"familyName,omitempty"`
Formatted string `json:"formatted,omitempty"`
}
type EnterpriseUserExt struct {
EmployeeNumber string `json:"employeeNumber,omitempty"`
Department string `json:"department,omitempty"`
CostCenter string `json:"costCenter,omitempty"`
Manager *Ref `json:"manager,omitempty"`
}
type Ref struct {
Value string `json:"value"`
DisplayName string `json:"displayName,omitempty"`
}
type Error struct {
Schemas []string `json:"schemas"`
Status string `json:"status"`
SCIMType string `json:"scimType,omitempty"`
Detail string `json:"detail,omitempty"`
}
type Repo interface {
Create(u *User) (*User, error)
Get(id string) (*User, error)
FindByExternalID(extID string) (*User, error)
FindByUserName(name string) (*User, error)
Patch(id, ifMatch string, ops []PatchOp) (*User, error)
List(filter string, start, count int) ([]*User, int, error)
Delete(id string) error
}
type PatchOp struct {
Op string `json:"op"`
Path string `json:"path,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
}
type Handler struct {
Repo Repo
BaseURL string
}
func (h *Handler) writeError(w http.ResponseWriter, status int, scimType, detail string) {
w.Header().Set("Content-Type", "application/scim+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(&Error{
Schemas: []string{ErrorSchema},
Status: strconv.Itoa(status), SCIMType: scimType, Detail: detail,
})
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var in User
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
h.writeError(w, 400, "invalidSyntax", "malformed json")
return
}
if in.UserName == "" {
h.writeError(w, 400, "invalidValue", "userName is required")
return
}
// 幂等:优先按 externalId 查,其次 userName
if in.ExternalID != "" {
if existing, err := h.Repo.FindByExternalID(in.ExternalID); err == nil && existing != nil {
writeJSON(w, 200, existing)
return
}
}
if existing, _ := h.Repo.FindByUserName(in.UserName); existing != nil {
h.writeError(w, 409, "uniqueness", "userName already exists")
return
}
in.Schemas = []string{UserSchema}
in.Meta.ResourceType = "User"
in.Meta.Created = time.Now().UTC()
in.Meta.LastModified = in.Meta.Created
created, err := h.Repo.Create(&in)
if err != nil {
h.writeError(w, 500, "", err.Error())
return
}
created.Meta.Location = h.BaseURL + "/Users/" + created.ID
w.Header().Set("Location", created.Meta.Location)
w.Header().Set("ETag", created.Meta.Version)
writeJSON(w, 201, created)
}
func (h *Handler) PatchUser(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/Users/")
ifMatch := r.Header.Get("If-Match")
var body struct {
Schemas []string `json:"schemas"`
Operations []PatchOp `json:"Operations"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.writeError(w, 400, "invalidSyntax", "malformed json")
return
}
updated, err := h.Repo.Patch(id, ifMatch, body.Operations)
if errors.Is(err, ErrPreconditionFailed) {
h.writeError(w, 412, "", "version mismatch")
return
}
if errors.Is(err, ErrNotFound) {
h.writeError(w, 404, "", "user not found")
return
}
if err != nil {
h.writeError(w, 500, "", err.Error())
return
}
w.Header().Set("ETag", updated.Meta.Version)
writeJSON(w, 200, updated)
}
var (
ErrNotFound = errors.New("not found")
ErrPreconditionFailed = errors.New("precondition failed")
)
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/scim+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}7.2 PATCH Operations 的解析
PATCH 是 SCIM 服务端实现里最复杂的一块,尤其是带过滤器的
path。下面这段 Python
代码展示了一种简化但可用的 path 解析与 apply:
import re
from typing import Any, Dict, List
PATH_RE = re.compile(
r'^(?P<attr>[a-zA-Z0-9_:\.]+)'
r'(?:\[(?P<filter>[^\]]+)\])?'
r'(?:\.(?P<sub>[a-zA-Z0-9_]+))?$'
)
FILTER_RE = re.compile(r'^(\w+)\s+eq\s+"([^"]+)"$')
def apply_patch(resource: Dict[str, Any], operations: List[Dict]) -> Dict[str, Any]:
for op in operations:
kind = op["op"].lower()
path = op.get("path")
value = op.get("value")
if path is None:
if kind in ("add", "replace"):
for k, v in (value or {}).items():
resource[k] = v
else:
raise ValueError("remove requires a path")
continue
m = PATH_RE.match(path)
if not m:
raise ValueError(f"invalid path: {path}")
attr, filt, sub = m.group("attr"), m.group("filter"), m.group("sub")
target = resource.setdefault(attr, [] if filt else None)
if filt:
fm = FILTER_RE.match(filt)
if not fm:
raise ValueError(f"unsupported filter: {filt}")
fk, fv = fm.group(1), fm.group(2)
matched = [i for i, item in enumerate(target) if item.get(fk) == fv]
if kind == "remove":
for i in reversed(matched):
target.pop(i)
else:
for i in matched:
if sub:
target[i][sub] = value
else:
target[i].update(value if isinstance(value, dict) else {})
else:
if kind == "remove":
resource.pop(attr, None)
elif kind == "add" and isinstance(resource.get(attr), list):
resource[attr].extend(value if isinstance(value, list) else [value])
else:
resource[attr] = value
return resource这段代码只支持最常见的
attr、attr[filter]、attr[filter].sub、attr.sub
四种形式,足以覆盖 Okta 和 Entra 绝大多数 PATCH
payload;如果要支持完整的 SCIM filter
语法(and/or/co/sw/嵌套括号),建议引入一个真正的
parser(如 go 生态的
github.com/scim2/filter-parser),而不要自己用正则拼。
7.3 数据库映射
SCIM User 到应用内部 User 的映射必须显式。一种推荐做法是拆成两张表:
CREATE TABLE scim_user_bindings (
scim_id UUID PRIMARY KEY,
external_id TEXT UNIQUE,
internal_user UUID NOT NULL REFERENCES users(id),
version TEXT NOT NULL,
raw_scim JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_modified TIMESTAMPTZ NOT NULL DEFAULT now(),
active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_scim_user_bindings_active ON scim_user_bindings(active) WHERE active = true;raw_scim 保留原始 SCIM JSON,方便对端 IdP
做回放和调试;internal_user
指向应用原有的用户表,保留单独的用户模型——不要把 SCIM schema
直接当成应用的业务模型,否则 SCIM
规范的演进会把业务绑死。
7.4 属性过滤与投影
GET /Users?attributes=userName,active
要求服务端只返回请求的字段。一种实现是用字段白名单在序列化阶段投影:
func Project(u *User, allowed map[string]bool) map[string]any {
out := map[string]any{"schemas": u.Schemas, "id": u.ID, "meta": u.Meta}
if allowed["userName"] { out["userName"] = u.UserName }
if allowed["active"] { out["active"] = u.Active }
if allowed["emails"] { out["emails"] = u.Emails }
// ... 其他字段
return out
}同样要尊重 returned
元属性:即便客户端显式请求了
password(returned=never),也必须返回空或省略该字段。
八、落地建议与选型
8.1 从零起步的推荐路线
如果你的应用从未接入过 SCIM,一个稳妥的推进顺序是:
- 第 0 阶段:先实现 SAML SSO(见上一篇 SAML 遗留 SSO)和 OIDC(见SSO 与 OIDC),把”谁在登录”这件事先打通。
- 第 1 阶段:实现 SCIM 2.0 的最小子集——POST /Users、GET
/Users/{id}、PATCH /Users/{id}(仅支持
replace active),支持ServiceProviderConfig和Schemas发现端点,加 Bearer Token 认证。 - 第 2 阶段:加 filter 支持、分页、ETag,加 Group CRUD,支持 Push Groups。
- 第 3 阶段:加 Bulk(可选,许多场景不需要)、Enterprise User Schema 扩展、完整的 PATCH 过滤器语法。
- 第 4 阶段:在 SP 侧加每日 reconcile job,接入内部的 SIEM 审计平台,做孤儿账户告警。
8.2 选型:买 IGA 产品还是自研
对于身份数量超过 5 万或者下游应用超过 30 个的组织,通常要引入专门的 IGA(Identity Governance and Administration)产品,如 SailPoint、Saviynt、Okta Identity Governance。这些产品在 SCIM 之上叠加了访问审阅(access review)、职责分离(SoD)、风险评分等合规能力。对中小规模(< 5 万身份、< 30 应用)的组织,Okta / Entra 自带的 provisioning 已经足够,把精力放在让每个应用都实现 SCIM 服务端上,比引入重型 IGA 更划算。相关的权衡也可以参考IAM 全景图和认证架构分层。
8.3 安全底线清单
- SCIM 端点必须强制 HTTPS,拒绝 HTTP 重定向。
- Bearer Token 必须在 Vault / KMS 管理,严禁 checkin 到 git。
- SCIM 端点必须加 IP 白名单(IdP 的出口 IP 列表是公开的)。
- 所有 SCIM 请求要有 rate limit,默认 10 rps 起步。
- 审计日志不可变且异地备份。
DELETE默认降级为active=false,真正删除走专门审批。- 每个租户独立的 Bearer Token,不共用。
8.4 可观测性
SCIM 的可观测性指标至少要包括:每分钟 SCIM 请求数(按 method/path)、错误率(按 status)、P50/P99 延迟、Bearer Token 的认证失败数、reconcile 每次发现的孤儿数量、Provisioning Lag(从 IdP 事件发生到 SP 成功 ack 的耗时分布)。这几个指标做成仪表盘后,能把僵尸账号事件的发现时间从 “三周” 降到 “分钟级”。
九、参考资料
- RFC 7642,System for Cross-domain Identity Management: Definitions, Overview, Concepts, and Requirements。
- RFC 7643,System for Cross-domain Identity Management: Core Schema。
- RFC 7644,System for Cross-domain Identity Management: Protocol。
- Okta Developer,Build a SCIM Provisioning Integration,https://developer.okta.com/docs/concepts/scim/。
- Microsoft Learn,Tutorial: Develop and plan provisioning for a SCIM endpoint in Microsoft Entra ID。
- NIST SP 800-63-3,Digital Identity Guidelines,相关 lifecycle 管理章节。
- OWASP Identity Management Cheat Sheet。
- Verizon DBIR,2024 Data Breach Investigations Report,凭据相关章节。
- scim2/filter-parser(Go)与 django-scim2(Python),开源参考实现。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】IAM 全景:为什么这是高价值赛道
身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。
【身份与访问控制工程】CIAM 架构:面向 B2B / B2C SaaS 的身份平台
系统梳理 CIAM(Customer Identity and Access Management)的场景差异、数据模型、隐私合规与工程坑点,覆盖 B2C 社交登录、B2B 企业 SSO/SCIM、B2B2C 组织模型,以及全球多区域部署与选型建议。
【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界
SAML 2.0 是 2005 年的协议,却仍活在每一个和银行、保险、制造业做生意的 B2B SaaS 后台——本文从一封客户邮件开始,拆解 SAML 断言结构、SP-initiated 流程、Metadata、证书轮换与 XML 签名包装攻击等现实工程问题
【身份与访问控制工程】自建还是采购:Keycloak、Auth0、Entra、Okta 对比
从 TCO、合规、SLA、生态四个维度对比 Keycloak、Auth0、Microsoft Entra ID、Okta 及新兴开源方案,给出不同规模与合规场景下的选型矩阵与工程坑点清单。