Skip to content

Commit a794e32

Browse files
feat(platform-objects): mint and revoke SCIM connection credentials from the console (#31119)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9Hju7DThD7eoLKoJU9hnHj)_ Fixes #31032 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** `sys_scim_connection_credential` 加了两个操作:「签发凭据」(列表工具栏)调用已有的 `POST /api/v1/auth/admin/scim/mint-credential`,令牌在结果对话框里只显示一次;「撤销凭据」(凭据行和记录页头)调用 `POST /api/v1/auth/admin/scim/revoke-credential`,先确认再执行。四种语言的翻译一并补上。可选第 4 项也做了:在模拟登录会话里签发时,审计行多记一个 `impersonated_by`。 - **为什么改:** 这两个路由已经上线,但控制台里够不着,平台管理员配置 SCIM 预配时仍要用 curl 拿 Bearer 令牌。 - **风险与回滚:** 不放宽谁能签发或撤销:两个操作只对平台管理员显示,声明的权限就是路由自己的门槛,最终仍由路由裁决。签发不暴露给 AI 工具(ADR-0011 H 档),撤销暴露但必须人工确认(C 档)。回滚即 revert。 - **先看:** ① `packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts`(两个操作和 AI 分档);② `packages/plugins/plugin-auth/src/scim-connection-admin.ts`(审计行的 `impersonated_by`);③ `sys-scim-connection-credential-actions.test.ts`。 - **两分钟验证:** `pnpm --filter @objectstack/platform-objects exec vitest run src/identity/sys-scim-connection-credential-actions.test.ts src/identity/two-factor-one-shot-reveal.test.ts`,应为 12 个用例全绿。 - **需要你做的:** 无,席位落地。 ## What changes - **`sys_scim_connection_credential` gains `mint_scim_credential`** (list toolbar, `type: 'api'`, `POST /api/v1/auth/admin/scim/mint-credential`). Its params are named for the body keys the mount reads: `connectionId` (required), `label`, `organizationId` (a `sys_organization` lookup), `provisioningDomainId`, `expiresAt`. The mint answers the plaintext bearer once (`Cache-Control: no-store`; only the keyed digest is stored), so it is revealed in a `resultDialog` (`token` as `secret`, plus `connectionId` and `expiresAt`) the admin must close. The mount is keyed by the connection id, and the credential row is what it writes, so the action sits on the credential object, not the binding object (the binding is the library's lifecycle row and the mount never reads it). - **`revoke_scim_credential`** (list item and record header, `POST /api/v1/auth/admin/scim/revoke-credential`, `recordIdParam: 'credentialId'`, a confirmation, no param dialog). Offered on an active credential only (`has(record.active) && record.active == true`). The mount's 404 and 409 envelopes reach the admin through the console's api-action refusal path (`apiActionRefusalResult`), as every other identity action's do. - **Who is offered them.** Both carry `visible: 'current_user.isPlatformAdmin == true'` and `requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE]`, the declarations every action on an `/api/v1/auth/admin/` door carries. They narrow who is offered the button to who the door already admits; the door's platform-admin gate is unchanged, and no second permission path is added. - **AI exposure (ADR-0011 identity-actions note).** Mint is bucket H: it mints a credential, so it declares no `ai` block (an agent that ran it would hold a live SCIM bearer). Revoke is bucket C: `ai: { exposed: true, description, requiresConfirmation: true }`. It hands out no credential and widens no access; it switches one bearer off, as `sys_api_key.revoke_api_key` and `sys_session.revoke_session` (both C) do. It cannot be undone, so the confirmation is required. - **Precedent for the one-shot reveal:** `sys_api_key.create_api_key` and `sys_oauth_application.create_oauth_application` (a `resultDialog` with a `format: 'secret'` field and no `successMessage`). - **Translations** via `node scripts/check-i18n-bundles.mjs --write`; the zh-CN, zh-Hant, ja-JP and es-ES values written by hand (no en echo left, so no provenance rows added). - **Item 4 (`plugin-auth`).** A mint made on an impersonation session writes the administrator at the keyboard into its `sys_audit_log` row as `metadata.impersonated_by`. The key is the one the audit writers already use (`IMPERSONATED_BY_KEY` in plugin-audit), not the card's `impersonatedBy` spelling. The gate reads it off the session, never the body. The revoke door's row is unchanged (the order scoped item 4 to the mint door). ## Tests - New `src/identity/sys-scim-connection-credential-actions.test.ts` (6 cases): mint's route, method, location and body-key params; revoke's route, method, `recordIdParam`, no params; revoke's `visible` on active / revoked / absent / null `active`; a live control that both mounts have one action each; mint not AI-exposed; revoke exposed with `requiresConfirmation`. - `two-factor-one-shot-reveal.test.ts`: the mint route joins `ONE_SHOT_ROUTES` with its secret key `token`, so the class rule (a `resultDialog` covering the secret, no `successMessage`) holds this action and any later one on the route. - `platform-admin-affordance-standing.test.ts`: both actions join the family floor (the family is derived by target; the floor makes the instrument's reach explicit). - `plugin-auth` `scim-connection-admin.test.ts`: a mint on an impersonation session (with a forged `impersonatedBy` in the body) writes `impersonated_by` from the session. The existing first case pins the exact metadata of an ordinary mint, so it is the absent half. ## Acceptance notes - **No Setup navigation entry reaches this object.** Neither `SETUP_NAV_CONTRIBUTIONS` nor plugin-auth's contributions name `sys_scim_connection_credential` (the SSO entry is contributed by plugin-auth only while SSO is wired). The actions are reachable once the object's list is opened, but a platform admin has no menu item for it. A gated entry belongs in plugin-auth's `navigationContributions` beside the SSO one (there is no `scim` public auth feature to gate a static entry on). Outside this card's file surface; named for the seat. - **ADR-0011's addendum table** lists the identity actions by bucket and is read by reviewers, with no automatic check. These two actions are not in it yet (mint H, revoke C). Editing it is a Tier H ADR change, outside this card's file surface; named for the seat. - The two new pins on AI exposure are per-action pins the order asked for, scoped to these two actions; they are not the table-wide automatic check the ADR-0011 addendum records as struck. - Not measured: a live click through the console. The data half of the reveal (`token` at the inner `data` path) is read from the mount's response shape, which `scim-connection-admin.test.ts` pins. ## Verification All readings at head `82b61834`, branched from and merged with `origin/main` `5f0037449` (main has not moved since). - `pnpm --filter @objectstack/platform-objects test`: exit 0, 118 files, 1644 passed. - `pnpm --filter @objectstack/plugin-auth test`: exit 0, 232 files, 4190 passed, 10 skipped. - `pnpm --filter @objectstack/platform-objects --filter @objectstack/plugin-auth typecheck` (incl. `check:test-typecheck`): exit 0. The first run caught one TS18048 in the new test (`resultDialog.fields` possibly undefined), fixed in `82b61834`. - eslint `--no-inline-config` over `packages/platform-objects` and `packages/plugins/plugin-auth`: exit 0. - `node scripts/check-i18n-bundles.mjs --write` re-run after the hand translations: no diff; `pnpm check:i18n`: exit 0. - `dispatch-gates --commands` derived 63 families, all run with exit codes recorded; `--ran`: 63 derived, 63 run, 0 NOT-MEASURED (a derived zero). Two exited 3 on the first pass for missing prerequisites and were re-run green after supplying them: `check-plugin-teardown-shape --self-test` (fetched its pinned fixture commit) and `check:dual-build-cjs-loads` (after `check:type-check-debt` built the packages). The derived list is identical at the final head. <details><summary>Ablations (each through scripts/ablation-replace.mjs: anchor hit, mutation landed, run red, restored with blob == HEAD)</summary> - A1, mint declares `ai: { exposed: true, description }`: `mint is NOT exposed to AI tools` fails, "mint_scim_credential declares ai.exposed: expected true to be false" (1 failed, 5 passed). - A2, revoke `visible` without the `active` guard: `revoke is offered on an active credential, not on a revoked one` fails, "expected 'true' to be 'false'". - A3, the secret field's path `token` changed to `credentialId`: the one-shot reveal class pin fails ("does not reveal 'token'") and the new mint pin fails. - A4, revoke without `requiresConfirmation`: `revoke is exposed with its confirmation` fails. - A5, plugin-auth writes `impersonated_by` on every mint (`actor.impersonatedBy ?? actor.id`): the ordinary-mint case fails on its exact metadata (3 keys vs 2). The platform-objects pins import the object sources directly, so no `dist/` rebuild was involved; the plugin-auth suite imports `src` the same way. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on both touched packages passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. Session: https://claude.ai/code/session_017ihL2c3bRFyY1fQ6vWAW1Z 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98583cd commit a794e32

12 files changed

Lines changed: 504 additions & 2 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
feat(platform-objects): mint and revoke SCIM connection credentials from the console
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
`sys_scim_connection_credential` gains two actions, so a platform administrator setting up SCIM provisioning no longer needs curl to get a bearer token:
12+
13+
- **Mint Credential** (list toolbar) posts `POST /api/v1/auth/admin/scim/mint-credential` with a connection ID and the optional label, organization, provisioning domain and expiry, then shows the token once in a result dialog the administrator must close. Only the token's one-way digest is stored, so it cannot be shown again. Mint is not exposed to AI tools: an agent that ran it would hold a live SCIM bearer.
14+
- **Revoke Credential** (a credential row or its record header) posts `POST /api/v1/auth/admin/scim/revoke-credential` for that row, after a confirmation. It is offered on an active credential only; the route's 404 and 409 answers show as the action's error. Revoke is exposed to AI tools and always asks the human to confirm first.
15+
16+
Both are offered only to a platform administrator and declare the routes' own gate (`manage_platform_settings`). Who may mint or revoke is unchanged: the routes still decide.
17+
18+
`@objectstack/plugin-auth`: a mint made on an impersonation session now names the administrator at the keyboard in its `sys_audit_log` row, as `metadata.impersonated_by` (the key the audit writers use). A mint on an ordinary session writes the same row as before.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,6 +2282,49 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
22822282
all: {
22832283
label: "All"
22842284
}
2285+
},
2286+
_actions: {
2287+
mint_scim_credential: {
2288+
label: "Mint Credential",
2289+
description: "Mint a bearer credential for a SCIM connection. Give it to the identity provider as the SCIM token; it is shown once.",
2290+
params: {
2291+
connectionId: {
2292+
label: "Connection ID",
2293+
helpText: "The SCIM connection this credential authenticates, e.g. \"okta-prod\". At most 246 characters. To rotate, mint a second credential for the same connection, move the identity provider to it, then revoke the old one."
2294+
},
2295+
label: {
2296+
label: "Label",
2297+
helpText: "Optional. A name for this credential, e.g. \"rotation 2026-Q3\"."
2298+
},
2299+
organizationId: {
2300+
label: "Organization",
2301+
helpText: "Optional. The organization that owns the connection. Leave empty for the Default Organization."
2302+
},
2303+
provisioningDomainId: {
2304+
label: "Provisioning Domain",
2305+
helpText: "Optional. The boundary that receives the provisioned users and groups. Leave empty to use the connection ID."
2306+
},
2307+
expiresAt: {
2308+
label: "Expires At",
2309+
helpText: "Optional. After this time the credential is refused. Leave empty for a credential that does not expire."
2310+
}
2311+
},
2312+
resultDialog: {
2313+
title: "SCIM credential minted",
2314+
description: "Copy the token now — it is shown only once and cannot be recovered. Paste it into the identity provider as the SCIM bearer token. If it leaks, revoke it and mint a new one.",
2315+
acknowledge: "I have saved the token",
2316+
fields: {
2317+
token: "Token",
2318+
connectionId: "Connection ID",
2319+
expiresAt: "Expires At"
2320+
}
2321+
}
2322+
},
2323+
revoke_scim_credential: {
2324+
label: "Revoke Credential",
2325+
confirmText: "Revoke this SCIM credential? The identity provider using it is refused from its next request on. This cannot be undone: to restore provisioning, mint a new credential.",
2326+
successMessage: "SCIM credential revoked"
2327+
}
22852328
}
22862329
},
22872330
sys_scim_group: {

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,6 +2282,49 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
22822282
all: {
22832283
label: "Todas"
22842284
}
2285+
},
2286+
_actions: {
2287+
mint_scim_credential: {
2288+
label: "Emitir credencial",
2289+
description: "Emite una credencial de portador para una conexión SCIM. Entrégala al IdP como token SCIM; solo se muestra una vez.",
2290+
params: {
2291+
connectionId: {
2292+
label: "ID de conexión",
2293+
helpText: "Conexión SCIM que autentica esta credencial, p. ej., «okta-prod». Máximo 246 caracteres. Para rotarla, emite una segunda credencial para la misma conexión, cambia el IdP a ella y después revoca la anterior."
2294+
},
2295+
label: {
2296+
label: "Etiqueta",
2297+
helpText: "Opcional. Un nombre para esta credencial, p. ej., «rotation 2026-Q3»."
2298+
},
2299+
organizationId: {
2300+
label: "Organización",
2301+
helpText: "Opcional. La organización propietaria de la conexión. Déjalo vacío para usar la organización predeterminada."
2302+
},
2303+
provisioningDomainId: {
2304+
label: "Dominio de aprovisionamiento",
2305+
helpText: "Opcional. El límite que recibe los usuarios y grupos aprovisionados. Déjalo vacío para usar el ID de conexión."
2306+
},
2307+
expiresAt: {
2308+
label: "Caduca el",
2309+
helpText: "Opcional. Pasado este momento, la credencial se rechaza. Déjalo vacío para una credencial que no caduca."
2310+
}
2311+
},
2312+
resultDialog: {
2313+
title: "Credencial SCIM emitida",
2314+
description: "Copia el token ahora: solo se muestra una vez y no se puede recuperar. Pégalo en el IdP como token de portador SCIM. Si se filtra, revócalo y emite uno nuevo.",
2315+
acknowledge: "He guardado el token",
2316+
fields: {
2317+
token: "Token de portador",
2318+
connectionId: "ID de conexión",
2319+
expiresAt: "Caduca el"
2320+
}
2321+
}
2322+
},
2323+
revoke_scim_credential: {
2324+
label: "Revocar credencial",
2325+
confirmText: "¿Revocar esta credencial SCIM? El IdP que la usa se rechaza a partir de su siguiente solicitud. No se puede deshacer: para restablecer el aprovisionamiento, emite una credencial nueva.",
2326+
successMessage: "Credencial SCIM revocada"
2327+
}
22852328
}
22862329
},
22872330
sys_scim_group: {

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,6 +2282,49 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
22822282
all: {
22832283
label: "すべて"
22842284
}
2285+
},
2286+
_actions: {
2287+
mint_scim_credential: {
2288+
label: "資格情報を発行",
2289+
description: "SCIM 接続用のベアラー資格情報を発行します。SCIM トークンとして IdP に渡してください。一度しか表示されません。",
2290+
params: {
2291+
connectionId: {
2292+
label: "接続 ID",
2293+
helpText: "この資格情報が認証する SCIM 接続(例: 「okta-prod」)。最大 246 文字です。ローテーションするには、同じ接続に 2 つ目の資格情報を発行し、IdP をそちらに切り替えてから、古いものを失効させます。"
2294+
},
2295+
label: {
2296+
label: "ラベル",
2297+
helpText: "任意。この資格情報の名前(例: 「rotation 2026-Q3」)。"
2298+
},
2299+
organizationId: {
2300+
label: "組織",
2301+
helpText: "任意。この接続を所有する組織。空欄の場合は既定の組織になります。"
2302+
},
2303+
provisioningDomainId: {
2304+
label: "プロビジョニングドメイン",
2305+
helpText: "任意。プロビジョニングされたユーザーとグループを受け取る境界。空欄の場合は接続 ID が使われます。"
2306+
},
2307+
expiresAt: {
2308+
label: "有効期限",
2309+
helpText: "任意。この日時を過ぎると資格情報は拒否されます。空欄の場合は期限のない資格情報になります。"
2310+
}
2311+
},
2312+
resultDialog: {
2313+
title: "SCIM 資格情報を発行しました",
2314+
description: "今すぐトークンをコピーしてください。トークンは一度しか表示されず、後から取得することはできません。IdP に SCIM ベアラートークンとして貼り付けてください。漏えいした場合は失効させて新しく発行してください。",
2315+
acknowledge: "トークンを保存しました",
2316+
fields: {
2317+
token: "トークン",
2318+
connectionId: "接続 ID",
2319+
expiresAt: "有効期限"
2320+
}
2321+
}
2322+
},
2323+
revoke_scim_credential: {
2324+
label: "資格情報を失効",
2325+
confirmText: "この SCIM 資格情報を失効させますか?これを使っている IdP は次のリクエストから拒否されます。この操作は元に戻せません。プロビジョニングを再開するには新しい資格情報を発行してください。",
2326+
successMessage: "SCIM 資格情報を失効させました"
2327+
}
22852328
}
22862329
},
22872330
sys_scim_group: {

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,6 +2282,49 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
22822282
all: {
22832283
label: "全部"
22842284
}
2285+
},
2286+
_actions: {
2287+
mint_scim_credential: {
2288+
label: "签发凭据",
2289+
description: "为 SCIM 连接签发一个 Bearer 凭据。把它作为 SCIM 令牌交给 IdP;它只显示一次。",
2290+
params: {
2291+
connectionId: {
2292+
label: "连接 ID",
2293+
helpText: "该凭据认证的 SCIM 连接,例如 “okta-prod”。最多 246 个字符。如需轮换,请为同一连接再签发一个凭据,让 IdP 改用它,然后撤销旧凭据。"
2294+
},
2295+
label: {
2296+
label: "标签",
2297+
helpText: "可选。该凭据的名称,例如 “rotation 2026-Q3”。"
2298+
},
2299+
organizationId: {
2300+
label: "组织",
2301+
helpText: "可选。拥有该连接的组织。留空则为默认组织。"
2302+
},
2303+
provisioningDomainId: {
2304+
label: "预配域",
2305+
helpText: "可选。接收所预配的用户和组的边界。留空则使用连接 ID。"
2306+
},
2307+
expiresAt: {
2308+
label: "过期时间",
2309+
helpText: "可选。超过此时间后该凭据会被拒绝。留空则凭据永不过期。"
2310+
}
2311+
},
2312+
resultDialog: {
2313+
title: "SCIM 凭据已签发",
2314+
description: "请立即复制令牌——它只显示这一次,之后无法找回。请把它粘贴到 IdP 中作为 SCIM Bearer 令牌。如果泄露,请撤销它并签发新的凭据。",
2315+
acknowledge: "我已保存令牌",
2316+
fields: {
2317+
token: "令牌",
2318+
connectionId: "连接 ID",
2319+
expiresAt: "过期时间"
2320+
}
2321+
}
2322+
},
2323+
revoke_scim_credential: {
2324+
label: "撤销凭据",
2325+
confirmText: "确定要撤销此 SCIM 凭据吗?使用它的 IdP 从下一次请求起将被拒绝。此操作无法撤销:如需恢复预配,请签发新的凭据。",
2326+
successMessage: "SCIM 凭据已撤销"
2327+
}
22852328
}
22862329
},
22872330
sys_scim_group: {

‎packages/platform-objects/src/apps/translations/zh-Hant.objects.generated.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2282,6 +2282,49 @@ export const zhHantObjects: NonNullable<TranslationData['objects']> = {
22822282
all: {
22832283
label: "全部"
22842284
}
2285+
},
2286+
_actions: {
2287+
mint_scim_credential: {
2288+
label: "簽發憑證",
2289+
description: "為 SCIM 連線簽發一個 Bearer 憑證。把它作為 SCIM 權杖交給 IdP;它只顯示一次。",
2290+
params: {
2291+
connectionId: {
2292+
label: "連線 ID",
2293+
helpText: "該憑證驗證的 SCIM 連線,例如「okta-prod」。最多 246 個字元。如需輪換,請為同一連線再簽發一個憑證,讓 IdP 改用它,然後撤銷舊憑證。"
2294+
},
2295+
label: {
2296+
label: "標籤",
2297+
helpText: "選填。該憑證的名稱,例如「rotation 2026-Q3」。"
2298+
},
2299+
organizationId: {
2300+
label: "組織",
2301+
helpText: "選填。擁有該連線的組織。留空則為預設組織。"
2302+
},
2303+
provisioningDomainId: {
2304+
label: "佈建網域",
2305+
helpText: "選填。接收所佈建使用者與群組的邊界。留空則使用連線 ID。"
2306+
},
2307+
expiresAt: {
2308+
label: "到期時間",
2309+
helpText: "選填。超過此時間後該憑證會被拒絕。留空則憑證永不到期。"
2310+
}
2311+
},
2312+
resultDialog: {
2313+
title: "SCIM 憑證已簽發",
2314+
description: "請立即複製權杖——它只顯示這一次,之後無法找回。請把它貼到 IdP 中作為 SCIM Bearer 權杖。如果洩露,請撤銷它並簽發新的憑證。",
2315+
acknowledge: "我已儲存權杖",
2316+
fields: {
2317+
token: "權杖",
2318+
connectionId: "連線 ID",
2319+
expiresAt: "到期時間"
2320+
}
2321+
}
2322+
},
2323+
revoke_scim_credential: {
2324+
label: "撤銷憑證",
2325+
confirmText: "確定要撤銷此 SCIM 憑證嗎?使用它的 IdP 從下一次請求起將被拒絕。此操作無法復原:如需恢復佈建,請簽發新的憑證。",
2326+
successMessage: "SCIM 憑證已撤銷"
2327+
}
22852328
}
22862329
},
22872330
sys_scim_group: {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The console's two SCIM credential actions — mint and revoke — wired to
5+
* plugin-auth's platform-admin mounts, and the AI exposure each one declares.
6+
*
7+
* What the console cannot be allowed to drift from is the mount's contract:
8+
* the route and method, the body keys the mint reads (`connectionId`, `label`,
9+
* `organizationId`, `provisioningDomainId`, `expiresAt`) and the one the revoke
10+
* reads (`credentialId`). A param renamed to the row's column spelling would
11+
* reach the mount as an unknown key and be answered 400 ("connectionId is
12+
* required"), so the names are pinned against the mount's own spelling.
13+
*
14+
* The neighbouring pins this file does not repeat: the one-shot reveal of the
15+
* minted token is held by `two-factor-one-shot-reveal.test.ts` (its route
16+
* table names the mint), and the platform-admin standing plus the declared
17+
* gate by `platform-admin-affordance-standing.test.ts` (its family floor names
18+
* both actions).
19+
*/
20+
21+
import { describe, expect, it } from 'vitest';
22+
import { celEngine } from '@objectstack/formula';
23+
import type { Action } from '@objectstack/spec/ui';
24+
import { SysScimConnectionCredential } from './sys-scim-connection-credential.object.js';
25+
26+
const MINT_ROUTE = '/api/v1/auth/admin/scim/mint-credential';
27+
const REVOKE_ROUTE = '/api/v1/auth/admin/scim/revoke-credential';
28+
29+
function action(name: string): Action {
30+
const found = ((SysScimConnectionCredential.actions ?? []) as Action[]).find((a) => a.name === name);
31+
expect(found, `sys_scim_connection_credential declares no action '${name}'`).toBeDefined();
32+
return found!;
33+
}
34+
35+
/** Every action on this object whose target is one of the two mounts. */
36+
function actionsOnTheMounts(): Action[] {
37+
return ((SysScimConnectionCredential.actions ?? []) as Action[]).filter(
38+
(a) => a.target === MINT_ROUTE || a.target === REVOKE_ROUTE,
39+
);
40+
}
41+
42+
function offered(source: string, record: Record<string, unknown>): string {
43+
const admin = { id: 'u_pa', isPlatformAdmin: true, positions: [] };
44+
const r = celEngine.evaluate(
45+
{ dialect: 'cel', source },
46+
{ record, extra: { current_user: admin, user: admin, ctx: { user: admin }, os: { user: admin }, features: {} } },
47+
);
48+
return r.ok ? String(r.value) : `FAULT ${r.error.message.split('\n')[0]}`;
49+
}
50+
51+
describe('sys_scim_connection_credential: mint and revoke reach the platform-admin mounts', () => {
52+
it('mint POSTs the mint mount from the list toolbar, with the body keys the mount reads', () => {
53+
const mint = action('mint_scim_credential');
54+
expect(mint).toMatchObject({ type: 'api', method: 'POST', target: MINT_ROUTE, locations: ['list_toolbar'] });
55+
const params = (mint.params ?? []) as Array<{ name?: string; required?: boolean; type?: string; reference?: string }>;
56+
expect(params.map((p) => p.name)).toEqual(['connectionId', 'label', 'organizationId', 'provisioningDomainId', 'expiresAt']);
57+
// The mount refuses a mint with no connection id; nothing else is required.
58+
expect(params.filter((p) => p.required).map((p) => p.name)).toEqual(['connectionId']);
59+
// The organization is picked, not typed: the mount refuses an id that names no organization.
60+
expect(params.find((p) => p.name === 'organizationId')).toMatchObject({ type: 'lookup', reference: 'sys_organization' });
61+
// The mint answers `{ success, data: { token, ... } }`; the reveal reads the inner payload.
62+
expect(mint.resultDialog?.fields?.find((f) => f.format === 'secret')?.path).toBe('token');
63+
});
64+
65+
it('revoke POSTs the revoke mount from a credential row, sending the row id as credentialId', () => {
66+
const revoke = action('revoke_scim_credential');
67+
expect(revoke).toMatchObject({
68+
type: 'api',
69+
method: 'POST',
70+
target: REVOKE_ROUTE,
71+
recordIdParam: 'credentialId',
72+
locations: ['list_item', 'record_header'],
73+
});
74+
// No param dialog: the row is the whole input, so the confirmation is the one dialog.
75+
expect(revoke.params ?? []).toEqual([]);
76+
expect(revoke.confirmText).toBeTruthy();
77+
});
78+
79+
it('revoke is offered on an active credential, not on a revoked one, and never faults on a row without `active`', () => {
80+
const visible = action('revoke_scim_credential').visible as unknown;
81+
// The served predicate: the authored string, or the parsed `{ source }` form.
82+
const source = typeof visible === 'string' ? visible : String((visible as { source?: unknown })?.source);
83+
expect(offered(source, { active: true })).toBe('true');
84+
expect(offered(source, { active: false })).toBe('false');
85+
expect(offered(source, {})).toBe('false');
86+
expect(offered(source, { active: null })).toBe('false');
87+
});
88+
89+
it('the instrument is live: both mounts have exactly one action each on this object', () => {
90+
// Positive control for the exposure pins below: a renamed target would
91+
// leave them judging nothing.
92+
expect(actionsOnTheMounts().map((a) => a.target).sort()).toEqual([MINT_ROUTE, REVOKE_ROUTE]);
93+
});
94+
});
95+
96+
describe('sys_scim_connection_credential: AI exposure (ADR-0011 identity-actions note)', () => {
97+
it('mint is NOT exposed to AI tools: an agent that ran it would hold a live SCIM bearer', () => {
98+
// Bucket H. Exposing it is a security boundary and needs a maintainer
99+
// ruling that names this action, so any `ai.exposed` here is refused.
100+
const mint = action('mint_scim_credential');
101+
expect(mint.ai?.exposed ?? false, 'mint_scim_credential declares ai.exposed').toBe(false);
102+
});
103+
104+
it('revoke is exposed with its confirmation: bucket C, and the confirmation is never relaxed', () => {
105+
const revoke = action('revoke_scim_credential');
106+
expect(revoke.ai).toMatchObject({ exposed: true, requiresConfirmation: true });
107+
expect(revoke.ai?.description?.length ?? 0).toBeGreaterThan(0);
108+
});
109+
});

0 commit comments

Comments
 (0)