diff --git a/.changeset/gentle-buttons-shave.md b/.changeset/gentle-buttons-shave.md new file mode 100644 index 0000000000..c64f6c755d --- /dev/null +++ b/.changeset/gentle-buttons-shave.md @@ -0,0 +1,36 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/types': patch +--- + +fix(auth): `organization/create` gates on the authoritative `OS_TENANCY_POSTURE`, not the demoted `OS_MULTI_ORG_ENABLED` (#5233) + +A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or +`group`), legacy boolean unset — mounted the entire organization wall and still +answered `403 Creating additional organizations is disabled on this deployment.` +to `POST /api/v1/auth/organization/create`. Org-less users had no way to create +their workspace, so the guided "Create your workspace" path was a dead end. + +ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted +`OS_MULTI_ORG_ENABLED` to a back-compat *input* of `resolveTenancyPosture()`. +Two sites in `AuthManager` kept reading the demoted boolean directly, so both +reported "single-org" on a deployment that had asked for a wall and got one: + +- `organizationHooks.beforeCreateOrganization` — the 403 above. It now judges + `postureEnforcesWall(resolveTenancyPosture())`, matching the knob `serve.ts`'s + own ADR-0093 D5 boot guard keys on. Intent is unchanged (single-org still + refuses); only the knob is corrected. +- `/auth/config`'s `features.multiOrgEnabled` — its no-tenancy-service fallback + read the same boolean. It now falls back to the resolved posture, so a lean + embedding advertises the capability its own gate allows. + +**No configuration change is needed anywhere.** Deployments that set only +`OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()` +falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true` +workaround people used to unblock themselves stays valid. Deployments that set +only `OS_TENANCY_POSTURE` can now drop the redundant boolean. + +`resolveMultiOrgEnabled()`'s doc comment in `@objectstack/types` — which still +instructed "the auth manager's `/auth/config` feature flag and org-create guard +… MUST call this", written before the demotion — now says the opposite: ask the +posture, and never gate on this boolean. Its behaviour is unchanged. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f8c9c538e2..721317ac5a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -12,12 +12,13 @@ import type { } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; import type { IEmailService, ISmsService } from '@objectstack/spec/contracts'; -import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveTenancyPosture, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types'; import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN, MEMBERSHIP_ROLE_DELEGATED_ADMIN, } from '@objectstack/spec'; +import { postureEnforcesWall } from '@objectstack/spec/security'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js'; @@ -1891,12 +1892,20 @@ export class AuthManager { // never seed `sys_environment`) keep working: any lookup error // is treated as "no envs to protect". organizationHooks: { - // Gate fresh organization creation behind the multi-org flag. - // The plugin itself is always installed (so list/update/invite endpoints - // keep responding); only the `create` operation is denied when the - // deployment is provisioned in single-org mode. Resolution order: - // `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org / - // per-env runtime). + // Gate fresh organization creation behind the deployment's TENANCY + // POSTURE. The plugin itself is always installed (so list/update/invite + // endpoints keep responding); only the `create` operation is denied, + // and only where no organization wall is enforced — creating an + // organization there would mint a boundary nothing keeps (ADR-0049 at + // the deployment layer). + // + // [#5233] The judge is the REQUESTED tenancy posture + // (`multiOrgPostureRequested()`), never the `OS_MULTI_ORG_ENABLED` + // boolean ADR-0105 D1 demoted — that one reads `false` on a + // deployment configured with only the authoritative + // `OS_TENANCY_POSTURE=isolated`, so the whole organization wall + // mounted and every org-less user's guided "create your workspace" + // path still 403'd. Same defect shape as cloud#1020. beforeCreateOrganization: async ({ organization }: any = {}) => { // [ADR-0120 D3] `'__global__'` is the platform's name for the // NULL-organization bucket: the autonumber sequence table keys @@ -1913,7 +1922,7 @@ export class AuthManager { '(ADR-0120 D3) and cannot be used as an organization id or slug.', }); } - if (!resolveMultiOrgEnabled()) { + if (!this.multiOrgPostureRequested()) { const { APIError } = await import('better-auth/api'); throw new APIError('FORBIDDEN', { message: @@ -3185,6 +3194,34 @@ export class AuthManager { return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false); } + /** + * [ADR-0105 D1 / #5233] Does this deployment ASK for a multi-organization + * posture? The `beforeCreateOrganization` gate's judge. + * + * ⛔ Never `resolveMultiOrgEnabled()`. ADR-0105 D1 DEMOTED that boolean to a + * back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a + * deployment configured with only the authoritative `OS_TENANCY_POSTURE` — + * the exact inversion of the declared contract. It shipped twice: cloud#1020 + * (the EE licence gate) and #5233, where a fully walled + * `OS_TENANCY_POSTURE=isolated` deployment 403'd `organization/create`, so + * every org-less user's guided "create your workspace" path dead-ended while + * `/auth/config` advertised the capability as present. + * + * REQUESTED, not effective — the same fact `serve.ts`'s ADR-0093 D5 boot + * guard keys on (`resolveTenancyPosture() !== 'single'`), and the same fact + * the old boolean expressed, so this corrects the KNOB and nothing else. + * Whether a requested wall is actually ENFORCED is the `tenancy` service's + * separate answer (`degraded`), which `/auth/config` reports and this gate + * deliberately does not consult; #5261 carries that question. + * + * Read live on every call — never cached. The posture is process-level + * config, and freezing it at plugin-build time would make the gate unable to + * see anything a later boot phase (or a test) established. + */ + private multiOrgPostureRequested(): boolean { + return postureEnforcesWall(resolveTenancyPosture()); + } + getPublicConfig() { // Extract social providers info (without sensitive data) const socialProviders = []; @@ -3244,16 +3281,23 @@ export class AuthManager { // Extract enabled features 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). // ADR-0093 D4 / ADR-0105 D1 — 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` reflects ACTUAL capability — - // any posture that enforces an organization wall (`group` or `isolated`) — - // so a degraded deployment (requested but no isolation resolves to `single`) - // reports `false` and the org-management UI hides instead of rendering broken. + // truth. Prefer it; fall back to the resolved POSTURE only when it isn't + // wired (e.g. a lean embedding). `multiOrgEnabled` reflects ACTUAL + // capability — any posture that enforces an organization wall (`group` or + // `isolated`) — so a degraded deployment (requested but no isolation + // resolves to `single`) reports `false` and the org-management UI hides + // instead of rendering broken. + // + // [#5233] That fallback used to read `resolveMultiOrgEnabled()`, the + // boolean ADR-0105 D1 demoted, which reports `false` on a deployment that + // sets only the authoritative `OS_TENANCY_POSTURE` — the same stale + // contract that broke the org-create gate, one site over. It reads the + // posture now, so an unwired-tenancy embedding advertises the capability + // its gate actually allows. const tenancy = this.config.getTenancy?.(); - const tenancyPosture = tenancy?.posture ?? (resolveMultiOrgEnabled() ? 'isolated' : 'single'); - const multiOrgEnabled = tenancyPosture !== 'single'; + const tenancyPosture = tenancy?.posture ?? resolveTenancyPosture(); + const multiOrgEnabled = postureEnforcesWall(tenancyPosture); const degradedTenancy = tenancy?.degraded ?? false; // Legal links shown beneath the login / register cards. Defaults to diff --git a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts new file mode 100644 index 0000000000..d4e7c14a39 --- /dev/null +++ b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5233 — `organization/create` is gated by the AUTHORITATIVE tenancy posture, +// never by the demoted `OS_MULTI_ORG_ENABLED` boolean. +// +// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted +// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`. +// The gate kept calling `resolveMultiOrgEnabled()` — a contract that stopped +// being true the day of the demotion — so a deployment configured the +// documented way (`OS_TENANCY_POSTURE=isolated`, legacy boolean unset) mounted +// the entire organization wall and still answered 403 "Creating additional +// organizations is disabled on this deployment." Every org-less user's guided +// "create your workspace" path dead-ended there, which under cloud#1012's +// option B (self-serve signup deliberately provisions NO organization) is the +// platform's only answer to "what happens after sign-up". Same defect shape as +// cloud#1020, one site over. +// +// Two things are pinned here, and they are the same fact seen from both ends: +// +// 1. the GATE — a real better-auth `POST /organization/create`, status and +// body, through `AuthManager.handleRequest`. Asserting the hook function +// in isolation would re-create the blind spot: the 403 in the field came +// out of the mounted route, so the route is what has to answer. +// 2. the `/auth/config` FLAG — `features.multiOrgEnabled`, which the console +// renders the "Create organization" action from, and whose no-tenancy +// fallback read the same demoted boolean. A flag that advertises a +// capability the gate refuses (or hides one it allows) is the same class +// of defect pointed the other way, so every scenario asserts BOTH and the +// table at the bottom asserts they cannot disagree. +// +// The gate judges the REQUESTED posture, which is what the old boolean also +// meant and what `serve.ts`'s ADR-0093 D5 boot guard keys on — this corrects +// the KNOB and nothing else. Whether a requested wall is actually ENFORCED is +// the `tenancy` service's separate answer; the deployment where those two come +// apart (degraded) is pinned at the bottom as CURRENT behaviour, unchanged by +// this fix, with the follow-up that owns it. +// +// Real better-auth pipeline throughout (the #3585 EdDSA / #4785 session-of-record +// precedent: patch the real thing, never stub our own code). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch } from '@objectstack/objectql'; +import { AuthManager } from './auth-manager'; +import { createTenancyService, type TenancyService, type TenancyPosture } from './tenancy-service'; + +/** + * In-memory IDataEngine — same shape as the #4785 harness (`fields` really + * projects, `update` matches on `id` the way the ObjectQL adapter calls it), so + * the fake cannot be more forgiving than the real engine about what better-auth + * asks for while minting `sys_organization` / `sys_member` rows. + * + * `delete` is pinned to ObjectQL's own dispatch predicate + * ({@link assertEngineDeleteDispatch}) rather than a hand-written copy of it, + * for the #4550 reason: a fake that accepts a call the real engine refuses is + * how #4434 shipped a dead REST route with its suite green. + */ +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date + ? new Date(a as any).getTime() === new Date(b as any).getTime() + : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + } + return eq(actual, v); + }); + const project = (row: any, fields?: string[]) => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out: any = {}; + for (const f of ['id', ...fields]) if (f in row) out[f] = row[f]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + const row = rows(name).find((r) => matches(r, q.where)); + return row ? project(row, q.fields) : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + if (q.offset) out = out.slice(q.offset); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => project(r, q.fields)); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any) { + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-5233'; +const ORIGIN = 'http://localhost:3000'; + +const makeManager = (engine: any, config: Record = {}) => + new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + plugins: { organization: true }, + ...config, + } as any); + +const signUp = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request(`${ORIGIN}/api/v1/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name: 'Workspace Founder' }), + }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +/** The guided "Create your workspace" call, exactly as the console makes it. */ +const createOrganization = (manager: AuthManager, cookie: string, slug: string) => + manager.handleRequest( + new Request(`${ORIGIN}/api/v1/auth/organization/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ name: `Acme ${slug}`, slug }), + }), + ); + +/** A tenancy service whose requested posture IS the posture in force. */ +const enforcedTenancy = (requested: TenancyPosture): TenancyService => + createTenancyService({ requested, probeIsolation: () => true }); + +/** + * ADR-0093 D5 degradation: a wall was REQUESTED and the enterprise + * `@objectstack/organizations` runtime is absent, so `posture` resolves to + * `single` and `degraded` is true. + */ +const degradedTenancy = (): TenancyService => + createTenancyService({ requested: 'isolated', probeIsolation: () => false }); + +interface Scenario { + /** `OS_TENANCY_POSTURE`, or `undefined` to leave it unset. */ + posture?: string; + /** `OS_MULTI_ORG_ENABLED`, or `undefined` to leave it unset. */ + legacy?: string; + /** A wired `tenancy` service, or `undefined` for a lean embedding. */ + tenancy?: TenancyService; +} + +/** + * Boot a manager under a scenario and run the whole guided path: sign up, then + * ask for a workspace. Returns the real HTTP answer plus the `/auth/config` + * flag the console would have rendered its button from. + */ +const runGuidedWorkspaceCreation = async (scenario: Scenario, slug = 'acme') => { + if (scenario.posture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = scenario.posture; + if (scenario.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = scenario.legacy; + + const engine = createMemoryEngine(); + const manager = makeManager( + engine, + scenario.tenancy ? { getTenancy: () => scenario.tenancy } : {}, + ); + const cookie = cookieFrom(await signUp(manager, `founder-${slug}@example.com`)); + expect(cookie).not.toBe(''); + + const response = await createOrganization(manager, cookie, slug); + const body = await response.json().catch(() => null); + const features = (manager.getPublicConfig() as any).features; + return { + engine, + manager, + status: response.status, + body, + multiOrgEnabled: features.multiOrgEnabled as boolean, + tenancyPosture: features.tenancyPosture as TenancyPosture, + orgRows: (engine.tables.get('sys_organization') ?? []) as any[], + }; +}; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5233 — the org-create gate reads OS_TENANCY_POSTURE, not the demoted boolean', () => { + it('posture-only deployment (OS_TENANCY_POSTURE=isolated, legacy boolean UNSET) creates the workspace', async () => { + // THE regression. Configured exactly as the docs say, and exactly as + // cloud#1012's real `objectstack serve` probe was: the authoritative knob + // and nothing else. Before the fix this was 403 FORBIDDEN. + const run = await runGuidedWorkspaceCreation({ posture: 'isolated' }, 'posture-only'); + + expect(run.status).toBe(200); + expect(run.orgRows).toHaveLength(1); + expect(run.orgRows[0].slug).toBe('posture-only'); + // …and the console was right to offer the button. + expect(run.multiOrgEnabled).toBe(true); + expect(run.tenancyPosture).toBe('isolated'); + }); + + it('`group` is a multi-org posture too — it creates, it is not just `isolated`', async () => { + // The gate asks `postureEnforcesWall`, the spec's own vocabulary, rather + // than comparing against `'isolated'`: `group` walls organizations just as + // much (ADR-0105 D1), it only widens READ scope across the membership set. + const run = await runGuidedWorkspaceCreation({ posture: 'group' }, 'group-posture'); + + expect(run.status).toBe(200); + expect(run.orgRows).toHaveLength(1); + expect(run.multiOrgEnabled).toBe(true); + expect(run.tenancyPosture).toBe('group'); + }); + + it('legacy-boolean-only deployment keeps working — back-compat via the posture resolver', async () => { + // No behaviour change for anything already deployed: `resolveTenancyPosture()` + // falls back to `OS_MULTI_ORG_ENABLED` when the posture knob is unset, so the + // pre-ADR-0105 configuration resolves to `isolated` and still creates. + const run = await runGuidedWorkspaceCreation({ legacy: 'true' }, 'legacy-only'); + + expect(run.status).toBe(200); + expect(run.orgRows).toHaveLength(1); + expect(run.multiOrgEnabled).toBe(true); + expect(run.tenancyPosture).toBe('isolated'); + }); + + it('single-org deployment is still refused, with the same message', async () => { + // The gate's INTENT is unchanged — only the knob it reads. A deployment + // with no organization wall must not mint an organization: the boundary + // would be declared and unenforced (ADR-0049 at the deployment layer). + const run = await runGuidedWorkspaceCreation({ posture: 'single' }, 'single-posture'); + + expect(run.status).toBe(403); + expect(JSON.stringify(run.body)).toContain( + 'Creating additional organizations is disabled on this deployment.', + ); + expect(run.orgRows).toHaveLength(0); + expect(run.multiOrgEnabled).toBe(false); + expect(run.tenancyPosture).toBe('single'); + }); + + it('neither knob set (the default) is refused — the default is single-org', async () => { + const run = await runGuidedWorkspaceCreation({}, 'unset'); + + expect(run.status).toBe(403); + expect(run.orgRows).toHaveLength(0); + expect(run.multiOrgEnabled).toBe(false); + }); + + it('an explicit legacy `false` does not veto the authoritative posture', async () => { + // The precise inversion the demotion created: the canonical knob asks for a + // wall, the superseded one says "no multi-org". The canonical knob wins — + // otherwise the legacy flag would still be authoritative in disguise. + const run = await runGuidedWorkspaceCreation( + { posture: 'isolated', legacy: 'false' }, + 'posture-beats-legacy', + ); + + expect(run.status).toBe(200); + expect(run.multiOrgEnabled).toBe(true); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5233 — with a `tenancy` service wired, as a real kernel boot has', () => { + it('a wired, enforced tenancy service creates the workspace', async () => { + const run = await runGuidedWorkspaceCreation( + { posture: 'isolated', tenancy: enforcedTenancy('isolated') }, + 'wired-isolated', + ); + + expect(run.status).toBe(200); + expect(run.multiOrgEnabled).toBe(true); + }); + + it('DEGRADED: the gate still allows while the flag hides — pinned as CURRENT behaviour (#5261)', async () => { + // ADR-0093 D5: a wall was REQUESTED and cannot be enforced (no enterprise + // `@objectstack/organizations`), so the tenancy service reports an + // effective posture of `single` + `degraded`. The gate judges the REQUEST, + // so it allows; the flag reports ACTUAL capability, so it hides. + // + // That divergence PREDATES this fix — `resolveMultiOrgEnabled()` was an env + // read too, and answered `true` here just the same — so #5233 does not + // silently change it: tightening the gate to the effective posture would + // take org creation away from every deployment running without the + // enterprise package, which is a capability decision for the maintainer, + // not a knob correction. Filed as #5261. This assertion exists so that + // whichever way it is settled, it is settled DELIBERATELY. + const run = await runGuidedWorkspaceCreation( + { posture: 'isolated', tenancy: degradedTenancy() }, + 'degraded', + ); + + expect(run.status).toBe(200); + expect(run.multiOrgEnabled).toBe(false); + expect(run.tenancyPosture).toBe('single'); + }); + + it('the verdict is taken LIVE per request, never frozen at plugin-build time', async () => { + // The org plugin is constructed once, at the first `getAuthInstance()`. If + // the gate captured the posture there instead of calling per request, a + // provider registering later in the same boot could never widen it — the + // recorded-verdict defect AGENTS.md's startup-registry rule names. + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const cookie = cookieFrom(await signUp(manager, 'live@example.com')); + + const refused = await createOrganization(manager, cookie, 'before'); + expect(refused.status).toBe(403); + expect((manager.getPublicConfig() as any).features.multiOrgEnabled).toBe(false); + + // Same manager, same already-built better-auth instance. + process.env.OS_TENANCY_POSTURE = 'isolated'; + + const allowed = await createOrganization(manager, cookie, 'after'); + expect(allowed.status).toBe(200); + expect((manager.getPublicConfig() as any).features.multiOrgEnabled).toBe(true); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5233 — /auth/config and the gate agree on every deployment shape', () => { + // For every configuration where the requested wall is the wall in force, + // `features.multiOrgEnabled` must predict the HTTP answer exactly. A future + // change that fixes one site and forgets the other fails here, which is the + // only reason #5233 was ever hard to see — the flag said yes, the route said + // no, and nothing compared them. (The one shape where the two legitimately + // report different facts — degraded — is asserted above, not here.) + const scenarios: Array<{ name: string; scenario: Scenario; allowed: boolean }> = [ + { name: 'posture=isolated (legacy unset)', scenario: { posture: 'isolated' }, allowed: true }, + { name: 'posture=group (legacy unset)', scenario: { posture: 'group' }, allowed: true }, + { name: 'posture=single (legacy unset)', scenario: { posture: 'single' }, allowed: false }, + { name: 'legacy=true (posture unset)', scenario: { legacy: 'true' }, allowed: true }, + { name: 'legacy=false (posture unset)', scenario: { legacy: 'false' }, allowed: false }, + { name: 'nothing set', scenario: {}, allowed: false }, + { + name: 'posture=isolated + legacy=true (the workaround config)', + scenario: { posture: 'isolated', legacy: 'true' }, + allowed: true, + }, + { + name: 'tenancy service: isolated + enforced', + scenario: { posture: 'isolated', tenancy: enforcedTenancy('isolated') }, + allowed: true, + }, + { + name: 'tenancy service: group + enforced', + scenario: { posture: 'group', tenancy: enforcedTenancy('group') }, + allowed: true, + }, + { + name: 'tenancy service: single', + scenario: { posture: 'single', tenancy: enforcedTenancy('single') }, + allowed: false, + }, + ]; + + it.each(scenarios)('$name', async ({ name, scenario, allowed }) => { + const run = await runGuidedWorkspaceCreation( + scenario, + `agree-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`, + ); + + expect(run.status).toBe(allowed ? 200 : 403); + expect(run.multiOrgEnabled).toBe(allowed); + // The flag PREDICTS the route: this is the invariant, not the two numbers. + expect(run.multiOrgEnabled).toBe(run.status === 200); + }); +}); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index e3e2cfb902..0fa62aa597 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -86,21 +86,33 @@ export function readEnvWithDeprecation( } /** - * Resolve whether the deployment runs in multi-org (a.k.a. multi-tenant) mode. - * - * Single source of truth for the `OS_MULTI_ORG_ENABLED` flag. Resolution: the - * canonical `OS_MULTI_ORG_ENABLED`; else `false`. Any value other than a + * Read the LEGACY `OS_MULTI_ORG_ENABLED` boolean. + * + * ⚠️ **[ADR-0105 D1] DEMOTED — not the knob to gate on.** `OS_TENANCY_POSTURE` + * superseded this flag and is the authoritative one; + * {@link resolveTenancyPosture} is where the two are reconciled (posture when + * set, else this boolean). This function only reports the legacy input, so a + * deployment that sets ONLY the canonical `OS_TENANCY_POSTURE` reads `false` + * here while genuinely running a walled multi-organization posture. + * + * **Answering "is this deployment multi-org?" with this function is a bug.** + * Ask the posture instead — `postureEnforcesWall(resolveTenancyPosture())` + * (`@objectstack/spec/security`) — or, inside a running kernel, the `tenancy` + * service, which additionally knows whether the requested wall is actually + * ENFORCED (ADR-0093 D4/D5). Two shipped defects came from gating on this + * boolean after the demotion: cloud#1020 (the EE licence gate) and #5233 + * (`organization/create` 403'd on a posture-only deployment whose organization + * wall was fully mounted — the guided "create your workspace" path dead-ended). + * The sentence this paragraph replaced actively instructed both. + * + * Legitimate remaining callers are the ones that specifically mean *the legacy + * input*: {@link resolveTenancyPosture}'s own back-compat fallback, and + * back-compat/reporting surfaces that must echo what the operator typed. + * + * Resolution: `OS_MULTI_ORG_ENABLED`; else `false`. Any value other than a * case-insensitive `'false'` enables it. (The legacy `OS_MULTI_TENANT` alias was * removed in 11.0.) * - * Every site that needs to know "is this multi-org?" — the SQL driver's - * tenant-audit gate, the auth manager's `/auth/config` feature flag and - * org-create guard, the CLI / dev / runtime org-scoping plugin wiring — MUST - * call this instead of re-reading the env, so the driver, the security layer, - * and the UI can never disagree about the mode. Previously each site inlined - * its own `String(... ?? 'false').toLowerCase() !== 'false'` (and the SQL - * driver read `process.env` directly, skipping the deprecation warning). - * * Reads `process.env` live on each call; memoise at the call site if the * result must be stable for the process lifetime. */ @@ -288,7 +300,9 @@ export function resolveMcpStdioAutoStart(): { enabled: boolean; viaDeprecatedAli * self-created orgs (each of which can auto-provision a free environment on the * cloud control plane) without penalising a user invited into many orgs. * - * Only meaningful when multi-org is enabled ({@link resolveMultiOrgEnabled}). + * Only meaningful under a posture that enforces an organization wall, i.e. + * `postureEnforcesWall({@link resolveTenancyPosture}())` — NOT the demoted + * `resolveMultiOrgEnabled()` boolean (ADR-0105 D1, #5233). * Returns `undefined` when unset or non-positive → no limit (better-auth treats * an absent `organizationLimit` as unlimited), preserving self-host behaviour. * Deployments that let users self-create orgs SHOULD set a generous cap.