Skip to content

fix(auth): a degraded tenancy posture must not hand out a default organization - #4423

Merged
os-zhuang merged 1 commit into
mainfrom
fix/tenancy-default-org-fail-closed
Aug 1, 2026
Merged

fix(auth): a degraded tenancy posture must not hand out a default organization#4423
os-zhuang merged 1 commit into
mainfrom
fix/tenancy-default-org-fail-closed

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

What

TenancyService.defaultOrgId() documents "returns null under any walled posture", but the implementation keyed on the posture actually in force (isolationActive()) instead of the one the operator requested.

Those two disagree in exactly one state — degraded: a deployment that asked for group/isolated and cannot enforce it (the enterprise @objectstack/organizations package is absent) reports posture: 'single', and the resolver then answered with "the slug='default' org, or the only org that exists".

 async defaultOrgId(): Promise<string | null> {
-  if (isolationActive()) return null;
+  if (postureEnforcesWall(requestedPosture)) return null;

Why it matters

Everything downstream of that resolver binds new users to whatever it returns:

  • reconcileMembership (ADR-0093 D2) is composed into better-auth's user.create.after — the one seam every creation path flows through (email signup, /admin/create-user, bulk import, SSO JIT);
  • backfillMemberships (D6) sweeps every pre-existing member-less user in on kernel:ready / app:seeded.

So a degraded deployment silently auto-bound every new user as a member of whichever organization happened to be resolvable.

This reached production. ObjectStack Cloud's control plane runs OS_MULTI_ORG_ENABLED=true while deliberately not mounting the enterprise package — it enforces its own control-plane org wall instead. The org-scoping probe therefore misses, the posture resolves degraded, and fresh self-serve signups landed inside a stranger's organization with read access to that org's environments (cloud#957). Reproduced deterministically against a control plane holding one organization:

[membership] bound user to organization {"userId":"…","organizationId":"org_seed_repro"}
sys_member for new user: [{"org":"org_seed_repro","role":"member"}]

Why fail closed

This is the same judgement D6 already applies to the backfill — "a wrong org in a tenant-isolated deployment is a data-exposure bug, not a convenience" — applied to the resolver those consumers share. Degrading the wall is survivable and already branded (tenancy.degraded, /auth/config, the Setup dashboard); degrading into cross-tenant membership writes is not.

It also removes an inconsistency: the default-org bootstrap in AuthPlugin.start() was already gated on !postureEnforcesWall(resolveTenancyPosture()) — the requested posture. Now the resolver agrees with it.

Blast radius

  • Single-org: unchanged. Nothing about requested: 'single' moves.
  • Enforced group/isolated: unchanged — already returned null.
  • Degraded: loses the auto-bind. That is the point, and ADR-0093 D5 already refuses to boot such a deployment at all unless the operator sets OS_ALLOW_DEGRADED_TENANCY=1.

Verification

  • Two new cases in tenancy-service.test.ts pin the degraded behaviour for both walled postures (including the slug='default' shortcut, which also must not fire).
  • plugin-auth: 26 files / 581 tests, 0 failures; tsc --noEmit clean.
  • The single-org dogfood (packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts) boots without OS_MULTI_ORG_ENABLED and is unaffected.

Cloud closes its own half in objectstack-ai/cloud#957 by declaring membershipPolicy: 'invite-only' on the control plane — it does not wait on this pin.

🤖 Generated with Claude Code

`TenancyService.defaultOrgId()` documented "returns null under any walled
posture" but keyed on the posture actually IN FORCE, not the one requested.
Those disagree in exactly one state — DEGRADED — and there the resolver
answered with "the slug='default' org, or the only org that exists".

Everything downstream binds new users to that answer. The membership
reconciler (ADR-0093 D2) sits on `user.create.after`, the seam every creation
path flows through, so a degraded deployment auto-bound every fresh signup,
admin-created user and SSO JIT user as a `member` of whichever organization
was resolvable — and `backfillMemberships` (D6) would sweep the pre-existing
member-less ones in on the next `kernel:ready`.

This reached production: ObjectStack Cloud's control plane requests
`isolated` while deliberately not mounting `@objectstack/organizations` (it
enforces its own control-plane wall), so the `org-scoping` probe missed, the
posture resolved degraded, and self-serve signups landed inside a stranger's
organization with read access to its environments (cloud#957).

`defaultOrgId()` now keys on `requestedPosture` — any walled request,
enforced or degraded, returns null and the framework never guesses. Same
judgement D6 already applies to the backfill ("a wrong org in a
tenant-isolated deployment is a data-exposure bug, not a convenience"),
applied to the resolver those consumers share, and now consistent with the
default-org bootstrap in `AuthPlugin.start()`, which was already gated on the
requested posture.

Single-org is unchanged. A degraded deployment loses the auto-bind, which is
the point — ADR-0093 D5 already refuses to boot it without
`OS_ALLOW_DEGRADED_TENANCY=1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 12:48am

Request Review

@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit c03108c into main Aug 1, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the fix/tenancy-default-org-fail-closed branch August 1, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant