Repository navigation
feat(plugin-auth): an SSO sign-in grants the permission set its IdP group is mapped to - #31657
Conversation
Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n Co-authored-by: Claude <noreply@anthropic.com>
…ough the provider first Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n Co-authored-by: Claude <noreply@anthropic.com>
…ource; translations; changeset Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n Co-authored-by: Claude <noreply@anthropic.com>
… logger's error channel Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n Co-authored-by: Claude <noreply@anthropic.com>
…n as its tenant The grant removal on deleting an SSO group mapping now carries the system context with the grant's organization as tenant, as the sign-in's own grant writes do. The tenant-audit census and its page are recounted for the new write site. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmNwrm1hJ9e7eLARM7n
📓 Docs Drift CheckThis PR changes 2 package(s): 45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 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 85903471ca4c4c5a26f528a851b13c1adb0b1218 && git checkout 85903471ca4c4c5a26f528a851b13c1adb0b1218
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4a843911d50eb06ef045adc4035c9f6641f85c7 70db6d0601c6ca0f3d64750fcd93c9ce95b87a19 && git checkout -B drift-repro d4a843911d50eb06ef045adc4035c9f6641f85c7 && git merge --no-ff 70db6d0601c6ca0f3d64750fcd93c9ce95b87a19
node scripts/docs-audit/affected-docs.mjs --json d4a843911d50eb06ef045adc4035c9f6641f85c7
|
|
domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-steve · session_01ADHkmQKZzp4DLtqiCkZZRM 维护者速读
席位核对 — ACCEPT · domain:server#2 · Head-sha
|
…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>
Requested by Steve · project thread
Fixes #30967
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
sys_idp_group_mapping映射授予普通权限集,和 SCIM 共用同一个映射对象、同一道保存门和同一套边界判断;平台管理员用现有的set-group-mapping门加providerId保存映射。sso-group-projection标记,可按标记清理。packages/plugins/plugin-auth/src/sso-group-projection.ts(登录时读组、匹配、调用共享对账)、packages/plugins/plugin-auth/src/idp-group-mapping-admin.ts(providerId分支与删除即撤销)、packages/plugins/plugin-auth/src/scim-group-projection.ts(抽出共享的准入与对账,SCIM 行为不变)。cd packages/plugins/plugin-auth && pnpm exec vitest run src/sso-oidc-refusal-cause.test.ts -t "IdP group is mapped",预期 10 个用例全绿。What changes
Item 3 of #30967 (IdP group → permission set on SSO sign-in), the last open item, under maintainer ruling A (record 6718355623): it follows #30959's ruled model, the same
sys_idp_group_mappingobject, no second model. Built on what PR #31443 landed; nothing in it is duplicated.scim-group-projection.ts). The boundary admission and the reconcile are lifted intocreateMappingAdmission(deps, kind)andreconcileMappedGrants(engine, { userId, reason, mappingIds }, admits); the SCIM projection now calls them (its own behaviour, refusal text and marker are unchanged, and its suite passes unchanged). AsourceMappings(engine, kind, sourceId)read and an SSO markerssoGroupProjectionReason(providerId)(sso-group-projection: provider 'ID') are added.sso-group-projection.ts, wired inauth-manager-plugin-list.ts).sso({ provisionUser, provisionUserOnEveryLogin: true }): every sign-in readsuserInfo.groups, which the provider's ownmapping.extraFields.groupsfills (a string or a list), matches each value against the provider's rows with the SCIM half's matcher (group_external_idwhen the row names one, else exactgroup_display_name), and reconciles the user's grants carrying this provider's marker. A sign-in with no groups desires nothing: it removes only grants this provider made. Writes go through the engine, so the break-glass guard judges them. A failure never fails the sign-in; it is logged aterroronce, naming the consequence and the remedy.idp-group-mapping-admin.ts).POST /admin/scim/set-group-mappingacceptsproviderIdin place ofconnectionId(exactly one). The organization is the provider row'sorganization_id; unknown provider →reference_not_found, provider with no organization →rule_violation, both onproviderId; SSO off →503. The same boundary judgement and the same503without a security service. An SSO save applies at each member's next sign-in (reprojected: false).delete-group-mappingis source-aware: deleting an SSO mapping removes at once the grants its provider made of that set in that organization, unless another of its mappings still names it.platform-objects).sys_idp_group_mapping.source_kindgains the optionsso(source_id= the provider id); three field descriptions name both sources; translations for all nine locales (theenbundle regenerated, the eight others translated by hand).requires: 'scim'(the mount is unconditional, and the door now serves SSO with SCIM off); the delete row's note describes the SSO revoke.content/docs/permissions/tenant-audit-census.mdx,docs/audits/2026-08-tenant-audit-write-call-sites.counts.md). The delete-revoke is one new engine write site (275 → 276); the generated regions are regenerated and the page's prose figures updated to the census. Gate-required, not a behaviour change.Tests
New top-level describe in the existing OIDC SSO harness,
sso-oidc-refusal-cause.test.ts("an SSO sign-in grants the permission set its IdP group is mapped to"): the realAuthManagerwith the realsso()plugin, driven/sign-in/sso→ a stubbed IdP →/sso/callback, on a real ObjectQL engine over better-sqlite3 with the break-glass guard registered, the mapping saved through the door's own function, judged by plugin-security's realorganizationBoundaryFailure.400 VALIDATION_FAILED,permissionSet,rule_violation) and stores nothing.503, and a stored mapping grants nothing at sign-in (fail closed).errorline).Forced test edits: none. The only edit to existing harness code is additive:
TokenShapegains an optionalclaimsmember thatidToken()spreads into the signed claims.Acceptance notes
groupstherefore never grants and never removes anything but its own marked rows, of which it has none.scimsegment while now serving both sources (seat ruling: no/admin/sso/*mounts); listed in open questions as a naming note.content/docs/permissions/sso.mdxdoes not yet describe group mapping (outside this card's file surface); the changeset and the route-ledger notes carry the operator-facing text.userInfofrommapping.extraFieldsthe same way (SAML attribute values may be lists, which the reader takes); the end-to-end pins drive OIDC only.Verification
Head:
70db6d0601c6ca0f3d64750fcd93c9ce95b87a19(origin/main8784ccbcmerged in, no rebase).Red first on main
The new describe run against the base before any source change: 10 of 10 red (the door refused
providerIdas an unknown field, and no sign-in granted anything).Tests on the head
pnpm exec vitest run src/sso-oidc-refusal-cause.test.ts(plugin-auth): 1 file, 32 passed.tsc --noEmitfor plugin-auth and platform-objects: exit 0.eslint --no-inline-configon both packages: exit 0.pnpm install --frozen-lockfile && pnpm buildunder the verify lock: exit 0.Ablation: each pin goes red when its behaviour is removed
Each mutation was applied on disk to the source, the describe run, then restored (
git diff HEADempty after each).provisionUserOnEveryLoginoffOne first run was a no-op (its replacement still contained the anchor, so nothing changed); it was re-run with a mutation that moved the anchor, which went red. It is the "a projection failure fails the sign-in" row.
Derived gates
node scripts/pm/dispatch-gates.mjs --commandson the head derived 108 commands; all 108 ran with exit 0, and--ranreconciled: 108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN. On the first passcheck-tenant-audit-censusfound the new delete-revoke write without a tenant context: it now carries the grant's organization as its tenant, and the census page and ledger counts are regenerated (tenant-audit-census.mjs --write) with the page's prose figures updated to match (276 sites; the provable no-context figure stays 3).check:i18nandcheck:i18n-stale-fillpass with the regeneratedenbundle.Generated by Claude Code