ObjectStackObjectStack

Tenancy Postures & Membership

The three tenancy postures (single / group / isolated), how OS_TENANCY_POSTURE resolves, the membership policy for new users, and the degraded-tenancy boot guard.

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.

The three-posture spectrum is ADR-0105 D1; it generalizes the two-valued tenancy mode of ADR-0093, whose tenancy service, membership lifecycle and degraded-tenancy boot guard all still apply — widened to the three postures.

Renamed, not re-configured

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.


The three postures

single (default)groupisolated
Layer 0 organization wallnone — the layer is inertorganization_id IN accessible_org_ids (union / MOAC)organization_id = <active organization>
Read reachthe whole dataset, subject to RBACevery organization the caller currently holds a membership inthe active organization only
Shapeone logical tenant; sub-units modelled as business units in one treeorganizations are membership / invitation boundaries over one shared datasetlegal-entity / sovereignty isolation — the hard wall
organization_id filled in on writenoyesyes
Enterprise @objectstack/organizationsnot neededrequiredrequired
Default organizationone bootstrapped "Default Organization"owned by the enterprise runtimeowned by the enterprise runtime
Org-management UI (create / switch org)hiddenshownshown

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.


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_POSTUREOS_MULTI_ORG_ENABLEDRequested posture
single / group / isolatednot consultedthat posture
multi (legacy spelling)not consultedisolated
unset, or empty/whitespaceany value other than a case-insensitive falseisolated
unset, or empty/whitespaceunset, or falsesingle
anything elsenot consultednone — 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 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):

import type { TenancyPosture } from '@objectstack/spec/security';

interface TenancyService {
  /** 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<string | null>;
}

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.


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

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.

`add-member` is a route — here it is

The add-member named above is POST /api/v1/auth/organization/add-member, an ObjectStack mount wrapping better-auth's server-only addMember. Under a walled posture it is the only way to attach an existing account to an organization: the reconciler declines to guess a target org, generic sys_member writes are suppressed under the ADR-0010 lock, and invitations need an email round-trip that phone-number-only accounts cannot complete. Its admit set is platform admin only — an organization owner or administrator is refused 403 PERMISSION_DENIED. See Attaching an existing user to an organization.

Membership policy

PolicyBehavior
'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:

new AuthPlugin({ membershipPolicy: 'invite-only' /* … */ });

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.

Membership is not access control in the single posture

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), 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.

There is a second, unrelated boot-time backfill

The platform also runs a seed tenancy repair at kernel:ready — a different mechanism that rewrites organization_id on business rows and merges autonumber counters. It is gated on the posture being single, so it is adjacent to this page's subject, but it is not this backfill: OS_SKIP_MEMBERSHIP_BACKFILL has no effect on it, and it has no opt-out of its own. See Seed Tenancy Repair.


Degraded tenancy: the boot guard

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

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, 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 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 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.)

Upgrading?

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.


Environment variables

VariableDefaultEffect
OS_TENANCY_POSTUREderived from OS_MULTI_ORG_ENABLEDThe 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_ENABLEDfalseSuperseded 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_TENANCYfalseBoot 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_LIMITunset (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_POLICYautoEnv override for the auth.membership_policy setting — auto or invite-only.
OS_SKIP_MEMBERSHIP_BACKFILLunsetSet to 1 to skip the boot-time membership backfill.

See Environment variables for the full list.

On this page