Skip to content

Commit 824a395

Browse files
authored
feat(auth): tenancy service + membership reconciler + degraded-tenancy fail-fast (ADR-0093 Phases 1–3) (#2884)
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.
1 parent 35f6c61 commit 824a395

18 files changed

Lines changed: 1170 additions & 98 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/plugin-auth": minor
4+
"@objectstack/plugin-security": patch
5+
"@objectstack/cli": minor
6+
---
7+
8+
Tenancy mode as a first-class capability + a single owner for the user→membership
9+
lifecycle (ADR-0093, Phases 1–3).
10+
11+
**Tenancy service (`@objectstack/types`, `@objectstack/plugin-auth`).** plugin-auth
12+
registers a `tenancy` service — the single source of truth for tenancy mode
13+
(`mode`, `isolationActive`, `requested`, `degraded`, `defaultOrgId()`). It derives
14+
`isolationActive` from the presence of the `org-scoping` service, so the
15+
enterprise `@objectstack/organizations` package lights it up with no change.
16+
SecurityPlugin's RLS-strip gate and `/auth/config` (`features.multiOrgEnabled`,
17+
new `features.degradedTenancy`) now consume it instead of re-deriving the fact.
18+
19+
**Fail-fast on degraded tenancy (`@objectstack/cli`, ADR-0093 D5).**
20+
`OS_MULTI_ORG_ENABLED=true` without a working `@objectstack/organizations` now
21+
**refuses to boot** — a deployment that requested tenant isolation must not serve
22+
traffic without it (tenant RLS would be silently stripped). Escape hatch:
23+
`OS_ALLOW_DEGRADED_TENANCY=1` boots in an explicitly branded degraded state
24+
(`features.degradedTenancy`). **This may halt upgrades for deployments that were
25+
silently degraded — intentionally; install the enterprise package or set the
26+
escape hatch.**
27+
28+
**Membership reconciler (`@objectstack/plugin-auth`, ADR-0093 D1–D3, D6).** A
29+
single reconciler composed into better-auth's `user.create.after` hook owns the
30+
"every new user gets a membership" invariant across all creation paths (signup,
31+
admin create-user, import, SSO JIT). It yields to any existing membership (host
32+
hooks win), honors a new `membershipPolicy: 'auto' | 'invite-only'` auth option
33+
(default `auto`), and binds only to an unambiguous target org (single-org default;
34+
multi-org binds nothing). A bounded, idempotent `kernel:ready` backfill covers
35+
pre-existing member-less users in single-org/auto deployments
36+
(`OS_SKIP_MEMBERSHIP_BACKFILL=1` to opt out). The endpoint-level create-user bind
37+
from #2882 now delegates to this shared reconciler.
38+
39+
New env vars: `OS_ALLOW_DEGRADED_TENANCY`, `OS_SKIP_MEMBERSHIP_BACKFILL`. New docs:
40+
Deployment → Tenancy Modes & Membership.

content/docs/deployment/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"publish-and-preview",
99
"environment-variables",
1010
"single-project-mode",
11+
"tenancy-modes",
1112
"migration-from-objectql",
1213
"troubleshooting"
1314
]

