土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】SCIM 与账号生命周期:开通、变更、离职自动化

文章导航

分类入口
architecturesecurity
标签入口
#SCIM#provisioning#identity#lifecycle#Okta#Entra#IGA#automation

目录

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 控制项的直接失分项——审计师只需要抽一个离职员工样本就能发现问题。

SCIM 账号生命周期自动化

这起事件背后的系统性根因,是身份的生命周期管理(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=falsemeta.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 支持什么:

一个最小的 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 里都带有一组元属性,直接决定了服务端的校验和响应逻辑:

工程实现中最容易出错的是 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 ConflictuserName 重复)、413 Payload Too Large429 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" }
  ]
}

几个要点:opadd / 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")

配合 startIndexcount 实现分页(注意 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 认为同步失败并进入重试循环。正确的幂等实现应当把 externalIduserName 作为逻辑主键:

// 伪代码:幂等创建
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,否则重试会把本来能成功的操作打成失败。

当 IdP 第一次推一个用户过来,SP 里可能已经存在一个相同邮箱的”历史账号”(手动创建的、或早期通过 SAML JIT 创建的)。此时有三种策略:

  1. reject:返回 409,让 IdP 管理员人工处理。最保守但会阻塞首次同步。
  2. merge:把现有账号的 externalId 字段设为 IdP 传来的值,从此两者绑定。需要明确的业务规则决定谁的属性生效。
  3. create-new:强制创建一个新账号,旧账号保留或停用。会产生”同一人两个账号”的混乱。

推荐 2,并且提供一个离线脚本让管理员审查合并结果。match 的字段优先级通常是:externalId > userName > primary email

6.3 软删除 vs 硬删除

PATCH active=false 是软删除,账号的历史数据、权限快照、登录日志都保留,审计时可以回答”这个账号在过去某个时间点有什么权限”。DELETE /Users/{id} 是硬删除,规范并未强制要求物理删除数据库行,允许实现只是”标记为不可访问”。工程建议:

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-IdRequest-ID 头部并级联),并且要能抵抗管理员修改。通常落库的同时 tee 到一个集中式 SIEM(Splunk、Datadog、Elasticsearch),retention 周期不短于合规要求(SOC 2 通常 1 年,HIPAA 6 年)。

6.6 错误处理:429 / 422 / 409

所有错误必须返回合规的 Error schema,客户端会按 scimType 决定是否重试;返回一个纯字符串或自定义 JSON 会让重试逻辑短路成”无条件重试”,酿成雪崩。

6.7 Bearer Token 的轮换策略

SCIM 标准的认证方式是 OAuth Bearer Token(固定长效 token)。这东西一旦泄漏,攻击者就能冒充 IdP 对你所有租户做 CRUD。落地建议:

对安全要求更高的场景,可以要求 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

这段代码只支持最常见的 attrattr[filter]attr[filter].subattr.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 元属性:即便客户端显式请求了 passwordreturned=never),也必须返回空或省略该字段。

八、落地建议与选型

8.1 从零起步的推荐路线

如果你的应用从未接入过 SCIM,一个稳妥的推进顺序是:

  1. 第 0 阶段:先实现 SAML SSO(见上一篇 SAML 遗留 SSO)和 OIDC(见SSO 与 OIDC),把”谁在登录”这件事先打通。
  2. 第 1 阶段:实现 SCIM 2.0 的最小子集——POST /Users、GET /Users/{id}、PATCH /Users/{id}(仅支持 replace active),支持 ServiceProviderConfigSchemas 发现端点,加 Bearer Token 认证。
  3. 第 2 阶段:加 filter 支持、分页、ETag,加 Group CRUD,支持 Push Groups。
  4. 第 3 阶段:加 Bulk(可选,许多场景不需要)、Enterprise User Schema 扩展、完整的 PATCH 过滤器语法。
  5. 第 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 安全底线清单

8.4 可观测性

SCIM 的可观测性指标至少要包括:每分钟 SCIM 请求数(按 method/path)、错误率(按 status)、P50/P99 延迟、Bearer Token 的认证失败数、reconcile 每次发现的孤儿数量、Provisioning Lag(从 IdP 事件发生到 SP 成功 ack 的耗时分布)。这几个指标做成仪表盘后,能把僵尸账号事件的发现时间从 “三周” 降到 “分钟级”。

九、参考资料


上一篇SAML 还值得学吗:企业遗留 SSO 的现实世界

下一篇JWT、JWS、JWE、JWKS 一次讲透

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】IAM 全景:为什么这是高价值赛道

身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。


By .