Skip to content

Commit c03108c

Browse files
os-zhuangclaude
andauthored
fix(auth): degraded tenancy must not hand out a default organization (#4423)
`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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent eb4204b commit c03108c

3 files changed

Lines changed: 91 additions & 6 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): a degraded tenancy posture must not hand out a default organization
6+
7+
`TenancyService.defaultOrgId()` documented "returns `null` under any walled
8+
posture", but the implementation keyed on the posture actually **in force**
9+
(`isolationActive()`) rather than the one the operator **requested**. Those two
10+
disagree in exactly one state — DEGRADED: a deployment that asked for `group`
11+
or `isolated` and could not enforce it (the enterprise `@objectstack/organizations`
12+
package is absent) reports `posture: 'single'`, and the resolver then happily
13+
answered with "the `slug='default'` org, or the only org that exists".
14+
15+
Everything downstream of that resolver binds new users to whatever it returns.
16+
The membership reconciler (ADR-0093 D2) runs on `user.create.after` — the seam
17+
every creation path flows through — so in a degraded deployment **every fresh
18+
signup, admin-created user and SSO JIT user was auto-bound as a `member` of
19+
whichever organization happened to be resolvable**, and `backfillMemberships`
20+
(D6) would sweep the pre-existing member-less ones in on the next
21+
`kernel:ready`.
22+
23+
This reached production. ObjectStack Cloud's control plane runs
24+
`OS_MULTI_ORG_ENABLED=true` while deliberately not mounting the enterprise
25+
package — it enforces its own control-plane org wall instead — so the
26+
`org-scoping` probe missed, the posture resolved degraded, and self-serve
27+
signups landed inside a stranger's organization with read access to that org's
28+
environments (cloud#957).
29+
30+
`defaultOrgId()` now keys on `requestedPosture`: any walled request, enforced or
31+
degraded, returns `null` and the framework never guesses. This is the same
32+
judgement D6 already applies to the backfill — "a wrong org in a tenant-isolated
33+
deployment is a data-exposure bug, not a convenience" — applied to the resolver
34+
those consumers share. It also makes the resolver agree with the default-org
35+
bootstrap in `AuthPlugin.start()`, which was already gated on the requested
36+
posture.
37+
38+
Single-org deployments are unaffected: nothing about `requested: 'single'`
39+
changes. A degraded deployment loses the auto-bind, which is the point — and
40+
ADR-0093 D5 already refuses to boot that deployment at all unless the operator
41+
sets `OS_ALLOW_DEGRADED_TENANCY=1`.

packages/plugins/plugin-auth/src/tenancy-service.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,36 @@ describe('createTenancyService', () => {
129129
expect(engine.find).not.toHaveBeenCalled(); // short-circuits before any query
130130
});
131131

132+
// cloud#957 — the case that reached production. A deployment that ASKED for
133+
// a wall and did not get one must not fall back to "the only org I can
134+
// see": the cloud control plane runs `isolated` while mounting its own
135+
// scoping plugin instead of the enterprise package, so this resolver was
136+
// handing the reconciler a target org and every fresh self-serve signup
137+
// landed as a `member` of a stranger's organization.
138+
it('degraded (walled requested, isolation inactive) still never guesses', async () => {
139+
const engine = makeEngine([{ id: 'org_only' }]);
140+
const t = createTenancyService({
141+
requested: 'isolated',
142+
probeIsolation: () => false, // enterprise package absent → degraded
143+
getEngine: () => engine,
144+
});
145+
expect(t.degraded).toBe(true);
146+
expect(t.posture).toBe('single'); // behaves single-org-like…
147+
expect(await t.defaultOrgId()).toBeNull(); // …but still refuses to guess
148+
expect(engine.find).not.toHaveBeenCalled();
149+
});
150+
151+
it('degraded does not guess the slug=default org either', async () => {
152+
const engine = makeEngine([{ id: 'org_default', slug: 'default' }, { id: 'org_b' }]);
153+
const t = createTenancyService({
154+
requested: 'group',
155+
probeIsolation: () => false,
156+
getEngine: () => engine,
157+
});
158+
expect(t.degraded).toBe(true);
159+
expect(await t.defaultOrgId()).toBeNull();
160+
});
161+
132162
it('single mode prefers the slug=default bootstrap org', async () => {
133163
const engine = makeEngine([{ id: 'org_x' }, { id: 'org_default', slug: 'default' }]);
134164
const t = createTenancyService({

packages/plugins/plugin-auth/src/tenancy-service.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,22 @@ export interface TenancyService {
7777
readonly degraded: boolean;
7878
/**
7979
* The default organization id to bind new users to when no wall is enforced
80-
* (ADR-0093 D3). Returns `null` under any walled posture — the framework
81-
* never guesses a target org there; invite / add-member / SSO JIT own
82-
* membership. Also `null` before an org exists (e.g. before the default-org
83-
* bootstrap runs). Positive resolutions are memoized (the id is stable).
80+
* (ADR-0093 D3). Returns `null` whenever a walled posture was REQUESTED — the
81+
* framework never guesses a target org there; invite / add-member / SSO JIT
82+
* own membership. Also `null` before an org exists (e.g. before the
83+
* default-org bootstrap runs). Positive resolutions are memoized (the id is
84+
* stable).
85+
*
86+
* Keyed on {@link requestedPosture}, not on {@link posture}: a DEGRADED
87+
* deployment asked for a wall and did not get one, and the safe reading of
88+
* that is "I don't know which org this user belongs to", not "everyone
89+
* belongs to the only org I can see". Guessing there is the failure ADR-0093
90+
* D6 already refuses for the backfill — "a wrong org in a tenant-isolated
91+
* deployment is a data-exposure bug, not a convenience" — and it reached
92+
* production once (cloud#957): a control plane running `isolated` without the
93+
* enterprise package bound every fresh self-serve signup into whichever
94+
* organization happened to be the only one, handing them its environments.
95+
* Degrading the WALL is survivable; degrading into cross-tenant writes is not.
8496
*/
8597
defaultOrgId(): Promise<string | null>;
8698
}
@@ -213,8 +225,10 @@ export function createTenancyService(deps: TenancyServiceDeps): TenancyService {
213225
return postureEnforcesWall(requestedPosture) && !isolationActive();
214226
},
215227
async defaultOrgId(): Promise<string | null> {
216-
// Any walled posture: the framework never guesses a target org.
217-
if (isolationActive()) return null;
228+
// Any walled posture REQUEST — enforced or degraded — means the framework
229+
// never guesses a target org. See the interface doc for why the degraded
230+
// case fails closed rather than falling back to "the only org I can see".
231+
if (postureEnforcesWall(requestedPosture)) return null;
218232
if (cachedDefaultOrgId) return cachedDefaultOrgId;
219233
const resolved = await resolveDefaultOrgId(deps.getEngine?.());
220234
// Memoize only a positive resolution — a null (org not bootstrapped yet)

0 commit comments

Comments
 (0)