content/docs/deployment/production-readiness.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/framework/blob/main
8282
- [ ] better-auth session TTL / refresh / revoke verified (curl
8383
checklist in HARDENING.md).
8484
- [ ] Cross-tenant negative tests in CI.
85+
- [ ] Multi-org deployments: tenant isolation is **active**, not degraded —
86+
confirm `features.degradedTenancy` is `false` in `/auth/config`. A
87+
deployment requesting multi-org without `@objectstack/organizations` now
88+
refuses to boot unless `OS_ALLOW_DEGRADED_TENANCY=1`; never set that flag
89+
in production. See [Tenancy Modes & Membership](/docs/deployment/tenancy-modes).
8590
- [ ] Backup / restore drill documented and tested.
8691
- [ ] Data-retention windows reviewed (ADR-0057): the platform's default
8792
`lifecycle` declarations bound telemetry (activity 14d, job runs 30d,
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
title: Tenancy Modes & Membership
3+
description: Single-org vs multi-org tenancy, the membership policy for new users, and the degraded-tenancy boot guard (ADR-0093).
4+
---
5+
6+
# Tenancy Modes & Membership
7+
8+
An ObjectStack deployment runs in one of two tenancy modes. The mode governs
9+
whether organization boundaries isolate data, how new users are placed into an
10+
organization, and which organization-management UI is available.
11+
12+
This page describes the runtime contract introduced by
13+
[ADR-0093](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md).
14+
15+
---
16+
17+
## The two modes
18+
19+
| | **Single-org** (default) | **Multi-org** |
20+
|---|---|---|
21+
| 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 |
25+
| Org-management UI (create/switch/delete org) | hidden | shown |
26+
27+
The multi-org runtime lives in the enterprise `@objectstack/organizations`
28+
package. When it is installed it registers an `org-scoping` service; the
29+
framework detects that and turns tenant isolation on.
30+
31+
### One source of truth: the `tenancy` service
32+
33+
Rather than re-deriving "what mode is this?" from the env flag, a service probe,
34+
or row counts, the platform exposes a single `tenancy` kernel service:
35+
36+
```ts
37+
interface TenancyService {
38+
mode: 'single' | 'multi'; // multi iff isolation is actually active
39+
isolationActive: boolean; // org-scoping wired?
40+
requested: boolean; // OS_MULTI_ORG_ENABLED
41+
degraded: boolean; // requested && !isolationActive
42+
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. |
78+
79+
```ts
80+
createAuthPlugin({ membershipPolicy: 'invite-only' /**/ });
81+
```
82+
83+
> **Note** — In single-org mode, membership does **not** gate data access (there
84+
> is no tenant isolation to enforce); RBAC permission sets do. Membership drives
85+
> the Members list, the active-organization a session resolves, and invitations.
86+
87+
### Backfill for pre-existing users
88+
89+
On boot (`kernel:ready`), single-org / `auto` deployments backfill memberships
90+
for any pre-existing member-less users (e.g. accounts created before the
91+
reconciler existed), binding them to the default organization. It is bounded,
92+
idempotent, and self-guards (it no-ops under `invite-only` and in multi-org).
93+
Opt out with `OS_SKIP_MEMBERSHIP_BACKFILL=1`.
94+
95+
---
96+
97+
## Degraded tenancy: the boot guard
98+
99+
Setting `OS_MULTI_ORG_ENABLED=true` **without** a working
100+
`@objectstack/organizations` package is dangerous: tenant isolation cannot be
101+
enforced, so the wildcard tenant RLS is stripped and every organization
102+
boundary becomes inert — while the operator believes the deployment is
103+
multi-tenant.
104+
105+
The platform **refuses to boot** in this state:
106+
107+
```
108+
✖ FATAL: OS_MULTI_ORG_ENABLED=true but @objectstack/organizations could not be
109+
loaded, so tenant isolation is INACTIVE. Refusing to boot …
110+
```
111+
112+
Resolve it one of three ways:
113+
114+
- **install `@objectstack/organizations`** (the enterprise multi-org runtime); or
115+
- **unset `OS_MULTI_ORG_ENABLED`** to run single-org; or
116+
- **set `OS_ALLOW_DEGRADED_TENANCY=1`** to boot anyway in an explicitly degraded
117+
single-org state.
118+
119+
When you opt into the degraded state, it is branded everywhere an operator
120+
looks — a boot warning, `features.degradedTenancy: true` in `/auth/config`, and
121+
the Setup system-overview surface — so degraded operation is always a visible,
122+
chosen state, never a silent one.
123+
124+
> **Upgrading?** A deployment that was *silently* degraded before this guard
125+
> existed will now fail to boot after upgrade. That is intentional — it was not
126+
> actually isolating tenants. Either install the enterprise package or set
127+
> `OS_ALLOW_DEGRADED_TENANCY=1` to acknowledge the state.
128+
129+
---
130+
131+
## Environment variables
132+
133+
| Variable | Default | Effect |
134+
|---|---|---|
135+
| `OS_MULTI_ORG_ENABLED` | `false` | Request multi-org tenancy. Requires `@objectstack/organizations`. |
136+
| `OS_ALLOW_DEGRADED_TENANCY` | `false` | Boot even when multi-org is requested but isolation is unavailable (degraded). |
137+
| `OS_ORG_LIMIT` | unset (unlimited) | Cap on organizations a single user may create (multi-org only). |
138+
| `OS_SKIP_MEMBERSHIP_BACKFILL` | unset | Skip the boot-time membership backfill. |

docs/adr/0093-tenancy-mode-and-membership-lifecycle.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle
22

3-
- **Status:** Proposed
3+
- **Status:** Proposed (implementation in progress)
44
- **Date:** 2026-07-13
55
- **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.
67
- **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)
78

89
## TL;DR
@@ -129,6 +130,14 @@ while the set of creation paths is still enumerable.
129130
toggle; this ADR does not couple the two.)
130131
- Default role for auto-bound users is `member`. Elevation is a separate,
131132
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.
132141

133142
**Rejected alternative:** making membership strictly mandatory (no policy knob).
134143
Rejected because invite-only single-org deployments are legitimate (a shared
@@ -166,10 +175,19 @@ host user.create.after (if any) → framework membership reconciler
166175
`policy-skip` / `no-target-org` / `failed`) emits one structured log line,
167176
and `bound` writes the same audit metadata PR #2882 introduced
168177
(`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).
178+
- **Endpoint delegation (revised from "retirement"):** the endpoint-level
179+
`bindUserToSoleOrganization` (PR #2882) now *delegates to the shared
180+
reconciler* — one implementation, two call sites — and this is ratified as
181+
the **final** state, not an interim one. Runtime verification confirmed the
182+
hook fires for `admin.createUser` (the created user's membership pre-existed
183+
when the endpoint-side call ran), so deletion would be *safe in the common
184+
case* — but better-auth defers `user.create.after` via
185+
`queueAfterTransactionHook` (post-commit, not awaited inline; framework
186+
#1881), so an endpoint that must *report* membership state in its response
187+
(`organizationId`, `membershipCreated`) cannot rely on the deferred hook
188+
having completed. The delegated endpoint call makes the response
189+
deterministic; both call sites are idempotent and yield-to-existing, so
190+
double-coverage never double-binds.
173191

174192
**Rejected alternatives:**
175193
- *Per-endpoint helper calls* (status quo after #2882): every future endpoint
@@ -194,6 +212,17 @@ long-term contract: it infers configuration from data shape, so a multi-org
194212
deployment's transient first-boot state (one org created, second pending) is
195213
indistinguishable from single-org. Mode is configuration; read it as such.
196214

215+
**Verified against the cloud host (the multi-org reality check):** the cloud
216+
control plane attaches `createUserSignupOrgHook`
217+
(`service-cloud/src/personal-org-hook.ts`) — a `user.create.after` hook that
218+
provisions a personal org + `owner` membership per signup. Under this ADR's
219+
composition it chains *first*; the framework reconciler then either finds the
220+
membership and yields, or (multi mode) resolves no target org and no-ops. Both
221+
sides are idempotent-on-existing-membership, which the cloud hook's own header
222+
already relies on ("whichever runs first wins and the other no-ops"). "Framework
223+
never guesses in multi mode" is therefore not a hedge — it is exactly the
224+
division of labor the production host already assumes.
225+
197226
### D4 — The `tenancy` kernel service
198227

199228
Registered under the service name **`tenancy`**:
@@ -252,6 +281,24 @@ to load (missing, or its `init()` throws):
252281
release notes must say so loudly, and the error message must make recovery
253282
a two-minute task. Shipping this in a minor release with a prominent
254283
BREAKING callout is acceptable; shipping it silently is not.
284+
- **Blast radius (verified against the cloud repo):** the guard lives in the
285+
CLI's `serve.ts`, and the cloud does **not** boot through it — control-plane
286+
and per-env kernels come from the cloud's own `artifact-kernel-factory`,
287+
where `@objectstack/organizations` is a bundled workspace dependency that
288+
cannot fail to import. Self-hosted EE additionally *forces*
289+
`OS_MULTI_ORG_ENABLED=false` when the multi-org entitlement is absent
290+
(`objectos-ee/objectstack.config.ts`). The only deployments the guard can
291+
stop are **misconfigured CE self-hosts** — the flag set, the enterprise
292+
package absent — which were running with zero isolation while believing
293+
otherwise. That is precisely the population the guard exists to stop; it
294+
supports shipping in the next minor rather than waiting for a major.
295+
- **Host-side follow-up (cloud repo):** the cloud's kernel factories carry the
296+
same silent `catch → warn` degradation pattern this decision removes from
297+
`serve.ts`. It is unreachable in practice there (bundled dependency), but as
298+
defense-in-depth the factories should *fail the kernel build* (throw — not
299+
`process.exit`, which would kill a multi-tenant host serving other envs)
300+
when multi-org is requested and the plugin cannot load. Tracked as a cloud
301+
PR alongside this one.
255302

256303
### D6 — Backfill for pre-existing member-less users
257304

0 commit comments

Comments
 (0)