Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/tenancy-mode-and-membership-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/types": minor
"@objectstack/plugin-auth": minor
"@objectstack/plugin-security": patch
"@objectstack/cli": minor
---

Tenancy mode as a first-class capability + a single owner for the user→membership
lifecycle (ADR-0093, Phases 1–3).

**Tenancy service (`@objectstack/types`, `@objectstack/plugin-auth`).** plugin-auth
registers a `tenancy` service — the single source of truth for tenancy mode
(`mode`, `isolationActive`, `requested`, `degraded`, `defaultOrgId()`). It derives
`isolationActive` from the presence of the `org-scoping` service, so the
enterprise `@objectstack/organizations` package lights it up with no change.
SecurityPlugin's RLS-strip gate and `/auth/config` (`features.multiOrgEnabled`,
new `features.degradedTenancy`) now consume it instead of re-deriving the fact.

**Fail-fast on degraded tenancy (`@objectstack/cli`, ADR-0093 D5).**
`OS_MULTI_ORG_ENABLED=true` without a working `@objectstack/organizations` now
**refuses to boot** — a deployment that requested tenant isolation must not serve
traffic without it (tenant RLS would be silently stripped). Escape hatch:
`OS_ALLOW_DEGRADED_TENANCY=1` boots in an explicitly branded degraded state
(`features.degradedTenancy`). **This may halt upgrades for deployments that were
silently degraded — intentionally; install the enterprise package or set the
escape hatch.**

**Membership reconciler (`@objectstack/plugin-auth`, ADR-0093 D1–D3, D6).** A
single reconciler composed into better-auth's `user.create.after` hook owns the
"every new user gets a membership" invariant across all creation paths (signup,
admin create-user, import, SSO JIT). It yields to any existing membership (host
hooks win), honors a new `membershipPolicy: 'auto' | 'invite-only'` auth option
(default `auto`), and binds only to an unambiguous target org (single-org default;
multi-org binds nothing). A bounded, idempotent `kernel:ready` backfill covers
pre-existing member-less users in single-org/auto deployments
(`OS_SKIP_MEMBERSHIP_BACKFILL=1` to opt out). The endpoint-level create-user bind
from #2882 now delegates to this shared reconciler.

