fix(auth): a degraded tenancy posture must not hand out a default organization - #4423
Merged
Merged
Conversation
`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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
59 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
TenancyService.defaultOrgId()documents "returnsnullunder 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/isolatedand cannot enforce it (the enterprise@objectstack/organizationspackage is absent) reportsposture: 'single', and the resolver then answered with "theslug='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'suser.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 onkernel:ready/app:seeded.So a degraded deployment silently auto-bound every new user as a
memberof whichever organization happened to be resolvable.This reached production. ObjectStack Cloud's control plane runs
OS_MULTI_ORG_ENABLED=truewhile deliberately not mounting the enterprise package — it enforces its own control-plane org wall instead. Theorg-scopingprobe 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: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
requested: 'single'moves.group/isolated: unchanged — already returnednull.OS_ALLOW_DEGRADED_TENANCY=1.Verification
tenancy-service.test.tspin the degraded behaviour for both walled postures (including theslug='default'shortcut, which also must not fire).plugin-auth: 26 files / 581 tests, 0 failures;tsc --noEmitclean.packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts) boots withoutOS_MULTI_ORG_ENABLEDand 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