# 身份同步 · SCIM 令牌轮换与凭据故障处置

适用于 `kind='scim'` / `kind='navan'` 的同步目标(`aws-sso`、`aws-ai`、
`alicloud-sso`、`navan-prod` …)。这些目标用一个 **Bearer 令牌** 写对端,
令牌存在 `integrations.secrets_encrypted` 的 `api_token` 字段里。

AWS IAM Identity Center 的 SCIM access token **有效期只有 1 年**,到期或被
管理员在控制台删除后,Helix 侧所有读写立刻变成 `401`(空 body,没有
`WWW-Authenticate` 头,也没有任何提前警告)。这不是 Helix 的 bug,只能靠
在对端重新生成令牌来修 —— **没有任何代码改动够得着一个死掉的凭据**。

> 判定逻辑本身(为什么 sweep 现在报 `failed` 而不是 `partial`)记在
> `docs/architecture/PLATFORM.md` §10.5 的
> 「A sweep that touched the vendor zero times successfully is a DOWN
> target…」条目里,本文只讲怎么处置。

---

## 1. 症状:怎么认出是令牌死了

一整个 target 的读写全部 `401`,但被 hash-skip 的人看起来「正常」。典型
现场(`aws-ai`,2026-08-14):

| 时间 (UTC) | 现象 |
|---|---|
| 08-13 02:01 | sweep `ok`(updated=2, skipped=406, failed=0) |
| 08-13 03:03 | 最后一次成功的厂商快照 |
| 08-14 02:04 | sweep:skipped=282、failed=127,**127 条全是 `401`**,0 条写入成功 |
| 08-14 03:00 | `identity_sync.vendor_snapshot_refresh` 失败告警:`aws-ai: GET /Users page start=1 → 401:` |
| 08-14 03:47 | `identity_sync.leaver_assurance` P2:某离职者 `last_status=failed` 未确认回收 |

判定要点:

- `identity_sync_state.last_error` 里出现 `→ 401:`。**空 body** 是 AWS IdC
  拒绝令牌的特征;令牌对但权限不足通常是 `403` 且带 body。
- 同一 target 上**所有**尝试过的写都 `401`,不是零星几个人。
- 读侧(`vendor_snapshot_refresh`)同时 `401` → 读写都不通 = 凭据问题,
  不是某个人的数据问题。

> **注意**:如果只有部分人失败、另一部分写成功了,那不是令牌问题,别按本手册走
> —— 这种情况 sweep 会记 `partial`(target 活着,个别人没过去)。

### 快速确认(只读)

必须在**生产容器**里跑:本地 `.env` 的 `CREDENTIAL_ENCRYPTION_KEY` 是 dev
key,解不开生产密文(`InvalidTag`)。脚本用 `_resolve_integration` 取出
`api_token` + `base_url`,对 `GET {base_url}/Users?count=1` 打一发:

```bash
aws ssm send-command --region ap-southeast-1 --instance-ids i-0c15d98cba3ebba9c --document-name AWS-RunShellScript --parameters 'commands=["echo <base64-of-probe.py> | base64 -d | sudo docker exec -i -w /app compose-backend-1 /app/.venv/bin/python -"]'
```

**同时探一个已知健康的同类 target 作对照**(如 `aws-sso`)。两边请求形状
完全相同,一个 `401` 一个 `200`,就把「Helix 代码问题」彻底排除了 ——
2026-08-14 就是这么定的性。脚本首行需要 `import app.main` 注册全部 mapper,
否则报 `AuthUserRole is not defined`。

---

## 2. 修复:重新生成令牌

AWS IdC 的 SCIM access token **只能在控制台生成,没有 API**(`sso-admin`
没有对应操作),所以这一步必须人工做。

1. 用**该 IdC 实例所属账号**的管理员身份登录 AWS 控制台,切到实例所在
   区域(`aws-ai` / `aws-sso` 都在 **ap-northeast-1**)。
   > `aws-ai` 与 `aws-sso` 属于**不同的 AWS 组织**。本地 `ljq` 那把 IAM key
   > (账号 `239023060546`)只看得到 `aws-sso` 那个实例
   > (`ssoins-7758e707bb6ea352`,owner `317766989874`);`aws-ai` 必须用 AI
   > 组织自己的管理员登录。见 `reference_aws_org_two_integrations_assume_wall`。
2. **IAM Identity Center → Settings → Identity source → Automatic provisioning**。
3. 核对页面上的 **SCIM endpoint** 与 Helix 里
   `integrations.config_public.base_url` 一致,确认改的是同一个实例:
   - `aws-sso` → `https://scim.ap-northeast-1.amazonaws.com/tEOa8f972a5-04b3-436e-b10e-0177bbee805b/scim/v2`
   - `aws-ai` → `https://scim.ap-northeast-1.amazonaws.com/tEO9e536ea2-cd0a-415f-8c3a-d65040ef544d/scim/v2`
4. **Generate token** → 复制(只显示一次)。旧令牌先留着,等新令牌验证通过
   再删。
5. 在 Helix 后台 **集成管理 → 对应集成 → 凭据** 更新 `api_token` 并保存。
   **不要手改数据库** —— 密文是 AES-GCM,必须走应用层加密。