New env vars: `OS_ALLOW_DEGRADED_TENANCY`, `OS_SKIP_MEMBERSHIP_BACKFILL`. New docs:
Deployment → Tenancy Modes & Membership.
1 change: 1 addition & 0 deletions content/docs/deployment/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"publish-and-preview",
"environment-variables",
"single-project-mode",
"tenancy-modes",
"migration-from-objectql",
"troubleshooting"
]
Expand Down
5 changes: 5 additions & 0 deletions content/docs/deployment/production-readiness.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/framework/blob/main
- [ ] better-auth session TTL / refresh / revoke verified (curl
checklist in HARDENING.md).
- [ ] Cross-tenant negative tests in CI.
- [ ] Multi-org deployments: tenant isolation is **active**, not degraded —
confirm `features.degradedTenancy` is `false` in `/auth/config`. A
deployment requesting multi-org without `@objectstack/organizations` now
refuses to boot unless `OS_ALLOW_DEGRADED_TENANCY=1`; never set that flag
in production. See [Tenancy Modes & Membership](/docs/deployment/tenancy-modes).
- [ ] Backup / restore drill documented and tested.
- [ ] Data-retention windows reviewed (ADR-0057): the platform's default
`lifecycle` declarations bound telemetry (activity 14d, job runs 30d,
Expand Down
138 changes: 138 additions & 0 deletions content/docs/deployment/tenancy-modes.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
title: Tenancy Modes & Membership
description: Single-org vs multi-org tenancy, the membership policy for new users, and the degraded-tenancy boot guard (ADR-0093).
---

# Tenancy Modes & Membership

An ObjectStack deployment runs in one of two tenancy modes. The mode governs
whether organization boundaries isolate data, how new users are placed into an
organization, and which organization-management UI is available.

This page describes the runtime contract introduced by
[ADR-0093](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md).

---

## The two modes

| | **Single-org** (default) | **Multi-org** |
|---|---|---|
| Enabled by | unset / `OS_MULTI_ORG_ENABLED=false` | `OS_MULTI_ORG_ENABLED=true` **and** `@objectstack/organizations` installed |
| 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 |
| Access control | RBAC permission sets only | RBAC permission sets **plus** per-org tenant isolation |
| Organization row | one bootstrapped "Default Organization" | many, operator/user created |
| Org-management UI (create/switch/delete org) | hidden | shown |

The multi-org runtime lives in the enterprise `@objectstack/organizations`
package. When it is installed it registers an `org-scoping` service; the
framework detects that and turns tenant isolation on.

### One source of truth: the `tenancy` service

Rather than re-deriving "what mode is this?" from the env flag, a service probe,
or row counts, the platform exposes a single `tenancy` kernel service:

```ts
interface TenancyService {
mode: 'single' | 'multi'; // multi iff isolation is actually active
isolationActive: boolean; // org-scoping wired?
requested: boolean; // OS_MULTI_ORG_ENABLED
degraded: boolean; // requested && !isolationActive
defaultOrgId(): Promise<string | null>; // single → default org; multi → null
}
```

`/auth/config` reports `features.multiOrgEnabled` (from `mode`) and
`features.degradedTenancy` so the console renders the correct UI.

---

## Membership: how new users join an organization

Every human user should end up as a member of an organization (a `sys_member`
row). A single reconciler owns this invariant — it runs as a
`user.create.after` hook, so **every** creation path is covered uniformly:
email sign-up, the admin **Create User** and **Import Users** flows, and SSO
just-in-time provisioning.

The reconciler:

- **yields** to any membership that already exists (e.g. one created by an
invitation, `add-member`, SSO provisioning, or a host hook) — it never
creates a second membership;
- binds only to an **unambiguous** target org — in single-org mode, the default
organization; in multi-org mode it binds nothing (invitations, `add-member`,
and SSO provisioning own membership there, where guessing an org would risk
the wrong tenant);
- is **best-effort** — a failure logs a warning and never fails user creation.

### Membership policy

Control auto-binding with the `membershipPolicy` auth option:

| Policy | Behavior |
|---|---|
| `'auto'` (default) | New member-less users are bound to the single-org default organization. |
| `'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. |

```ts
createAuthPlugin({ membershipPolicy: 'invite-only' /* … */ });
```

> **Note** — In single-org mode, membership does **not** gate data access (there
> is no tenant isolation to enforce); RBAC permission sets do. Membership drives
> the Members list, the active-organization a session resolves, and invitations.

### Backfill for pre-existing users

On boot (`kernel:ready`), single-org / `auto` deployments backfill memberships
for any pre-existing member-less users (e.g. accounts created before the
reconciler existed), binding them to the default organization. It is bounded,
idempotent, and self-guards (it no-ops under `invite-only` and in multi-org).
Opt out with `OS_SKIP_MEMBERSHIP_BACKFILL=1`.

---

## Degraded tenancy: the boot guard

Setting `OS_MULTI_ORG_ENABLED=true` **without** a working
`@objectstack/organizations` package is dangerous: tenant isolation cannot be
enforced, so the wildcard tenant RLS is stripped and every organization
boundary becomes inert — while the operator believes the deployment is
multi-tenant.

The platform **refuses to boot** in this state:

```
✖ FATAL: OS_MULTI_ORG_ENABLED=true but @objectstack/organizations could not be
loaded, so tenant isolation is INACTIVE. Refusing to boot …
```

Resolve it one of three ways:

- **install `@objectstack/organizations`** (the enterprise multi-org runtime); or
- **unset `OS_MULTI_ORG_ENABLED`** to run single-org; or
- **set `OS_ALLOW_DEGRADED_TENANCY=1`** to boot anyway in an explicitly degraded
single-org state.

When you opt into the degraded state, it is branded everywhere an operator
looks — a boot warning, `features.degradedTenancy: true` in `/auth/config`, and
the Setup system-overview surface — so degraded operation is always a visible,
chosen state, never a silent one.

> **Upgrading?** A deployment that was *silently* degraded before this guard
> existed will now fail to boot after upgrade. That is intentional — it was not
> actually isolating tenants. Either install the enterprise package or set
> `OS_ALLOW_DEGRADED_TENANCY=1` to acknowledge the state.

---

## Environment variables

| Variable | Default | Effect |
|---|---|---|
| `OS_MULTI_ORG_ENABLED` | `false` | Request multi-org tenancy. Requires `@objectstack/organizations`. |
| `OS_ALLOW_DEGRADED_TENANCY` | `false` | Boot even when multi-org is requested but isolation is unavailable (degraded). |
| `OS_ORG_LIMIT` | unset (unlimited) | Cap on organizations a single user may create (multi-org only). |
| `OS_SKIP_MEMBERSHIP_BACKFILL` | unset | Skip the boot-time membership backfill. |
57 changes: 52 additions & 5 deletions docs/adr/0093-tenancy-mode-and-membership-lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# ADR-0093: Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle

- **Status:** Proposed
- **Status:** Proposed (implementation in progress)
- **Date:** 2026-07-13
- **Deciders:** ObjectStack Protocol Architects
- **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.
- **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
Expand Down Expand Up @@ -129,6 +130,14 @@ while the set of creation paths is still enumerable.
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.
- **`auto` joins; it never creates.** The reconciler binds a user to the one
organization the deployment *already is* — it never mints an organization.
This keeps the framework's deliberate B2B/invitation posture (documented in
the cloud's `personal-org-hook.ts`: "the framework's SecurityPlugin
deliberately does NOT auto-create a personal workspace per signup").
Workspace-per-user is a *product* decision that stays with hosts (the cloud's
personal-org hook); joining the sole existing org is *bookkeeping* the
framework owns. The two must not be conflated when evaluating this default.

**Rejected alternative:** making membership strictly mandatory (no policy knob).
Rejected because invite-only single-org deployments are legitimate (a shared
Expand Down Expand Up @@ -166,10 +175,19 @@ host user.create.after (if any) → framework membership reconciler
`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).
- **Endpoint delegation (revised from "retirement"):** the endpoint-level
`bindUserToSoleOrganization` (PR #2882) now *delegates to the shared
reconciler* — one implementation, two call sites — and this is ratified as
the **final** state, not an interim one. Runtime verification confirmed the
hook fires for `admin.createUser` (the created user's membership pre-existed
when the endpoint-side call ran), so deletion would be *safe in the common
case* — but better-auth defers `user.create.after` via
`queueAfterTransactionHook` (post-commit, not awaited inline; framework
#1881), so an endpoint that must *report* membership state in its response
(`organizationId`, `membershipCreated`) cannot rely on the deferred hook
having completed. The delegated endpoint call makes the response
deterministic; both call sites are idempotent and yield-to-existing, so
double-coverage never double-binds.

**Rejected alternatives:**
- *Per-endpoint helper calls* (status quo after #2882): every future endpoint
Expand All @@ -194,6 +212,17 @@ 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.

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

### D4 — The `tenancy` kernel service

Registered under the service name **`tenancy`**:
Expand Down Expand Up @@ -252,6 +281,24 @@ to load (missing, or its `init()` throws):
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.
- **Blast radius (verified against the cloud repo):** the guard lives in the
CLI's `serve.ts`, and the cloud does **not** boot through it — control-plane
and per-env kernels come from the cloud's own `artifact-kernel-factory`,
where `@objectstack/organizations` is a bundled workspace dependency that
cannot fail to import. Self-hosted EE additionally *forces*
`OS_MULTI_ORG_ENABLED=false` when the multi-org entitlement is absent
(`objectos-ee/objectstack.config.ts`). The only deployments the guard can
stop are **misconfigured CE self-hosts** — the flag set, the enterprise
package absent — which were running with zero isolation while believing
otherwise. That is precisely the population the guard exists to stop; it
supports shipping in the next minor rather than waiting for a major.
- **Host-side follow-up (cloud repo):** the cloud's kernel factories carry the
same silent `catch → warn` degradation pattern this decision removes from
`serve.ts`. It is unreachable in practice there (bundled dependency), but as
defense-in-depth the factories should *fail the kernel build* (throw — not
`process.exit`, which would kill a multi-tenant host serving other envs)
when multi-org is requested and the plugin cannot load. Tracked as a cloud
PR alongside this one.

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

Expand Down
Loading