From 441d794efaa06c9cd48b24fbeb8eb301e40b6a44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 11:49:34 +0000 Subject: [PATCH 1/2] fix(auth): bind admin-created users to the sole organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/admin/create-user` built a login-capable account but never wrote a `sys_member` row, unlike the invite / add-member flows. In a single-org deployment the new user therefore did not "belong to" the Default Organization and was missing from the Members list, even though both are just "add a teammate". Bind the created user to the organization when it is unambiguous (exactly one `sys_organization` — single-org, incl. plugin-auth's default-org bootstrap). Multi-org (>=2 orgs) is left untouched: there the active-org-aware invite / add-member endpoints own membership. The bind is idempotent, best-effort (never fails account creation), and its outcome is surfaced in the response + audit metadata (organizationId, membershipCreated). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU --- .../src/admin-user-endpoints.test.ts | 123 ++++++++++++++++++ .../plugin-auth/src/admin-user-endpoints.ts | 103 +++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index dc1e65ab1..695683532 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -283,6 +283,129 @@ describe('runAdminCreateUser', () => { expect(sent.data.phoneNumber).toBe('+8613800000000'); expect((res.body.data as any).placeholderEmail).toBe(false); }); + + // ── single-org membership: bind the created user to the sole org ───────── + + /** + * Build deps whose data engine also exposes `find`, seeded with a fixed set + * of `sys_organization` / `sys_member` rows. Records `sys_member` inserts so + * a test can assert the membership bind. + */ + function makeDepsWithOrgs(opts: { + orgs?: Array<{ id: string }>; + members?: Array<{ organization_id: string; user_id: string }>; + }) { + const orgs = opts.orgs ?? []; + const members = opts.members ?? []; + const find = vi.fn(async (object: string, query: any) => { + const where = query?.where ?? {}; + if (object === 'sys_organization') return orgs.slice(0, query?.limit ?? orgs.length); + if (object === 'sys_member') { + return members.filter( + (m) => + (where.organization_id === undefined || m.organization_id === where.organization_id) && + (where.user_id === undefined || m.user_id === where.user_id), + ); + } + return []; + }); + const engineUpdate = vi.fn(async () => ({})); + const engineInsert = vi.fn(async () => ({})); + const m = makeDeps({ + getDataEngine: () => ({ update: engineUpdate, insert: engineInsert, find }), + }); + return { ...m, find, engineUpdate, engineInsert }; + } + + it('binds the created user to the sole organization (single-org)', async () => { + const m = makeDepsWithOrgs({ orgs: [{ id: 'org_only' }] }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.organizationId).toBe('org_only'); + expect(data.membershipCreated).toBe(true); + + const memberInsert = m.engineInsert.mock.calls.find((c) => c[0] === 'sys_member'); + expect(memberInsert).toBeTruthy(); + expect(memberInsert![1]).toMatchObject({ + organization_id: 'org_only', + user_id: 'user-9', + role: 'member', + }); + // audit records the membership outcome + const auditRow = m.engineInsert.mock.calls.find((c) => c[0] === 'sys_audit_log')![1]; + const meta = JSON.parse(auditRow.metadata); + expect(meta.organizationId).toBe('org_only'); + expect(meta.membershipCreated).toBe(true); + }); + + it('does NOT bind when the org is ambiguous (multi-org, ≥2 orgs)', async () => { + const m = makeDepsWithOrgs({ orgs: [{ id: 'org_a' }, { id: 'org_b' }] }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.organizationId).toBeUndefined(); + expect(data.membershipCreated).toBe(false); + expect(m.engineInsert.mock.calls.some((c) => c[0] === 'sys_member')).toBe(false); + }); + + it('is idempotent when a membership already exists', async () => { + const m = makeDepsWithOrgs({ + orgs: [{ id: 'org_only' }], + members: [{ organization_id: 'org_only', user_id: 'user-9' }], + }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.organizationId).toBe('org_only'); + expect(data.membershipCreated).toBe(false); + expect(m.engineInsert.mock.calls.some((c) => c[0] === 'sys_member')).toBe(false); + }); + + it('does not fail account creation when the membership bind throws', async () => { + const m = makeDepsWithOrgs({ orgs: [{ id: 'org_only' }] }); + m.engineInsert.mockImplementation(async (object: string) => { + if (object === 'sys_member') throw new Error('unique violation'); + return {}; + }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.user.id).toBe('user-9'); + expect(data.membershipCreated).toBe(false); + expect(m.warn).toHaveBeenCalled(); + }); + + it('no-ops the bind (no throw) when the engine has no find surface', async () => { + // Default makeDeps engine exposes only update/insert — the bind must be a + // clean no-op, leaving exactly the audit insert. + const m = makeDeps(); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + expect((res.body.data as any).membershipCreated).toBe(false); + expect((res.body.data as any).organizationId).toBeUndefined(); + expect(m.engineCreate).toHaveBeenCalledTimes(1); // audit only + }); }); describe('runAdminSetUserPassword', () => { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index cff93cfe6..3ecd2de7e 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -88,6 +88,12 @@ export interface AdminUserEndpointDeps { export interface AdminUserDataEngine { update(object: string, doc: Record, opts?: unknown): Promise; insert(object: string, doc: Record, opts?: unknown): Promise; + /** + * Optional read surface — used to resolve the sole organization and to keep + * the membership bind idempotent. Absent on lean mocks / when the data plugin + * isn't wired, in which case the org bind simply no-ops. + */ + find?(object: string, query?: unknown, opts?: unknown): Promise; } /** The gated caller, passed by the route after its ADR-0068 check. */ @@ -240,6 +246,94 @@ async function stampMustChangePassword( } } +/** Normalize an ObjectQL find result (array or `{ records }`) to a row array. */ +async function findRows( + engine: AdminUserDataEngine, + object: string, + where: Record, + limit: number, +): Promise { + if (typeof engine.find !== 'function') return []; + try { + const rows = await engine.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) + ? rows + : Array.isArray((rows as any)?.records) + ? (rows as any).records + : []; + } catch { + return []; + } +} + +function genMemberId(): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `mem_${ts}${rand}`; +} + +/** + * Bind an admin-created user to the organization (single-org membership). + * + * Unlike the invite / add-member flows, `/admin/create-user` only ever built a + * login-capable account and never wrote a `sys_member` row — so in a single-org + * deployment the new user did not "belong to" the Default Organization and was + * missing from the Members list, even though both are just "add a teammate". + * + * This binds the new user to the org only when it is UNAMBIGUOUS: exactly one + * `sys_organization` exists (single-org, incl. plugin-auth's default-org + * bootstrap — ADR-0081 D1). Multi-org (≥2 orgs) is deliberately left untouched: + * there the active-org-aware invite / add-member endpoints own membership, and + * guessing an org here would risk the wrong one. Zero orgs (org capability off) + * is a no-op too. + * + * Idempotent and never throws — membership is a convenience, so a failure must + * not fail account creation; the real state is surfaced in the response. + */ +async function bindUserToSoleOrganization( + deps: AdminUserEndpointDeps, + userId: string, +): Promise<{ organizationId: string | null; membershipCreated: boolean }> { + const engine = deps.getDataEngine(); + if (!engine || typeof engine.find !== 'function' || typeof engine.insert !== 'function') { + return { organizationId: null, membershipCreated: false }; + } + try { + // Only auto-bind when the org is unambiguous — exactly one row. + const orgs = await findRows(engine, 'sys_organization', {}, 2); + if (orgs.length !== 1 || !orgs[0]?.id) { + return { organizationId: null, membershipCreated: false }; + } + const organizationId = String(orgs[0].id); + + // Idempotent: respect an existing membership (retry / race) so we never + // trip the (organization_id, user_id) unique index. + const existing = await findRows( + engine, + 'sys_member', + { organization_id: organizationId, user_id: userId }, + 1, + ); + if (existing.length > 0) { + return { organizationId, membershipCreated: false }; + } + + await engine.insert( + 'sys_member', + { id: genMemberId(), organization_id: organizationId, user_id: userId, role: 'member' }, + { context: SYSTEM_CTX }, + ); + return { organizationId, membershipCreated: true }; + } catch (error) { + deps.logger?.warn( + `[AuthPlugin] failed to bind created user ${userId} to the default organization: ${ + (error as Error)?.message ?? error + }`, + ); + return { organizationId: null, membershipCreated: false }; + } +} + /** * Best-effort explicit audit row. better-auth writes bypass the ObjectQL * lifecycle hooks that plugin-audit subscribes to, so admin identity @@ -368,6 +462,11 @@ export async function runAdminCreateUser( ? await stampMustChangePassword(deps, userId, true) : false; + // Match the invite / add-member flows: give the new user a membership so a + // single-org deployment shows them under the Default Organization instead of + // as a member-less account. No-op in multi-org (≥2 orgs) — see the helper. + const membership = await bindUserToSoleOrganization(deps, userId); + await writeAdminAudit(deps, { action: 'create', actor, @@ -380,6 +479,8 @@ export async function runAdminCreateUser( placeholderEmail: !hasEmail, passwordGenerated: resolved.generated, mustChangePassword: stamped, + ...(membership.organizationId ? { organizationId: membership.organizationId } : {}), + membershipCreated: membership.membershipCreated, }, }); @@ -396,6 +497,8 @@ export async function runAdminCreateUser( }, placeholderEmail: !hasEmail, mustChangePassword: stamped, + ...(membership.organizationId ? { organizationId: membership.organizationId } : {}), + membershipCreated: membership.membershipCreated, ...(resolved.generated ? { temporaryPassword: resolved.password } : {}), }, }, From 5483dc0eb78dd6de9d375f7c8b9c0a3c806be446 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:56:02 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(adr):=20ADR-0093=20=E2=80=94=20tenancy?= =?UTF-8?q?=20mode=20as=20a=20capability=20+=20single-owner=20membership?= =?UTF-8?q?=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposes closing the two structural gaps behind the create-user membership bug (PR #2882): (1) the "every new user gets a membership" invariant gets a single owner — a membership reconciler composed into better-auth's user.create.after hook, with an explicit membershipPolicy (auto/invite-only); (2) tenancy mode becomes a first-class `tenancy` kernel service consumed by SecurityPlugin / SQL driver / auth config, retiring ambient re-derivation (env flag, service probe, org row-counting). Degraded tenancy (multi-org requested, enterprise package missing) fails fast instead of silently stripping tenant RLS, with an explicit OS_ALLOW_DEGRADED_TENANCY escape hatch. Includes backfill plan, edge-case ledger, non-goals, and a 4-phase rollout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU --- ...3-tenancy-mode-and-membership-lifecycle.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/adr/0093-tenancy-mode-and-membership-lifecycle.md diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md new file mode 100644 index 000000000..06a00c72a --- /dev/null +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -0,0 +1,333 @@ +# ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle + +- **Status:** Proposed +- **Date:** 2026-07-13 +- **Deciders:** ObjectStack Protocol Architects +- **Relates to:** [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (org-scoped identity optionality), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0092](./0092-sys-user-profile-field-delegation.md) (identity write guard), the default-org bootstrap (`plugin-auth/src/ensure-default-organization.ts`, referenced in code as "ADR-0081 D1" — that decision record predates this repo's ADR series), #2766 (admin user management), PR #2882 (single-org create-user membership bind — the tactical fix this ADR generalizes) + +## TL;DR + +Two structural gaps produced the bug fixed in PR #2882, and will keep producing +siblings of it until closed: + +1. **"Every new user gets an organization membership" is not owned by anyone.** + It is re-implemented (or forgotten) per creation path: invite ✅, add-member ✅, + SSO JIT ✅, cloud host hook ✅, `/admin/create-user` ❌ (fixed tactically in + #2882), `/admin/import-users` ❌, self-signup ❓ (undefined). Each new creation + path is a new place to forget the invariant. +2. **"What tenancy mode is this deployment in?" has no single answer.** At least + four independent signals answer it today — the `OS_MULTI_ORG_ENABLED` env flag, + the `org-scoping` service probe, counting `sys_organization` rows (PR #2882's + heuristic), and the frontend `features.multiOrgEnabled` / `features.organization` + pair — and they can disagree. The worst disagreement is silent: requesting + multi-org without the enterprise package installed degrades to **zero tenant + isolation with only a console warning** (`serve.ts`), an ADR-0049-class + unenforced security property. + +Decision: + +- **D1** — The membership invariant is: *in any deployment where the organization + capability is on, every human user ends the creation pipeline with ≥ 1 + `sys_member` row, unless the deployment's membership policy says otherwise.* + Policy is explicit, not emergent: `auto` (default) / `invite-only`. +- **D2** — One owner: plugin-auth composes a **membership reconciler** into + better-auth's `user.create.after` database hook — the one seam every creation + path (signup, admin create, import, SSO JIT) already flows through. Host hooks + chain first and win; the reconciler yields to any membership that already + exists. Endpoint-level binds (PR #2882) are retired in favor of it. +- **D3** — Target-org resolution consumes the tenancy service (D4), never data + shape: `single` mode → the default org; `multi` mode → the framework does not + guess (invite / JIT / host hooks own it). The "exactly one org row" counting + heuristic from PR #2882 is retired. +- **D4** — A **`tenancy` kernel service** becomes the single source of truth: + `{ mode, isolationActive, requested, degraded, defaultOrgId() }`. plugin-auth + registers the baseline; `@objectstack/organizations` upgrades it. Every current + consumer (SecurityPlugin RLS stripping, SQL-driver tenant audit gate, + `/auth/config` features, CLI wiring) migrates to it. +- **D5** — The degraded middle state **fails fast**: `OS_MULTI_ORG_ENABLED=true` + without a working `@objectstack/organizations` refuses to boot. The only escape + hatch is an explicit `OS_ALLOW_DEGRADED_TENANCY=1`, which brands the deployment + `degraded: true` end-to-end (boot banner, `/auth/config`, Setup dashboard). +- **D6** — A bounded, idempotent **backfill** binds pre-existing member-less users + to the default org on `kernel:ready` — single-org mode only. +- **D7** — Edge-case ledger: existing memberships always win; platform-admin + bootstrap keeps its own path; phone-only users bind like anyone else; + multi-org backfill is explicitly refused. +- **D8** — Non-goals: single-org membership still does **not** gate data access; + RLS strip semantics, the dual frontend feature flags, and better-auth's + ownership of member CRUD are all unchanged. + +## Context + +### How the platform got here + +The single-org / multi-org split is an open-core seam: the open framework ships +member-management basics (better-auth org plugin, invitations, the default-org +bootstrap), while `@objectstack/organizations` (enterprise) adds the multi-tenant +runtime — `organization_id` auto-stamping, per-org seed replay, and the +`org-scoping` service whose *presence* SecurityPlugin probes at `start()` to +decide whether wildcard `tenant_isolation` RLS policies apply or are stripped. + +This seam is sound. What grew up around it is not: + +**Membership creation is distributed.** better-auth owns `sys_member` rows for +its own flows (invite-accept, add-member, SSO `organizationProvisioning`). The +cloud host injects a `user.create.after` hook that provisions a personal org per +new user. The open framework bootstraps a Default Organization for the platform +admin. But the framework itself never took ownership of the general invariant — +so paths that create users *outside* better-auth's org flows (`/admin/create-user`, +`/admin/import-users`, plain email signup) produce **member-less users**. In +single-org mode a member-less user is degraded in concrete ways: + +- the ADR-0081-D1 `session.create.before` hook resolves `activeOrganizationId` + from the user's `sys_member` row — no row ⇒ **null active org for every + session**, so better-auth org endpoints can't resolve an org for them and + `{current_org_id}` navigation tokens fall back; +- the Setup app's Members list omits them, right next to an Invite flow that + *does* create membership — the operator-visible inconsistency reported against + PR #2882. + +**Mode detection is ambient.** `resolveMultiOrgEnabled()` (env), the +`org-scoping` probe (service graph), `sys_organization` row-count (data), and +`features.*` (frontend) are four cached-at-different-times views of one fact. +PR #2882 had to add the fourth precisely because no service exposed the first +two to an endpoint at request time. Ambient facts drift; drifted security facts +violate ADR-0049. + +**The degraded state is silent.** `serve.ts` handles "multi-org requested, +enterprise package missing" with a `console.warn` and continues booting with +tenant RLS stripped. An operator who reads logs carefully knows their org +boundaries are inert; everyone else believes they are multi-tenant. This is the +platform's largest tenancy footgun and it costs one boot-time check to close. + +### Why now + +PR #2882 fixed one endpoint. The same fix is owed to `/admin/import-users`; the +signup question is open; and any future creation surface (SCIM, phone-OTP +first-login, marketplace flows) re-asks the same question. Fixing call sites +one-by-one is how the platform got here. The cheap moment to centralize is now, +while the set of creation paths is still enumerable. + +## Decisions + +### D1 — The membership invariant, stated once + +> In a deployment where the organization capability is enabled, every **human** +> user must hold at least one `sys_member` row by the end of the creation +> pipeline, unless the deployment's membership policy opts out. + +- **Policy knob:** `auth.membershipPolicy: 'auto' | 'invite-only'`, default + `'auto'`. + - `auto` — the reconciler (D2) binds new users to the resolved target org + (D3) when no membership exists. + - `invite-only` — the reconciler never auto-binds; membership is granted only + through explicit flows (invite-accept, add-member, SSO JIT, host hooks). + Deployments that run invite-only discipline choose it consciously instead + of getting it as an accident of which endpoint created the user. +- Self-signup follows the same policy — no signup-specific membership branch. + (Whether signup is *open at all* remains the existing, separate registration + toggle; this ADR does not couple the two.) +- Default role for auto-bound users is `member`. Elevation is a separate, + audited action (`update-member-role`), never part of creation. + +**Rejected alternative:** making membership strictly mandatory (no policy knob). +Rejected because invite-only single-org deployments are legitimate (a shared +service with app end-users who are deliberately *not* teammates), and a +framework that force-joins every end-user into the operator org would be wrong +for them. + +### D2 — One owner: the membership reconciler hook + +plugin-auth composes a `user.create.after` step into `composeDatabaseHooks`, +exactly as the identity-source stamp (`account.create.after`) is composed today: + +``` +host user.create.after (if any) → framework membership reconciler +``` + +- **Coverage:** every creation path flows through better-auth's user pipeline — + email signup, `/admin/create-user` (wrapper drives `authApi.createUser`), + `/admin/import-users` (same), SSO JIT. One seam, all paths — including future + ones, which inherit the invariant without knowing it exists. +- **Yield rule:** the reconciler first checks for *any* existing `sys_member` + row for the user and no-ops if one exists. This makes host composition safe + by construction: the cloud's personal-org provisioning hook runs first, + creates the membership, and the framework reconciler sees it and yields. + No double-membership, no ordering negotiation beyond "host first" (the + existing composition contract). +- **Failure semantics:** best-effort. A failed bind logs a structured warning + and never fails user creation — membership is recoverable bookkeeping; a + created-but-unbindable user is strictly better than a failed signup. The + D6 backfill is the self-healing net for exactly these misses. +- **Idempotency:** keyed on the `(organization_id, user_id)` unique index; + the check-then-insert tolerates races because a lost race hits the unique + index and is swallowed as "already bound". +- **Observability:** every reconciler outcome (`bound` / `yielded` / + `policy-skip` / `no-target-org` / `failed`) emits one structured log line, + and `bound` writes the same audit metadata PR #2882 introduced + (`organizationId`, `membershipCreated`). +- **Retirement:** the endpoint-level `bindUserToSoleOrganization` (PR #2882) + is deleted once the reconciler lands; its tests migrate to the reconciler. + Interim double-coverage is harmless (both sides are idempotent and + yield-to-existing). + +**Rejected alternatives:** +- *Per-endpoint helper calls* (status quo after #2882): every future endpoint + re-must-remember; this is the failure mode being fixed. +- *ObjectQL lifecycle hook on `sys_user` insert*: better-auth adapter writes + carry no request context, and the identity write guard (ADR-0092) already + establishes that better-auth-managed tables are governed at the better-auth + seam, not the engine seam. Follow the precedent. + +### D3 — Target-org resolution consumes declared mode, never data shape + +The reconciler asks the tenancy service (D4): + +| `tenancy.mode` | Target org | +|:---|:---| +| `single` | `tenancy.defaultOrgId()` — the bootstrap org (stable `slug='default'`), or the sole org row if a deployment renamed it | +| `multi` | none — framework never guesses; invite / JIT / host hooks own membership | + +The PR #2882 heuristic — *bind iff exactly one `sys_organization` row exists* — +was the correct call for an endpoint that had no better signal, and the wrong +long-term contract: it infers configuration from data shape, so a multi-org +deployment's transient first-boot state (one org created, second pending) is +indistinguishable from single-org. Mode is configuration; read it as such. + +### D4 — The `tenancy` kernel service + +Registered under the service name **`tenancy`**: + +```ts +interface TenancyService { + /** Resolved mode. Static after kernel:ready. */ + mode: 'single' | 'multi'; + /** True iff org-scoping (auto-stamp + tenant RLS) is actually active. */ + isolationActive: boolean; + /** What the operator asked for (OS_MULTI_ORG_ENABLED). */ + requested: boolean; + /** requested && !isolationActive — the D5 branded state. */ + degraded: boolean; + /** Single mode: the default org id (bootstrapping it if absent). Multi: null. */ + defaultOrgId(): Promise; +} +``` + +- **Registration:** plugin-auth registers the baseline implementation at + `init()` (env flag + lazy default-org resolution). + `@objectstack/organizations` **replaces** it during its own `init()` (it + registers before SecurityPlugin per the existing ordering contract), setting + `isolationActive: true`. Presence-probing of `org-scoping` remains for one + deprecation cycle, then SecurityPlugin consumes `tenancy.isolationActive`. +- **Migration of consumers** (each currently re-derives the fact): + 1. SecurityPlugin's RLS strip gate → `tenancy.isolationActive`; + 2. SQL driver's tenant-audit gate → `tenancy.isolationActive`; + 3. auth-manager `/auth/config` `features.multiOrgEnabled` → `tenancy.mode`; + 4. `serve.ts` / dev / runtime wiring → `tenancy.requested`; + 5. the D2 reconciler → `tenancy.mode` + `defaultOrgId()`. +- `resolveMultiOrgEnabled()` remains the *input parser* for the env flag but + stops being a decision point anywhere outside the tenancy implementation. + +**Rejected alternative:** a kernel-built-in tenancy object. The kernel has no +tenancy concept today and should not grow one for what is an auth/organizations +concern; a service keeps the open-core seam where it already is. + +### D5 — Degraded tenancy fails fast + +At boot, when `tenancy.requested === true` and the organizations package fails +to load (missing, or its `init()` throws): + +- **Default: refuse to boot.** Exit non-zero with an actionable error naming + the package, the flag, and the two remedies (install the package / unset the + flag). A deployment that *asked* for tenant isolation and cannot have it + must not serve traffic pretending otherwise — this is ADR-0049 applied to + deployment configuration. +- **Escape hatch:** `OS_ALLOW_DEGRADED_TENANCY=1` boots anyway, with + `tenancy.degraded = true` propagated everywhere an operator looks: a red + boot banner, `/auth/config` (`degradedTenancy: true`), and the Setup + system-overview dashboard. Degraded operation becomes a visible, chosen + state instead of a log line. +- **Rollout honesty:** some existing deployments are unknowingly degraded + today; fail-fast will stop them on upgrade. That is the point — but the + release notes must say so loudly, and the error message must make recovery + a two-minute task. Shipping this in a minor release with a prominent + BREAKING callout is acceptable; shipping it silently is not. + +### D6 — Backfill for pre-existing member-less users + +On `kernel:ready`, **single mode + `membershipPolicy: 'auto'` only**: + +- find users with zero `sys_member` rows (bounded scan, same pattern and + limits as `backfillOrgAdminGrants`), bind each to `defaultOrgId()` with + role `member`; +- idempotent, failure-isolated per user, one structured summary log + (`scanned / bound / skipped`); +- ordered *after* `ensureDefaultOrganization` (which it composes with — the + platform admin's own bind stays that helper's job, and the yield rule keeps + them from colliding); +- opt-out: `OS_SKIP_MEMBERSHIP_BACKFILL=1` for operators who curate + memberships manually. + +Multi-org backfill is **refused by design** — there is no correct guess, and a +wrong org assignment in a tenant-isolated deployment is a data-exposure bug, +not a convenience. Multi-org operators repair membership through the existing +admin surfaces. + +### D7 — Edge-case ledger + +| Case | Ruling | +|:---|:---| +| User already has any membership | Reconciler and backfill always yield (existing rows win — same principle as `ensureDefaultOrganization`) | +| Platform admin bootstrap | Unchanged; `ensureDefaultOrganization` keeps owning the admin's `owner` bind. The reconciler would bind admins as `member` only if the bootstrap hasn't run, and the bootstrap's own yield check makes the sequence safe in either order | +| Phone-only / placeholder-email users | Bind normally — they are teammates; membership is not email-dependent | +| IdP-provisioned (SSO JIT) users | `organizationProvisioning` creates their membership before the reconciler sees them → yield. If JIT ever misses (no domain-matched org in single mode), the reconciler is the net | +| Impersonation sessions | No change — active-org resolution is session-time, membership is create-time | +| User leaves their only org | Allowed today, unchanged. The invariant governs *creation*, not the full lifecycle; a leave-then-rejoin flow is user intent | +| Machine / service accounts | Out of scope now; when a first-class service-account marker exists, it should exempt from auto-bind (tracked as a follow-up, not blocking) | + +### D8 — Non-goals (explicit refusals) + +1. **Single-org membership still does not gate data access.** RLS stripping + semantics are untouched; RBAC permission sets remain the single-org access + authority. Making `sys_member` authorization-relevant in single mode is a + different, larger decision — refused here to keep this ADR mechanical. +2. **The dual frontend flags keep their meaning.** `features.organization` + (member management available) vs `features.multiOrgEnabled` (org management + available) is a deliberate ADR-0081-D1 distinction; only their *backing + fact* moves to the tenancy service. +3. **better-auth keeps owning `sys_member` CRUD.** The reconciler writes + through the system context exactly as `ensureDefaultOrganization` does; no + new generic-CRUD opening on identity tables (ADR-0092 stands). +4. **No new org-resolution cleverness in multi mode.** Domain matching, + actor-active-org inheritance, etc. remain the province of the flows that + have real context (JIT, invites, host hooks). + +## Rollout + +| Phase | Contents | Risk | +|:---|:---|:---| +| 0 (shipped) | PR #2882 — endpoint-level bind on `/admin/create-user` | none (idempotent, best-effort) | +| 1 | `tenancy` service + D5 fail-fast (+ `OS_ALLOW_DEGRADED_TENANCY`) | boot-blocking for unknowingly-degraded deployments — release-notes callout required | +| 2 | D2 reconciler (+ `membershipPolicy`), consumers migrate to `tenancy`, retire the #2882 endpoint bind and extend coverage to `/admin/import-users` for free | low — yield rule + idempotency make double-coverage safe during the transition | +| 3 | D6 backfill + docs (deployment guide: tenancy modes, membership policy, degraded state) | low — bounded, opt-out | + +Phases 1 and 2 are independently shippable; 1 first is recommended because the +reconciler (2) consumes the service (1), and because 1 closes the only +security-relevant gap. + +## Consequences + +**Positive.** The membership invariant becomes unforgeable-by-omission — future +creation paths inherit it. Tenancy mode becomes a declared, queryable fact with +one implementation; the security-relevant "degraded" state becomes impossible +to enter silently. The class of bug behind PR #2882 is closed, not patched. + +**Negative / accepted.** One more kernel service and one more config knob +(`membershipPolicy`). Fail-fast will halt upgrades for deployments that were +silently degraded — accepted deliberately; the alternative is leaving them +believing they are tenant-isolated. The reconciler adds one indexed read per +user creation (negligible; creation is rare and already multi-write). + +**Deferred.** Service-account exemption (D7); any authorization role for +single-org membership (D8.1); multi-org target-org policy beyond "don't guess" +(D8.4).