From 25c4bb38d1dcb010543a3f648924f0d4af8d9e05 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:48:22 +0800 Subject: [PATCH] fix(auth): degraded tenancy must not hand out a default organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TenancyService.defaultOrgId()` documented "returns null under any walled posture" but keyed on the posture actually IN FORCE, not the one requested. Those disagree in exactly one state — DEGRADED — and there the resolver answered with "the slug='default' org, or the only org that exists". Everything downstream binds new users to that answer. The membership reconciler (ADR-0093 D2) sits on `user.create.after`, the seam every creation path flows through, so a degraded deployment auto-bound every fresh signup, admin-created user and SSO JIT user as a `member` of whichever organization was resolvable — and `backfillMemberships` (D6) would sweep the pre-existing member-less ones in on the next `kernel:ready`. This reached production: ObjectStack Cloud's control plane requests `isolated` while deliberately not mounting `@objectstack/organizations` (it enforces its own control-plane wall), so the `org-scoping` probe missed, the posture resolved degraded, and self-serve signups landed inside a stranger's organization with read access to its environments (cloud#957). `defaultOrgId()` now keys on `requestedPosture` — any walled request, enforced or degraded, returns null and the framework never guesses. Same judgement D6 already applies to the backfill ("a wrong org in a tenant-isolated deployment is a data-exposure bug, not a convenience"), applied to the resolver those consumers share, and now consistent with the default-org bootstrap in `AuthPlugin.start()`, which was already gated on the requested posture. Single-org is unchanged. A degraded deployment loses the auto-bind, which is the point — ADR-0093 D5 already refuses to boot it without `OS_ALLOW_DEGRADED_TENANCY=1`. Co-Authored-By: Claude Opus 5 --- .changeset/tenancy-default-org-fail-closed.md | 41 +++++++++++++++++++ .../plugin-auth/src/tenancy-service.test.ts | 30 ++++++++++++++ .../plugin-auth/src/tenancy-service.ts | 26 +++++++++--- 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 .changeset/tenancy-default-org-fail-closed.md diff --git a/.changeset/tenancy-default-org-fail-closed.md b/.changeset/tenancy-default-org-fail-closed.md new file mode 100644 index 0000000000..8588f72f92 --- /dev/null +++ b/.changeset/tenancy-default-org-fail-closed.md @@ -0,0 +1,41 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): a degraded tenancy posture must not hand out a default organization + +`TenancyService.defaultOrgId()` documented "returns `null` under any walled +posture", but the implementation keyed on the posture actually **in force** +(`isolationActive()`) rather than the one the operator **requested**. Those two +disagree in exactly one state — DEGRADED: a deployment that asked for `group` +or `isolated` and could not enforce it (the enterprise `@objectstack/organizations` +package is absent) reports `posture: 'single'`, and the resolver then happily +answered with "the `slug='default'` org, or the only org that exists". + +Everything downstream of that resolver binds new users to whatever it returns. +The membership reconciler (ADR-0093 D2) runs on `user.create.after` — the seam +every creation path flows through — so in a degraded deployment **every fresh +signup, admin-created user and SSO JIT user was auto-bound as a `member` of +whichever organization happened to be resolvable**, and `backfillMemberships` +(D6) would sweep the pre-existing member-less ones in on the next +`kernel:ready`. + +This reached production. ObjectStack Cloud's control plane runs +`OS_MULTI_ORG_ENABLED=true` while deliberately not mounting the enterprise +package — it enforces its own control-plane org wall instead — so the +`org-scoping` probe missed, the posture resolved degraded, and self-serve +signups landed inside a stranger's organization with read access to that org's +environments (cloud#957). + +`defaultOrgId()` now keys on `requestedPosture`: any walled request, enforced or +degraded, returns `null` and the framework never guesses. This is the same +judgement D6 already applies to the backfill — "a wrong org in a tenant-isolated +deployment is a data-exposure bug, not a convenience" — applied to the resolver +those consumers share. It also makes the resolver agree with the default-org +bootstrap in `AuthPlugin.start()`, which was already gated on the requested +posture. + +Single-org deployments are unaffected: nothing about `requested: 'single'` +changes. A degraded deployment loses the auto-bind, which is the point — and +ADR-0093 D5 already refuses to boot that deployment at all unless the operator +sets `OS_ALLOW_DEGRADED_TENANCY=1`. diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts index e151f47ffd..f94f17fce8 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.test.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -129,6 +129,36 @@ describe('createTenancyService', () => { expect(engine.find).not.toHaveBeenCalled(); // short-circuits before any query }); + // cloud#957 — the case that reached production. A deployment that ASKED for + // a wall and did not get one must not fall back to "the only org I can + // see": the cloud control plane runs `isolated` while mounting its own + // scoping plugin instead of the enterprise package, so this resolver was + // handing the reconciler a target org and every fresh self-serve signup + // landed as a `member` of a stranger's organization. + it('degraded (walled requested, isolation inactive) still never guesses', async () => { + const engine = makeEngine([{ id: 'org_only' }]); + const t = createTenancyService({ + requested: 'isolated', + probeIsolation: () => false, // enterprise package absent → degraded + getEngine: () => engine, + }); + expect(t.degraded).toBe(true); + expect(t.posture).toBe('single'); // behaves single-org-like… + expect(await t.defaultOrgId()).toBeNull(); // …but still refuses to guess + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('degraded does not guess the slug=default org either', async () => { + const engine = makeEngine([{ id: 'org_default', slug: 'default' }, { id: 'org_b' }]); + const t = createTenancyService({ + requested: 'group', + probeIsolation: () => false, + getEngine: () => engine, + }); + expect(t.degraded).toBe(true); + expect(await t.defaultOrgId()).toBeNull(); + }); + it('single mode prefers the slug=default bootstrap org', async () => { const engine = makeEngine([{ id: 'org_x' }, { id: 'org_default', slug: 'default' }]); const t = createTenancyService({ diff --git a/packages/plugins/plugin-auth/src/tenancy-service.ts b/packages/plugins/plugin-auth/src/tenancy-service.ts index 99cbe492c2..3579a91ed4 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.ts @@ -77,10 +77,22 @@ export interface TenancyService { readonly degraded: boolean; /** * The default organization id to bind new users to when no wall is enforced - * (ADR-0093 D3). Returns `null` under any walled posture — the framework - * never guesses a target org there; invite / add-member / SSO JIT own - * membership. Also `null` before an org exists (e.g. before the default-org - * bootstrap runs). Positive resolutions are memoized (the id is stable). + * (ADR-0093 D3). Returns `null` whenever a walled posture was REQUESTED — the + * framework never guesses a target org there; invite / add-member / SSO JIT + * own membership. Also `null` before an org exists (e.g. before the + * default-org bootstrap runs). Positive resolutions are memoized (the id is + * stable). + * + * Keyed on {@link requestedPosture}, not on {@link posture}: a DEGRADED + * deployment asked for a wall and did not get one, and the safe reading of + * that is "I don't know which org this user belongs to", not "everyone + * belongs to the only org I can see". Guessing there is the failure ADR-0093 + * D6 already refuses for the backfill — "a wrong org in a tenant-isolated + * deployment is a data-exposure bug, not a convenience" — and it reached + * production once (cloud#957): a control plane running `isolated` without the + * enterprise package bound every fresh self-serve signup into whichever + * organization happened to be the only one, handing them its environments. + * Degrading the WALL is survivable; degrading into cross-tenant writes is not. */ defaultOrgId(): Promise; } @@ -213,8 +225,10 @@ export function createTenancyService(deps: TenancyServiceDeps): TenancyService { return postureEnforcesWall(requestedPosture) && !isolationActive(); }, async defaultOrgId(): Promise { - // Any walled posture: the framework never guesses a target org. - if (isolationActive()) return null; + // Any walled posture REQUEST — enforced or degraded — means the framework + // never guesses a target org. See the interface doc for why the degraded + // case fails closed rather than falling back to "the only org I can see". + if (postureEnforcesWall(requestedPosture)) return null; if (cachedDefaultOrgId) return cachedDefaultOrgId; const resolved = await resolveDefaultOrgId(deps.getEngine?.()); // Memoize only a positive resolution — a null (org not bootstrapped yet)