diff --git a/.changeset/tenancy-modes-doc-posture-rewrite.md b/.changeset/tenancy-modes-doc-posture-rewrite.md new file mode 100644 index 0000000000..f4b2802980 --- /dev/null +++ b/.changeset/tenancy-modes-doc-posture-rewrite.md @@ -0,0 +1,15 @@ +--- +--- + +docs(deployment): rewrite `tenancy-modes.mdx` around the ADR-0105 D1 posture spectrum — releases nothing. + +整页此前仍按 ADR-0105 D1 之前的世界写:两态 tenancy mode、`OS_MULTI_ORG_ENABLED` 作为 +开关、`TenancyService.mode`、以及只提 `isolated` 的降级 FATAL 文案;对权威 knob +`OS_TENANCY_POSTURE` 全页 grep 零命中。重写后 `single` / `group` / `isolated` 三态为 +主线,遗留布尔降为「posture 未设时才读的回落输入」,并逐条对码核证:posture 解析优先级 +与未设/非法值行为、两种有墙 posture 都需要企业版 runtime 且都会降级、`tenancy` 服务的 +`posture` / `requestedPosture` 双事实、`/auth/config` 的 +`tenancyPosture`/`multiOrgEnabled`/`degradedTenancy`、`organization/create` 闸门读的是 +**生效** posture、以及 import 阶段与 mount 阶段两种拒绝启动的区别。 + +Docs only — no package behaviour changes, so this changeset releases nothing. diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 44214b1e05..9c015065b5 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -1,51 +1,145 @@ --- -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). +title: Tenancy Postures & Membership +description: The three tenancy postures (single / group / isolated), how OS_TENANCY_POSTURE resolves, the membership policy for new users, and the degraded-tenancy boot guard. --- -# Tenancy Modes & Membership +# Tenancy Postures & 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. +An ObjectStack deployment runs in one of **three tenancy postures**. The posture +is the single fact that decides what the authorization kernel's Layer 0 +organization wall enforces, 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/objectstack/blob/main/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md). +The three-posture spectrum is +[ADR-0105 D1](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md); +it generalizes the two-valued *tenancy mode* of +[ADR-0093](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md), +whose `tenancy` service, membership lifecycle and degraded-tenancy boot guard all +still apply — widened to the three postures. + + +The posture formerly called `multi` is now `isolated`, and the authoritative knob +is `OS_TENANCY_POSTURE`, not `OS_MULTI_ORG_ENABLED`. Existing deployments keep +their behaviour with no config change: the legacy boolean is still honoured as a +fallback **input** to posture resolution. See +[How the posture resolves](#how-the-posture-resolves). + --- -## The two modes +## The three postures + +| | `single` (default) | `group` | `isolated` | +|---|---|---|---| +| Layer 0 organization wall | none — the layer is inert | `organization_id IN accessible_org_ids` (union / MOAC) | `organization_id = ` | +| Read reach | the whole dataset, subject to RBAC | every organization the caller currently holds a membership in | the active organization only | +| Shape | one logical tenant; sub-units modelled as business units in one tree | organizations are membership / invitation boundaries over one shared dataset | legal-entity / sovereignty isolation — the hard wall | +| `organization_id` filled in on write | no | yes | yes | +| Enterprise `@objectstack/organizations` | not needed | **required** | **required** | +| Default organization | one bootstrapped "Default Organization" | owned by the enterprise runtime | owned by the enterprise runtime | +| Org-management UI (create / switch org) | hidden | shown | shown | + +Only the wall's **predicate** differs between `group` and `isolated`. Everything +about how it composes is identical: Layer 0 is AND-composed ahead of, and +independently of, business RLS, and no permissive business policy, sharing rule +or `viewAllRecords` / `modifyAllRecords` superuser bit can widen it (ADR-0095 +W1/W2, ADR-0105 D2/D4). A walled posture with no resolvable scope — no active +organization under `isolated`, an empty membership set under `group` — fails +closed to zero rows rather than falling through. + +`single` is not "multi-org with the walls left standing": Layer 0 contributes no +filter at all, and the platform's own tenant-scoped RLS policies are stripped. +Policies **you** authored that mention `current_user.organization_id` are +retained and fail closed (ADR-0105 D3) — only the platform's own wildcard tenant +policies are dropped. + +### Open code, entitled activation + +Both walled postures require the enterprise `@objectstack/organizations` runtime +to **activate**. The wall's implementation is open — the Layer 0 compiler, +`accessible_org_ids` resolution, and the write-side validation that rejects a +forged `organization_id` all ship in open packages — but *enabling* a +multi-organization posture is a commercial capability (ADR-0105 D12). Filling in +an **absent** `organization_id` on write is the enterprise runtime's job; +**validating** a supplied one is a security property and stays open. + +The installed runtime may narrow this further: the `org-scoping` service can +declare `supportedPostures`, and the open core fails closed on any walled +posture it does not list. A runtime that declares nothing entitles both. -| | **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. +## How the posture resolves + +`OS_TENANCY_POSTURE` is the authoritative knob; `OS_MULTI_ORG_ENABLED` is a +back-compat input consulted only when the posture is unset. + +| `OS_TENANCY_POSTURE` | `OS_MULTI_ORG_ENABLED` | Requested posture | +|---|---|---| +| `single` / `group` / `isolated` | *not consulted* | that posture | +| `multi` (legacy spelling) | *not consulted* | `isolated` | +| unset, or empty/whitespace | any value other than a case-insensitive `false` | `isolated` | +| unset, or empty/whitespace | unset, or `false` | `single` | +| anything else | *not consulted* | none — resolution **throws** rather than guessing | + +Values are trimmed and lower-cased before matching, so `Isolated` and ` group ` +resolve. An unrecognized value is never coerced to a weaker posture: resolution +throws (`Invalid OS_TENANCY_POSTURE=…`) and `os serve` reports the error and +exits non-zero rather than serving with a posture it could not parse. + +Two consequences worth stating plainly: + +- **The legacy boolean is an input, not a second switch.** With + `OS_TENANCY_POSTURE` set, `OS_MULTI_ORG_ENABLED` is not read at all — setting + it as well changes nothing, and *unsetting* it takes nothing away. A + deployment that sets only `OS_TENANCY_POSTURE=isolated` is fully walled. +- **Never gate application code on `OS_MULTI_ORG_ENABLED`.** It reports `false` + on exactly the deployment shape the documentation recommends (posture set, + boolean unset), so any gate keyed off it inverts its own contract there. Ask + the posture instead — or, inside a running kernel, the `tenancy` service, + which additionally knows whether the requested wall is actually standing. Two + shipped defects came from getting this wrong, one of which 403'd + `organization/create` on a fully walled deployment. ### 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: +Rather than re-deriving "what posture is this?" from an env flag, a service +probe, or row counts, the platform exposes a single `tenancy` kernel service +(registered by `plugin-auth`): {/* os:check */} ```ts +import type { TenancyPosture } from '@objectstack/spec/security'; + 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; // single → default org; multi → null + /** The posture actually IN FORCE — an unenforceable request resolves to `single`. */ + readonly posture: TenancyPosture; + /** What the operator asked for: OS_TENANCY_POSTURE, else OS_MULTI_ORG_ENABLED. */ + readonly requestedPosture: TenancyPosture; + /** True iff an organization wall is actually enforced. */ + readonly isolationActive: boolean; + /** True iff a wall-enforcing posture (`group` or `isolated`) was requested. */ + readonly requested: boolean; + /** `requested && !isolationActive` — asked for a wall, did not get one. */ + readonly degraded: boolean; + /** Single-org default org; `null` under any walled REQUEST, degraded included. */ + defaultOrgId(): Promise; } ``` -`/auth/config` reports `features.multiOrgEnabled` (from `mode`) and -`features.degradedTenancy` so the console renders the correct UI. +`posture` and `requestedPosture` are the two facts that can disagree, and the +difference is the whole point: a request that cannot be enforced resolves to +`posture: 'single'` and raises `degraded`, because a wall that is not standing is +not a wall. + +`/auth/config` reports `features.tenancyPosture` (the **effective** posture), +`features.multiOrgEnabled` (true iff that posture enforces a wall) and +`features.degradedTenancy`, so the console renders the correct UI. The +`organization/create` gate is derived from the *same* call, so the API can never +refuse a capability `/auth/config` advertises — or mint an organization no engine +walls. Concretely: creating additional organizations is refused (`403`) whenever +the effective posture is `single`, including a degraded deployment that only +booted because `OS_ALLOW_DEGRADED_TENANCY=1`. --- @@ -62,75 +156,117 @@ 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); +- binds only to an **unambiguous** target org — that is `tenancy.defaultOrgId()`, + which returns the single-org default organization under `single` and `null` + under any walled **request** (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 +The "walled request" wording is deliberate: a *degraded* deployment asked for a +wall and did not get one, and the safe reading of that is "I don't know which org +this user belongs to", not "everyone belongs to the only org I can see". So +`defaultOrgId()` returns `null` there too. -Control auto-binding with the `membershipPolicy` auth option: +### Membership policy | 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. | +Set it as an `AuthPlugin` option, as the `auth.membership_policy` platform +setting, or via the `OS_AUTH_MEMBERSHIP_POLICY` env override: + ```ts new AuthPlugin({ 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. +The setting is read live on every use, so an admin changing it reaches both the +sign-up reconciler and the backfill below without a restart. An unrecognized +value is rejected rather than coerced: the deployment keeps its current policy +and the reconciler refuses to auto-bind under it. + + +With no wall to enforce, membership does not gate data access — 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`. +On boot (`kernel:ready`), deployments with `membershipPolicy: 'auto'` 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 under any walled +request `defaultOrgId()` is `null` so there is nothing to bind to. 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. +Requesting a walled posture **without** a working `@objectstack/organizations` +package is dangerous: the organization wall cannot be enforced, so Layer 0 goes +inert and every organization boundary becomes decorative — while the operator +believes the deployment is walled. This applies to **both** walled postures; +`group` is entitled exactly like `isolated` and degrades the same way. 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 … + ✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations could not be loaded, + so the organization wall is INACTIVE. Refusing to boot — a deployment that requested + multi-organization isolation must not serve traffic without it (ADR-0093 D5). + + Fix one of: + • add @objectstack/organizations (the enterprise multi-org runtime) to THIS APP + — declare it in the app's package.json and install; the CLI resolves it from the + app, not from the framework it is linked out of. … + • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or + • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state. ``` -Resolve it one of three ways: +So, three ways out: - **add `@objectstack/organizations`** (the enterprise multi-org runtime) **to the app you are serving** — declare it in that app's `package.json` and install it there. The CLI resolves the package from the served app, not from the framework - it is linked out of, so installing it anywhere else does not lift the guard; or -- **unset `OS_MULTI_ORG_ENABLED`** to run single-org; or + it is linked out of, and being merely reachable through `NODE_PATH` or a hoisted + workspace store is deliberately not enough; or +- **set `OS_TENANCY_POSTURE=single`** (and 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. +If the package is already declared but the install is broken, the message says so +and tells you to repair the install rather than re-read `package.json`. + +### A present-but-refusing runtime is a different failure + +If `@objectstack/organizations` **is** found and its plugin then refuses to +mount, boot is refused unconditionally and the plugin's own message is printed +verbatim. `OS_ALLOW_DEGRADED_TENANCY` does **not** get past that: the escape +hatch means "the capability is ABSENT and I accept doing without it", not "a +present runtime declined and I would like to ignore why". + +### What a degraded boot looks like + When you opt into the degraded state, it is surfaced where an operator looks — a -loud red **terminal boot warning** and `features.degradedTenancy: true` in +loud yellow **terminal boot warning** and `features.degradedTenancy: true` in `/auth/config` — so degraded operation is always a visible, chosen state, never a -silent one. (The `/auth/config` flag is there for any tooling that wants to -render its own warning; the framework itself does not ship a console banner for -this extreme, opt-in-past-a-refusal case.) - -> **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. +silent one. The effective posture is `single`, so organization creation is +refused there as well. (The `/auth/config` flag is there for any tooling that +wants to render its own warning; the framework itself does not ship a console +banner for this extreme, opt-in-past-a-refusal case.) + + +A deployment that was *silently* degraded before this guard existed will fail to +boot after upgrade. That is intentional — it was not actually walling anything. +Either install the enterprise package or set `OS_ALLOW_DEGRADED_TENANCY=1` to +acknowledge the state. Deployments that adopted `OS_TENANCY_POSTURE=group` during +the window when it self-activated are in the same position: `group` now requires +the enterprise runtime. + --- @@ -138,7 +274,12 @@ this extreme, opt-in-past-a-refusal case.) | 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. | +| `OS_TENANCY_POSTURE` | derived from `OS_MULTI_ORG_ENABLED` | The authoritative knob: `single`, `group`, or `isolated` (legacy spelling `multi` = `isolated`). An unrecognized value throws instead of falling back. Both walled postures require `@objectstack/organizations`. | +| `OS_MULTI_ORG_ENABLED` | `false` | Superseded by `OS_TENANCY_POSTURE` and still honoured **only** when it is unset: any value other than a case-insensitive `false` selects `isolated`. | +| `OS_ALLOW_DEGRADED_TENANCY` | `false` | Boot even when a walled posture is requested but the runtime is absent (degraded). Accepts `1` / `true` / `on` / `yes`. Does not cover a runtime that refused to mount. | +| `OS_ORG_LIMIT` | unset (unlimited) | Cap on organizations a single user may **own**; organizations they were merely invited into never count against it. Only meaningful under a walled posture, since org creation is refused otherwise. | +| `OS_AUTH_MEMBERSHIP_POLICY` | `auto` | Env override for the `auth.membership_policy` setting — `auto` or `invite-only`. | +| `OS_SKIP_MEMBERSHIP_BACKFILL` | unset | Set to `1` to skip the boot-time membership backfill. | + +See [Environment variables](/docs/deployment/environment-variables) for the full +list.