Skip to content

Commit 7ee116e

Browse files
authored
fix(auth): bind admin-created users to the sole organization (#2882)
Bind admin-created users to the sole organization in single-org mode, matching the invite / add-member flows, and add ADR-0093 (Proposed) proposing the systemic fix: a single-owner membership reconciler + a first-class tenancy service with fail-fast on degraded tenancy.
1 parent f71339c commit 7ee116e

3 files changed

Lines changed: 559 additions & 0 deletions

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
# ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-07-13
5+
- **Deciders:** ObjectStack Protocol Architects
6+
- **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)
7+
8+
## TL;DR
9+
10+
Two structural gaps produced the bug fixed in PR #2882, and will keep producing
11+
siblings of it until closed:
12+
13+
1. **"Every new user gets an organization membership" is not owned by anyone.**
14+
It is re-implemented (or forgotten) per creation path: invite ✅, add-member ✅,
15+
SSO JIT ✅, cloud host hook ✅, `/admin/create-user` ❌ (fixed tactically in
16+
#2882), `/admin/import-users` ❌, self-signup ❓ (undefined). Each new creation
17+
path is a new place to forget the invariant.
18+
2. **"What tenancy mode is this deployment in?" has no single answer.** At least
19+
four independent signals answer it today — the `OS_MULTI_ORG_ENABLED` env flag,
20+
the `org-scoping` service probe, counting `sys_organization` rows (PR #2882's
21+
heuristic), and the frontend `features.multiOrgEnabled` / `features.organization`
22+
pair — and they can disagree. The worst disagreement is silent: requesting
23+
multi-org without the enterprise package installed degrades to **zero tenant
24+
isolation with only a console warning** (`serve.ts`), an ADR-0049-class
25+
unenforced security property.
26+
27+
Decision:
28+
29+
- **D1** — The membership invariant is: *in any deployment where the organization
30+
capability is on, every human user ends the creation pipeline with ≥ 1
31+
`sys_member` row, unless the deployment's membership policy says otherwise.*
32+
Policy is explicit, not emergent: `auto` (default) / `invite-only`.
33+
- **D2** — One owner: plugin-auth composes a **membership reconciler** into
34+
better-auth's `user.create.after` database hook — the one seam every creation
35+
path (signup, admin create, import, SSO JIT) already flows through. Host hooks
36+
chain first and win; the reconciler yields to any membership that already
37+
exists. Endpoint-level binds (PR #2882) are retired in favor of it.
38+
- **D3** — Target-org resolution consumes the tenancy service (D4), never data
39+
shape: `single` mode → the default org; `multi` mode → the framework does not
40+
guess (invite / JIT / host hooks own it). The "exactly one org row" counting
41+
heuristic from PR #2882 is retired.
42+
- **D4** — A **`tenancy` kernel service** becomes the single source of truth:
43+
`{ mode, isolationActive, requested, degraded, defaultOrgId() }`. plugin-auth
44+
registers the baseline; `@objectstack/organizations` upgrades it. Every current
45+
consumer (SecurityPlugin RLS stripping, SQL-driver tenant audit gate,
46+
`/auth/config` features, CLI wiring) migrates to it.
47+
- **D5** — The degraded middle state **fails fast**: `OS_MULTI_ORG_ENABLED=true`
48+
without a working `@objectstack/organizations` refuses to boot. The only escape
49+
hatch is an explicit `OS_ALLOW_DEGRADED_TENANCY=1`, which brands the deployment
50+
`degraded: true` end-to-end (boot banner, `/auth/config`, Setup dashboard).
51+
- **D6** — A bounded, idempotent **backfill** binds pre-existing member-less users
52+
to the default org on `kernel:ready` — single-org mode only.
53+
- **D7** — Edge-case ledger: existing memberships always win; platform-admin
54+
bootstrap keeps its own path; phone-only users bind like anyone else;
55+
multi-org backfill is explicitly refused.
56+
- **D8** — Non-goals: single-org membership still does **not** gate data access;
57+
RLS strip semantics, the dual frontend feature flags, and better-auth's
58+
ownership of member CRUD are all unchanged.
59+
60+
## Context
61+
62+
### How the platform got here
63+
64+
The single-org / multi-org split is an open-core seam: the open framework ships
65+
member-management basics (better-auth org plugin, invitations, the default-org
66+
bootstrap), while `@objectstack/organizations` (enterprise) adds the multi-tenant
67+
runtime — `organization_id` auto-stamping, per-org seed replay, and the
68+
`org-scoping` service whose *presence* SecurityPlugin probes at `start()` to
69+
decide whether wildcard `tenant_isolation` RLS policies apply or are stripped.
70+
71+
This seam is sound. What grew up around it is not:
72+
73+
**Membership creation is distributed.** better-auth owns `sys_member` rows for
74+
its own flows (invite-accept, add-member, SSO `organizationProvisioning`). The
75+
cloud host injects a `user.create.after` hook that provisions a personal org per
76+
new user. The open framework bootstraps a Default Organization for the platform
77+
admin. But the framework itself never took ownership of the general invariant —
78+
so paths that create users *outside* better-auth's org flows (`/admin/create-user`,
79+
`/admin/import-users`, plain email signup) produce **member-less users**. In
80+
single-org mode a member-less user is degraded in concrete ways:
81+
82+
- the ADR-0081-D1 `session.create.before` hook resolves `activeOrganizationId`
83+
from the user's `sys_member` row — no row ⇒ **null active org for every
84+
session**, so better-auth org endpoints can't resolve an org for them and
85+
`{current_org_id}` navigation tokens fall back;
86+
- the Setup app's Members list omits them, right next to an Invite flow that
87+
*does* create membership — the operator-visible inconsistency reported against
88+
PR #2882.
89+
90+
**Mode detection is ambient.** `resolveMultiOrgEnabled()` (env), the
91+
`org-scoping` probe (service graph), `sys_organization` row-count (data), and
92+
`features.*` (frontend) are four cached-at-different-times views of one fact.
93+
PR #2882 had to add the fourth precisely because no service exposed the first
94+
two to an endpoint at request time. Ambient facts drift; drifted security facts
95+
violate ADR-0049.
96+
97+
**The degraded state is silent.** `serve.ts` handles "multi-org requested,
98+
enterprise package missing" with a `console.warn` and continues booting with
99+
tenant RLS stripped. An operator who reads logs carefully knows their org
100+
boundaries are inert; everyone else believes they are multi-tenant. This is the
101+
platform's largest tenancy footgun and it costs one boot-time check to close.
102+
103+
### Why now
104+
105+
PR #2882 fixed one endpoint. The same fix is owed to `/admin/import-users`; the
106+
signup question is open; and any future creation surface (SCIM, phone-OTP
107+
first-login, marketplace flows) re-asks the same question. Fixing call sites
108+
one-by-one is how the platform got here. The cheap moment to centralize is now,
109+
while the set of creation paths is still enumerable.
110+
111+
## Decisions
112+
113+
### D1 — The membership invariant, stated once
114+
115+
> In a deployment where the organization capability is enabled, every **human**
116+
> user must hold at least one `sys_member` row by the end of the creation
117+
> pipeline, unless the deployment's membership policy opts out.
118+
119+
- **Policy knob:** `auth.membershipPolicy: 'auto' | 'invite-only'`, default
120+
`'auto'`.
121+
- `auto` — the reconciler (D2) binds new users to the resolved target org
122+
(D3) when no membership exists.
123+
- `invite-only` — the reconciler never auto-binds; membership is granted only
124+
through explicit flows (invite-accept, add-member, SSO JIT, host hooks).
125+
Deployments that run invite-only discipline choose it consciously instead
126+
of getting it as an accident of which endpoint created the user.
127+
- Self-signup follows the same policy — no signup-specific membership branch.
128+
(Whether signup is *open at all* remains the existing, separate registration
129+
toggle; this ADR does not couple the two.)
130+
- Default role for auto-bound users is `member`. Elevation is a separate,
131+
audited action (`update-member-role`), never part of creation.
132+
133+
**Rejected alternative:** making membership strictly mandatory (no policy knob).
134+
Rejected because invite-only single-org deployments are legitimate (a shared
135+
service with app end-users who are deliberately *not* teammates), and a
136+
framework that force-joins every end-user into the operator org would be wrong
137+
for them.
138+
139+
### D2 — One owner: the membership reconciler hook
140+
141+
plugin-auth composes a `user.create.after` step into `composeDatabaseHooks`,
142+
exactly as the identity-source stamp (`account.create.after`) is composed today:
143+
144+
```
145+
host user.create.after (if any) → framework membership reconciler
146+
```
147+
148+
- **Coverage:** every creation path flows through better-auth's user pipeline —
149+
email signup, `/admin/create-user` (wrapper drives `authApi.createUser`),
150+
`/admin/import-users` (same), SSO JIT. One seam, all paths — including future
151+
ones, which inherit the invariant without knowing it exists.
152+
- **Yield rule:** the reconciler first checks for *any* existing `sys_member`
153+
row for the user and no-ops if one exists. This makes host composition safe
154+
by construction: the cloud's personal-org provisioning hook runs first,
155+
creates the membership, and the framework reconciler sees it and yields.
156+
No double-membership, no ordering negotiation beyond "host first" (the
157+
existing composition contract).
158+
- **Failure semantics:** best-effort. A failed bind logs a structured warning
159+
and never fails user creation — membership is recoverable bookkeeping; a
160+
created-but-unbindable user is strictly better than a failed signup. The
161+
D6 backfill is the self-healing net for exactly these misses.
162+
- **Idempotency:** keyed on the `(organization_id, user_id)` unique index;
163+
the check-then-insert tolerates races because a lost race hits the unique
164+
index and is swallowed as "already bound".
165+
- **Observability:** every reconciler outcome (`bound` / `yielded` /
166+
`policy-skip` / `no-target-org` / `failed`) emits one structured log line,
167+
and `bound` writes the same audit metadata PR #2882 introduced
168+
(`organizationId`, `membershipCreated`).
169+
- **Retirement:** the endpoint-level `bindUserToSoleOrganization` (PR #2882)
170+
is deleted once the reconciler lands; its tests migrate to the reconciler.
171+
Interim double-coverage is harmless (both sides are idempotent and
172+
yield-to-existing).
173+
174+
**Rejected alternatives:**
175+
- *Per-endpoint helper calls* (status quo after #2882): every future endpoint
176+
re-must-remember; this is the failure mode being fixed.
177+
- *ObjectQL lifecycle hook on `sys_user` insert*: better-auth adapter writes
178+
carry no request context, and the identity write guard (ADR-0092) already
179+
establishes that better-auth-managed tables are governed at the better-auth
180+
seam, not the engine seam. Follow the precedent.
181+
182+
### D3 — Target-org resolution consumes declared mode, never data shape
183+
184+
The reconciler asks the tenancy service (D4):
185+
186+
| `tenancy.mode` | Target org |
187+
|:---|:---|
188+
| `single` | `tenancy.defaultOrgId()` — the bootstrap org (stable `slug='default'`), or the sole org row if a deployment renamed it |
189+
| `multi` | none — framework never guesses; invite / JIT / host hooks own membership |
190+
191+
The PR #2882 heuristic — *bind iff exactly one `sys_organization` row exists*
192+
was the correct call for an endpoint that had no better signal, and the wrong
193+
long-term contract: it infers configuration from data shape, so a multi-org
194+
deployment's transient first-boot state (one org created, second pending) is
195+
indistinguishable from single-org. Mode is configuration; read it as such.
196+
197+
### D4 — The `tenancy` kernel service
198+
199+
Registered under the service name **`tenancy`**:
200+
201+
```ts
202+
interface TenancyService {
203+
/** Resolved mode. Static after kernel:ready. */
204+
mode: 'single' | 'multi';
205+
/** True iff org-scoping (auto-stamp + tenant RLS) is actually active. */
206+
isolationActive: boolean;
207+
/** What the operator asked for (OS_MULTI_ORG_ENABLED). */
208+
requested: boolean;
209+
/** requested && !isolationActive — the D5 branded state. */
210+
degraded: boolean;
211+
/** Single mode: the default org id (bootstrapping it if absent). Multi: null. */
212+
defaultOrgId(): Promise<string | null>;
213+
}
214+
```
215+
216+
- **Registration:** plugin-auth registers the baseline implementation at
217+
`init()` (env flag + lazy default-org resolution).
218+
`@objectstack/organizations` **replaces** it during its own `init()` (it
219+
registers before SecurityPlugin per the existing ordering contract), setting
220+
`isolationActive: true`. Presence-probing of `org-scoping` remains for one
221+
deprecation cycle, then SecurityPlugin consumes `tenancy.isolationActive`.
222+
- **Migration of consumers** (each currently re-derives the fact):
223+
1. SecurityPlugin's RLS strip gate → `tenancy.isolationActive`;
224+
2. SQL driver's tenant-audit gate → `tenancy.isolationActive`;
225+
3. auth-manager `/auth/config` `features.multiOrgEnabled``tenancy.mode`;
226+
4. `serve.ts` / dev / runtime wiring → `tenancy.requested`;
227+
5. the D2 reconciler → `tenancy.mode` + `defaultOrgId()`.
228+
- `resolveMultiOrgEnabled()` remains the *input parser* for the env flag but
229+
stops being a decision point anywhere outside the tenancy implementation.
230+
231+
**Rejected alternative:** a kernel-built-in tenancy object. The kernel has no
232+
tenancy concept today and should not grow one for what is an auth/organizations
233+
concern; a service keeps the open-core seam where it already is.
234+
235+
### D5 — Degraded tenancy fails fast
236+
237+
At boot, when `tenancy.requested === true` and the organizations package fails
238+
to load (missing, or its `init()` throws):
239+
240+
- **Default: refuse to boot.** Exit non-zero with an actionable error naming
241+
the package, the flag, and the two remedies (install the package / unset the
242+
flag). A deployment that *asked* for tenant isolation and cannot have it
243+
must not serve traffic pretending otherwise — this is ADR-0049 applied to
244+
deployment configuration.
245+
- **Escape hatch:** `OS_ALLOW_DEGRADED_TENANCY=1` boots anyway, with
246+
`tenancy.degraded = true` propagated everywhere an operator looks: a red
247+
boot banner, `/auth/config` (`degradedTenancy: true`), and the Setup
248+
system-overview dashboard. Degraded operation becomes a visible, chosen
249+
state instead of a log line.
250+
- **Rollout honesty:** some existing deployments are unknowingly degraded
251+
today; fail-fast will stop them on upgrade. That is the point — but the
252+
release notes must say so loudly, and the error message must make recovery
253+
a two-minute task. Shipping this in a minor release with a prominent
254+
BREAKING callout is acceptable; shipping it silently is not.
255+
256+
### D6 — Backfill for pre-existing member-less users
257+
258+
On `kernel:ready`, **single mode + `membershipPolicy: 'auto'` only**:
259+
260+
- find users with zero `sys_member` rows (bounded scan, same pattern and
261+
limits as `backfillOrgAdminGrants`), bind each to `defaultOrgId()` with
262+
role `member`;
263+
- idempotent, failure-isolated per user, one structured summary log
264+
(`scanned / bound / skipped`);
265+
- ordered *after* `ensureDefaultOrganization` (which it composes with — the
266+
platform admin's own bind stays that helper's job, and the yield rule keeps
267+
them from colliding);
268+
- opt-out: `OS_SKIP_MEMBERSHIP_BACKFILL=1` for operators who curate
269+
memberships manually.
270+
271+
Multi-org backfill is **refused by design** — there is no correct guess, and a
272+
wrong org assignment in a tenant-isolated deployment is a data-exposure bug,
273+
not a convenience. Multi-org operators repair membership through the existing
274+
admin surfaces.
275+
276+
### D7 — Edge-case ledger
277+
278+
| Case | Ruling |
279+
|:---|:---|
280+
| User already has any membership | Reconciler and backfill always yield (existing rows win — same principle as `ensureDefaultOrganization`) |
281+
| 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 |
282+
| Phone-only / placeholder-email users | Bind normally — they are teammates; membership is not email-dependent |
283+
| 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 |
284+
| Impersonation sessions | No change — active-org resolution is session-time, membership is create-time |
285+
| User leaves their only org | Allowed today, unchanged. The invariant governs *creation*, not the full lifecycle; a leave-then-rejoin flow is user intent |
286+
| 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) |
287+
288+
### D8 — Non-goals (explicit refusals)
289+
290+
1. **Single-org membership still does not gate data access.** RLS stripping
291+
semantics are untouched; RBAC permission sets remain the single-org access
292+
authority. Making `sys_member` authorization-relevant in single mode is a
293+
different, larger decision — refused here to keep this ADR mechanical.
294+
2. **The dual frontend flags keep their meaning.** `features.organization`
295+
(member management available) vs `features.multiOrgEnabled` (org management
296+
available) is a deliberate ADR-0081-D1 distinction; only their *backing
297+
fact* moves to the tenancy service.
298+
3. **better-auth keeps owning `sys_member` CRUD.** The reconciler writes
299+
through the system context exactly as `ensureDefaultOrganization` does; no
300+
new generic-CRUD opening on identity tables (ADR-0092 stands).
301+
4. **No new org-resolution cleverness in multi mode.** Domain matching,
302+
actor-active-org inheritance, etc. remain the province of the flows that
303+
have real context (JIT, invites, host hooks).
304+
305+
## Rollout
306+
307+
| Phase | Contents | Risk |
308+
|:---|:---|:---|
309+
| 0 (shipped) | PR #2882 — endpoint-level bind on `/admin/create-user` | none (idempotent, best-effort) |
310+
| 1 | `tenancy` service + D5 fail-fast (+ `OS_ALLOW_DEGRADED_TENANCY`) | boot-blocking for unknowingly-degraded deployments — release-notes callout required |
311+
| 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 |
312+
| 3 | D6 backfill + docs (deployment guide: tenancy modes, membership policy, degraded state) | low — bounded, opt-out |
313+
314+
Phases 1 and 2 are independently shippable; 1 first is recommended because the
315+
reconciler (2) consumes the service (1), and because 1 closes the only
316+
security-relevant gap.
317+
318+
## Consequences
319+
320+
**Positive.** The membership invariant becomes unforgeable-by-omission — future
321+
creation paths inherit it. Tenancy mode becomes a declared, queryable fact with
322+
one implementation; the security-relevant "degraded" state becomes impossible
323+
to enter silently. The class of bug behind PR #2882 is closed, not patched.
324+
325+
**Negative / accepted.** One more kernel service and one more config knob
326+
(`membershipPolicy`). Fail-fast will halt upgrades for deployments that were
327+
silently degraded — accepted deliberately; the alternative is leaving them
328+
believing they are tenant-isolated. The reconciler adds one indexed read per
329+
user creation (negligible; creation is rare and already multi-write).
330+
331+
**Deferred.** Service-account exemption (D7); any authorization role for
332+
single-org membership (D8.1); multi-org target-org policy beyond "don't guess"
333+
(D8.4).

0 commit comments

Comments
 (0)