Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/tenancy-default-org-fail-closed.md
Original file line number Diff line number Diff line change
@@ -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`.
30 changes: 30 additions & 0 deletions packages/plugins/plugin-auth/src/tenancy-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
26 changes: 20 additions & 6 deletions packages/plugins/plugin-auth/src/tenancy-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;
}
Expand Down Expand Up @@ -213,8 +225,10 @@ export function createTenancyService(deps: TenancyServiceDeps): TenancyService {
return postureEnforcesWall(requestedPosture) && !isolationActive();
},
async defaultOrgId(): Promise<string | null> {
// 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)
Expand Down
Loading