You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implements ADR-0093 Phases 1-3: a `tenancy` kernel service as the single source of truth for tenancy mode; fail-fast boot guard on degraded tenancy (OS_ALLOW_DEGRADED_TENANCY escape hatch); a membership reconciler composed into better-auth user.create.after as the single owner of the "every new user gets a membership" invariant (membershipPolicy auto/invite-only); consumer migration (SecurityPlugin RLS gate, /auth/config); kernel:ready backfill; docs + ADR evidence from cloud-host verification and runtime dogfooding.
| Enabled by | unset / `OS_MULTI_ORG_ENABLED=false`|`OS_MULTI_ORG_ENABLED=true`**and**`@objectstack/organizations` installed |
22
+
| Tenant isolation |**off** — `organization_id` is not auto-stamped and the wildcard `tenant_isolation` RLS is stripped |**on** — `organization_id` is auto-stamped and tenant RLS filters every read |
23
+
| Access control | RBAC permission sets only | RBAC permission sets **plus** per-org tenant isolation |
24
+
| Organization row | one bootstrapped "Default Organization" | many, operator/user created |
defaultOrgId():Promise<string|null>; // single → default org; multi → null
43
+
}
44
+
```
45
+
46
+
`/auth/config` reports `features.multiOrgEnabled` (from `mode`) and
47
+
`features.degradedTenancy` so the console renders the correct UI.
48
+
49
+
---
50
+
51
+
## Membership: how new users join an organization
52
+
53
+
Every human user should end up as a member of an organization (a `sys_member`
54
+
row). A single reconciler owns this invariant — it runs as a
55
+
`user.create.after` hook, so **every** creation path is covered uniformly:
56
+
email sign-up, the admin **Create User** and **Import Users** flows, and SSO
57
+
just-in-time provisioning.
58
+
59
+
The reconciler:
60
+
61
+
-**yields** to any membership that already exists (e.g. one created by an
62
+
invitation, `add-member`, SSO provisioning, or a host hook) — it never
63
+
creates a second membership;
64
+
- binds only to an **unambiguous** target org — in single-org mode, the default
65
+
organization; in multi-org mode it binds nothing (invitations, `add-member`,
66
+
and SSO provisioning own membership there, where guessing an org would risk
67
+
the wrong tenant);
68
+
- is **best-effort** — a failure logs a warning and never fails user creation.
69
+
70
+
### Membership policy
71
+
72
+
Control auto-binding with the `membershipPolicy` auth option:
73
+
74
+
| Policy | Behavior |
75
+
|---|---|
76
+
|`'auto'` (default) | New member-less users are bound to the single-org default organization. |
77
+
|`'invite-only'`| Users are **never** auto-bound; membership comes only from invitations, `add-member`, SSO provisioning, or host hooks. Choose this for a deployment whose end-users are deliberately not teammates. |
Copy file name to clipboardExpand all lines: docs/adr/0093-tenancy-mode-and-membership-lifecycle.md
+52-5Lines changed: 52 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,8 +1,9 @@
1
1
# ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle
2
2
3
-
-**Status:** Proposed
3
+
-**Status:** Proposed (implementation in progress)
4
4
-**Date:** 2026-07-13
5
5
-**Deciders:** ObjectStack Protocol Architects
6
+
-**Implementation:**#2882 (Phase 0 — tactical create-user bind, merged) → this PR (Phases 1–3 — `tenancy` service, fail-fast boot guard, membership reconciler, consumer migration, backfill, docs). One revision from the original plan, ratified in D2: the endpoint-level create-user bind **delegates to the shared reconciler** (one implementation, two call sites) instead of being deleted. Runtime verification confirmed the hook fires for `admin.createUser`, but better-auth *defers*`user.create.after` post-commit (#1881), so the endpoint keeps its delegated call to report `organizationId` / `membershipCreated` deterministically in its response. Cloud-host semantics (personal-org hook precedence, multi-org non-binding, D5 blast radius) verified against `objectstack-ai/cloud` — see D2/D3/D5.
6
7
-**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
8
9
## TL;DR
@@ -129,6 +130,14 @@ while the set of creation paths is still enumerable.
129
130
toggle; this ADR does not couple the two.)
130
131
- Default role for auto-bound users is `member`. Elevation is a separate,
131
132
audited action (`update-member-role`), never part of creation.
133
+
-**`auto` joins; it never creates.** The reconciler binds a user to the one
134
+
organization the deployment *already is* — it never mints an organization.
135
+
This keeps the framework's deliberate B2B/invitation posture (documented in
136
+
the cloud's `personal-org-hook.ts`: "the framework's SecurityPlugin
137
+
deliberately does NOT auto-create a personal workspace per signup").
138
+
Workspace-per-user is a *product* decision that stays with hosts (the cloud's
139
+
personal-org hook); joining the sole existing org is *bookkeeping* the
140
+
framework owns. The two must not be conflated when evaluating this default.
132
141
133
142
**Rejected alternative:** making membership strictly mandatory (no policy knob).
134
143
Rejected because invite-only single-org deployments are legitimate (a shared
0 commit comments