6. 点 **测试连接**:应返回 `OK · totalResults=… · itemsPerPage=…`,并写入
   `integrations.last_tested_at` / `last_test_status`。

---

## 3. 收尾:把积压的失败追平

令牌换好后,失败的行不会自己消失 —— `identity_sync_state.last_status` 还停在
`failed`,零信任对账每晚 03:47 会继续告警,直到这些人真的被写成功 / 回收。

1. **手动跑一次该 target 的 sweep**(后台 → 同步目标 → 立即运行)。
   `failed` 行会被重新尝试;成功后 `last_status` 转 `success` /
   `deactivated`,`last_error` 清空。
2. **重跑 `identity_sync.leaver_assurance`**(定时任务 → 运行历史 → 重跑)。
   跑绿了才算真收敛。**跑绿之前不要 ack 那条失败记录** —— ack 只是把它移出
   红队列,不代表离职者的账号真的关了。
3. 确认离职者:`identity_sync_state` 里该人在该 target 上的
   `last_status ∈ (deactivated, deleted, excluded)`。

### AWS target 的快照:走 Identity Store,不走 SCIM(2026-08-15 起)

**AWS IdC 的 SCIM `ListUsers` 根本不支持分页**(封顶 100 条、`startIndex`
永远回 1、无 `nextToken`),所以 `ScimRunner` 拒绝返回截断名册,
`vendor_snapshot_refresh` 对 AWS target 每晚必红。解法不是修 SCIM,而是
**换一个入口读同一份目录**:AWS Identity Store API
(`identitystore:ListUsers` / `ListGroups`)有真正的 `NextToken` 分页。

打开方式 —— 在 target 的 `runner_options` 里:

```json
{
  "roster_source": "aws_identity_store",
  "aws_integration_slug": "aws-primary"
}
```

`aws_integration_slug` 指向一个 **`aws_cloud` 集成**(target 自己的
`integration_id` 已经被 SCIM token 占着,不能复用)。Identity Store 的 id 和
region 默认继承该集成的 `sso_identity_store_id` / `sso_region`;要覆盖就加
`identity_store_id` / `identity_store_region`。写侧(停用/删除)**仍然走
SCIM**,这只是读侧换传输。

**凭据必须来自那个 IdC 实例自己的账号** —— 可达范围由 AWS IAM 决定,不由
Helix 决定。生产现状:

| target | Identity Store | 凭据 | 状态 |
|---|---|---|---|
| `aws-sso` | `d-95670a3c83` (ap-northeast-1) | `aws-primary`(账号 317766989874) | ✅ 已切换,497 人 |
| `aws-sg-idc` | 账号 841162690310,us-east-1 | 现有 key **被拒** | ⛔ 待补 IAM 权限 |

#### 🔴 aws-sg-idc 的人工步骤(补 IAM 权限)

账号 `841162690310` 里那个 IAM 用户 `shuai.feng@pingcap.com` 目前没有
`sso:ListInstances` / `identitystore:*`,所以名册读不到(`AccessDeniedException`)。
该用户自带 `IAMFullAccess`,可以自己补。控制台:IAM → 用户
`shuai.feng@pingcap.com` → 添加权限 → 创建内联策略,粘贴:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "sso:ListInstances",
      "sso:DescribeInstance",
      "identitystore:ListUsers",
      "identitystore:ListGroups",
      "identitystore:DescribeUser"
    ],
    "Resource": "*"
  }]
}
```

全是只读动作。补完之后:

1. `aws sso-admin list-instances --region us-east-1` 拿到 `IdentityStoreId`。
2. 在 Helix 建/改一个 `aws_cloud` 集成承载这个账号的 key,并填
   `sso_identity_store_id` + `sso_region`(或直接在 target 的 runner_options
   里用 `identity_store_id` / `identity_store_region` 覆盖)。
3. 把 `aws-sg-idc` 的 `runner_options.roster_source` 改成
   `aws_identity_store`,手工触发一次「刷新厂商快照」,确认条数远大于 100。

在这一步做完之前,`aws-sg-idc` 的 `vendor_snapshot_refresh` **每晚仍会失败**
—— 这是**诚实的红灯**:它的 Signal B 只覆盖 100 个账号,告警是唯一说出这件事
的东西。不要为了消掉告警去关掉这个 target 的快照。

采集失败发生在 wipe-and-replace **之前**,所以旧快照是被保留而不是被清空;
离职对账的新鲜度闸(快照拍摄日必须严格晚于离职日)会自动把过期快照
discount 掉,不会产生假阴性。

---

## 4. 已知盲区(不是这次改的,登记备查)

`identity_sync_targets.alert_recipient_ids` 为 NULL 的 target(当前生产
**全部** target 都是)**不会发任何 target 级失败告警** —— 按 DECISIONS.md
2026-06-19 的决定,这里故意没有 IT-admin 兜底:一个 target 只告警给管理员
明确挑的人。所以能把凭据事故捞出来的只有 `identity_sync.dispatch` 的
raise-on-failure(它只认 `failed`,不认 `partial` —— 这正是 #2734 修的东西)
和两条零信任 cron。想让 target 自己也报警,就去后台给它配收件人。
