diff --git a/.changeset/tenancy-mode-and-membership-lifecycle.md b/.changeset/tenancy-mode-and-membership-lifecycle.md new file mode 100644 index 000000000..1e9212d60 --- /dev/null +++ b/.changeset/tenancy-mode-and-membership-lifecycle.md @@ -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. diff --git a/content/docs/deployment/meta.json b/content/docs/deployment/meta.json index b72957ac7..31e07869a 100644 --- a/content/docs/deployment/meta.json +++ b/content/docs/deployment/meta.json @@ -8,6 +8,7 @@ "publish-and-preview", "environment-variables", "single-project-mode", + "tenancy-modes", "migration-from-objectql", "troubleshooting" ] diff --git a/content/docs/deployment/production-readiness.mdx b/content/docs/deployment/production-readiness.mdx index 50428a2e2..7d6edf437 100644 --- a/content/docs/deployment/production-readiness.mdx +++ b/content/docs/deployment/production-readiness.mdx @@ -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, diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx new file mode 100644 index 000000000..012def8d2 --- /dev/null +++ b/content/docs/deployment/tenancy-modes.mdx @@ -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; // 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. | diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index 06a00c72a..fd346c9c8 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -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 @@ -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 @@ -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 @@ -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`**: @@ -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 diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d66ef4994..7912f809f 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -8,7 +8,7 @@ import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; -import { readEnvWithDeprecation, resolveMultiOrgEnabled, isMcpServerEnabled } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled } from '@objectstack/types'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; import { @@ -1407,11 +1407,45 @@ export default class Serve extends Command { const mod: any = await import(/* webpackIgnore: true */ organizationsPkg); await kernel.use(new mod.OrganizationsPlugin()); trackPlugin('Organizations'); - } catch { - // Requested multi-org without the enterprise package — loud, - // not silent: RLS tenant policies will be STRIPPED and every - // org boundary is inert until the package is installed. - console.warn(chalk.yellow(' ⚠ OS_MULTI_ORG_ENABLED=true but @objectstack/organizations (enterprise) is not installed — running single-org.')); + } catch (orgErr) { + // ADR-0093 D5 — degraded tenancy fails fast. Multi-org was + // requested but the enterprise package can't provide tenant + // isolation: `tenant_isolation` RLS would be stripped and every + // org boundary inert. A deployment that asked for isolation must + // NOT serve traffic pretending to have it (ADR-0049 at the + // deployment layer). Refuse to boot unless the operator has + // explicitly opted into the degraded state. + // + // process.exit (not throw): this catch sits inside the broad + // AuthPlugin try below, which swallows errors — a throw would be + // caught and boot would silently continue degraded, which is the + // exact footgun this guard closes. + const cause = orgErr instanceof Error ? orgErr.message : String(orgErr); + if (!resolveAllowDegradedTenancy()) { + console.error( + chalk.red( + '\n ✖ FATAL: OS_MULTI_ORG_ENABLED=true but @objectstack/organizations could not be loaded,\n' + + ' so tenant isolation is INACTIVE. Refusing to boot — a deployment that requested\n' + + ' multi-tenant isolation must not serve traffic without it (ADR-0093 D5).\n\n' + + ' Fix one of:\n' + + ' • install @objectstack/organizations (the enterprise multi-org runtime), or\n' + + ' • unset OS_MULTI_ORG_ENABLED to run single-org, or\n' + + ' • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n' + + ` cause: ${cause}\n`, + ), + ); + process.exit(1); + } + // Explicitly opted into degraded operation — boot, but brand it + // loudly. The `tenancy` service also reports `degraded: true` to + // /auth/config and the Setup dashboard so it stays visible. + console.warn( + chalk.yellow( + ' ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): OS_MULTI_ORG_ENABLED=true but ' + + '@objectstack/organizations is unavailable — booting with tenant isolation INACTIVE. ' + + 'Organization boundaries are NOT enforced; wildcard tenant RLS is stripped. (ADR-0093 D5)', + ), + ); } } diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 695683532..549e66868 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -292,14 +292,19 @@ describe('runAdminCreateUser', () => { * a test can assert the membership bind. */ function makeDepsWithOrgs(opts: { - orgs?: Array<{ id: string }>; + orgs?: Array<{ id: string; slug?: string }>; members?: Array<{ organization_id: string; user_id: string }>; }) { const orgs = opts.orgs ?? []; const members = opts.members ?? []; const find = vi.fn(async (object: string, query: any) => { const where = query?.where ?? {}; - if (object === 'sys_organization') return orgs.slice(0, query?.limit ?? orgs.length); + if (object === 'sys_organization') { + // Honor the slug filter like the real engine — resolveDefaultOrgId + // queries { slug: 'default' } first, then an unfiltered top-2. + const rows = where.slug === undefined ? orgs : orgs.filter((o) => o.slug === where.slug); + return rows.slice(0, query?.limit ?? rows.length); + } if (object === 'sys_member') { return members.filter( (m) => diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 3ecd2de7e..35b3913cc 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -112,6 +112,8 @@ export interface EndpointResult { } import { generatePlaceholderEmail } from './placeholder-email.js'; +import { reconcileMembership } from './reconcile-membership.js'; +import { resolveDefaultOrgId } from './tenancy-service.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; @@ -246,92 +248,40 @@ async function stampMustChangePassword( } } -/** Normalize an ObjectQL find result (array or `{ records }`) to a row array. */ -async function findRows( - engine: AdminUserDataEngine, - object: string, - where: Record, - limit: number, -): Promise { - if (typeof engine.find !== 'function') return []; - try { - const rows = await engine.find(object, { where, limit }, { context: SYSTEM_CTX }); - return Array.isArray(rows) - ? rows - : Array.isArray((rows as any)?.records) - ? (rows as any).records - : []; - } catch { - return []; - } -} - -function genMemberId(): string { - const rand = Math.random().toString(36).slice(2, 10); - const ts = Date.now().toString(36); - return `mem_${ts}${rand}`; -} - /** * Bind an admin-created user to the organization (single-org membership). * - * Unlike the invite / add-member flows, `/admin/create-user` only ever built a - * login-capable account and never wrote a `sys_member` row — so in a single-org - * deployment the new user did not "belong to" the Default Organization and was - * missing from the Members list, even though both are just "add a teammate". + * ADR-0093 D2 — this now delegates to the shared membership reconciler, the + * single owner of the "every new user gets a membership" invariant. The + * reconciler ALSO runs as a `user.create.after` hook (covering signup / import / + * SSO JIT); this endpoint-side call is retained as belt-and-suspenders for the + * admin create path until the hook's coverage is verified in integration — both + * are idempotent and yield to any existing membership, so double-coverage never + * double-binds (ADR-0093 D2 "interim double-coverage is harmless"). The target + * org is the single-org default (resolveDefaultOrgId); multi-org resolves to + * none, so this no-ops there just as before. * - * This binds the new user to the org only when it is UNAMBIGUOUS: exactly one - * `sys_organization` exists (single-org, incl. plugin-auth's default-org - * bootstrap — ADR-0081 D1). Multi-org (≥2 orgs) is deliberately left untouched: - * there the active-org-aware invite / add-member endpoints own membership, and - * guessing an org here would risk the wrong one. Zero orgs (org capability off) - * is a no-op too. - * - * Idempotent and never throws — membership is a convenience, so a failure must - * not fail account creation; the real state is surfaced in the response. + * Returns the shape the response/audit consumed pre-ADR-0093: + * `membershipCreated` is true only when THIS call inserted the row (a `bound` + * outcome); a `yielded` outcome (the hook or a race already bound it) reports + * the org with `membershipCreated: false`. */ async function bindUserToSoleOrganization( deps: AdminUserEndpointDeps, userId: string, ): Promise<{ organizationId: string | null; membershipCreated: boolean }> { const engine = deps.getDataEngine(); - if (!engine || typeof engine.find !== 'function' || typeof engine.insert !== 'function') { - return { organizationId: null, membershipCreated: false }; - } - try { - // Only auto-bind when the org is unambiguous — exactly one row. - const orgs = await findRows(engine, 'sys_organization', {}, 2); - if (orgs.length !== 1 || !orgs[0]?.id) { - return { organizationId: null, membershipCreated: false }; - } - const organizationId = String(orgs[0].id); - - // Idempotent: respect an existing membership (retry / race) so we never - // trip the (organization_id, user_id) unique index. - const existing = await findRows( - engine, - 'sys_member', - { organization_id: organizationId, user_id: userId }, - 1, - ); - if (existing.length > 0) { - return { organizationId, membershipCreated: false }; - } - - await engine.insert( - 'sys_member', - { id: genMemberId(), organization_id: organizationId, user_id: userId, role: 'member' }, - { context: SYSTEM_CTX }, - ); - return { organizationId, membershipCreated: true }; - } catch (error) { - deps.logger?.warn( - `[AuthPlugin] failed to bind created user ${userId} to the default organization: ${ - (error as Error)?.message ?? error - }`, - ); - return { organizationId: null, membershipCreated: false }; - } + const result = await reconcileMembership(engine, userId, { + policy: 'auto', + resolveTargetOrg: () => resolveDefaultOrgId(engine), + logger: deps.logger + ? { warn: (msg, meta) => deps.logger?.warn(`${msg} ${meta ? JSON.stringify(meta) : ''}`.trim()) } + : undefined, + }); + return { + organizationId: result.organizationId ?? null, + membershipCreated: result.outcome === 'bound', + }; } /** diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index b48acdacd..31a237930 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1484,6 +1484,7 @@ describe('AuthManager', () => { phoneNumber: false, phoneNumberOtp: false, multiOrgEnabled: false, + degradedTenancy: false, privacyUrl: 'https://objectstack.ai/privacy', termsUrl: 'https://objectstack.ai/terms', }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index da2226c66..eba3583ae 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -17,6 +17,8 @@ import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { isPlaceholderEmail } from './placeholder-email.js'; +import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js'; +import type { TenancyService } from './tenancy-service.js'; import { OtpSendGuard } from './otp-send-guard.js'; import { PHONE_SMS_TOPICS, @@ -387,6 +389,32 @@ export interface AuthManagerOptions extends Partial { */ databaseHooks?: BetterAuthOptions['databaseHooks']; + /** + * ADR-0093 D1/D2 — deployment membership policy for the reconciler composed + * into `user.create.after`. `'auto'` (default) binds every new member-less + * user to the single-org default org; `'invite-only'` never auto-binds + * (membership comes only from invite / add-member / SSO JIT / host hooks). + * @default 'auto' + */ + membershipPolicy?: MembershipPolicy; + + /** + * ADR-0093 D3/D4 — accessor for the `tenancy` service, consulted by the + * membership reconciler to resolve the target org (single → default org; + * multi → none). A lazy accessor because the service is registered on the + * kernel after the AuthManager is constructed; the reconciler calls it at + * hook-fire time (well after boot). Omitted → the reconciler no-ops + * (no target org), preserving pre-ADR-0093 behavior. + */ + getTenancy?: () => TenancyService | undefined; + + /** + * Optional structured logger (the kernel `ctx.logger`) for best-effort + * bookkeeping surfaces such as the ADR-0093 membership reconciler. Omitted → + * those surfaces run silently (they already fail closed to no-op). + */ + logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void }; + /** * ADR-0069 D2 — account lockout (anti-brute-force). After this many * consecutive failed sign-ins the account is locked for @@ -2590,7 +2618,14 @@ export class AuthManager { const pluginConfig: Partial = this.config.plugins ?? {}; // Multi-org capability (UI org-switcher, "create org" action, etc.). // `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org / per-env runtime). - const multiOrgEnabled = resolveMultiOrgEnabled(); + // ADR-0093 D4 — the `tenancy` service is the single source of truth. Prefer + // it; fall back to the raw env flag only when it isn't wired (e.g. a lean + // embedding). `multiOrgEnabled` now reflects ACTUAL capability + // (`mode === 'multi'`), so a degraded deployment (requested but no isolation) + // reports `false` and the org-management UI hides instead of rendering broken. + const tenancy = this.config.getTenancy?.(); + const multiOrgEnabled = tenancy ? tenancy.mode === 'multi' : resolveMultiOrgEnabled(); + const degradedTenancy = tenancy?.degraded ?? false; // Legal links shown beneath the login / register cards. Defaults to // the public ObjectStack pages so vanilla deployments don't link to @@ -2620,6 +2655,11 @@ export class AuthManager { magicLink: pluginConfig.magicLink ?? false, organization: pluginConfig.organization ?? true, multiOrgEnabled, + // ADR-0093 D5 — brand the degraded state everywhere an operator looks. + // True iff multi-org was requested but tenant isolation is inactive + // (booted only because OS_ALLOW_DEGRADED_TENANCY=1). The console can + // surface a warning banner off this flag. + degradedTenancy, // Shared decision point with `buildPluginList()` — the /auth/config // response MUST match what's actually wired, otherwise the frontend // renders UI for endpoints that 404. @@ -2944,6 +2984,39 @@ export class AuthManager { } : hostSessionBefore; + // ADR-0093 D2 — the single owner of the membership invariant. Composed into + // `user.create.after`, the one seam EVERY creation path flows through + // (email signup, admin create-user, bulk import, SSO JIT). Host hook (e.g. + // the cloud's personal-org provisioning) chains FIRST and wins; the + // reconciler then yields to whatever membership exists, so there is never a + // double bind. Best-effort — never fails user creation. + const hostUserAfter = (host as any)?.user?.create?.after; + const membershipReconciler = async (user: any) => { + try { + await reconcileMembership(this.config.dataEngine, user?.id, { + policy: this.config.membershipPolicy ?? 'auto', + resolveTargetOrg: async () => { + const tenancy = this.config.getTenancy?.(); + // Single-org → default org; multi-org → none (invite/JIT own it). + return tenancy ? await tenancy.defaultOrgId() : null; + }, + logger: this.config.logger, + }); + } catch { + // reconcileMembership never throws, but guard the hook regardless — + // membership bookkeeping must never break user creation. + } + }; + const userAfter = hostUserAfter + ? async (user: any, ctx: any) => { + const hostResult = await hostUserAfter(user, ctx); + await membershipReconciler(user); + return hostResult; + } + : async (user: any) => { + await membershipReconciler(user); + }; + return { ...(host ?? {}), account: { @@ -2953,6 +3026,13 @@ export class AuthManager { after, }, }, + user: { + ...((host as any)?.user ?? {}), + create: { + ...((host as any)?.user?.create ?? {}), + after: userAfter, + }, + }, ...(sessionBefore ? { session: { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index fcaa49916..c5f362ec8 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -18,6 +18,8 @@ import { type AuthManagerOptions, } from './auth-manager.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { createTenancyService, type TenancyService } from './tenancy-service.js'; +import { backfillMemberships, type MembershipPolicy } from './reconcile-membership.js'; import { registerIdentityWriteGuard, registerManagedUpdateWhitelist, @@ -37,6 +39,15 @@ import { * Extends AuthConfig from spec with additional runtime options */ export interface AuthPluginOptions extends Partial { + /** + * ADR-0093 D1 — deployment membership policy. `'auto'` (default) auto-binds + * every new user to the single-org default org via the reconciler; invite-only + * deployments set `'invite-only'` to grant membership solely through explicit + * flows (invite / add-member / SSO JIT / host hooks). + * @default 'auto' + */ + membershipPolicy?: MembershipPolicy; + /** * Whether to automatically register auth routes * @default true @@ -130,6 +141,8 @@ export class AuthPlugin implements Plugin { private options: AuthPluginOptions; private authManager: AuthManager | null = null; + /** ADR-0093 D4 — the tenancy service registered in init(); reused at kernel:ready. */ + private tenancy: TenancyService | null = null; private configuredSocialProviders: SocialProviderConfig | undefined; // ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or // the kernel-cache adapter wired in init). The identity write guard's @@ -188,6 +201,18 @@ export class AuthPlugin implements Plugin { const authConfig: AuthManagerOptions & AuthPluginOptions = { ...this.options, dataEngine, + logger: ctx.logger, + // ADR-0093 D2/D3 — the membership reconciler consults the tenancy service + // (lazily, at hook-fire time — the service is registered below, after the + // `auth` service) to resolve the target org. membershipPolicy defaults to + // 'auto' in the reconciler. + getTenancy: () => { + try { + return ctx.getService('tenancy'); + } catch { + return undefined; + } + }, }; // ADR-0069 D2 — wire the kernel `cache` service as better-auth's shared @@ -238,6 +263,35 @@ export class AuthPlugin implements Plugin { // Register auth service ctx.registerService('auth', this.authManager); + // ADR-0093 D4 — register the `tenancy` service (single source of truth for + // tenancy mode). Registered AFTER `auth` so `auth` stays the plugin's first + // service registration (consumers and tests rely on that ordering). Baseline + // derives `isolationActive` from the presence of the `org-scoping` service + // (registered by @objectstack/organizations when installed), so the + // enterprise package needs no change to light it up. `getService` is a cheap + // registry lookup and org-scoping registers AFTER plugin-auth, so the probe + // is deferred to first read (start()/request time). + const tenancy: TenancyService = createTenancyService({ + requested: resolveMultiOrgEnabled(), + probeIsolation: () => { + try { + return !!ctx.getService('org-scoping'); + } catch { + return false; + } + }, + getEngine: () => { + try { + return ctx.getService('objectql'); + } catch { + return undefined; + } + }, + logger: ctx.logger, + }); + ctx.registerService('tenancy', tenancy); + this.tenancy = tenancy; + ctx.getService<{ register(m: any): void }>('manifest').register({ ...authPluginManifestHeader, ...(this.options.manifestDatasource @@ -538,6 +592,39 @@ export class AuthPlugin implements Plugin { } } + // ADR-0093 D6 — backfill memberships for pre-existing member-less users + // (historical create-user / import rows from before the reconciler existed). + // Registered AFTER the default-org bootstrap hook so a target org exists by + // the time it runs. `backfillMemberships` self-guards: it no-ops under + // `invite-only` policy and in multi-org (tenancy.defaultOrgId() → null), + // where a wrong org guess would be a data-exposure bug, not a convenience. + // Opt out entirely via OS_SKIP_MEMBERSHIP_BACKFILL=1 (operators who curate + // memberships by hand). + if (String(process.env.OS_SKIP_MEMBERSHIP_BACKFILL ?? '').trim() !== '1') { + ctx.hook('kernel:ready', async () => { + try { + const ql: any = ctx.getService('objectql'); + const tenancy = this.tenancy; + if (!ql || !tenancy) return; + const res = await backfillMemberships(ql, { + policy: this.options.membershipPolicy ?? 'auto', + resolveTargetOrg: () => tenancy.defaultOrgId(), + logger: ctx.logger, + }); + if (res.bound > 0) { + ctx.logger.info( + `[auth] membership backfill bound ${res.bound} pre-existing member-less user(s) to the default organization (ADR-0093 D6)`, + res, + ); + } + } catch (e) { + ctx.logger.warn?.('[auth] membership backfill failed', { + error: (e as Error).message, + }); + } + }); + } + // Identity-source provenance for accounts created OUTSIDE better-auth's // `databaseHooks` — @better-auth/scim creates `sys_account` at the adapter // level, which BYPASSES `account.create.after` / `stampIdentitySource`. This diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.test.ts b/packages/plugins/plugin-auth/src/reconcile-membership.test.ts new file mode 100644 index 000000000..1b3be99cc --- /dev/null +++ b/packages/plugins/plugin-auth/src/reconcile-membership.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { reconcileMembership, backfillMemberships } from './reconcile-membership.js'; + +/** + * In-memory engine over sys_member (+ optional sys_user) with the find/insert + * surface the reconciler uses. `find` honors `user_id` / `organization_id` + * filters; `insert` appends. + */ +function makeEngine(seed: { + members?: Array<{ organization_id: string; user_id: string }>; + users?: Array<{ id: string }>; +} = {}) { + const members = [...(seed.members ?? [])]; + const users = [...(seed.users ?? [])]; + const insert = vi.fn(async (_object: string, row: any) => { + members.push({ organization_id: row.organization_id, user_id: row.user_id }); + return row; + }); + const find = vi.fn(async (object: string, query: any) => { + const where = query?.where ?? {}; + if (object === 'sys_member') { + return members.filter( + (m) => + (where.user_id === undefined || m.user_id === where.user_id) && + (where.organization_id === undefined || m.organization_id === where.organization_id), + ); + } + if (object === 'sys_user') return users; + return []; + }); + return { find, insert, _members: members }; +} + +describe('reconcileMembership', () => { + it('binds a member-less user to the resolved target org (auto)', async () => { + const engine = makeEngine(); + const res = await reconcileMembership(engine, 'user-1', { + policy: 'auto', + resolveTargetOrg: async () => 'org_default', + }); + expect(res).toEqual({ outcome: 'bound', organizationId: 'org_default' }); + const memberInsert = engine.insert.mock.calls[0]; + expect(memberInsert[0]).toBe('sys_member'); + expect(memberInsert[1]).toMatchObject({ + organization_id: 'org_default', + user_id: 'user-1', + role: 'member', + }); + }); + + it('yields to an existing membership (host hook already bound the user)', async () => { + const engine = makeEngine({ members: [{ organization_id: 'org_personal', user_id: 'user-1' }] }); + const res = await reconcileMembership(engine, 'user-1', { + policy: 'auto', + resolveTargetOrg: async () => 'org_default', + }); + expect(res.outcome).toBe('yielded'); + expect(res.organizationId).toBe('org_personal'); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('invite-only policy never auto-binds', async () => { + const engine = makeEngine(); + const resolveTargetOrg = vi.fn(async () => 'org_default'); + const res = await reconcileMembership(engine, 'user-1', { policy: 'invite-only', resolveTargetOrg }); + expect(res.outcome).toBe('policy-skip'); + expect(resolveTargetOrg).not.toHaveBeenCalled(); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('no target org (multi mode) → no bind', async () => { + const engine = makeEngine(); + const res = await reconcileMembership(engine, 'user-1', { + policy: 'auto', + resolveTargetOrg: async () => null, + }); + expect(res.outcome).toBe('no-target-org'); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('skips when userId or engine is missing', async () => { + expect((await reconcileMembership(makeEngine(), undefined, { policy: 'auto', resolveTargetOrg: async () => 'o' })).outcome).toBe('skipped'); + expect((await reconcileMembership(undefined, 'user-1', { policy: 'auto', resolveTargetOrg: async () => 'o' })).outcome).toBe('skipped'); + }); + + it('never throws — an insert failure resolves to failed', async () => { + const engine = makeEngine(); + engine.insert.mockRejectedValueOnce(new Error('unique violation')); + const warn = vi.fn(); + const res = await reconcileMembership(engine, 'user-1', { + policy: 'auto', + resolveTargetOrg: async () => 'org_default', + logger: { warn }, + }); + expect(res.outcome).toBe('failed'); + expect(warn).toHaveBeenCalled(); + }); + + it('is idempotent: re-running after a bind yields', async () => { + const engine = makeEngine(); + const deps = { policy: 'auto' as const, resolveTargetOrg: async () => 'org_default' }; + const first = await reconcileMembership(engine, 'user-1', deps); + expect(first.outcome).toBe('bound'); + const second = await reconcileMembership(engine, 'user-1', deps); + expect(second.outcome).toBe('yielded'); + expect(engine.insert).toHaveBeenCalledTimes(1); + }); +}); + +describe('backfillMemberships', () => { + it('binds only the member-less users (single/auto)', async () => { + const engine = makeEngine({ + users: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }], + members: [{ organization_id: 'org_default', user_id: 'u2' }], + }); + const res = await backfillMemberships(engine, { + policy: 'auto', + resolveTargetOrg: async () => 'org_default', + }); + expect(res).toMatchObject({ scanned: 3, bound: 2, skipped: 1 }); + // u1 and u3 got bound; u2 was already a member + const boundUsers = engine.insert.mock.calls.map((c) => c[1].user_id).sort(); + expect(boundUsers).toEqual(['u1', 'u3']); + }); + + it('refuses under invite-only policy', async () => { + const engine = makeEngine({ users: [{ id: 'u1' }] }); + const res = await backfillMemberships(engine, { + policy: 'invite-only', + resolveTargetOrg: async () => 'org_default', + }); + expect(res.reason).toBe('policy'); + expect(res.bound).toBe(0); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('refuses when there is no target org (multi mode)', async () => { + const engine = makeEngine({ users: [{ id: 'u1' }] }); + const res = await backfillMemberships(engine, { + policy: 'auto', + resolveTargetOrg: async () => null, + }); + expect(res.reason).toBe('no-target-org'); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('isolates a per-user insert failure and keeps going', async () => { + const engine = makeEngine({ users: [{ id: 'u1' }, { id: 'u2' }] }); + engine.insert.mockRejectedValueOnce(new Error('boom')); // u1 fails + const res = await backfillMemberships(engine, { + policy: 'auto', + resolveTargetOrg: async () => 'org_default', + }); + expect(res.scanned).toBe(2); + expect(res.bound).toBe(1); // u2 still bound + expect(res.skipped).toBe(1); + }); +}); diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.ts b/packages/plugins/plugin-auth/src/reconcile-membership.ts new file mode 100644 index 000000000..f86e2ffe5 --- /dev/null +++ b/packages/plugins/plugin-auth/src/reconcile-membership.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Membership reconciler — the single owner of the "every new user gets an + * organization membership" invariant (ADR-0093 D1/D2). + * + * Before this, the invariant was re-implemented (or forgotten) per creation + * path: invite / add-member / SSO JIT / the cloud host hook created a + * `sys_member` row; `/admin/create-user` and `/admin/import-users` did not, + * leaving member-less users who — in single-org mode — log in with a null + * active organization and are missing from the Members list. + * + * This reconciler is composed into better-auth's `user.create.after` database + * hook (see auth-manager `composeDatabaseHooks`), the one seam every creation + * path flows through — email signup, admin create, bulk import, SSO JIT, and + * any future path. It: + * - yields to any pre-existing membership (so a host hook that already bound + * the user — e.g. the cloud's personal-org provisioning — wins, and there + * is never a double membership); + * - honors the deployment's `membershipPolicy` (`auto` binds; `invite-only` + * never auto-binds); + * - binds only to an unambiguous target org (single-org's default org; + * `multi` mode returns none — invite / JIT own membership there); + * - is idempotent (keyed on the `(organization_id, user_id)` unique index) + * and never throws (a failed bind must not fail user creation — the + * kernel:ready backfill is the self-healing net). + */ + +export type MembershipPolicy = 'auto' | 'invite-only'; + +export type ReconcileOutcome = + /** Inserted a `sys_member` row binding the user to the target org. */ + | 'bound' + /** The user already had a membership — respected it, wrote nothing. */ + | 'yielded' + /** `membershipPolicy: 'invite-only'` — auto-bind is off by policy. */ + | 'policy-skip' + /** No unambiguous target org (multi mode, or single mode not bootstrapped). */ + | 'no-target-org' + /** An error occurred and was swallowed (never fails user creation). */ + | 'failed' + /** Preconditions unmet (no engine / no user id). */ + | 'skipped'; + +export interface ReconcileMembershipDeps { + /** Deployment membership policy. Default `'auto'` at the call site. */ + policy: MembershipPolicy; + /** + * Resolve the organization to bind the user to. Single-org → the default org; + * multi-org → `null` (the framework never guesses). Typically + * `tenancy.defaultOrgId`. + */ + resolveTargetOrg: () => Promise; + logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void }; +} + +const SYSTEM_CTX = { isSystem: true }; + +function genMemberId(): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `mem_${ts}${rand}`; +} + +async function findRows( + engine: any, + object: string, + where: Record, + limit: number, +): Promise { + if (!engine || typeof engine.find !== 'function') return []; + try { + const rows = await engine.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : []; + } catch { + return []; + } +} + +async function insertMembership(engine: any, organizationId: string, userId: string): Promise { + await engine.insert( + 'sys_member', + { id: genMemberId(), organization_id: organizationId, user_id: userId, role: 'member' }, + { context: SYSTEM_CTX }, + ); +} + +/** + * Reconcile membership for one freshly-created user. Safe to call from a + * better-auth `user.create.after` hook — never throws, always resolves to a + * structured outcome. See the module doc for the decision order. + */ +export async function reconcileMembership( + engine: any, + userId: string | undefined, + deps: ReconcileMembershipDeps, +): Promise<{ outcome: ReconcileOutcome; organizationId?: string }> { + if (!engine || typeof engine.find !== 'function' || typeof engine.insert !== 'function' || !userId) { + return { outcome: 'skipped' }; + } + if (deps.policy === 'invite-only') { + return { outcome: 'policy-skip' }; + } + try { + // Yield to ANY existing membership (a host hook may have just bound the + // user; a retry may have already run). This is what makes host composition + // safe by construction — no ordering negotiation beyond "host first". + const existingAny = await findRows(engine, 'sys_member', { user_id: userId }, 1); + if (existingAny.length > 0) { + return { outcome: 'yielded', organizationId: existingAny[0]?.organization_id }; + } + + const organizationId = await deps.resolveTargetOrg(); + if (!organizationId) { + return { outcome: 'no-target-org' }; + } + + // Re-check the exact pair right before insert to avoid tripping the + // (organization_id, user_id) unique index on a race. + const existingPair = await findRows( + engine, + 'sys_member', + { organization_id: organizationId, user_id: userId }, + 1, + ); + if (existingPair.length > 0) { + return { outcome: 'yielded', organizationId }; + } + + await insertMembership(engine, organizationId, userId); + deps.logger?.info?.('[membership] bound user to organization', { userId, organizationId }); + return { outcome: 'bound', organizationId }; + } catch (error) { + deps.logger?.warn?.('[membership] reconcile failed (user creation unaffected)', { + userId, + error: (error as Error)?.message ?? String(error), + }); + return { outcome: 'failed' }; + } +} + +export interface BackfillMembershipsResult { + scanned: number; + bound: number; + skipped: number; + reason?: 'policy' | 'no-target-org' | 'engine-unavailable'; +} + +/** + * One-shot backfill for pre-existing member-less users (ADR-0093 D6). Run on + * `kernel:ready` in single mode with `membershipPolicy: 'auto'` only — multi-org + * backfill is refused by design (there is no correct guess, and a wrong org in a + * tenant-isolated deployment is a data-exposure bug, not a convenience). + * + * Bounded, idempotent, failure-isolated per user. Ordered after the default-org + * bootstrap so a target org exists. + */ +export async function backfillMemberships( + engine: any, + deps: ReconcileMembershipDeps & { limit?: number }, +): Promise { + const summary: BackfillMembershipsResult = { scanned: 0, bound: 0, skipped: 0 }; + if (!engine || typeof engine.find !== 'function' || typeof engine.insert !== 'function') { + return { ...summary, reason: 'engine-unavailable' }; + } + if (deps.policy !== 'auto') { + return { ...summary, reason: 'policy' }; + } + const organizationId = await deps.resolveTargetOrg(); + if (!organizationId) { + return { ...summary, reason: 'no-target-org' }; + } + + const limit = deps.limit ?? 5000; + const users = await findRows(engine, 'sys_user', {}, limit); + const members = await findRows(engine, 'sys_member', {}, limit); + const membered = new Set(members.map((m: any) => String(m?.user_id ?? '')).filter(Boolean)); + + for (const user of users) { + const uid = String(user?.id ?? ''); + if (!uid) continue; + summary.scanned += 1; + if (membered.has(uid)) { + summary.skipped += 1; + continue; + } + try { + await insertMembership(engine, organizationId, uid); + summary.bound += 1; + membered.add(uid); + } catch (error) { + summary.skipped += 1; + deps.logger?.warn?.('[membership] backfill bind failed for user', { + userId: uid, + error: (error as Error)?.message ?? String(error), + }); + } + } + + deps.logger?.info?.('[membership] backfill complete', { organizationId, ...summary }); + return summary; +} diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts new file mode 100644 index 000000000..c576a44fa --- /dev/null +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { createTenancyService, resolveDefaultOrgId } from './tenancy-service.js'; + +function makeEngine(orgs: Array<{ id: string; slug?: string }>) { + return { + find: vi.fn(async (object: string, query: any) => { + if (object !== 'sys_organization') return []; + const where = query?.where ?? {}; + let rows = orgs; + if (where.slug !== undefined) rows = rows.filter((o) => o.slug === where.slug); + return rows.slice(0, query?.limit ?? rows.length); + }), + }; +} + +describe('createTenancyService', () => { + it('single mode: not requested, isolation off', () => { + const t = createTenancyService({ requested: false, probeIsolation: () => false }); + expect(t.mode).toBe('single'); + expect(t.isolationActive).toBe(false); + expect(t.requested).toBe(false); + expect(t.degraded).toBe(false); + }); + + it('multi mode: requested and isolation active', () => { + const t = createTenancyService({ requested: true, probeIsolation: () => true }); + expect(t.mode).toBe('multi'); + expect(t.isolationActive).toBe(true); + expect(t.degraded).toBe(false); + }); + + it('degraded: requested but isolation NOT active', () => { + const t = createTenancyService({ requested: true, probeIsolation: () => false }); + expect(t.mode).toBe('single'); // behaves single-org-like — nothing isolates + expect(t.isolationActive).toBe(false); + expect(t.requested).toBe(true); + expect(t.degraded).toBe(true); + }); + + it('a throwing probe is treated as isolation off (fail-closed to single)', () => { + const t = createTenancyService({ + requested: true, + probeIsolation: () => { + throw new Error('registry exploded'); + }, + }); + expect(t.isolationActive).toBe(false); + expect(t.degraded).toBe(true); + }); + + it('re-reads the probe each access (org-scoping may register after construction)', () => { + let active = false; + const t = createTenancyService({ requested: true, probeIsolation: () => active }); + expect(t.mode).toBe('single'); + active = true; // org-scoping registers later + expect(t.mode).toBe('multi'); + expect(t.degraded).toBe(false); + }); + + describe('defaultOrgId', () => { + it('multi mode never guesses — returns null', async () => { + const engine = makeEngine([{ id: 'org_a' }, { id: 'org_b' }]); + const t = createTenancyService({ + requested: true, + probeIsolation: () => true, + getEngine: () => engine, + }); + expect(await t.defaultOrgId()).toBeNull(); + expect(engine.find).not.toHaveBeenCalled(); // short-circuits before any query + }); + + it('single mode prefers the slug=default bootstrap org', async () => { + const engine = makeEngine([{ id: 'org_x' }, { id: 'org_default', slug: 'default' }]); + const t = createTenancyService({ + requested: false, + probeIsolation: () => false, + getEngine: () => engine, + }); + expect(await t.defaultOrgId()).toBe('org_default'); + }); + + it('single mode falls back to the sole org when no default slug', async () => { + const engine = makeEngine([{ id: 'org_only' }]); + const t = createTenancyService({ + requested: false, + probeIsolation: () => false, + getEngine: () => engine, + }); + expect(await t.defaultOrgId()).toBe('org_only'); + }); + + it('single mode returns null when the org is ambiguous (≥2, no default)', async () => { + const engine = makeEngine([{ id: 'org_a' }, { id: 'org_b' }]); + const t = createTenancyService({ + requested: false, + probeIsolation: () => false, + getEngine: () => engine, + }); + expect(await t.defaultOrgId()).toBeNull(); + }); + + it('memoizes a positive resolution but re-resolves a null', async () => { + const orgs: Array<{ id: string; slug?: string }> = []; + const engine = makeEngine(orgs); + const t = createTenancyService({ + requested: false, + probeIsolation: () => false, + getEngine: () => engine, + }); + expect(await t.defaultOrgId()).toBeNull(); // not bootstrapped yet + orgs.push({ id: 'org_default', slug: 'default' }); // bootstrap runs + expect(await t.defaultOrgId()).toBe('org_default'); // re-resolved + const callsAfterResolve = engine.find.mock.calls.length; + await t.defaultOrgId(); // memoized — no new query + expect(engine.find.mock.calls.length).toBe(callsAfterResolve); + }); + }); +}); + +describe('resolveDefaultOrgId', () => { + it('returns null for a missing/invalid engine', async () => { + expect(await resolveDefaultOrgId(undefined)).toBeNull(); + expect(await resolveDefaultOrgId({})).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/tenancy-service.ts b/packages/plugins/plugin-auth/src/tenancy-service.ts new file mode 100644 index 000000000..cfda9a9a7 --- /dev/null +++ b/packages/plugins/plugin-auth/src/tenancy-service.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `tenancy` service — the single source of truth for "what tenancy mode is this + * deployment in?" (ADR-0093 D4). + * + * Before this service, the same fact was re-derived from four independent + * signals that could disagree: the `OS_MULTI_ORG_ENABLED` env flag, the + * `org-scoping` service probe, `sys_organization` row counting, and the + * frontend feature flags. The worst disagreement was silent — requesting + * multi-org without the enterprise `@objectstack/organizations` package degrades + * to zero tenant isolation with only a console warning (an ADR-0049-class + * unenforced security property). This service makes the two facts that matter — + * what was *requested* and what is *actually active* — first-class and + * queryable, so consumers stop re-deriving and the degraded state stops being + * silent. + * + * Registered by plugin-auth (the open-core home, alongside the default-org + * bootstrap). The baseline implementation derives `isolationActive` from the + * presence of the `org-scoping` service — the exact signal SecurityPlugin + * probes today — so the enterprise package needs no change to light it up: + * installing `@objectstack/organizations` registers `org-scoping`, and this + * service reports `mode: 'multi'` / `isolationActive: true` as a result. + */ + +export type TenancyMode = 'single' | 'multi'; + +export interface TenancyService { + /** + * Resolved tenancy mode. `multi` iff org-scoping (auto-stamp + tenant RLS) is + * actually active; otherwise `single`. A *degraded* deployment (multi-org + * requested, isolation absent) reports `single` — it behaves single-org-like + * because nothing isolates its data. + */ + readonly mode: TenancyMode; + /** True iff org-scoping (auto-stamp + tenant RLS) is actually wired. */ + readonly isolationActive: boolean; + /** What the operator asked for (`OS_MULTI_ORG_ENABLED`). */ + readonly requested: boolean; + /** + * `requested && !isolationActive` — multi-org was asked for but cannot be + * enforced. Boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1` (serve.ts, + * ADR-0093 D5); when it boots anyway, this flag brands the deployment + * everywhere an operator looks (`/auth/config`, Setup dashboard). + */ + readonly degraded: boolean; + /** + * The default organization id to bind new users to in `single` mode + * (ADR-0093 D3). Returns `null` in `multi` mode — the framework never guesses + * a target org there; invite / add-member / SSO JIT own membership. Also + * `null` in single mode until an org exists (e.g. before the default-org + * bootstrap runs). Positive resolutions are memoized (the id is stable). + */ + defaultOrgId(): Promise; +} + +export interface TenancyServiceDeps { + /** `OS_MULTI_ORG_ENABLED` — what the operator asked for. */ + requested: boolean; + /** + * Whether org-scoping is actually wired. Called lazily (never at + * construction — the org-scoping provider registers after plugin-auth) and + * cheap (a service-registry lookup); consumers that read it hot should cache + * the result themselves, as SecurityPlugin does at `start()`. + */ + probeIsolation: () => boolean; + /** ObjectQL engine accessor, for {@link TenancyService.defaultOrgId}. */ + getEngine?: () => unknown | undefined; + logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void }; +} + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows( + engine: any, + object: string, + where: Record, + limit: number, +): Promise { + if (!engine || typeof engine.find !== 'function') return []; + try { + const rows = await engine.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : []; + } catch { + return []; + } +} + +/** + * Resolve the single-org default organization: prefer the stable `slug='default'` + * bootstrap org, else the sole org row when exactly one exists. Returns `null` + * when there is no unambiguous single org (none yet, or ≥2 — the latter is a + * multi-org shape and this should not have been called). + */ +export async function resolveDefaultOrgId(engine: any): Promise { + const bySlug = await findRows(engine, 'sys_organization', { slug: 'default' }, 1); + if (bySlug[0]?.id) return String(bySlug[0].id); + const any = await findRows(engine, 'sys_organization', {}, 2); + if (any.length === 1 && any[0]?.id) return String(any[0].id); + return null; +} + +export function createTenancyService(deps: TenancyServiceDeps): TenancyService { + let cachedDefaultOrgId: string | null = null; + + const isolationActive = (): boolean => { + try { + return !!deps.probeIsolation(); + } catch { + return false; + } + }; + + return { + get requested(): boolean { + return deps.requested; + }, + get isolationActive(): boolean { + return isolationActive(); + }, + get mode(): TenancyMode { + return isolationActive() ? 'multi' : 'single'; + }, + get degraded(): boolean { + return deps.requested && !isolationActive(); + }, + async defaultOrgId(): Promise { + // Multi-org: the framework never guesses a target org. + if (isolationActive()) return null; + if (cachedDefaultOrgId) return cachedDefaultOrgId; + const resolved = await resolveDefaultOrgId(deps.getEngine?.()); + // Memoize only a positive resolution — a null (org not bootstrapped yet) + // must re-resolve on the next call. + if (resolved) cachedDefaultOrgId = resolved; + return resolved; + }, + }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1e89b8697..abb5f2353 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -334,15 +334,24 @@ export class SecurityPlugin implements Plugin { }); } - // Probe for OrgScopingPlugin presence. When registered, its - // `init()` exposes itself as the `org-scoping` service. We capture - // the boolean once at start time (plugin DI graph is static after - // start) and let `collectRLSPolicies` consult it on every request. + // Probe whether org-scoping (auto-stamp + tenant RLS) is active. We capture + // the boolean once at start time (plugin DI graph is static after start) + // and let `collectRLSPolicies` consult it on every request. + // + // ADR-0093 D4 — prefer the `tenancy` service, the single source of truth. + // It derives `isolationActive` from the very same `org-scoping` presence + // probe, so this is behavior-identical while centralizing the fact. Fall + // back to probing `org-scoping` directly when the tenancy service isn't + // wired (e.g. an embedding without plugin-auth), preserving prior behavior. try { - const orgScoping = ctx.getService('org-scoping'); - this.orgScopingEnabled = !!orgScoping; + const tenancy = ctx.getService<{ isolationActive?: boolean }>('tenancy'); + this.orgScopingEnabled = !!tenancy?.isolationActive; } catch { - this.orgScopingEnabled = false; + try { + this.orgScopingEnabled = !!ctx.getService('org-scoping'); + } catch { + this.orgScopingEnabled = false; + } } if (this.orgScopingEnabled) { ctx.logger.info( diff --git a/packages/types/src/env.test.ts b/packages/types/src/env.test.ts index cff31a123..99a7e0de4 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -1,7 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { afterEach, describe, expect, it, vi } from 'vitest'; -import { _resetEnvDeprecationWarnings, readEnvWithDeprecation } from './env.js'; +import { + _resetEnvDeprecationWarnings, + readEnvWithDeprecation, + resolveAllowDegradedTenancy, +} from './env.js'; describe('readEnvWithDeprecation', () => { const originalPreferred = process.env.OS_TEST_FOO; @@ -98,3 +102,30 @@ describe('readEnvWithDeprecation', () => { } }); }); + +describe('resolveAllowDegradedTenancy (ADR-0093 D5)', () => { + const original = process.env.OS_ALLOW_DEGRADED_TENANCY; + afterEach(() => { + if (original === undefined) delete process.env.OS_ALLOW_DEGRADED_TENANCY; + else process.env.OS_ALLOW_DEGRADED_TENANCY = original; + }); + + it('defaults OFF (unset → fail fast)', () => { + delete process.env.OS_ALLOW_DEGRADED_TENANCY; + expect(resolveAllowDegradedTenancy()).toBe(false); + }); + + it('accepts truthy opt-in values case-insensitively', () => { + for (const v of ['1', 'true', 'TRUE', 'on', 'Yes']) { + process.env.OS_ALLOW_DEGRADED_TENANCY = v; + expect(resolveAllowDegradedTenancy()).toBe(true); + } + }); + + it('treats anything else as off', () => { + for (const v of ['0', 'false', 'off', 'no', '', 'maybe']) { + process.env.OS_ALLOW_DEGRADED_TENANCY = v; + expect(resolveAllowDegradedTenancy()).toBe(false); + } + }); +}); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index b478adbb9..3204a1c45 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -103,6 +103,23 @@ export function resolveMultiOrgEnabled(): boolean { return String(raw ?? 'false').toLowerCase() !== 'false'; } +/** + * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5). + * + * When `OS_MULTI_ORG_ENABLED=true` but the enterprise `@objectstack/organizations` + * package cannot provide tenant isolation, the platform refuses to boot — a + * deployment that asked for tenant isolation must not serve traffic pretending + * to have it (ADR-0049 at the deployment layer). Setting this to a truthy value + * (`true`/`1`/`on`/`yes`, case-insensitive) boots anyway in an explicitly + * *degraded* state that is branded everywhere an operator looks. Defaults OFF — + * an unset flag means "fail fast". + */ +export function resolveAllowDegradedTenancy(): boolean { + const raw = readEnvWithDeprecation('OS_ALLOW_DEGRADED_TENANCY', [], { silent: true }); + if (raw == null) return false; + return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase()); +} + /** * SINGLE decision point for "is the MCP HTTP surface (`/api/v1/mcp`) on?". *