Repository navigation
feat(auth)!: a managed environment adds pending, SSO-only people, and ban works without the admin plugin - #33227
objectstack-fleet[bot] wants to merge 9 commits into
Conversation
… ban works without the admin plugin On a cloud-managed environment a platform administrator adds a person ahead of their first sign-in (admin/add-sso-user) or many from CSV (admin/import-sso-users), with positions, permission sets and a manager. The row is born idp_pending: no password, refused by every password door and by every session until the first sign-in through the platform identity provider binds to it. admin/ban-user and admin/unban-user now work without the admin plugin: the ban columns are written through the data engine and a banned row is refused a session with the admin plugin's own BANNED_USER answer. An optional environment-user host is told when a person is added, and asked before one is deactivated or reactivated; it may refuse with a typed 409 or 403. Events carry the administrator's cloud user id from their linked platform account. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
…-cl505i # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
|
domain:server#7 · os-tesla · session_01L9qibvhGW6DCBPjrcAPjvh Contract-review-claim: session_01L9qibvhGW6DCBPjrcAPjvh · head f5a084c · 2026-11-14T14:40Z The review runs at |
📓 Docs Drift CheckThis PR changes 2 package(s): 89 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 18 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 eba4238f03a7e2f79bae14c552c5306dfc1ea517 && git checkout eba4238f03a7e2f79bae14c552c5306dfc1ea517
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d683e6f3af5633a5c4a13ead523e06fbeb36db75 b8af0027e6bc974331c5e295d2c180ad4b5f7034 && git checkout -B drift-repro d683e6f3af5633a5c4a13ead523e06fbeb36db75 && git merge --no-ff b8af0027e6bc974331c5e295d2c180ad4b5f7034
node scripts/docs-audit/affected-docs.mjs --json d683e6f3af5633a5c4a13ead523e06fbeb36db75
|
|
domain:server#7 · os-tesla · session_01L9qibvhGW6DCBPjrcAPjvh The brief the isolated reviewer was given for the record on head Contract review brief — PR #33227 @
|
|
domain:server#7 · os-tesla · session_01L9qibvhGW6DCBPjrcAPjvh Contract reviewServed-tier: Inputs: card #33181 body and all 8 comments, PR #33227 body and its 51-file list, the net diff against Check-runs when read (54 total):
① Derived judgments
② Semver levelClause-②: yes (narrowing) — matches the tree. One arm from the closed pair, with the BREAKING banner in both the PR body and the changeset.
③ Boundary flags
④ Design judgmentA row of the new files per contract sentence, then the findings.
Findings:
No must-fix. Findings 1 to 7 each earn a delta review of their fix. This record was rendered by an isolated sub-agent at CONTRACT_REVIEW_TIER, fed only the brief's inputs. Implemented-by: VERDICT: PASS |
…ivation waits for the host, re-adds name what they skip Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
|
domain:server#7 · os-tesla · session_01L9qibvhGW6DCBPjrcAPjvh Contract-review-claim: session_01L9qibvhGW6DCBPjrcAPjvh · head 032dcc5 · 2026-11-14T15:09Z The delta review of record 6879138774 should-fixes 1 to 7, by the same isolated reviewer at |
|
domain:server#7 · os-tesla · session_01L9qibvhGW6DCBPjrcAPjvh Contract reviewServed-tier: This is the delta review of record 6879138774 (head Inputs: the delta Check-runs when read (46 total):
① Derived judgments
② Semver levelClause-②: yes (narrowing) — the line is byte-identical in the PR body and the changeset, and matches the tree.
③ Boundary flags
④ Design judgmentEach finding of record 6879138774, against the delta:
New in the delta:
No must-fix and no should-fix. The nits end the round. Implemented-by: VERDICT: PASS |
…double The sys_account matcher in the admission test double read `$and`/`$or` as field names. It now refuses any operator key, per the where-matcher conformance gate. The host accessor's contract comment also states that a managed host answers a host or throws, never `undefined` while starting. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
The import-fault test wrapped the engine's `insert` and passed a third argument its declared signature does not take, which the test-layer typecheck reports. The wrapper now forwards whatever it receives. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: The seat's own read of the part of this head no record covers: ① Derived judgments
② Semver levelUnchanged: ③ Boundary flagsNone new. Delta nit A is answered by the comment; nit B was a PR-body edit. ④ Design judgmentNo design moves in this delta. The comment narrows nothing and the two test edits keep their assertions. Check-runs on this head: 38 success, 15 skipped, 0 failed. Implemented-by: VERDICT: PASS |
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx 维护者速读 · PR #33227 @
|
Conflicts with the direct sign-in work, both sides kept: the `environmentUserHost` and `directSignIn` plugin options, the pending-SSO and direct sign-in exports, and both describe blocks in the implicit account-linking suite. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: The seat's own read of the part of this head that no record covers: ① Derived judgments
② Semver levelUnchanged: ③ Boundary flagsNone new. Main's direct sign-in door and this PR's pending-person doors are separate routes. The ledger auto-merged, and its conformance test covers both sides. ④ Design judgmentNo design moves. Check-runs on this head: 38 success, 15 skipped, 0 failed, 0 in progress. Implemented-by: VERDICT: PASS |
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Re-land · 2026-11-14T22:27Z · PR #33227 @
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 43651866154 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
domain:server#7 · os-tesla · session_01L9qibvhGW6DCBPjrcAPjvh
Requested by Tesla · project thread
Fixes #33181
Clause-②: yes (narrowing) — narrowed: ban works without the admin plugin —
admin/ban-usernow works without the admin plugin, and a banned row is refused a session, with the admin plugin's own403 BANNED_USERanswer; both ban doors answered501 NOT_IMPLEMENTEDthere. Widened: two doors,POST /admin/add-sso-userandPOST /admin/import-sso-users(cloud-managed environments only;409 RESOURCE_CONFLICTelsewhere); theidp_pendingvalue ofsys_user.source, which every password door and every session refuses; the auth plugin's optionalenvironmentUserHostoption;AuthManager#isAdminPluginMounted(); the exports ofpending-sso-marker,environment-user-hostandpending-sso-users; and, re-exported from the package root, the helpersbindUserToSoleOrganization,writeAdminAudit,joinCurrentOrganizationandmultiOrgCreationJoinsCurrentOrganization, the classCreationRefused, the typeAdminAuditDecisions(with itsuser.admin_added_pendingevent),SetInitialPasswordOptions(the optional third argument ofrunSetInitialPassword) and the optionalgetBanWriter,host,getDataEngineandloggermembers ofAdminBanEndpointDeps. The cloud-managed refusals ofcreate-user,import-usersand the invitation doors point to Setup → Users.Metadata-protocol: none
Skills-docs: none — no skill under
skills/**teaches the admin user doors, the ban doors or the cloud-managed refusals;content/docs/permissions/authentication.mdxgains the two doors, the pending state, the host option and the ban change in this change.维护者速读
idp_pending)状态存在,没有密码,在通过云端登录绑定之前,任何设密码、重置密码和其他登录方式都会被拒绝。packages/plugins/plugin-auth/src/pending-sso-users.ts(两个入口)、user-session-admission.ts(会话拒绝)、admin-ban-endpoints.ts与user-ban-write.ts(无插件时的停用)。pnpm --filter @objectstack/plugin-auth exec vitest run src/pending-sso-users.test.ts src/pending-sso-seams.test.ts,预期全部通过。What changes
Adding a pending person (managed only). Two platform-admin-gated doors, both mounted by the plugin itself and needing no admin plugin:
POST /admin/add-sso-useremail,name, optionalpositions,permissionSets,manager(the manager's email); a closed key set201;200withalreadyPendingfor a person still pending (the host is told again);409for an address already held by someone not pendingPOST /admin/import-sso-users{ csv }(columnsemail,name,positions,permission_sets,manager_id, lists;-separated) or{ rows }, at most 500200withsummaryand one result per row; a bad row fails alone, managers resolve in a second passOff a managed environment both answer
409 RESOURCE_CONFLICT. The row is written by one engine insert as the system, attributed to the administrator (the AI service-account door's pattern), so it is bornidp_pending: nosys_account,email_verified: false, and no moment where it readsenv_native. Positions and permission sets are inserted under the caller's own execution context (create-user's grant seam, one unit of work), membership comes from create-user's organization bind, and the manager link isadmin/set-user-manager's. An audit rowuser.admin_added_pendingis written after the commit.What a pending row is refused.
admin/set-user-password,set-initial-password(judged after the body is read, through an optionalbeforeWriteonrunSetInitialPassword),/reset-passwordand/phone-number/reset-passwordanswer409 RESOURCE_CONFLICTwith their own sentence (pending-sso-marker.ts), in every mode. Session creation answers403 PERMISSION_DENIED(user-session-admission.ts, composed intosession.create.beforeafter the AI service-account refusal, and reading the user row in that refusal's one read). The first sign-in through the platform identity provider binds to the row (implicit-linking rule 2) andstampIdentitySourcemoves it toidp_provisionedinaccount.create.after, before that sign-in's session; an enterprise provider does not bind (rule 1), and its link does not clear the marker either, since the stamp moves a pending row only on the platform provider's link.Ban works without the admin plugin.
admin/ban-userandadmin/unban-userno longer answer501there: the ban columns are written through the data engine (engineUserBanWriter, the vendor-shaped write mapped tobanned/ban_reason/ban_expires/updated_at; the last-administrator guard judges engine writes in every context), sessions are revoked as before, andsession.create.beforerefuses a banned row with the vendor's own403 BANNED_USERcode and sentence, lifting an expired ban at sign-in as the vendor does. It runs only when the admin plugin is not mounted (AuthManager#isAdminPluginMounted()), so a kernel with the plugin answers exactly as before.The host seam.
environmentUserHost(auth plugin option, read per call) is tolduserAddedafter the person's unit of work commits, and askeduserDeactivated/userReactivatedbefore the ban or unban write. It is never called inside a transaction (execution terms, comment 6870710213). A member that throwsEnvironmentUserHostRefusal(409, an organization owner or admin;403, for exampleDEVELOPER_ORGANIZATION_MEMBER_LIMIT_REACHED) refuses the operation with its own status, code and sentence. A refused deactivation or reactivation writes nothing. A refused add takes that person back out (grants, membership, row) and fails alone on its import row. A refused re-add of a still-pending person leaves that row as it was. Any other throw from a wired host refuses a deactivation (503 SERVICE_UNAVAILABLE, nothing written), since only the host can say whether the person is an organization owner or admin (ruling 5). It never undoes or blocks an add or a reactivation: the answer carrieshost: { notified: false, reason: 'host_failed' | 'no_host' }and the failure is logged atwarn. On the ban doorshostappears only when a host is wired. Every event carriesactorCloudUserId, theaccount_idof the administrator's linkedobjectstack-cloudaccount, read fromsys_accountand never from the request (ruling 3), absent where there is no such link; on an impersonation session it also carriesimpersonatedBy, the administrator at the keyboard, read from the session. A session minted by the host's own sign-in handoff carries no record of itsbyin the environment; attributing those stays with ruling 9's work.Re-adding a pending person re-tells the host and changes nothing else: positions, permission sets or a manager sent with the re-add are named on the answer (and on the import row) as
notApplied. Only the platform provider's link clears the pending marker; a pending row that already holds its platform account but missed its stamp is stamped at its next session. An import row that faults answers the generic sentence, as the single-add door does.Elsewhere.
sys_user.sourcegains the optionidp_pending("Pending SSO Sign-In") with its description; the eleven translation bundles are regenerated withcheck-i18n-bundles.mjs --write. The cloud-managed409sentences point to Setup → Users (final, above). Route ledger rows for the two doors; the tenant audit census regenerated for the new write sites;authentication.mdxdocuments it all.Unchanged: every
env_nativeandidp_provisionedrow at every door;create-userandimport-usersoff a managed environment; the vendor's ban enforcement where the admin plugin is mounted.Breaking
BREAKING for a deployment without the better-auth admin plugin (
auth.plugins.adminoff and SCIM off):admin/ban-usernow works without the admin plugin, and a banned row is refused a session, with the admin plugin's own403 BANNED_USERcode and sentence;admin/unban-userlifts the ban there too. Level stayspatch. ADR-0087 disposition:not-required (no-migration-prescription), in the changeset.Why
Card #33181, child 1a of objectstack-ai/cloud#4465: a managed environment needs people to exist, with their positions, permission sets and manager, before their first sign-in, so seeds and approval flows can name them; on Free and Team hosted kernels (no admin plugin) there was no add door and no working deactivation. Seat rulings on the card: claim amendments 6869729952 and 6870557312.
Verification
Delta at
032dcc50(the contract review record 6879138774 onf5a084c7, its should-fixes 1 to 7 and nit 10): pins added inpending-sso-users.test.ts(fail-closed deactivation and its CONTROLs,notAppliedon both doors, the generic import fault,impersonatedByon ban and add) andimplicit-account-linking.test.ts(only the platform link clears the marker, with its CONTROL; a missed stamp healed at the next session, with its CONTROL). Ablations, each red: C1 the stamp's provider scope (1 failed), C2 the heal (1), C3 the heal's provider filter (2), C4 fail-closed deactivation (2), C5notAppliedon add (1), C6 on import (1), C7 the generic import sentence (1), C8impersonatedBy(2). The tenant audit census was regenerated for the new write site (320 sites) andcheck-tenant-audit-censuspasses. Typecheck exits 0,pnpm lintexits 0, andcheck-issue-citations,check-comment-mask-adoption,check-adr-0087-registration,check-empty-changeset,check-i18n-bundles,check:cross-package-test-inputsandcheck:test-source-aliasall exit 0. The plugin-auth full suite passes after a seam-test fake was taught to answer per object (271 files). The check-runs on032dcc50are the gate verdicts.Readings below were taken at
be43f4e1(one commit onorigin/main7257f57a). Headf5a084c7mergesorigin/maine540baf8. Only the generated tenant audit census conflicted: it was regenerated withtenant-audit-census.mjs --write, its prose figures were updated to the new count (319 sites), andcheck-tenant-audit-censuspasses. After the merge and a rebuild of the packages: plugin-auth typecheck exits 0,check-i18n-bundlesexits 0, and the plugin-auth full suite passes (271 files, 5177 passed, 10 skipped). The check-runs onf5a084c7are the gate verdicts for the merged tree.Measured at main first
At
origin/main88355328, managed (OS_CLOUD_MANAGED=1) with the admin plugin off, as on a Free or Team hosted kernel:admin/create-userandadmin/import-usersanswer the managed409, andadmin/ban-user/admin/unban-useranswer501 NOT_IMPLEMENTED, so no door adds or deactivates a person there.admin/set-user-passwordwrites a credential on a credential-lessenv_nativerow, which is why the pending marker is its own value. The platform provider (objectstack-cloud) binds to an unverified credential-less row andstampIdentitySourcerestamps itidp_provisioned, while an enterprise provider is refused (rule 1). The new test files fail against main's wiring.Ablations (each leg mutated through `scripts/ablation-replace.mjs`, red recorded, restored to HEAD)
enforceBanwiring (admin plugin mounted or not): 4 failed. A8 a user-row read fault answers 503: 1 failed. A16 the one session read carries the admission columns: 6 failed.403/409refusals are honoured: 1 failed. B4 a typed refusal is recognised at all: 5 failed. B5 a refused add takes the person back out: 2 failed. B6 a refused re-add fails the request: 1 failed. B7 the cloud id is read only from theobjectstack-cloudaccount: 3 failed. B8 the ban event carries the cloud id: 2 failed. B9 the take-back removes grants and membership: 1 failed.POST /api/v1/auth/admin/add-sso-user,POST /api/v1/auth/admin/import-sso-users), and pass after.Gates and suites
dispatch-gates --commandsderived 108 families. All 108 ran with</dev/null, with exit codes recorded first, and--ranreconciles:✓ 108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none of them is 3). After the dogfood-table change, the gates that read test sources were re-run:check-issue-citations,check-comment-mask-adoption,check:cross-package-test-inputsandcheck:test-source-alias, all exit 0.pnpm lint: exit 0.@objectstack/plugin-authand@objectstack/platform-objectsbuilt with declarations: exit 0.@objectstack/plugin-authfull suite: 270 files, 5169 passed, 10 skipped.@objectstack/platform-objects: 122 files, 1958 passed.@objectstack/dogfood: the full run had two failures, the admin-route sweeps naming the two new doors. Both are now classified (ADR-0068 gate first; the admin gets409 RESOURCE_CONFLICToff a managed environment), and the two sweep files re-run green (15 passed). The rest of the run was green: 461 files passed.Acceptance notes
409sentences are final per the maintainer's decision 6878953650 on the card; a later rewording is its own card.sourceat the three password-door pending arms falls through to the existing arms (the pending arm does not narrow main on a read fault). Session admission refuses503on a read fault, as the AI service-account refusal composed before it does.set-initial-passwordrefusal first; a pending caller with that standing meets the pending refusal.sys_userban_user/unban_useractions keeprequiresFeature: 'admin', so the console hides them on Free and Team kernels although the doors now answer there.Human Minimum
packages/specholds no runtime logic. (packages/specuntouched.)pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched; the translation bundles were regenerated by their generator.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01L9qibvhGW6DCBPjrcAPjvh