|
| 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