Repository navigation
fix(plugin-auth)!: deleting the last identity provider is refused, and a boot that serves no sign-in method says so (#30300) - #30449
objectstack-fleet[bot] wants to merge 4 commits into
Conversation
… say a boot without one Deleting the last identity provider while email and password is off is refused in the global before-hook (409 DELETE_RESTRICTED, 503 when the provider list cannot be read), judged by the sign-in method floor. A boot that serves no sign-in method says no_sign_in_method_at_boot at error and still boots. Claude-Session: https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx Co-authored-by: Claude <noreply@anthropic.com>
…gn-in method Claude-Session: https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx Co-authored-by: Claude <noreply@anthropic.com>
…r's shared harness Claude-Session: https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 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 fecb7c1d82be1586b19deba7fa4a10354815e7a9 && git checkout fecb7c1d82be1586b19deba7fa4a10354815e7a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0892a94d8e190b7547408d1a37e49ba087ddd635 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9 && git checkout -B drift-repro 0892a94d8e190b7547408d1a37e49ba087ddd635 && git merge --no-ff 385ecdcc7e7a0d9d47b50bd146a9da0dbea814e9
node scripts/docs-audit/affected-docs.mjs --json 0892a94d8e190b7547408d1a37e49ba087ddd635
|
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract reviewServed-tier: Face: narrowing. No path of the diff is a published schema or a governed path; the line-initial ① Derived judgments
② Semver levelChangeset ③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC 席位核对ACCEPT · head
|
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC 维护者速读(席位终稿)
席位意见:同意落地。p1 锁死漏洞,拒删条件窄且与 #30298 设置保存门共用同一判定;两条已知余项不阻塞:并发删最后两条的竞态(与 #30298 同窗,下次启动会报),503 只在判定层钉住。 席位核对Supplements ACCEPT 6657794372 on the same head
|
Requested by Bill · project thread
Fixes #30300
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**teaches deleting an SSO provider or the sign-in boot reports;content/docs/deployment/self-hosting.mdxgains "No sign-in method left".维护者速读
sign-in-method-floor.ts的ssoProviderDeleteRefusal与resolveNoSignInMethodReport;auth-manager-before-hook.ts的/sso/delete-provider分支;auth-plugin.tskernel:ready 新增的那一段。pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts,预期 35 条全过。What changes
Two doors removed the last way to sign in without a word. Both are now judged by the sign-in method floor that #30298 added to the auth settings save (
sign-in-method-floor.ts), so the three doors cannot drift apart.Door 1: deleting the last identity provider.
POST /api/v1/auth/sso/delete-provider(Setup's "Delete SSO Provider") is refused in plugin-auth's global better-auth before-hook, before the vendor deletes anything, when all of these hold:sys_sso_providerrow. One bounded read of two rows answers this.The answer is
409withcode: DELETE_RESTRICTED,lastSignInMethod: enterprise_sso, and a message in the caller's language (en, zh-CN, ja-JP, es-ES). This is an existing standard code, the one an organization delete answers when it is refused for the same kind of reason. When the provider list cannot be read, the answer is503 SERVICE_UNAVAILABLE, because a guard that could not be evaluated does not pass.Nothing changes for an unauthenticated caller (still the vendor's
401), an unknown provider (still the vendor's404), or the vendor's own provider-access check.AuthManager.setSsoProviderDeleteFloor()(@internal):AuthPluginhands the manager the judge, because the manager does not hold the host handoff declaration or the boot credential index.AuthPlugin.signInMethodFloorDeps(): one builder of the floor's live facts, used by the settings save, the delete, and the boot report.Door 2: a boot that serves no way in. At the
kernel:readysite that already reportsno_sign_in_account_at_boot, after the auth settings are applied, a deployment that serves no sign-in method now reportsno_sign_in_method_at_bootaterror. The line names the consequence and the recovery:OS_AUTH_EMAIL_PASSWORD_ENABLED=trueand a restart.ssoOnlyMode, which the floor does not count, must keep starting.Docs.
content/docs/deployment/self-hosting.mdxgains "No sign-in method left", covering the three doors and the recovery.Why
Both doors were measured on
mainbfca0ef on a real boot before any code (report 6652003986).200 {"success":true}. After sign-out,/sign-in/emailanswered400 EMAIL_PASSWORD_DISABLEDand/sign-in/ssoanswered404.GOOGLE_CLIENT_*pair booted to "Server is ready" with no sign-in line, and served no method.Tests
Pins added to the floor's shared harness
packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts, as top-level describes. There are 18 new cases, and the file has 35 in all.ObjectKernel+ObjectQLPluginover in-memory SQLite, the realSettingsServicePlugin, the realAuthPluginwith the SSO plugin, and a real sign-up and session cookie. Email and password is turned off throughsettings.setMany, which the floor admits while a provider row exists. Then the realPOST /api/v1/auth/sso/delete-provider:409 DELETE_RESTRICTED, the zh-CN copy underAccept-Language: zh-CN, then the en copy, and the row is still there;200) and the one left is then refused;404, and no session gets the vendor's401.emailAndPassword.enabled: falsewith no provider reports once aterrorand the boot completes;GOOGLE_CLIENT_*set reports nothing;OS_AUTH_EMAIL_PASSWORD_ENABLED=trueturns email and password back on over the stack config, and nothing is reported.503on the delete and silence at boot;error, or towarnon a sink withouterror, and a throwing sink does not throw.Ablations (each through
scripts/ablation-replace.mjs, restored, worktree clean after)Each mutation landed (anchor 1 → 0, blob changed), the run went red as below, and the file was restored to the HEAD blob (
git statusclean).await this.assertSsoProviderDeletable(ctx);): the last-provider refusal and the "one left is then refused" control go red,expected 200 to be 409. 2 failed, 33 passed. This reproduces the bug on the test stack.kernel:ready: both door 2 report cases go red. 2 failed, 33 passed.present: the positive-claim case goes red. 1 failed, 17 passed (same).AuthPlugindoes not hand the manager the judge: the two door 1 refusal cases go red,{"success":true}: expected 200 to be 409. 2 failed, 16 passed (same).A1 and A2 were re-run against the final shared file.
Acceptance notes
no — the dev re-measures. Measured: a request accepted before (POST /sso/delete-provideron the last provider,200) is now refused (409). That is an accept-set narrowing at a public endpoint, the same kind fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in #30298 declared for the settings save. Hence the!and the BREAKING section in the changeset. The widening half is one@internalsetter on the exportedAuthManager./sso/register.Verification
All readings are on head
385ecdcc: this branch merged withorigin/main7d7babb1, which carries #30368 and #30376. Heavy runs were underscripts/pm/os-verify-lock.shon a shared box.Real boot re-measure of both doors (pnpm dev, SqlDriver better-sqlite3)
Door 1: fresh boot with
OS_SSO_ENABLED=true, signed in as the dev admin. Two SAML providers were registered through/api/v1/auth/sso/register.PUT /api/settings/auth {email_password_enabled:false}answered200; the floor admits it while SSO has rows.POST /api/v1/auth/sso/delete-provider {providerId:"beta-saml"}→200 {"success":true}(another provider remains).POST /api/v1/auth/sso/delete-provider {providerId:"acme-saml"}→409 {"code":"DELETE_RESTRICTED","message":"This is the last enterprise SSO identity provider, and with email and password off nothing else signs anyone in, …","lastSignInMethod":"enterprise_sso"}.Accept-Language: zh-CN→409with the zh-CN copy.GET /api/v1/data/sys_sso_provider→acme-samlstill there;/auth/configfeatures.sso: true.POST /api/v1/auth/sign-in/sso {email:"someone@acme.test"}→200with the IdP redirect URL.Door 2: the database from the measurement round, with
auth.email_password_enabledstoredfalseand Google previously declared by env.GOOGLE_CLIENT_*loggedERROR [auth] no_sign_in_method_at_boot: this deployment serves NO way to sign in, … Recover: set OS_AUTH_EMAIL_PASSWORD_ENABLED=true and restart … The boot continues., then✓ Server is ready./sign-in/email→400 EMAIL_PASSWORD_DISABLED.OS_AUTH_EMAIL_PASSWORD_ENABLED=truelogged no such line, and/sign-in/email→200.Suites, typecheck, lint
pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=3→VERDICT command-exit 0, 119/119 tasks.pnpm --filter @objectstack/plugin-auth test→VERDICT command-exit 0; Test Files 224 passed (224), Tests 3967 passed, 10 skipped (3977).pnpm --filter @objectstack/plugin-auth typecheck→VERDICT command-exit 0(check:test-typecheck: OK, debt ledger unchanged).src/sign-in-method-floor.test.tsalone → 35/35.pnpm lint→VERDICT command-exit 0.Full dogfood battery (owed by the narrowing arm)
pnpm --filter @objectstack/dogfood test(no shard, no path), after the whole-workspace build above →VERDICT command-exit 0, held the lock 33m24s. Test Files 419 passed, 3 skipped (422). Tests 3492 passed, 36 skipped (3528). No failure.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 95 commands on385ecdcc. Each was run with its exit code recorded before any pipe, and all 95 exited 0.--ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.On the pre-merge head
e5f6926f, two commands were non-zero:check:liveness: main's own red, fixed by fix(spec): repoint the query.searchFields liveness row at engine-find.ts#expandSearchOnAst #30368 and green after the merge.check-plugin-teardown-shape --self-test: exit 3, its pinned fixture commit missing from the shallow clone. It passed aftergit fetch --depth=1of that commit.Human Minimum
packages/specholds no runtime logic. (No spec change.)pnpm lintpasses.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CadeYZeJahkVWxXfYx