feat(auth): tenancy service + membership reconciler + degraded-tenancy fail-fast (ADR-0093 Phases 1–3)#2884
Merged
Merged
Conversation
…wner membership reconciler (ADR-0093) Implements ADR-0093 Phases 1-3. Phase 1 — tenancy as a first-class capability: - New `tenancy` kernel service (plugin-auth) — single source of truth for mode / isolationActive / requested / degraded / defaultOrgId(). Derives isolationActive from the org-scoping probe, so @objectstack/organizations lights it up with no change. - serve.ts fail-fast (D5): OS_MULTI_ORG_ENABLED=true without a working @objectstack/organizations refuses to boot (tenant RLS would be silently stripped). Escape hatch OS_ALLOW_DEGRADED_TENANCY=1 boots in an explicitly branded degraded state. New env resolver resolveAllowDegradedTenancy. Phase 2 — single-owner membership + consumer migration: - reconcile-membership.ts: the membership invariant's one owner, composed into better-auth user.create.after (covers signup / admin create / import / SSO JIT). Yields to existing membership, honors membershipPolicy (auto | invite-only), binds only to an unambiguous target org. - SecurityPlugin RLS-strip gate and getPublicConfig (features.multiOrgEnabled now reflects actual capability; new features.degradedTenancy) consume the tenancy service instead of re-deriving. - The #2882 endpoint create-user bind delegates to the shared reconciler (belt-and-suspenders; both idempotent and yield-to-existing). Phase 3 — backfill + docs: - kernel:ready backfill for pre-existing member-less users (single/auto only; OS_SKIP_MEMBERSHIP_BACKFILL to opt out). - Deployment → Tenancy Modes & Membership docs; ADR-0093 implementation notes. Tests: tenancy-service (11), reconcile-membership (11), admin-user-endpoints (29, refactor preserved), env resolveAllowDegradedTenancy (added). Pure modules fully unit-tested; wiring verified by typecheck (no new-symbol errors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 26 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…nancy guard Cross-links the new fail-fast guard (ADR-0093 D5) from the production-readiness checklist — operators reviewing a multi-org deployment should confirm features.degradedTenancy is false and never set OS_ALLOW_DEGRADED_TENANCY in production. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…rst registration Test Core failed: auth-plugin.test.ts captures the registered AuthManager via registerService.mock.calls[0][1] (first registration). The tenancy service was registered before `auth`, so calls[0] became tenancy (no setRuntimeBaseUrl) and the spies threw. Move the tenancy registration to after the `auth` service registration — the getTenancy closure in authConfig is lazy (read at hook-fire time), so nothing needs it earlier. Also update getPublicConfig's exact-match features assertion to include the new `degradedTenancy` field. Verified locally with built workspace deps: plugin-auth 433, plugin-security 308, types 10 — all green; plugin-auth + plugin-security typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
Verified against objectstack-ai/cloud and runtime dogfooding: - D1: clarify that `auto` JOINS the sole existing org and never CREATES one — workspace-per-user stays a host/product decision (cloud personal-org hook), so the default does not conflict with the framework's B2B posture. - D2: ratify endpoint delegation as the FINAL state (not interim). The hook fires for admin.createUser (runtime-verified), but better-auth defers user.create.after post-commit (#1881), so the endpoint keeps its delegated call to report organizationId/membershipCreated deterministically. - D3: add the cloud reality-check — personal-org hook chains first, reconciler yields/no-ops; the division of labor is what the host already assumes. - D5: blast-radius note — cloud SaaS/EE boot outside serve.ts with the plugin bundled (immune); EE license-gates the flag off; only misconfigured CE self-hosts are stopped, which is the guard's exact purpose. Supports shipping in the next minor. Cloud-side defense-in-depth (factory throw, not process.exit) tracked as a cloud PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
os-zhuang
added a commit
that referenced
this pull request
Jul 13, 2026
… tests (#2887) Follow-up to #2884. Multi-org runtime verification caught a real bug: the endpoint create-user bind resolved its target org via resolveDefaultOrgId, which prefers slug='default' regardless of org count — so in multi-org it would have mis-bound every new user into the bootstrap org (violating ADR-0093 D3). The bind now consults the tenancy service (single → default org; multi → no bind), falling back to the old resolution only when tenancy isn't wired. Adds dogfood integration tests driving the real better-auth pipeline: sign-up reconciler membership, D6 backfill, invite-only policy, and the D2 yield rule. Exports reconcile-membership + tenancy-service as the ADR-0093 host API.
This was referenced Jul 13, 2026
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.
Implements ADR-0093 Phases 1–3. Follows #2882 (Phase 0 — tactical create-user bind, merged), which surfaced the two structural gaps this PR closes.
Why
The bug in #2882 (an admin-created user wasn't a member of the org) was one instance of two systemic gaps:
OS_MULTI_ORG_ENABLED=truewithout the enterprise package strips tenant RLS with only a console warning (an ADR-0049-class unenforced security property).What
Phase 1 — tenancy as a first-class capability
tenancykernel service (plugin-auth/src/tenancy-service.ts) — single source of truth:mode/isolationActive/requested/degraded/defaultOrgId(). DerivesisolationActivefrom theorg-scopingprobe, so@objectstack/organizations(separate repo) lights it up with no change.serve.ts, D5) — multi-org requested but unavailable ⇒ refuse to boot (process.exit(1)with an actionable message; a plain throw would be swallowed by the surrounding AuthPlugin try). Escape hatchOS_ALLOW_DEGRADED_TENANCY=1boots in an explicitly branded degraded state. New resolverresolveAllowDegradedTenancyin@objectstack/types.Phase 2 — single-owner membership + consumer migration
plugin-auth/src/reconcile-membership.ts) composed into better-auth'suser.create.after— the one seam every creation path flows through (signup, admin create-user, import, SSO JIT). Yields to any existing membership (host hooks win — no double bind), honorsmembershipPolicy: 'auto' | 'invite-only'(defaultauto), binds only to an unambiguous target org (single → default; multi → nothing).getPublicConfig(features.multiOrgEnablednow reflects actual capability; newfeatures.degradedTenancy). Both keep a direct-probe fallback so lean embeddings are unchanged.admin.createUseris integration-verified; both idempotent and yield-to-existing, so double-coverage is harmless (ADR-0093 D2).Phase 3 — backfill + docs
kernel:readybackfill for pre-existing member-less users (single-org /autoonly; self-guards in multi-org;OS_SKIP_MEMBERSHIP_BACKFILL=1opt-out).Testing
Pure modules are fully unit-tested; wiring verified by typecheck (no new-symbol errors — remaining tsc output is the repo's pre-existing unbuilt-workspace cascade).
tenancy-service.test.tsreconcile-membership.test.tsadmin-user-endpoints.test.tsenv.test.ts(+resolveAllowDegradedTenancy)New env vars
OS_ALLOW_DEGRADED_TENANCYOS_SKIP_MEMBERSHIP_BACKFILLRollout note (breaking for silently-degraded deployments)
A deployment running
OS_MULTI_ORG_ENABLED=truewithout@objectstack/organizationswas silently unisolated; it will now fail to boot after upgrade. That is intentional (ADR-0093 D5) — install the enterprise package or setOS_ALLOW_DEGRADED_TENANCY=1to acknowledge the state.Deferred (per ADR non-goals)
Single-org membership still does not gate data access; the dual frontend feature flags keep their meaning; better-auth keeps
sys_memberCRUD; multi-org target-org policy stays "don't guess". Fully deleting the endpoint bind waits on integration confirmation that the hook fires foradmin.createUser.🤖 Generated with Claude Code
https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
Generated by Claude Code