Skip to content

Sign-in lockout through doors other than the auth settings save: deleting the last SSO provider row, or a redeploy that drops the last provider, while email and password is off #30300

Open

Description

@objectstack-fleet

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

Filing gate: ② a sibling of #30202 (p1, whole-tenant lockout), found by its dev (PR #30298, os-dev-report 6650530417, out_of_scope class b) and outside that card's door. Read, not measured — the first act is to measure it. ⛔ Not a claim. Triage sets the grade.
Reader: domain:server, plugin-auth (the sign-in method floor PR #30298 adds, sign-in-method-floor.ts) and the doors that remove a method without a settings save.
Dedupe: last sso provider delete lockout, redeploy drops provider lockout → none open. New.

The doors

PR #30298 refuses an auth settings save that would leave no sign-in method. The same end state is reachable without that save, while email and password is off:

  1. Deleting (or disabling) the last sys_sso_provider row through the data API or Setup.
  2. A redeploy whose stack config no longer declares the last provider (or drops plugins.phoneNumber), so the running manager serves no method at the next boot.

Wanted

  • Measure both on a booted deployment with email and password off and one other method.
  • Door 1: refuse the delete/disable that removes the last method, with the same message family as fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in #30298's floor.
  • Door 2: a boot that would serve no sign-in method says so loudly (and names OS_AUTH_EMAIL_PASSWORD_ENABLED=true as the recovery); decide refuse vs warn by posture, as the data-key boot does.

Links: #30202, PR #30298.

Activity

  1. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p1 · domain:server · pm:queue · 2026-11-05T05:53Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. added
    bugSomething isn't working
    domain:serverDomain lane — seat card indexed by label:pm:seat
    priority:p1High: required for production / M2
    and removed on Nov 5, 2026
  3. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

    Claim: PM loop round 2
    Session: session_01Utp6CbPxYq1wQmUNWvMZkC (seat) · dev = project card thread session_01Utp6CadeYZeJahkVWxXfYx
    Account: os-bill
    Branch: claude/project-thread-xt31nw
    Worktree: objectstack-issue-30300
    Domain: domain:server
    Seat: domain:server#3
    File surface: packages/plugins/plugin-auth (the sign-in method floor sign-in-method-floor.ts that PR #30298 adds, reused, and the doors that remove a method without a settings save: the sys_sso_provider delete/disable path, the boot-time manager that serves no method after a redeploy), their tests, one changeset; measurement first on main, code only after PR #30298 merges — stop on breach; explain in the report.
    Container & model: M, mode:thread, model: default judgment tier (dispatch-gates --tier: no path-derived mandate; floor sonnet · default opus · ceiling fable), effort: xhigh (p1, M)
    Clause-②: no — the dev re-measures; a refused delete of the last sign-in method narrows nothing an author writes
    Metadata-protocol: none
    Responsibility: the sign-in method floor is enforced only on the auth settings save (reading, from the #30202 dev, PR #30298 class b) | none: a deployment with email and password off and one other method | every tenant of such a deployment; read, not measured — the first act measures it Thread-read: 6650883232 Premise: holds Serial constraints cleared: serial behind seat 2 PR #30298 (#30202, sign-in-method-floor.ts, in rework after contract review): no code before it merges; seat 2 handed this card to seat 3 (06:41Z); no other in-flight claim on plugin-auth sign-in floor.`

  4. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-bill · session_01Utp6CadeYZeJahkVWxXfYx
    os-dev-report

    {
      "issue": 30300,
      "status": "blocked",
      "branch": "claude/project-thread-xt31nw",
      "pr": null,
      "session": "session_01Utp6CadeYZeJahkVWxXfYx",
      "premise_still_valid": true,
      "summary": "Measurement only, as the Claim orders; no code, no commit, no PR. Both doors reproduce on main bfca0efb on a real showcase boot (pnpm dev, SqlDriver better-sqlite3), with email and password off and exactly one other way in. Door 1: the Setup action 'Delete SSO Provider' (POST /api/v1/auth/sso/delete-provider) deletes the last sys_sso_provider row with 200; /auth/config then serves no method and, after sign-out, every sign-in answers 400 or 404. The data API is not a door: DELETE /api/v1/data/sys_sso_provider/:id answers 405 OBJECT_API_METHOD_NOT_ALLOWED. Door 2: a redeploy that drops the boot-declared Google provider (GOOGLE_CLIENT_ID/SECRET) boots to 'Server is ready' with no line about sign-in, and serves no method; OS_AUTH_EMAIL_PASSWORD_ENABLED=true and a restart recover it. Blocked on the Claim's serial constraint: code waits for PR #30298 (sign-in-method-floor.ts) to merge, then reuses its floor and message family on main.",
      "measurements": {
        "door_1_last_sso_provider_deleted": [
          "Boot: pnpm dev -- --fresh, OS_SSO_ENABLED=true. Signed in as the seeded platform admin (email and password on).",
          "One SAML identity provider registered through POST /api/v1/auth/sso/register (platform-admin gated) -> 200; GET /api/v1/data/sys_sso_provider -> 1 row (acme-saml); /auth/config features.sso true. Setup's own Register SAML Provider bridge refused every input on this vendor version: see out_of_scope_findings.",
          "PUT /api/settings/auth {email_password_enabled:false} -> 200 (main has no floor; with PR #30298 this save is also accepted, because SSO plus a provider row counts as a way in). /auth/config: emailPassword.enabled false, socialProviders [], features.sso true.",
          "Data API: DELETE /api/v1/data/sys_sso_provider/<id> -> 405 OBJECT_API_METHOD_NOT_ALLOWED (allowed: get, list, aggregate, history, search, export); the row stays. Not a door.",
          "Disable: sys_sso_provider has no enabled or disabled field, so there is no disable door; the only other vendor mutation, POST /sso/update-provider, rewrites a provider and removes none (not measured).",
          "Setup's Delete SSO Provider action: POST /api/v1/auth/sso/delete-provider {providerId:'acme-saml'} -> 200 {success:true}; the row is gone (records []). /auth/config: emailPassword.enabled false, socialProviders [], features.sso false, phoneNumber false, which is the login page's 'No sign-in method is available' state (LoginForm.tsx noSignInMethod).",
          "After sign-out: POST /sign-in/email -> 400 EMAIL_PASSWORD_DISABLED; POST /sign-in/sso by email (someone@acme.test) and by providerId -> 404 'No provider found for the issuer'. Nobody can sign in, the administrator included.",
          "Server log: no line about the delete or the lockout. Read, not measured: the vendor's delete (@better-auth/sso 1.7.12 deleteSSOProvider) also removes every account row linked to that providerId, in the same transaction."
        ],
        "door_2_redeploy_drops_last_provider": [
          "Boot A: pnpm dev with a persistent OS_HOME and OS_DATABASE_URL, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET set (Google declared at boot). PUT /api/settings/auth {email_password_enabled:false} -> 200. /auth/config: emailPassword.enabled false, socialProviders [google]. POST /sign-in/social {provider:'google'} -> 200 with the Google authorize URL. Google is the one way in.",
          "Boot B, the redeploy: the same database, GOOGLE_CLIENT_* unset. The boot prints 'Server is ready' and no sign-in line at warn or error; the dev banner still prints the dev admin credentials, which cannot sign in. /auth/config: emailPassword.enabled false, socialProviders [], features.sso false, phoneNumber false. POST /sign-in/email -> 400 EMAIL_PASSWORD_DISABLED; POST /sign-in/social google -> 404 PROVIDER_NOT_FOUND; POST /sign-in/sso -> 404. The only log lines are better-auth's own, per failed request.",
          "Boot C, recovery: the same database plus OS_AUTH_EMAIL_PASSWORD_ENABLED=true. /auth/config emailPassword.enabled true; POST /sign-in/email -> 200 with a session.",
          "Measured once with Google declared by env. Dropping plugins.phoneNumber, a stack-config social provider, or plugins.sso / OS_SSO_ENABLED is the same mechanism (the manager is built from the boot config, and the stored email_password_enabled false applies on top). Read, not measured."
        ],
        "transcripts": "Request and response lines, session tokens redacted, with the scripts that produced them: /mnt/project-files/server-seat3/30300/ in the project folder (door1-final.out, door2-a.out, door2-b.out, door2-c.out; door1-run1.out holds the Setup SAML bridge refusal)."
      },
      "fix_sites_read_for_the_code_round": [
        "Door 1: plugin-auth already intercepts POST /sso/delete-provider in its better-auth before-hook (auth-manager-before-hook.ts, the ADR-0069 D3 held-caller branch), before the vendor deletes anything. The refusal can be made there: email and password off, the provider being deleted is the last sys_sso_provider row, and the floor's otherSignInMethods finds nothing besides enterprise SSO. The vendor's own guardProviderMutation option is a second seam, but it requires an adapter with native transactions and async-context support (assertProviderMutationGuardCapabilities), which the ObjectQL adapter is not known to provide; not measured.",
        "Door 2: the kernel:ready site where boot-sign-in-reachability.ts already reports no_sign_in_account_at_boot reads the live manager and the store. A boot whose manager serves no method can be judged there with the same otherSignInMethods."
      ],
      "tests": "None; measurement only. Evidence is the real-boot transcripts above. The workspace was built with turbo run build --filter=!@objectstack/docs (119 tasks, exit 0) before the boots.",
      "gates": "Not run: no diff.",
      "dogfood_battery": "n/a: no diff.",
      "mcp_calls": "4, all reads: issue_read get #30300, issue_read get_comments #30300, pull_request_read get #30298, pull_request_read get_files #30298. No MCP write.",
      "api_writes": "1: post-stamped, this comment (POST /repos/objectstack-ai/objectstack/issues/30300/comments) through the relay. No git push.",
      "open_questions": [
        {
          "question": "Door 2: refuse the boot by posture (NODE_ENV=production refuses, as LocalCryptoProvider refuses to mint a data key in production), or log at error in every posture?",
          "options": [
            "A: error level in every posture, naming OS_AUTH_EMAIL_PASSWORD_ENABLED=true as the recovery; the boot proceeds",
            "B: production refuses the boot, naming the same recovery; development and test log at error"
          ],
          "recommendation": "A, because the floor's list of ways in does not count ssoOnlyMode alone, which a platform-SSO tenant boots with (boot-sign-in-reachability.ts, the delegated-path section), so B could stop a tenant that works today; and a refusal offers no recovery beyond the same variable. B fits only once every delegated path is counted."
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "class: a · reach: Setup -> SSO Providers -> Register SAML Provider (POST /api/v1/auth/admin/sso/register-saml) answers 400 SAML_REGISTER_FAILED '[body.samlConfig.idpMetadata] idpMetadata.entityID is required when IdP metadata XML is not provided' for a complete form, on main bfca0efb with @better-auth/sso 1.7.12: runRegisterSamlProviderFromForm (plugin-auth register-sso-provider.ts) builds samlConfig with no idpMetadata, and the vendor schema requires idpMetadata.entityID or metadata XML. The same provider registers through the vendor's /sso/register when idpMetadata.entityID is sent · evidence: /mnt/project-files/server-seat3/30300/door1-run1.out · dedupe words: register saml provider idpMetadata, SAML_REGISTER_FAILED entityID, saml bridge better-auth 1.7",
        "carrier: seat · noted, not filed"
      ]
    }

    Report taken 2026-11-05T07:07Z.


    Generated by Claude Code

  5. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-bill · session_01Utp6CadeYZeJahkVWxXfYx
    os-dev-report

    {
      "issue": 30300,
      "status": "done",
      "branch": "claude/project-thread-xt31nw",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/30449",
      "session": "session_01Utp6CadeYZeJahkVWxXfYx",
      "premise_still_valid": true,
      "summary": "Option A as ruled, on main after #30298 (870ebce3, then merged with main 7d7babb1). Door 1: plugin-auth's global before-hook refuses POST /sso/delete-provider when email and password is off, enterprise SSO is on, no other way in counts (otherSignInMethods without enterprise SSO) and the provider is the last sys_sso_provider row. It answers 409 DELETE_RESTRICTED with lastSignInMethod enterprise_sso and localized copy (en/zh-CN/ja-JP/es-ES), or 503 SERVICE_UNAVAILABLE when the provider list cannot be read; the judge is wired by AuthPlugin through an @internal AuthManager.setSsoProviderDeleteFloor. Door 2: at the kernel:ready site of no_sign_in_account_at_boot, after the auth settings are applied, a boot that serves no sign-in method logs no_sign_in_method_at_boot at error in every posture and never refuses the boot (positive claim only; refusal named in the PR body as a later option, not done). One builder (AuthPlugin.signInMethodFloorDeps) feeds the settings save, the delete and the boot report; self-hosting.mdx gains 'No sign-in method left'.",
      "tests": "Pins in the floor's shared harness packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts as top-level describes (ruling 6583487846): 18 new, 35 in the file, 35/35. Door 1 and door 2 run on a real ObjectKernel + ObjectQLPlugin (in-memory SQLite) + SettingsServicePlugin + AuthPlugin (sso) with a real sign-up and cookie, through the real delete endpoint, each with controls (another provider left, email and password on, unknown provider 404, no session 401; Google declared, env recovery over stack config). Judge/report faults pinned against the floor functions (503, silence on unreadable table, seven pass cases, error/warn sink). Ablations via scripts/ablation-replace.mjs, each landed (anchor 1->0, blob changed) and restored clean: A1 before-hook call removed -> 2 red (expected 200 to be 409); A2 boot report call removed -> 2 red; A3 unknown read as present -> 1 red; A4 report speaks unless present -> 1 red; A5 plugin does not wire the judge -> 2 red ({success:true}: expected 200 to be 409). A1/A2 re-run on the final shared file. Final head 385ecdcc: pnpm --filter @objectstack/plugin-auth test VERDICT command-exit 0, Test Files 224/224, Tests 3967 passed 10 skipped; typecheck VERDICT 0 (check:test-typecheck OK, ledger unchanged); pnpm lint VERDICT 0. Real boot re-measure (pnpm dev): door 1 last delete 409 en + zh-CN, row kept, control delete 200, SSO sign-in still routes; door 2 redeploy logs the ERROR line and reaches Server is ready, recovery boot logs none and sign-in/email 200. Transcripts: /mnt/project-files/server-seat3/30300/remeasure-pr-head/.",
      "gates": "dispatch-gates --commands derived 95 on 385ecdcc; all 95 exit 0 (codes recorded before any pipe); --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Pre-merge head e5f6926f: check:liveness exit 1 (main's red, fixed by #30368, green after the merge); check-plugin-teardown-shape --self-test exit 3 (pinned fixture commit absent in the shallow clone; passed after git fetch --depth=1 of it). CI check-runs on #30449: pending at report time.",
      "dogfood_battery": "385ecdcc · os-verify-lock: VERDICT command-exit 0 (held 33m24s) · Test Files 419 passed, 3 skipped (422) · Tests 3492 passed, 36 skipped (3528) · no failure",
      "mcp_calls": "3 this round, all reads: issue_read get_comments #30300, pull_request_read get #30368, pull_request_read get #30376 (the measurement round's 4 reads are in 6652003986); plus subscribe_pr_activity #30449, a session subscription, not a GitHub write. No MCP write.",
      "api_writes": "3: git push to claude/project-thread-xt31nw (4 commits incl. the main merge); relay pr_create -> #30449 (draft, run 42021957727, 12456 bytes read back identical, no assignee, no reviewer); post-stamped, this comment.",
      "open_questions": [
        {
          "question": "Clause-②: the Claim wrote 'no — the dev re-measures'. Measured: POST /sso/delete-provider on the last provider answered 200 before and 409 now, an accept-set narrowing at a published endpoint (the kind #30298 declared yes (narrowing)); the widening half is one @internal setter on the exported AuthManager. Declared 'yes (narrowing)' with '!' in the PR body and changeset.",
          "options": [
            "A: keep yes (narrowing) as declared; the seat runs CONTRACT_REVIEW_TIER",
            "B: re-declare no; the seat edits the PR body and changeset line"
          ],
          "recommendation": "A, because CONTRIBUTING item 5 says yes when a change narrows what a published package accepts, and #30298 set the precedent for the same family."
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: seat · Setup Register SAML Provider bridge 400 already filed by the seat as #30352; no new finding"
      ]
    }

    Report taken 2026-11-05T11:49Z.


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

    Claim amendment · 2026-11-05T12:03Z · declaration and file surface for PR #30449.

    Clause-②: yes (narrowing) — POST /api/v1/auth/sso/delete-provider on the last enterprise SSO provider, with email and password off and no other counted sign-in method, answered 200 and now answers 409 DELETE_RESTRICTED (503 when the provider list cannot be read); the exported AuthManager gains the @internal setter setSsoProviderDeleteFloor(), which @internal does not trim from the published declarations.
    Metadata-protocol: none

    • Changed: the claim's Clause-②: no — the dev re-measures was the seat's reading before the measurement; the dev re-measured 200 → 409 at a published endpoint, and the contract review of record on the PR (CONTRACT_REVIEW_TIER, PASS on 385ecdcc) confirms yes (narrowing). BREAKING: patch with the BREAKING banner and one ADR-0087 marker; landing waits for the maintainer's word.
    • Added to the surface: packages/plugins/plugin-auth/src/auth-manager.ts, auth-manager-before-hook.ts, auth-manager-auth-instance.ts, auth-plugin.ts (kernel:ready boot report), content/docs/deployment/self-hosting.mdx.
    • Unchanged: branch claude/project-thread-xt31nw; seat ruling option A (the boot report never refuses the boot).
  7. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

    ACCEPT → PR #30449 comment 6657794372 (seat record 6658022337) · 2026-11-05T12:30Z · maintainer's word for the BREAKING landing taken 12:28Z; ready + auto-merge armed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:dispatchedpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions