Repository navigation
feat(plugin-auth): a SCIM group push grants the ordinary permission set its group mapping names - #31443
Conversation
A sys_idp_group_mapping row per SCIM connection names which ordinary permission set a group grants; the vendor projection reconciles grants inside the SCIM transaction. The save door refuses any set outside the organization boundary, judged by plugin-security's own predicate served as an optional security-service member. Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
…nd dogfood rows, changeset Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
…; census and ownership rows Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ Contract reviewServed-tier: Gate verdicts on this head, as read: ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each named right or wrong.
② Semver level
Clause-②: yes (widening) ③ Boundary flags
④ Design judgment
Findings
No must-fix. The ruling's two forks hold as ruled: fork 1 — rows are per-connection, typed, and written only through the platform-admin door (no data-API, batch or import write path exists); fork 2 — no mapping naming an admin set can be stored, by the gate's own criteria over the gate's own catalog, and the stored mapping cannot be re-pointed; the residual exposure is the post-save redefinition of an ordinary set (S2) and the unscoped-organization case (S1), neither of which confers administrator standing. Implemented-by: VERDICT: PASS |
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
…e-time boundary (red) Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
…; reconcile judges every grant before it writes Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
… write's tenant; census regenerated Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 109 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 21 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 185 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 7cf36d660780d2a43174be0b328e49a39aab452e && git checkout 7cf36d660780d2a43174be0b328e49a39aab452e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ee59f5ef449de22926af221d8fc9b7baf78a2d0 d8ef8e6930b0c876b3c81f5988871973a4703817 && git checkout -B drift-repro 5ee59f5ef449de22926af221d8fc9b7baf78a2d0 && git merge --no-ff d8ef8e6930b0c876b3c81f5988871973a4703817
node scripts/docs-audit/affected-docs.mjs --json 5ee59f5ef449de22926af221d8fc9b7baf78a2d0
|
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ Contract reviewServed-tier: Delta round on the record of head Gate verdicts on this head, as read: ① Derived judgmentsCarried unchanged from the
② Semver level
Clause-②: yes (widening) ③ Boundary flags
④ Design judgment
Translations, Findings
No must-fix. S1, S2 and S3 of the Implemented-by: VERDICT: PASS |
…ing's organization (red) Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
… credentials only Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ Contract reviewServed-tier: Delta round on the record of head Gate verdicts on this head, as read: ① Derived judgmentsItems 1-9 of the
② Semver levelUnchanged from Clause-②: yes (widening) ③ Boundary flags
④ Design judgment
Findings
No must-fix. S4 is resolved in the tree and pinned; the bearer verifier's accept set is unchanged against Implemented-by: VERDICT: PASS |
…ization (red), and liveness unit cases Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
…n another organization Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
…refusal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ Contract reviewServed-tier: Delta round on the record of head Gate verdicts on this head, as read: ① Derived judgmentsItems 1-9 of the
② Semver levelUnchanged from Clause-②: yes (widening) ③ Boundary flags
④ Design judgment
Findings
No must-fix. S5 and N3 are resolved in the tree and pinned; the delete door has no counterpart blind spot; the only red check-run is main's Implemented-by: VERDICT: PASS |
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mEnsnzoPLq43wQyYy
|
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ 席位核对 · ACCEPT · 2026-11-08T16:42Z · head
|
…the inherited admin cookie (objectstack-ai#31871) <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi96VGP7vMPA9C54zyjz2LMch)_ Fixes objectstack-ai#31827 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:夜间 live 层的成员夹具改为只用成员自己的凭据发 API 请求(成员自己的会话 cookie 加它的 Bearer),成员登录也从空状态开始;`read-only-member-record.spec.ts` 只换成调用新助手,断言一条未动。 - 为什么改:在 `@playwright/test` 里,不指定 `storageState` 新建的请求上下文会继承配置里的管理员 cookie,于是成员请求同时带着管理员 cookie 和成员 Bearer。objectstack-ai#31469 起服务端拒绝这种「两个账号」的请求,数据路由回 401,夜间层因此变红。席位裁定:改夹具,不改产品。 - 风险与回滚:只动 `ui/e2e/live/` 下两个测试文件,不发布任何包,不动 `packages/`。回滚即 revert。 - 先看:`ui/e2e/live/member.ts` 的 `memberRequestContext` 和模块注释里的 ⛔ 段。 - 两分钟验证:起后端后跑 `OS_TEST_TIERS=nightly pnpm --filter @objectstack/example-showcase exec vitest run test/console-live-specs.live.test.ts`,预期 `28 passed`。 - 需要你做的:无,席位落地。 ## What changes - `ui/e2e/live/member.ts` gains `memberRequestContext(member)`: a request context carrying the member's own session cookie and its Bearer, with its `storageState` named so the config's administrator cookie is not inherited. - The member's sign-in context starts from an empty `storageState` (`{ cookies: [], origins: [] }`) instead of the inherited administrator cookie, so the sign-in request names the member only. - `read-only-member-record.spec.ts` builds its premise context with the helper. That is its only change: the `playwright` fixture it no longer uses leaves the destructuring, and no assertion moves. - The module comment states the trap: inside `@playwright/test`, `playwright.request.newContext()` and the imported `request.newContext()` both start with the config's `use.storageState`. No test was skipped, renamed or loosened. No change under `packages/`. ## Why The nightly live tier went red at `6be71abc` on `read-only-member-record.spec.ts:48`, premise step: the member's `GET /api/v1/data/showcase_inquiry/<id>` answered 401 where 200 was expected. Step 0 (seat ruling 6735238125): - **Bisect.** Each commit measured on a fresh backend booted by `ui/e2e/live/ci/start-backend.sh`, with the spec's request shape: | Commit | Result | |:--|:--| | `44a030bf` (last green nightly) | green, read 200 / write 403 | | `133f75aec0~1` | green, 200 / 403 | | `133f75aec0` objectstack-ai#31469 | **red**, 401 / 401 | | `ed4b83226b` objectstack-ai#31443 | red | | `59630d9357` objectstack-ai#31622 | red | | `8c9260f79c` objectstack-ai#31657 | red | | `f127b04e07` objectstack-ai#31686 | red | | `6be71abc` (red nightly) | red | - **Cause.** The member's context was `playwright.request.newContext({ extraHTTPHeaders: { Authorization: 'Bearer <member>' } })`. Inside `@playwright/test` that context inherits the config's `use.storageState`, which is the global setup's administrator cookie. Measured: the context's cookie equals the administrator's. So each premise request carried two accounts' credentials. Before objectstack-ai#31469 the bearer won. Since objectstack-ai#31469 the session read refuses such a request with `CREDENTIALS_NAME_DIFFERENT_ACCOUNTS`, and the data route answers its own anonymous 401. objectstack-ai#31469's acceptance notes describe exactly that data-route answer, so the product behaves as ruled and the fixture relied on what objectstack-ai#31469 ruled out. ## Sweep (owed by the ruling) Every live spec and helper under `ui/e2e/live/` that builds a request context or sends a credential: | Site | Accounts it sends | Action | |:--|:--|:--| | `read-only-member-record.spec.ts` premise context | admin cookie (inherited) + member Bearer | **moved onto `memberRequestContext`** | | `member.ts` sign-in context (`request.newContext()`) | admin cookie (inherited) on the member's sign-in | **moved onto an empty `storageState`** | | `read-only-member-record.spec.ts` member browser context | member `storageState` named explicitly | one account, unchanged | | `global-setup.ts` sign-in context | none (global setup runs before any `use` state exists) | one account, unchanged | | `inline-edit-polish-2572.spec.ts` `request` + Bearer | admin cookie + admin token from the same `state.json` | one account, unchanged | | `studio-customize-journey.spec.ts` `request` override | `storageState` passed by name (admin), no other credential | one account, unchanged | | `studio-object-designer.spec.ts` in-page `fetch` | admin page's own cookie + its own localStorage token | one account, unchanged | | every other spec's `request.get/post/…` (cascading-options, grid-file-upload, list-row-action-cel, master-detail, record-form-direct-url, record-history-display) | admin `request` fixture alone | one account, unchanged | Measured for the two inherited sites: the imported `request.newContext()` and `playwright.request.newContext()` each start with 1 cookie under this config; with `storageState: { cookies: [], origins: [] }` they start with 0. ## Verification All heavy runs went through `scripts/pm/os-verify-lock.sh`. Head `86082eb9b5`, backend closure and `pnpm console:build` rebuilt at the head first. - **Nightly live file, head:** `OS_TEST_TIERS=nightly pnpm exec vitest run test/console-live-specs.live.test.ts` in `examples/app-showcase`: Playwright `28 passed (1.3m)`, vitest `Test Files 1 passed (1)`, `Tests 1 passed (1)`. - **Ablation:** `scripts/ablation-replace.mjs` deletes the helper's `storageState` line in `member.ts` (anchor 1 → 0, blob `d4e1a39d33f5` → `dc6c2ff0d686`), so the inherited administrator cookie is back. The same run: `✘ read-only-member-record.spec.ts:48`, `Expected: 200`, `Received: 401`, `Test Files 1 failed (1)`. Restored: blob equals HEAD's (`d4e1a39d33f5`), `git diff HEAD` empty, `git status --porcelain` empty. - **Before the fix, on main `3487ccc1`:** the same nightly run was red, 1 of 28, the same 401. - **Dispatch gates:** see the os-dev-report on objectstack-ai#31827 for each command's exit code and the `--ran` reconciliation; `pnpm lint` and `eslint` over the two touched files are reported there too. No changeset: the diff publishes nothing (`ui/e2e/live/` only), so this PR carries `skip-changeset`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` 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. (Publishes nothing; `skip-changeset`.) - [x] 6. `packages/spec` touched: n/a. - [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 维护者速读 section is the seat's required Chinese summary.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmPpwZNVKwJLQGotdJy --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPpwZNVKwJLQGotdJy)_ Co-authored-by: Claude <noreply@anthropic.com>
Refs #30959
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
packages/plugins/plugin-auth/src/scim-group-projection.ts(对账只删带本投影标记的授权),packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts(保存时的边界拒绝),packages/plugins/plugin-security/src/delegated-admin-gate.ts(判定函数原样上提)。pnpm --filter @objectstack/plugin-auth exec vitest run src/scim-group-projection.test.ts,预期 14 个用例全绿。What changes
An identity provider that pushes a group over SCIM now changes what its members may do, through a mapping a platform administrator saves.
sys_idp_group_mapping(packages/platform-objects/src/identity/sys-idp-group-mapping.object.ts): one row per mapping with typed columnssource_kind(closed select, V1 valuescim, the seam plugin-auth SSO: expose the operator knobs Enterprise SSO needs — IdP-initiated sign-in, IdP group to role / permission set, and refusing unknown users instead of JIT #30967 reuses for SSO group claims),source_id(the SCIM connection),group_external_idorgroup_display_name(exactly one),permission_setandorganization_id. The data API only lists it. One row inPLATFORM_OBJECTS_BY_PACKAGE.POST /api/v1/auth/admin/scim/set-group-mapping(connectionId,groupExternalIdorgroupDisplayName,permissionSet) andPOST /api/v1/auth/admin/scim/delete-group-mapping(mappingId), behind the same platform-admin gate as/admin/scim/mint-credential. Both are in the route ledger, its conformance pin and the two dogfood admin-route censuses. The organization is the one that owns the connection's live credentials (active and unexpired, the same check the SCIM bearer verifier uses); a connection with no credential is refusedreference_not_found, one whose every credential is revoked or expired, or whose live credentials name no organization or two,rule_violation. Re-saving the same group and set is idempotent in that organization; when the same-key row was saved while another organization owned the connection, the save is refused409 RESOURCE_CONFLICTnaming that mapping (delete it first), so the answer never names an organization the row does not carry.400 VALIDATION_FAILED, fieldpermissionSet, coderule_violation, and stores nothing:admin_full_access, the organization-administrator sets, the agent ceiling sets, a set with a superuser wildcard or anadminScope, and a set with a capability not scoped to the organization. The judgement is plugin-security's ownorganizationBoundaryFailure, lifted verbatim from a private method ofDelegatedAdminGateto a module function (the method is now a one-line delegate) and served as an optional member of the registeredsecurityservice. plugin-auth takes no dependency on plugin-security and keeps no copy of the list; it feature-detects the member and fails closed (503, nothing saved, nothing granted).ISecurityService.organizationBoundaryFailure?(setName: string), resolving to the failure reason as a string ornullwhen the set is inside the boundary (packages/spec/src/contracts/security-service.ts), declared inregistered-security-service-members.pin.test.ts. Optional, so every existing implementer still type-checks.scim({ … })inauth-manager-plugin-list.tsnow takes the vendorprojection(createScimGroupProjection).roles.mapreturns the connection's mapping rows that match the group (by external id when the row names one, else by exact display name);roles.existsre-judges the boundary at grant time;reconcileUserwrites through the ObjectQL engine inside the SCIM transaction with the request's system context, so the audit actor issvc:scim:CONNECTION_ID. An engine refusal becomes the vendor'sAPIError, never a SCIM 500.reason=scim-group-projection: provisioning domain 'ID'. Reconcile deletes only rows with that exact marker, so a grant made by hand, including one for the same set, is never removed, and a set already held by hand is not written again. Saving or deleting a mapping re-runs the vendor'sreconcileSCIMProjectionfor the connection, so the change applies to current members at once.mainships (now nine, with pt-BR, ru and de); the bundles are regenerated, andbundle-ownership.test.tslists the object.@objectstack/cli's ADR-0131 D10 inventory (organization-ownership-inventory.ts) gains a row forsys_idp_group_mappingwith the attribution fate, likesys_connector_connectionbeside it: every mapping row is written with its organization, so a NULL row has no producer. Without itos migrate organization-ownershiprefused any database holding the new table.content/docs/permissions/tenant-audit-census.mdxand its counts file) and the platform-object tenancy census are regenerated for the three new write sites and the new object; the page's hand-written figures are updated to the census.Follow-up: the ADR recording this decision (mechanism, storage, admin boundary, vendor
@better-auth/scim1.7.12) plus one pointer line in ADR-0134 D4 comes as a second PR from this branch after this one merges, so its symbol anchors resolve against landed code. That PR saysFixes #30959, so the card stays open until the ADR lands (this build PR only refers to it).Tests
packages/plugins/plugin-auth/src/scim-group-projection.test.tsdrives the real producer:AuthManager.handleRequest()with a real SCIM bearer over@better-auth/scim1.7.12, on a real ObjectQL engine over better-sqlite3, with mappings saved through the door's own function and judged by the real plugin-securitysecurityservice over a test catalog.svc:scim:okta-30959VALIDATION_FAILED,[permissionSet, rule_violation], no rowroles.map→ redadmin_full_access+ the mapped set by hand)VALIDATION_FAILED,[connectionId, rule_violation], no rowadmitscall removed → red;admitsorganization check disabled → redreconcileUsercalled withoutroles.exists: no grant, one warning; a real push then grants nothingadmitscall removed → redVALIDATION_FAILED,[connectionId, rule_violation], no rowRESOURCE_CONFLICT, message names the A mapping and A, the single row stays in A; CONTROL same-organization re-save answers 200created: falseEach ablation ran through
scripts/ablation-replace.mjs(anchor hit 1 → 0, blob restored equal to HEAD). The three S1/S2 pins were committed red first (cdd23489) before the fix (fe3aea79), the two S4 pins likewise (ef76a598beforeff3c8f8e), and the S5 pin too (82e0cee3before18a95de4).scim-connection-service.test.tsadds unit cases forscimCredentialIsLive(active false, 0, null or absent; no expiry; past; exactly now; future; unreadable).Acceptance notes
origin/mainonce and re-runs the plugin-auth and platform-objects suites.Verification
Head
d8ef8e69, after mergingorigin/mainfour times without rebasing (1efd345f,ae12be81,52aa0fc6, and1ccf149eat645ed3d1, which includes the de bundle from #31511). The first merge resolveddelegated-admin-gate.tsby keeping #31384's helpers; the lifted predicate is still verbatim. The census artefacts were regenerated from main's copy.turbo run build120/120 tasks.pnpm --filter PKG typecheck(including each package'scheck:test-typecheck) exit 0 for plugin-security, platform-objects, plugin-auth, spec and cli;check:type-check-debtexit 0.eslint --no-inline-configover the touched packages, exit 0.d8ef8e69); plugin-security 5886 passed / 45 skipped; platform-objects 1886 passed (ond8ef8e69); cli 7582 passed / 9 skipped (unit and integration). The ownership-ceremony files (inventory unit,dev-organization-ownership-stale-column,start-organization-ownership-refusal,declared-rule-mirrors,no-organization,sqlite-wasm) were 10 red before the inventory row and are 42/42 green on42f60764with cli rebuilt.check:i18n,check:i18n-coverage(none new),check:i18n-stale-fillandcheck:i18n-walk-parityexit 0 ond8ef8e69; the de bundle keeps only theidsource-hash entry, like the other locales.check-tenant-audit-census,check:platform-object-tenancy-censusandcheck:auth-mount-ledgerexit 0 there too.dispatch-gates --commandsderived 130 families on95349ede; all 130 ran there, every one exit 0, and--ranreconciled 130 run, 0 NOT-MEASURED, 0 UNRUN.d8ef8e69adds only the main merge and the de bundle; the i18n, census and ledger gates above were re-run on it.pnpm --filter @objectstack/plugin-auth typecheck(withcheck:test-typecheck) exit 0 on95349ede.main: 48 files, +2061 −111. Generated output (translation bundles in nine locales, two census artefacts and the tenancy census JSON) is 585 of it; authored is 26 files, +1476 −69.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code