Repository navigation
feat(plugin-auth): a platform administrator can mint and revoke SCIM connection credentials - #30991
Conversation
…ion credentials Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
…ed runtime Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
…ntime answers Measured on the composed runtime: the export door answers 403 EXPORT_NOT_PERMITTED for the platform admin on sys_scim_connection_credential, so the pin asserts that refusal and that its body names neither the bearer nor its digest. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
…xt, and the census counts them The door's audit insert and the revoke update now pass their system context as a readable literal, so the tenant-audit census reads both as decidably elevated instead of unreadable or context-free. The census artefacts are regenerated for the two new write call sites (261 -> 263) and the page's hand-written figures follow. The test file's log reader gets the parameter type tsconfig.test.json asks for. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 57 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 17 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7de2dab0a943d3447d6b66080497407647ed69e3 && git checkout 7de2dab0a943d3447d6b66080497407647ed69e3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 69e133ea0d9734dd0447d08875bbd8e3af0e1517 306a19fc8e6fdd5c809c4641d2a885d7c73182f3 && git checkout -B drift-repro 69e133ea0d9734dd0447d08875bbd8e3af0e1517 && git merge --no-ff 306a19fc8e6fdd5c809c4641d2a885d7c73182f3
node scripts/docs-audit/affected-docs.mjs --json 69e133ea0d9734dd0447d08875bbd8e3af0e1517
|
…ving room for its audit actor A SCIM connection's own writes are audited as `svc:scim:<connectionId>` (#30960) in sys_audit_log.actor, which holds 255 characters. The mint door now refuses a longer id with the existing 400 INVALID_REQUEST, so a connection can never be minted whose every audit insert would fail. Pin: 246 characters mint, 247 are refused and nothing is stored. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 Contract reviewServed-tier: Inputs read: card #30958 body and its four comments (triage 6687301683, seat claim 6687520822, door choice 6687604379, os-dev-report 6689313446); the dispatch order ① Derived judgments
② Semver level
Check-runs on ③ Boundary flags
④ Design judgment
Findings (none must-fix):
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 ACCEPT · #30958 · 2026-11-06T16:35Z · Head-sha: 席位核对
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42254111633 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…redential mounts The full dogfood suite's platform-admin standing sweep derives every served /admin/ route and requires each to be classified. The SCIM mint and revoke mounts carry the ADR-0068 gate first, so they join ADMITTED with probe payloads that reach the handler: SCIM is on at this boot, so the mint answers 200, and the revoke of an unknown id answers 404. Claude-Session: https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE Co-Authored-By: Claude <noreply@anthropic.com>
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42257486876 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5 ACCEPT addendum · #30958 · 2026-11-06T17:46Z · Head-sha: The ACCEPT 6689999407 on
|
Requested by Elon · project thread
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维护者速读
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 个测试全绿(签发后 SCIMPOST /Users201、非管理员 403、吊销后 401、令牌和摘要不出现在审计行和日志里)。Why
ObjectStack owns the SCIM credential lifecycle: the stable
@better-auth/scimplugin keeps no bearer of its own, andverifyScimBearerTokenresolves every connection from asys_scim_connection_credentialrow. 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
auth-plugin.ts, beside the other/admin/*mounts:POST ${basePath}/admin/scim/mint-credentialandPOST ${basePath}/admin/scim/revoke-credential. Each runs the sharedgateAdmin(judgePlatformAdmin, ADR-0068 D2 + ADR-0069 D3 held-session) before reading the body, then hands off toscim-connection-admin.ts. Both rows are inauth-route-ledger.ts(mintrequires: 'scim') and in the conformance test's hand-written objectstack set.runMintScimCredential): body{ connectionId, label?, provisioningDomainId?, organizationId?, expiresAt? }.connectionIdis required, at most 246 characters (a SCIM connection's own writes are audited assvc:scim:<connectionId>(plugin-auth: SCIM-driven writes leave sys_audit_log rows with actor, user_id and metadata null, so no audit row names the SCIM connection that acted #30960) insys_audit_log.actor, which holds 255), no surrounding whitespace;expiresAtmust be an ISO instant in the future; anything else is 400INVALID_REQUEST. SCIM not wired is 503SERVICE_UNAVAILABLE. The row is written underwithSystemContext(engine)insiderunAttributedToUser(admin), with the row'suser_id= the admin (the service'smintedByUserIdinput). The response is 200{ success: true, data: { credentialId, connectionId, token, expiresAt } }withCache-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.organizationIdmust exist (else 400); with none named, the Default Organization (findDefaultOrganization); if that cannot be told apart, 400 asking fororganizationId. This decides only which organization owns the credential row.runRevokeScimCredential): body{ credentialId }. It calls the newrevokeScimConnectionCredential(appended toscim-connection-service.ts, nothing above it edited), which setsactive: false;verifyScimBearerTokenalready refuses inactive rows, so the bearer answers 401 at the next SCIM request. Unknown id 404RESOURCE_NOT_FOUND, already revoked 409RESOURCE_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).sys_audit_logrow directly (as/admin/create-userdoes): admin asuser_id/actor,object_namesys_scim_connection_credential,record_idthe 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 nosys_audit_logobject and the write is skipped.content/docs/permissions/authentication.mdxgets "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 realAuthManagerwith SCIM and organizations on, the realregisterAuthRouteson Hono. Admin mints, then the bearer creates a user through SCIMPOST /Users(201); the row's owner,user_id(the admin), label andactive; 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 runsexpectSecretNowhereElse: the token and its digest are absent from everysys_audit_logrow 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-storeasserted), 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 nosys_audit_logrow, nosys_activityrow, and not inGET /data/sys_scim_connection_credential(200, lists the credential). The/exportdoor answers 403EXPORT_NOT_PERMITTEDeven to the platform admin (measured: no permission set grantsallowExporton 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
ui/is touched.internalfields, sotoken_digestis not in them (the dogfood case asserts the token and digest are absent from everysys_audit_logandsys_activityrow). The door's own row is the one that names the event and the connection.check-tenant-audit-censusasked for its artefacts to be regenerated:content/docs/permissions/tenant-audit-census.mdxanddocs/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.scim-connection-service.ts; this PR only appends below the existing code there.Verification
Delta
306a19fc8e(test-only; after the merge queue ejected677f474461): the queue's full dogfood shard 3 failed inadmin-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).79a87832adds both toADMITTEDwith probe bodies (SCIM is on at that boot, so the mint answers 200; the revoke of an unknown id answers 404), and306a19fcmergesorigin/main8ae0ab9b. At306a19fc8e: that file plusadmin-identity-audit-trail,admin-route-nonadmin-refusal,admin-ledger-decision-metadata,route-ledger-live-mount-parityanddiscovery-auth-families:Test Files 6 passed (6),Tests 45 passed (45). Ablation (the twoADMITTEDlines removed): exit 1, the unclassified assertion names exactly the two SCIM routes, restored clean.Delta
677f474461(after the seat's review started on6c7e436129): the connection-id cap of 246 above, its pin, and one line each in the docs and the changeset. At677f474461:vitest run src/scim-connection-admin.test.tsTests 9 passed (9); plugin-authtsc --noEmitexit 0; ablation (cap back to 255 viaablation-replace.mjs): exit 1, 2 red (the malformed-request case and the new 246/247 pin, bothexpected 200 to be 400), restored clean. Re-run at677f474461, 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 on6c7e436129was itstypecheck-consumerslane concludingcancelled(that run was cancelled by this push), not a type error; the other three lanes were green.Tests and gates at
fc4a6a7801(origin/main1aeeafd9merged, which brought #30879's plugin-auth change). The final head6c7e436129mergesorigin/main000a4c0d(#30927, #30938, #30952, #30956, #30961, #30969), none of which touches a file in this diff; at6c7e436129check-tenant-audit-census,check:auth-mount-ledgerandcheck-empty-changeset --base origin/mainwere 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).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-testexited 3 until its pinned fixture commit was fetched, andcheck:skill-examples/check:dual-build-cjs-loadsexited 3 until the packages they read were built.check-tenant-audit-censusfirst exited 1 on this diff's own two write sites; fixed inc8e10b4f(see Acceptance notes).dispatch-gates.mjs --ran: exit 0, "96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED".Ablations: seven legs, each red for its own reason, each restored clean
At
6c7e436129, the final head. Each leg throughnode scripts/ablation-replace.mjs(anchor must drop by exactly one, restore verified against the HEAD blob), runningvitest run src/scim-connection-admin.test.ts. Every leg: exit 1, thengit diff HEADempty; the run ended RESTORED-CLEAN.gateAdminand takes any callerexpected 200 to be 403(a non-admin got a bearer)active: falseexpected 201 to be 401(revoked bearer still provisions)expected [] to have a length of 1not to contain 'oss_scim_…'warnnot to contain 'oss_scim_…'expected 200 to be 503POST /Usersexpected 401 to be 20196 gates, exit codes at fc4a6a7
🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4cycHNJyS5MXHqaGsFE
Generated by Claude Code