Skip to content

Commit dda8af0

Browse files
committed
fix(tenancy): eight sites read the demoted OS_MULTI_ORG_ENABLED as "is this multi-org?" (#5262)
ADR-0105 D1 made OS_TENANCY_POSTURE the authoritative knob and demoted OS_MULTI_ORG_ENABLED to a back-compat *input* of resolveTenancyPosture(). A deployment configured the documented way — OS_TENANCY_POSTURE=isolated (or group), legacy boolean unset — therefore reads false from resolveMultiOrgEnabled() while running a fully mounted organization wall. #5233 fixed two sites in plugin-auth; a census found eight more, all written before that function's doc comment was corrected. Third recurrence of the shape (cloud#1020, #5233). Each site was judged separately for WHICH posture answers its question — what the operator REQUESTED, or what the `tenancy` service reports is actually IN FORCE — rather than converted mechanically: REQUESTED posture (postureEnforcesWall(resolveTenancyPosture())): - objectql SchemaRegistry — constructed below the kernel, no service registry to ask. The organization_id column was always provisioned; what diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's hottest predicate unindexed while SecurityPlugin compiled that same wall. - plugin-dev — this branch is what MOUNTS the wall, so asking whether the wall is up would be circular. Mirrors serve.ts. Its diagnostic now names the requested posture instead of asserting OS_MULTI_ORG_ENABLED=true at an operator who never set it. - driver-sql isMultiTenantMode() — a driver has no kernel to ask, and a suppressed warning is the costlier error for a diagnostic. Also drops the _multiTenantMode memo, which froze a process-level fact into a per-instance verdict on whichever write landed first; the read is now live, affordable because auditMissingTenant consults it only after the tenantId early-out. - cli `os verify` — resolves a flag before any kernel exists. This site produced a GREEN verification run over an unverified property. EFFECTIVE posture (the `tenancy` service, falling back to requested): - runtime AppPlugin, both seeder sites — they ask "will the per-org replay run instead of me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so keying on the request would defer to a replay that can never happen. - cloud-connection marketplace local install, both sites — same reasoning; the install path is a write path that landed sample rows with no organization_id, outside the wall its own reads apply. Single-org behaviour is unchanged at every site; only the knob each one reads is corrected. resolveTenancyPosture()'s own fallback chain is untouched — it remains the sole legitimate consumer of the legacy boolean, and is now the only caller left in the repo. Every new test was run against the unfixed source and confirmed red; the runtime suite additionally pins that a REQUESTED-posture fix fails exactly the three degraded assertions, which is what makes the per-site judgment falsifiable rather than asserted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
1 parent 88b9b2d commit dda8af0

14 files changed

Lines changed: 1395 additions & 33 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/plugin-dev': patch
5+
'@objectstack/driver-sql': patch
6+
'@objectstack/cli': patch
7+
'@objectstack/cloud-connection': patch
8+
---
9+
10+
fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#5262)
11+
12+
ADR-0105 D1 made `OS_TENANCY_POSTURE` the authoritative knob and demoted
13+
`OS_MULTI_ORG_ENABLED` to a back-compat *input* of `resolveTenancyPosture()`.
14+
A deployment configured the documented way — `OS_TENANCY_POSTURE=isolated` (or
15+
`group`), legacy boolean unset — therefore reads `false` from
16+
`resolveMultiOrgEnabled()` while running a fully mounted organization wall.
17+
#5233 corrected two sites in `plugin-auth`; a census found eight more, all
18+
written before that function's doc comment was corrected. Third recurrence of
19+
the shape (cloud#1020, #5233).
20+
21+
Each site was judged separately for **which** posture answers its question —
22+
what the operator REQUESTED, or what the `tenancy` service reports is actually
23+
IN FORCE — rather than converted mechanically:
24+
25+
- `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the
26+
REQUESTED posture (it is constructed below the kernel, with no service
27+
registry to ask). The `organization_id` column was always provisioned; what
28+
diverged is its INDEX, so a posture-only deployment ran the Layer 0 wall's
29+
hottest predicate unindexed while SecurityPlugin compiled that same wall.
30+
- `plugin-dev` — whether to load the enterprise `@objectstack/organizations`.
31+
REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall,
32+
so asking whether the wall is up would be circular. A posture-only dev stack
33+
previously never loaded the package at all and served traffic unwalled. Its
34+
diagnostic now names the posture that was requested instead of asserting
35+
`OS_MULTI_ORG_ENABLED=true` at an operator who never set it.
36+
- `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture,
37+
via the `tenancy` service. These ask "will the per-org replay run instead of
38+
me?", and on an ADR-0093 D5 degraded boot that replay does not exist, so
39+
keying on the request would defer to a replay that can never happen. Walled
40+
deployments previously inline-seeded exactly the NULL-organization rows the
41+
code's own comment exists to avoid.
42+
- `cloud-connection` marketplace local install (install-time seed + rehydrate
43+
heal) — EFFECTIVE posture, same reasoning. The install path is a write path:
44+
a walled deployment wrote every sample row with no `organization_id`, landing
45+
the app's data outside the wall its own reads apply.
46+
- `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no
47+
kernel to ask, and a suppressed warning is the costlier error for a
48+
diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a
49+
process-level fact into a per-instance verdict on whichever write landed
50+
first. The gate now resolves live, which is affordable because
51+
`auditMissingTenant` consults it only after the `tenantId` early-out.
52+
- `cli` `os verify` — REQUESTED posture. This one produced a green verification
53+
run over an unverified property: a posture-only deployment silently skipped
54+
every multi-tenant proof and exited 0.
55+
56+
**No configuration change is needed anywhere.** Deployments setting only
57+
`OS_MULTI_ORG_ENABLED=true` keep working unchanged — `resolveTenancyPosture()`
58+
falls back to it — and the `OS_TENANCY_POSTURE=isolated` + `OS_MULTI_ORG_ENABLED=true`
59+
belt-and-braces configuration stays valid. Deployments that set only
60+
`OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is
61+
unchanged at every site; only the knob each one reads is corrected.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #5262 — `os verify` decides whether to run its multi-tenant proofs from the
4+
// AUTHORITATIVE tenancy posture, never the demoted `OS_MULTI_ORG_ENABLED`.
5+
//
6+
// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted
7+
// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`.
8+
// The command kept calling `resolveMultiOrgEnabled()`, so on a deployment
9+
// configured the documented way (posture knob only, which is exactly what v17's
10+
// own docs tell an operator to set) `os verify` booted a SINGLE-ORG stack and
11+
// silently skipped every multi-tenant proof — then exited 0.
12+
//
13+
// This is the worst site in the #5262 sweep for the defect to have landed. The
14+
// other five produce wrong behaviour that some later signal can still catch;
15+
// this one produces a GREEN VERIFICATION RUN over an unverified property, and a
16+
// verifier that under-verifies reports success it never established. Same
17+
// defect shape as cloud#1020 and #5233.
18+
//
19+
// ── Evidence boundary (stated plainly) ──────────────────────────────────────
20+
// This pins `resolveVerifyMultiTenant`, the exported decision, not an
21+
// end-to-end `os verify` invocation. That is this package's established shape
22+
// for command-level decisions — `describeRegisteredDriver` in `serve.ts` is
23+
// tested exactly this way — because the alternative boots two full kernels per
24+
// scenario. The command body is a single call to this function, so the wiring
25+
// it does not cover is one line. Nothing about the RESOLVER is stubbed: the
26+
// real env vars are set and the real `resolveTenancyPosture()` folds them.
27+
28+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
29+
import { resolveVerifyMultiTenant } from './verify.js';
30+
31+
const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
32+
const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED;
33+
34+
const under = (
35+
env: { posture?: string; legacy?: string },
36+
flags: { 'multi-tenant'?: boolean } = {},
37+
) => {
38+
if (env.posture === undefined) delete process.env.OS_TENANCY_POSTURE;
39+
else process.env.OS_TENANCY_POSTURE = env.posture;
40+
if (env.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
41+
else process.env.OS_MULTI_ORG_ENABLED = env.legacy;
42+
return resolveVerifyMultiTenant(flags);
43+
};
44+
45+
beforeEach(() => {
46+
delete process.env.OS_TENANCY_POSTURE;
47+
delete process.env.OS_MULTI_ORG_ENABLED;
48+
});
49+
afterEach(() => {
50+
if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
51+
else process.env.OS_TENANCY_POSTURE = OLD_POSTURE;
52+
if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
53+
else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY;
54+
});
55+
56+
describe('#5262 — os verify keys its multi-tenant suite off OS_TENANCY_POSTURE', () => {
57+
it('posture-only deployment (OS_TENANCY_POSTURE=isolated, legacy boolean UNSET) verifies multi-tenant', () => {
58+
// THE regression. Before the fix this run silently proved nothing about
59+
// tenant isolation and still exited 0.
60+
expect(under({ posture: 'isolated' })).toBe(true);
61+
});
62+
63+
it('`group` is a walled posture too — not just `isolated`', () => {
64+
// `group` has no legacy-boolean spelling at all, so under the bug NO
65+
// configuration could get `os verify` to exercise a group deployment.
66+
expect(under({ posture: 'group' })).toBe(true);
67+
});
68+
69+
it('legacy-boolean-only deployment keeps working — back-compat via the posture resolver', () => {
70+
expect(under({ legacy: 'true' })).toBe(true);
71+
});
72+
73+
it('single-org deployments still run the single-org suite', () => {
74+
// Intent unchanged — only the knob is corrected.
75+
expect(under({ posture: 'single' })).toBe(false);
76+
expect(under({ legacy: 'false' })).toBe(false);
77+
expect(under({})).toBe(false);
78+
});
79+
80+
it('an explicit legacy `false` does not veto the authoritative posture', () => {
81+
expect(under({ posture: 'isolated', legacy: 'false' })).toBe(true);
82+
});
83+
84+
it('--multi-tenant still forces the suite on regardless of environment', () => {
85+
// The flag is an explicit operator request and stays independent of the
86+
// env: `os verify --multi-tenant` on an unconfigured box is how a developer
87+
// proves the multi-org path locally.
88+
expect(under({}, { 'multi-tenant': true })).toBe(true);
89+
expect(under({ posture: 'single' }, { 'multi-tenant': true })).toBe(true);
90+
expect(under({ legacy: 'false' }, { 'multi-tenant': true })).toBe(true);
91+
});
92+
93+
it('an absent flag object behaves like an unset flag', () => {
94+
expect(under({ posture: 'single' }, {})).toBe(false);
95+
expect(under({ posture: 'isolated' }, {})).toBe(true);
96+
});
97+
});

packages/cli/src/commands/verify.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import { Command, Flags } from '@oclif/core';
44
import chalk from 'chalk';
5-
import { resolveMultiOrgEnabled } from '@objectstack/types';
5+
import { resolveTenancyPosture } from '@objectstack/types';
6+
import { postureEnforcesWall } from '@objectstack/spec/security';
67
import {
78
bootStack,
89
runCrudVerification,
@@ -14,6 +15,37 @@ import {
1415
} from '@objectstack/verify';
1516
import { loadConfig } from '../utils/config.js';
1617

18+
/**
19+
* Should this `os verify` run boot an org-scoped (multi-tenant) stack?
20+
*
21+
* Two independent ways to ask for it, ORed: the explicit `--multi-tenant` flag,
22+
* or a deployment environment that already asks for an organization wall.
23+
*
24+
* [ADR-0105 D1 / #5262] The env half reads the resolved POSTURE — ⛔ never
25+
* `resolveMultiOrgEnabled()`, which ADR-0105 D1 demoted to a back-compat INPUT
26+
* of `resolveTenancyPosture()`. On a deployment configured the documented way
27+
* (`OS_TENANCY_POSTURE=isolated|group`, legacy boolean unset) that boolean reads
28+
* `false`, so `os verify` booted a single-org stack and SILENTLY skipped every
29+
* multi-tenant proof. That is the worst place in the codebase for this defect to
30+
* land: the whole purpose of `verify` is to be the thing that notices, and a
31+
* verifier that under-verifies reports success it never established. Third
32+
* recurrence of the shape (cloud#1020, #5233).
33+
*
34+
* REQUESTED posture is the right judge here — this resolves a CLI flag before
35+
* any kernel exists, and the question is literally "what did the operator ask
36+
* this run to prove". `bootStack({ multiTenant: true })` then REQUESTS the
37+
* `isolated` posture for the fixture and hard-fails if the enterprise runtime
38+
* is missing, so an unenforceable request surfaces as an error rather than as a
39+
* quietly single-org pass.
40+
*
41+
* Extracted and exported so the decision is testable on its own, following
42+
* `describeRegisteredDriver` in `serve.ts` — this package's established shape
43+
* for a command-level decision worth pinning.
44+
*/
45+
export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): boolean {
46+
return Boolean(flags['multi-tenant']) || postureEnforcesWall(resolveTenancyPosture());
47+
}
48+
1749
/**
1850
* `objectstack verify` — boot the app in-process and exercise it through the
1951
* real HTTP stack, asserting runtime behavior the static gates can't see:
@@ -42,7 +74,7 @@ export default class Verify extends Command {
4274
default: false,
4375
}),
4476
'multi-tenant': Flags.boolean({
45-
description: 'Boot org-scoped (register the enterprise @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)',
77+
description: 'Boot org-scoped (register the enterprise @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors a walled $OS_TENANCY_POSTURE, and the legacy $OS_MULTI_ORG_ENABLED it falls back to)',
4678
default: false,
4779
}),
4880
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
@@ -53,7 +85,7 @@ export default class Verify extends Command {
5385

5486
const { config, absolutePath } = await loadConfig(flags.app);
5587

56-
const multiTenant = flags['multi-tenant'] || resolveMultiOrgEnabled();
88+
const multiTenant = resolveVerifyMultiTenant(flags);
5789

5890
// Data fidelity runs on its own pristine stack.
5991
let crud: VerifyReport;

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
*/
4343

4444
import type { Plugin, PluginContext } from '@objectstack/core';
45-
import { resolveMultiOrgEnabled } from '@objectstack/types';
45+
import { resolveTenancyPosture } from '@objectstack/types';
46+
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
4647
import { resolveCloudUrl } from './cloud-url.js';
4748
import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js';
4849
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';
@@ -57,6 +58,37 @@ function manifestIdOf(p: any): string | undefined {
5758
return p?.manifest?.id ?? p?.id ?? p?.manifest?.name ?? undefined;
5859
}
5960

61+
/**
62+
* [ADR-0093 D4/D5, ADR-0105 D1 / #5262] Is an organization wall actually IN
63+
* FORCE for this boot? Both seeding decisions in this plugin key off it.
64+
*
65+
* ⛔ Never `resolveMultiOrgEnabled()`. ADR-0105 D1 demoted that boolean to a
66+
* back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a
67+
* deployment configured the documented way (`OS_TENANCY_POSTURE=isolated|group`,
68+
* legacy boolean unset) — and a marketplace install on such a deployment wrote
69+
* its sample rows with NO `organization_id` at all, landing them outside the
70+
* wall every subsequent read applies. Same shape as cloud#1020 and #5233.
71+
*
72+
* EFFECTIVE, not requested. Both call sites ask "is the per-org replay going to
73+
* own this seeding instead of me?", and that replay is the enterprise
74+
* `@objectstack/organizations` middleware on `sys_organization` insert. On a
75+
* DEGRADED boot that middleware is absent, so deferring to it would strand the
76+
* data permanently; the `tenancy` service reports the posture in force
77+
* (`single` there), which correctly hands the work back to the inline path.
78+
*
79+
* Falls back to the requested posture when no `tenancy` service is registered
80+
* (a lean embedding without plugin-auth). Read live — never cached.
81+
*/
82+
function organizationWallActive(ctx: PluginContext): boolean {
83+
try {
84+
const tenancy = ctx.getService?.('tenancy') as { posture?: TenancyPosture } | undefined;
85+
if (tenancy?.posture) return postureEnforcesWall(tenancy.posture);
86+
} catch {
87+
/* no `tenancy` service registered — fall through */
88+
}
89+
return postureEnforcesWall(resolveTenancyPosture());
90+
}
91+
6092
export interface MarketplaceInstallLocalPluginConfig {
6193
/** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL
6294
* and then to the public ObjectStack cloud so a fresh `objectstack dev`
@@ -233,8 +265,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
233265
: [];
234266
if (datasets.length === 0) return;
235267
if (entry.sampleDataPurged === true) return;
236-
if (resolveMultiOrgEnabled()) {
237-
ctx.logger?.info?.(`[MarketplaceInstallLocal] multi-tenant — sample-data heal for ${entry.manifestId} left to per-org replay`);
268+
if (organizationWallActive(ctx)) {
269+
ctx.logger?.info?.(`[MarketplaceInstallLocal] organization wall active — sample-data heal for ${entry.manifestId} left to per-org replay`);
238270
return;
239271
}
240272

@@ -981,7 +1013,12 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
9811013
// writes tenant-scoped rows the same way AppPlugin's
9821014
// single-tenant branch + SecurityPlugin's per-org replay do.
9831015
if (opts.seedNow && datasets.length > 0) {
984-
const multiTenant = resolveMultiOrgEnabled();
1016+
// See `organizationWallActive` — the wall in FORCE, not the demoted
1017+
// boolean. This one is the write path: judged wrong, the install's
1018+
// rows are inserted with no `organization_id` on a walled
1019+
// deployment, i.e. behind the wall and unreadable by every caller
1020+
// the wall applies to (#5262).
1021+
const multiTenant = organizationWallActive(ctx);
9851022
try {
9861023
const ql: any = ctx.getService('objectql');
9871024
let metadata: any;

0 commit comments

Comments
 (0)