Skip to content

Commit 41dc302

Browse files
feat(plugin-auth): a platform administrator can mint and revoke SCIM connection credentials (#30991)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DJjeMCH9mdodCQWD68YLVEE)_ domain:server#3 · os-elon-musk · session_016WV4cycHNJyS5MXHqaGsFE Fixes #30958 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/authentication.mdx` ("SCIM Connection Credentials" under Admin User Management); `skills/**` teaches no SCIM setup, so no skill changes ## 维护者速读 - **改了什么**:plugin-auth 新增两个仅平台管理员可用的端点 `POST /api/v1/auth/admin/scim/mint-credential` 和 `POST /api/v1/auth/admin/scim/revoke-credential`。前者为一个 SCIM 连接签发 bearer,明文只在这一次响应里出现(`Cache-Control: no-store`),库里只存带密钥的单向摘要;后者把凭证置为失效,下一次 SCIM 请求即 401。轮换 = 同一连接再签一张、IdP 换上新的、再吊销旧的。每次签发和吊销各写一行 `sys_audit_log`,记管理员和连接,不记令牌也不记摘要。 - **为什么改**:`mintScimConnectionCredential` 写好了却没有调用方,`sys_scim_connection_credential` 在数据 API 上又拒绝创建,所以任何 IdP 都拿不到 bearer,SCIM 实际上接不通。 - **风险与回滚**:纯新增两条路由,不改现有行为;门禁复用 `/admin/create-user` 同一个 `judgePlatformAdmin`(匿名 401、非管理员 403、挂起会话 403)。SCIM 未开启时签发答 503。回滚即 revert。 - **先看**:① `packages/plugins/plugin-auth/src/scim-connection-admin.ts`(两个处理函数、组织归属、审计行);② `auth-plugin.ts` 里新挂的两条路由,`gateAdmin` 在读 body 之前;③ `scim-connection-service.ts` 末尾追加的 `revokeScimConnectionCredential`。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run src/scim-connection-admin.test.ts`,预期 9 个测试全绿(签发后 SCIM `POST /Users` 201、非管理员 403、吊销后 401、令牌和摘要不出现在审计行和日志里)。 - **需要你做的**:无,席位做合约审查并落地。只有一点请留意:签发权限只给平台管理员,若要放给组织管理员,需要你另行裁决,本 PR 不做。 ## Why ObjectStack owns the SCIM credential lifecycle: the stable `@better-auth/scim` plugin keeps no bearer of its own, and `verifyScimBearerToken` resolves every connection from a `sys_scim_connection_credential` row. The mint (`mintScimConnectionCredential`) existed with no caller, and the object refuses create on the data API (`enable.apiMethods: ['list']`, engine-owned), so no identity provider could be handed a bearer. Door choice (route, platform admin only) recorded on the card before building: comment 6687604379. ## What changes 1. **Two raw auth mounts** in `auth-plugin.ts`, beside the other `/admin/*` mounts: `POST ${basePath}/admin/scim/mint-credential` and `POST ${basePath}/admin/scim/revoke-credential`. Each runs the shared `gateAdmin` (`judgePlatformAdmin`, ADR-0068 D2 + ADR-0069 D3 held-session) before reading the body, then hands off to `scim-connection-admin.ts`. Both rows are in `auth-route-ledger.ts` (mint `requires: 'scim'`) and in the conformance test's hand-written objectstack set. 2. **Mint** (`runMintScimCredential`): body `{ connectionId, label?, provisioningDomainId?, organizationId?, expiresAt? }`. `connectionId` is required, at most 246 characters (a SCIM connection's own writes are audited as `svc:scim:<connectionId>` (#30960) in `sys_audit_log.actor`, which holds 255), no surrounding whitespace; `expiresAt` must be an ISO instant in the future; anything else is 400 `INVALID_REQUEST`. SCIM not wired is 503 `SERVICE_UNAVAILABLE`. The row is written under `withSystemContext(engine)` inside `runAttributedToUser(admin)`, with the row's `user_id` = the admin (the service's `mintedByUserId` input). The response is 200 `{ success: true, data: { credentialId, connectionId, token, expiresAt } }` with `Cache-Control: no-store`; it is the only place the plaintext appears. A storage failure answers 500 without the driver's cause text, because a driver error can echo the inserted row and that row carries the digest. 3. **Organization ownership.** A system insert must carry an organization (ADR-0131 D9). A named `organizationId` must exist (else 400); with none named, the Default Organization (`findDefaultOrganization`); if that cannot be told apart, 400 asking for `organizationId`. This decides only which organization owns the credential row. 4. **Revoke** (`runRevokeScimCredential`): body `{ credentialId }`. It calls the new `revokeScimConnectionCredential` (appended to `scim-connection-service.ts`, nothing above it edited), which sets `active: false`; `verifyScimBearerToken` already refuses inactive rows, so the bearer answers 401 at the next SCIM request. Unknown id 404 `RESOURCE_NOT_FOUND`, already revoked 409 `RESOURCE_CONFLICT`, missing id 400. Revoke does not require SCIM to be on, so a credential can still be switched off after SCIM is disabled. The row stays (its digest is one-way and the audit trail points at it). 5. **Audit.** Each mint and each revoke writes one `sys_audit_log` row directly (as `/admin/create-user` does): admin as `user_id` / `actor`, `object_name` `sys_scim_connection_credential`, `record_id` the credential, `metadata` `{ event: 'scim_credential_minted' | 'scim_credential_revoked', connectionId }` (a closed type with no field for a secret). A refused audit write does not undo the operation; it warns once, naming no secret. With plugin-audit absent there is no `sys_audit_log` object and the write is skipped. 6. **Docs.** `content/docs/permissions/authentication.mdx` gets "SCIM Connection Credentials": the curl call, the once-shown token, the 503, revoke and its 404 / 409, rotation, the audit row. ## Tests - `packages/plugins/plugin-auth/src/scim-connection-admin.test.ts` (9 tests): a real ObjectQL engine on the SQL driver (sqlite), the real `AuthManager` with SCIM and organizations on, the real `registerAuthRoutes` on Hono. Admin mints, then the bearer creates a user through SCIM `POST /Users` (201); the row's owner, `user_id` (the admin), label and `active`; one audit row. Rotation: a second mint for the same connection, revoke the first, old bearer 401, new bearer 201, second revoke 409, two audit rows. Non-admin 403 and anonymous 401 on both mounts, with nothing written. Malformed bodies 400 (including a past or unparseable expiry); a 246-character connection id mints and a 247-character one is refused 400 with nothing stored; a future expiry stored and answered, SCIM off 503, unknown 404, missing 400. Every case runs `expectSecretNowhereElse`: the token and its digest are absent from every `sys_audit_log` row and every captured console line, the at-rest row (driver read) holds the digest and never the token, and an engine read under system context does not serve the digest. - `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts` (new case W5): on the composed runtime with plugin-audit and SCIM on, the admin mints over HTTP (`no-store` asserted), the bearer creates a SCIM user (201), revoke answers 200 and the bearer then 401; exactly two door audit rows (minted, revoked) naming the admin and the connection; the token and the digest appear in no `sys_audit_log` row, no `sys_activity` row, and not in `GET /data/sys_scim_connection_credential` (200, lists the credential). The `/export` door answers 403 `EXPORT_NOT_PERMITTED` even to the platform admin (measured: no permission set grants `allowExport` on this engine-owned object); the pin asserts that refusal and that its body carries neither value, so a future export grant has to come back to this test. - `packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts`: both mounts join the derived `/admin/` sweep in the objectstack-gate bucket (non-admin 403, admin 200 / 404). ## Acceptance notes - **Out of scope: a Setup button.** No UI calls the door yet; the seat files that follow-up card after landing. Nothing under `ui/` is touched. - **Who may mint.** Platform administrator only, as the card and the dispatch require. Letting an organization administrator mint for their own organization would be a maintainer decision and is not proposed here. - **Two kinds of audit row.** With plugin-audit installed, its generic mirror also records the credential row's create and update as ordinary data-change rows; plugin-audit omits `internal` fields, so `token_digest` is not in them (the dogfood case asserts the token and digest are absent from every `sys_audit_log` and `sys_activity` row). The door's own row is the one that names the event and the connection. - **Tenant-audit census.** The two new engine writes (the door's audit insert, the revoke update) are new application-surface write call sites, so `check-tenant-audit-census` asked for its artefacts to be regenerated: `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` (`node scripts/tenant-audit-census.mjs --write`), plus the page's hand-written figures (261 → 263 sites, 184 → 186 decidable, 144 → 146 elevated, 68 → 69 const-named). Both writes spell `{ context: { isSystem: true } }` at the call, so the census reads them as decidably elevated, and the "provably no tenant context" figure stays 2. These two files are outside the card's file surface; they change only because the gate requires it. - **#30960 (SCIM audit attribution, server seat 5)** may also edit `scim-connection-service.ts`; this PR only appends below the existing code there. ## Verification **Delta `306a19fc8e`** (test-only; after the merge queue ejected `677f474461`): the queue's full dogfood shard 3 failed in `admin-platform-admin-standing.dogfood.test.ts`, whose derived `/admin/` sweep requires every served route to be classified; the two new mounts were not (summary issue #31012, which this delta fixes; the same failure ejected #31002 from that speculative stack). `79a87832` adds both to `ADMITTED` with probe bodies (SCIM is on at that boot, so the mint answers 200; the revoke of an unknown id answers 404), and `306a19fc` merges `origin/main` `8ae0ab9b`. At `306a19fc8e`: that file plus `admin-identity-audit-trail`, `admin-route-nonadmin-refusal`, `admin-ledger-decision-metadata`, `route-ledger-live-mount-parity` and `discovery-auth-families`: `Test Files 6 passed (6)`, `Tests 45 passed (45)`. Ablation (the two `ADMITTED` lines removed): exit 1, the unclassified assertion names exactly the two SCIM routes, restored clean. **Delta `677f474461`** (after the seat's review started on `6c7e436129`): the connection-id cap of 246 above, its pin, and one line each in the docs and the changeset. At `677f474461`: `vitest run src/scim-connection-admin.test.ts` `Tests 9 passed (9)`; plugin-auth `tsc --noEmit` exit 0; ablation (cap back to 255 via `ablation-replace.mjs`): exit 1, 2 red (the malformed-request case and the new 246/247 pin, both `expected 200 to be 400`), restored clean. Re-run at `677f474461`, each exit 0: `check:doc-anchors`, `check:doc-authoring`, `check:doc-frontmatter`, `check:docs-single-h1`, `check-doc-route-spelling --advisory`, `check-docs-section-name`, `docs-audit/check-affected-docs`, `check:doc-security-posture`, `check-empty-changeset`, `check-changeset-no-major`, `check-tenant-audit-census`, `check:nul-bytes`, `check:issue-citations`, `check-issue-citations`, `check:keyed-text-bounds`, `check:error-status-conformance`. The red "TypeScript Type Check" rollup on `6c7e436129` was its `typecheck-consumers` lane concluding `cancelled` (that run was cancelled by this push), not a type error; the other three lanes were green. Tests and gates at `fc4a6a7801` (`origin/main` `1aeeafd9` merged, which brought #30879's plugin-auth change). The final head `6c7e436129` merges `origin/main` `000a4c0d` (#30927, #30938, #30952, #30956, #30961, #30969), none of which touches a file in this diff; at `6c7e436129` `check-tenant-audit-census`, `check:auth-mount-ledger` and `check-empty-changeset --base origin/main` were re-run, each exit 0. #30960 had not landed when this was opened. - `pnpm --filter @objectstack/plugin-auth typecheck` (tsc, examples, `check:test-typecheck`): exit 0. `vitest run`: `Test Files 230 passed (230)`, `Tests 4155 passed | 10 skipped (4165)`. - Dogfood, after building the closure: `vitest run test/admin-identity-audit-trail.dogfood.test.ts test/admin-route-nonadmin-refusal.dogfood.test.ts`: `Test Files 2 passed (2)`, `Tests 11 passed (11)`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (exit 0) derived 96 commands; all 96 ran, every one exit 0. Two needed a prerequisite first: `check-plugin-teardown-shape --self-test` exited 3 until its pinned fixture commit was fetched, and `check:skill-examples` / `check:dual-build-cjs-loads` exited 3 until the packages they read were built. `check-tenant-audit-census` first exited 1 on this diff's own two write sites; fixed in `c8e10b4f` (see Acceptance notes). `dispatch-gates.mjs --ran`: exit 0, "96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED". <details><summary>Ablations: seven legs, each red for its own reason, each restored clean</summary> At `6c7e436129`, the final head. Each leg through `node scripts/ablation-replace.mjs` (anchor must drop by exactly one, restore verified against the HEAD blob), running `vitest run src/scim-connection-admin.test.ts`. Every leg: exit 1, then `git diff HEAD` empty; the run ended RESTORED-CLEAN. | leg | mutation | red tests | a failing assertion | | :--- | :--- | :--- | :--- | | gate | the mint mount skips `gateAdmin` and takes any caller | mint→201; non-admin/anon refusal | `expected 200 to be 403` (a non-admin got a bearer) | | revoke-write | the revoke never writes `active: false` | revoke/rotation; unknown/missing/SCIM-off | `expected 201 to be 401` (revoked bearer still provisions) | | audit-row | the mint writes no audit row | mint; refusal; revoke/rotation | `expected [] to have a length of 1` | | audit-secret | the mint's audit metadata carries the bearer | mint; revoke/rotation | audit rows `not to contain 'oss_scim_…'` | | log-secret | the mint logs the bearer at `warn` | mint; revoke/rotation | log text `not to contain 'oss_scim_…'` | | scim-off | the 503 refusal while SCIM is off is removed | SCIM-off | `expected 200 to be 503` | | secret-binding | the mint digests with a key the verifier does not use | mint; refusal; revoke/rotation | SCIM `POST /Users` `expected 401 to be 201` | </details> <details><summary>96 gates, exit codes at fc4a6a7</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:auth-mount-ledger :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE)_ <!-- signed: domain:server#3 · os-elon-musk · session_016WV4cycHNJyS5MXHqaGsFE --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d9f1e9 commit 41dc302

14 files changed

Lines changed: 1117 additions & 30 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
feat(plugin-auth): a platform administrator can mint and revoke SCIM connection credentials
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
Skills-docs: updated here — `content/docs/permissions/authentication.mdx` ("SCIM Connection Credentials" under Admin User Management); `skills/**` teaches no SCIM setup
10+
11+
An identity provider (Okta, Entra ID) provisions users through `/api/v1/auth/scim/v2/*` with a bearer credential that this platform issues, but nothing could issue one: the mint existed with no caller, and the credential object refuses create on the data API, so no identity provider could connect. Two routes now do it, for the platform administrator only (the shared ADR-0068 gate: anyone else is refused `403 PERMISSION_DENIED`, `401 UNAUTHENTICATED` when signed out):
12+
13+
- `POST /api/v1/auth/admin/scim/mint-credential` with `{ connectionId, label?, expiresAt?, provisioningDomainId?, organizationId? }` answers `{ credentialId, connectionId, token, expiresAt }`. `connectionId` is at most 246 characters, so the connection's audit actor `svc:scim:<connectionId>` fits `sys_audit_log.actor`. The `token` is shown once, in that response only (`Cache-Control: no-store`); only its keyed one-way digest is stored, and it is not readable through the data API, the audit log or the logs. Refused `503 SERVICE_UNAVAILABLE` while SCIM is off. The credential row is owned by `organizationId` when given (it must exist), otherwise by the Default Organization.
14+
- `POST /api/v1/auth/admin/scim/revoke-credential` with `{ credentialId }` switches the credential off, so the identity provider's next SCIM request with it answers `401`. `404 RESOURCE_NOT_FOUND` for an unknown credential, `409 RESOURCE_CONFLICT` for one already revoked. Served whether or not SCIM is on.
15+
16+
Rotation is a second mint for the same `connectionId`, moving the identity provider to the new bearer, then revoking the old one. Each mint and each revoke writes one `sys_audit_log` row naming the administrator and the connection, never the credential. Nothing that existed changes: no route, response or stored row moves, and the credential object stays read-only on the data API.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,43 @@ rows that resolve to a temporary password (all of them under `temporary`, only
11661166
the unreachable ones under `auto`) — reveals the generated passwords once with a
11671167
download, splitting files above 500 rows into batches automatically.
11681168

1169+
#### SCIM Connection Credentials
1170+
1171+
With SCIM enabled (`OS_SCIM_ENABLED=true`, or `plugins.scim`), an identity
1172+
provider such as Okta or Entra ID provisions users through
1173+
`/api/v1/auth/scim/v2/*` with a bearer credential the platform issues. Only a
1174+
platform administrator mints or revokes one, through the same gate as the
1175+
routes above; anyone else is refused `403 PERMISSION_DENIED` (`401` when
1176+
signed out).
1177+
1178+
```bash
1179+
curl -X POST http://localhost:3000/api/v1/auth/admin/scim/mint-credential \
1180+
-H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_SESSION" \
1181+
-d '{ "connectionId": "okta-prod", "label": "rotation 2026-Q4" }'
1182+
# → { success, data: { credentialId, connectionId, token, expiresAt } }
1183+
```
1184+
1185+
- `connectionId` (required) names the connection every provisioned resource
1186+
is scoped to: at most 246 characters, so the connection's audit actor
1187+
`svc:scim:<connectionId>` fits the audit log's 255. Optional: `label`, `expiresAt` (a future ISO date-time),
1188+
`provisioningDomainId`, and `organizationId` (the organization that owns the
1189+
credential row; the Default Organization when omitted).
1190+
- The `token` is shown **once**, in this response only (`Cache-Control:
1191+
no-store`). Only a keyed one-way digest of it is stored; it is not readable
1192+
through the data API, the audit log or the logs afterwards. Paste it into the
1193+
identity provider's SCIM settings as the bearer token.
1194+
- While SCIM is off the mint is refused `503 SERVICE_UNAVAILABLE`: a credential
1195+
nothing can verify is not issued.
1196+
1197+
`POST /api/v1/auth/admin/scim/revoke-credential` with `{ "credentialId": "…" }`
1198+
switches that credential off: the identity provider's next SCIM request with it
1199+
is refused `401`. An unknown credential answers `404 RESOURCE_NOT_FOUND`, one
1200+
already revoked `409 RESOURCE_CONFLICT`. Revocation works whether or not SCIM is
1201+
on. To **rotate**, mint a second credential for the same `connectionId`, move
1202+
the identity provider to it, then revoke the old one; both authenticate until
1203+
you do. Each mint and each revoke writes one `sys_audit_log` row naming the
1204+
administrator and the connection, never the credential.
1205+
11691206
---
11701207

11711208
## Client Integration

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A
135+
forwarding shim cannot, and **55 of the 263 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **261** |
200+
| 175 write call sites | quoted in the merged changeset | **263** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **184 of 261** decidable, **77** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 144 decidably elevated, 0 decidably not, 109 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **186 of 263** decidable, **77** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 109 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -211,17 +211,17 @@ at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213213
one, and both are counted in the generated tables below: the 62 sites reached
214-
through an erased (`any`) receiver, and the 68 that name their object through a
214+
through an erased (`any`) receiver, and the 69 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
144 of 261 (55%) as decidably elevated, with 109 more whose elevation is a
220+
146 of 263 (56%) as decidably elevated, with 109 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 263`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,29 +233,29 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **261** |
237-
| …whose object name is statically decidable | 184 |
236+
| write call sites on the application surface | **263** |
237+
| …whose object name is statically decidable | 186 |
238238
| …whose object name is chosen at run time | 77 |
239-
| …against an object with tenancy ENABLED | 178 |
239+
| …against an object with tenancy ENABLED | 180 |
240240
| …against an object that declares tenancy off | 6 |
241-
| threading a tenant context | 198 |
241+
| threading a tenant context | 200 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 55 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 144 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 146 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 109 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 199 |
252+
| receiver carried a readable engine type | 201 |
253253
| receiver erased, placed by the object NAME | 52 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 116 |
258-
| object name spelled through a `const` | 68 |
257+
| object name spelled inline | 117 |
258+
| object name spelled through a `const` | 69 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 70 |
261261

@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-05 at `e40e04854`.
310+
Measured on 2026-11-06 at `ac688f409`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 871 |
315-
| engine-shaped types recognised | 105 |
316-
| declared objects in the registry | 127 |
317-
| same-named calls subtracted as non-engine | 229 |
314+
| tracked non-test sources scanned | 1037 |
315+
| engine-shaped types recognised | 111 |
316+
| declared objects in the registry | 130 |
317+
| same-named calls subtracted as non-engine | 230 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 261 |
37-
| Object name statically decidable | 184 |
36+
| Write call sites | 263 |
37+
| Object name statically decidable | 186 |
3838
| Object name chosen at run time | 77 |
39-
| Against a tenancy-enabled object | 178 |
39+
| Against a tenancy-enabled object | 180 |
4040
| Against an object declaring tenancy off | 6 |
41-
| Threading a tenant context | 198 |
41+
| Threading a tenant context | 200 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 55 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 144 |
46+
| Threading a decidably elevated context | 146 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 109 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-11-05 at `e40e04854`.
93+
Measured on 2026-11-06 at `ac688f409`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 871 |
98-
| engine-shaped types recognised | 105 |
99-
| declared objects in the registry | 127 |
100-
| same-named calls subtracted as non-engine | 229 |
97+
| tracked non-test sources scanned | 1037 |
98+
| engine-shaped types recognised | 111 |
99+
| declared objects in the registry | 130 |
100+
| same-named calls subtracted as non-engine | 230 |
101101

102102
## Every site
103103

@@ -166,7 +166,9 @@ Measured on 2026-11-05 at `e40e04854`.
166166
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team_member` | enabled | elevated | 1 |
167167
| `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_user_permission_set` | enabled | elevated | 1 |
168168
| `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 |
169+
| `packages/plugins/plugin-auth/src/scim-connection-admin.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
169170
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
171+
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `update` | `sys_scim_connection_credential` | enabled | elevated | 1 |
170172
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
171173
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
172174
| `packages/plugins/plugin-auth/src/sys-session-revoke.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 |

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-i
218218
import { buildPluginList, type PluginListHost } from './auth-manager-plugin-list.js';
219219
import { resolveTurnstileKeys } from './turnstile-sign-up.js';
220220
import { assertTrustedAppSchemes, registeredAppSchemesOf } from './one-time-token-handoff.js';
221+
import type { MintScimConnectionCredentialInput, MintedScimConnectionCredential } from './scim-connection-service.js';
221222

222223
/**
223224
* Detect WebContainer (StackBlitz) environment.
@@ -6194,6 +6195,32 @@ export class AuthManager {
61946195
return this.config.trustProxy === true;
61956196
}
61966197

6198+
/**
6199+
* [#30958] Is the SCIM 2.0 service provider mounted on this deployment? The
6200+
* same decision `buildPluginList` mounts `@better-auth/scim` on
6201+
* ({@link resolveScimEnabled}), so the credential door's "SCIM is off"
6202+
* refusal can never disagree with the `/scim/v2` routes actually served.
6203+
*/
6204+
public isScimWired(): boolean {
6205+
return resolveScimEnabled(this.config.plugins);
6206+
}
6207+
6208+
/**
6209+
* [#30958] Mint a SCIM connection bearer with this deployment's auth secret,
6210+
* the key the mounted SCIM plugin's verifier digests with
6211+
* (`buildPluginList`), so the bearer is one that plugin accepts. The secret
6212+
* never leaves the manager. The only caller is the platform-admin
6213+
* `/admin/scim/mint-credential` mount (`scim-connection-admin.ts`), which
6214+
* hands the engine to write through.
6215+
*/
6216+
public async mintScimConnectionCredential(
6217+
engine: IDataEngine,
6218+
input: MintScimConnectionCredentialInput,
6219+
): Promise<MintedScimConnectionCredential> {
6220+
const { mintScimConnectionCredential } = await import('./scim-connection-service.js');
6221+
return mintScimConnectionCredential(engine as never, this.resolveAuthSecret(), input);
6222+
}
6223+
61976224
/**
61986225
* Returns the data engine wired into this auth manager. Used by route
61996226
* handlers (e.g. bootstrap-status) that need to query identity tables

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3874,6 +3874,51 @@ export class AuthPlugin implements Plugin {
38743874
}
38753875
});
38763876

3877+
// ────────────────────────────────────────────────────────────────────
3878+
// [#30958] ADR-0134 — admin: mint and revoke the bearer credentials SCIM
3879+
// connections authenticate with. The SCIM plugin verifies against
3880+
// `sys_scim_connection_credential` rows, which the data API never creates
3881+
// or updates (engine-owned, list only), so these two mounts are the
3882+
// credential lifecycle's only door. Same family as `set-user-manager`: the
3883+
// shared platform-admin gate runs first, ahead of any body read, and the
3884+
// store writes are credited to the admin. The minted bearer is answered
3885+
// once, `no-store`, and kept nowhere; see `scim-connection-admin.ts`.
3886+
{
3887+
const scimCredentialDeps = (actor: PlatformAdminActor): import('./scim-connection-admin.js').ScimCredentialAdminDeps => ({
3888+
getDataEngine: () => this.authManager!.getDataEngine(),
3889+
isScimEnabled: () => this.authManager!.isScimWired(),
3890+
mint: (engine, input) => this.authManager!.mintScimConnectionCredential(engine, input),
3891+
attributed: (fn) => runAttributedToUser(actor, fn),
3892+
logger: { warn: (m) => ctx.logger.warn(m), error: (m) => ctx.logger.error(m) },
3893+
});
3894+
rawApp.post(`${basePath}/admin/scim/mint-credential`, async (c: any) => {
3895+
try {
3896+
const actor = await gateAdmin(c);
3897+
if (actor instanceof Response) return actor;
3898+
const { runMintScimCredential } = await import('./scim-connection-admin.js');
3899+
const { status, body, headers } = await runMintScimCredential(scimCredentialDeps(actor), actor, c.req.raw);
3900+
return c.json(body, status as any, headers);
3901+
} catch (error) {
3902+
const err = error instanceof Error ? error : new Error(String(error));
3903+
ctx.logger.error('[AuthPlugin] admin/scim/mint-credential failed', err);
3904+
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500);
3905+
}
3906+
});
3907+
rawApp.post(`${basePath}/admin/scim/revoke-credential`, async (c: any) => {
3908+
try {
3909+
const actor = await gateAdmin(c);
3910+
if (actor instanceof Response) return actor;
3911+
const { runRevokeScimCredential } = await import('./scim-connection-admin.js');
3912+
const { status, body } = await runRevokeScimCredential(scimCredentialDeps(actor), actor, c.req.raw);
3913+
return c.json(body, status as any);
3914+
} catch (error) {
3915+
const err = error instanceof Error ? error : new Error(String(error));
3916+
ctx.logger.error('[AuthPlugin] admin/scim/revoke-credential failed', err);
3917+
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500);
3918+
}
3919+
});
3920+
}
3921+
38773922
// ────────────────────────────────────────────────────────────────────
38783923
// #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed
38793924
// (managedBy better-auth), and until now the only add-a-teammate path was

‎packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,13 @@ describe('auth route ledger hygiene', () => {
246246
// below holds it to the same proof — better-auth serves no roster.
247247
'POST /api/v1/auth/admin/list-platform-admins',
248248
'POST /api/v1/auth/admin/oauth2/toggle-disabled',
249+
// [#30958] On this pin's own terms: `auth-plugin.ts` mounts both itself
250+
// (`rawApp.post` on the `${basePath}/admin/scim/mint-credential` and
251+
// `${basePath}/admin/scim/revoke-credential` templates, ahead of the
252+
// catch-all), and the `live.has(route)` loop below holds them to the
253+
// same proof — stable `@better-auth/scim` serves no credential endpoint.
254+
'POST /api/v1/auth/admin/scim/mint-credential',
255+
'POST /api/v1/auth/admin/scim/revoke-credential',
249256
// [#16678] The thirteenth, added on this pin's own terms exactly as the
250257
// set-initial-password row below was: `auth-plugin.ts` mounts it itself
251258
// (a `rawApp.post` on the `${basePath}/admin/set-user-manager`

0 commit comments

Comments
 (0)