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
61 changes: 61 additions & 0 deletions .changeset/posture-misreads-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@objectstack/objectql': patch
'@objectstack/runtime': patch
'@objectstack/plugin-dev': patch
'@objectstack/driver-sql': patch
'@objectstack/cli': patch
'@objectstack/cloud-connection': patch
---

fix(tenancy): eight sites answered "is this deployment multi-org?" with the demoted `OS_MULTI_ORG_ENABLED` (#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 corrected 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:

- `objectql` `SchemaRegistry` — the env-derived multi-tenant default. Reads the
REQUESTED posture (it is constructed below the kernel, with 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` — whether to load the enterprise `@objectstack/organizations`.
REQUESTED posture, mirroring `serve.ts`: this branch is what mounts the wall,
so asking whether the wall is up would be circular. A posture-only dev stack
previously never loaded the package at all and served traffic unwalled. Its
diagnostic now names the posture that was requested instead of asserting
`OS_MULTI_ORG_ENABLED=true` at an operator who never set it.
- `runtime` `AppPlugin` (inline seed + hot-reload seeder) — EFFECTIVE posture,
via the `tenancy` service. These 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. Walled
deployments previously inline-seeded exactly the NULL-organization rows the
code's own comment exists to avoid.
- `cloud-connection` marketplace local install (install-time seed + rehydrate
heal) — EFFECTIVE posture, same reasoning. The install path is a write path:
a walled deployment wrote every sample row with no `organization_id`, landing
the app's data outside the wall its own reads apply.
- `driver-sql` `isMultiTenantMode()` — REQUESTED posture (a driver has no
kernel to ask, and a suppressed warning is the costlier error for a
diagnostic). It also no longer memoises into `_multiTenantMode`: that froze a
process-level fact into a per-instance verdict on whichever write landed
first. The gate now resolves live, which is affordable because
`auditMissingTenant` consults it only after the `tenantId` early-out.
- `cli` `os verify` — REQUESTED posture. This one produced a green verification
run over an unverified property: a posture-only deployment silently skipped
every multi-tenant proof and exited 0.

**No configuration change is needed anywhere.** Deployments setting 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`
belt-and-braces configuration stays valid. Deployments that set only
`OS_TENANCY_POSTURE` can now drop the redundant boolean. Single-org behaviour is
unchanged at every site; only the knob each one reads is corrected.
97 changes: 97 additions & 0 deletions packages/cli/src/commands/verify-tenancy-posture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #5262 — `os verify` decides whether to run its multi-tenant proofs from the
// AUTHORITATIVE tenancy posture, never the demoted `OS_MULTI_ORG_ENABLED`.
//
// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted
// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`.
// The command kept calling `resolveMultiOrgEnabled()`, so on a deployment
// configured the documented way (posture knob only, which is exactly what v17's
// own docs tell an operator to set) `os verify` booted a SINGLE-ORG stack and
// silently skipped every multi-tenant proof — then exited 0.
//
// This is the worst site in the #5262 sweep for the defect to have landed. The
// other five produce wrong behaviour that some later signal can still catch;
// this one produces a GREEN VERIFICATION RUN over an unverified property, and a
// verifier that under-verifies reports success it never established. Same
// defect shape as cloud#1020 and #5233.
//
// ── Evidence boundary (stated plainly) ──────────────────────────────────────
// This pins `resolveVerifyMultiTenant`, the exported decision, not an
// end-to-end `os verify` invocation. That is this package's established shape
// for command-level decisions — `describeRegisteredDriver` in `serve.ts` is
// tested exactly this way — because the alternative boots two full kernels per
// scenario. The command body is a single call to this function, so the wiring
// it does not cover is one line. Nothing about the RESOLVER is stubbed: the
// real env vars are set and the real `resolveTenancyPosture()` folds them.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolveVerifyMultiTenant } from './verify.js';

const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED;

const under = (
env: { posture?: string; legacy?: string },
flags: { 'multi-tenant'?: boolean } = {},
) => {
if (env.posture === undefined) delete process.env.OS_TENANCY_POSTURE;
else process.env.OS_TENANCY_POSTURE = env.posture;
if (env.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
else process.env.OS_MULTI_ORG_ENABLED = env.legacy;
return resolveVerifyMultiTenant(flags);
};

beforeEach(() => {
delete process.env.OS_TENANCY_POSTURE;
delete process.env.OS_MULTI_ORG_ENABLED;
});
afterEach(() => {
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('#5262 — os verify keys its multi-tenant suite off OS_TENANCY_POSTURE', () => {
it('posture-only deployment (OS_TENANCY_POSTURE=isolated, legacy boolean UNSET) verifies multi-tenant', () => {
// THE regression. Before the fix this run silently proved nothing about
// tenant isolation and still exited 0.
expect(under({ posture: 'isolated' })).toBe(true);
});

it('`group` is a walled posture too — not just `isolated`', () => {
// `group` has no legacy-boolean spelling at all, so under the bug NO
// configuration could get `os verify` to exercise a group deployment.
expect(under({ posture: 'group' })).toBe(true);
});

it('legacy-boolean-only deployment keeps working — back-compat via the posture resolver', () => {
expect(under({ legacy: 'true' })).toBe(true);
});

it('single-org deployments still run the single-org suite', () => {
// Intent unchanged — only the knob is corrected.
expect(under({ posture: 'single' })).toBe(false);
expect(under({ legacy: 'false' })).toBe(false);
expect(under({})).toBe(false);
});

it('an explicit legacy `false` does not veto the authoritative posture', () => {
expect(under({ posture: 'isolated', legacy: 'false' })).toBe(true);
});

it('--multi-tenant still forces the suite on regardless of environment', () => {
// The flag is an explicit operator request and stays independent of the
// env: `os verify --multi-tenant` on an unconfigured box is how a developer
// proves the multi-org path locally.
expect(under({}, { 'multi-tenant': true })).toBe(true);
expect(under({ posture: 'single' }, { 'multi-tenant': true })).toBe(true);
expect(under({ legacy: 'false' }, { 'multi-tenant': true })).toBe(true);
});

it('an absent flag object behaves like an unset flag', () => {
expect(under({ posture: 'single' }, {})).toBe(false);
expect(under({ posture: 'isolated' }, {})).toBe(true);
});
});
38 changes: 35 additions & 3 deletions packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { resolveMultiOrgEnabled } from '@objectstack/types';
import { resolveTenancyPosture } from '@objectstack/types';
import { postureEnforcesWall } from '@objectstack/spec/security';
import {
bootStack,
runCrudVerification,
Expand All @@ -14,6 +15,37 @@ import {
} from '@objectstack/verify';
import { loadConfig } from '../utils/config.js';

/**
* Should this `os verify` run boot an org-scoped (multi-tenant) stack?
*
* Two independent ways to ask for it, ORed: the explicit `--multi-tenant` flag,
* or a deployment environment that already asks for an organization wall.
*
* [ADR-0105 D1 / #5262] The env half reads the resolved POSTURE — ⛔ never
* `resolveMultiOrgEnabled()`, which ADR-0105 D1 demoted to a back-compat INPUT
* of `resolveTenancyPosture()`. On a deployment configured the documented way
* (`OS_TENANCY_POSTURE=isolated|group`, legacy boolean unset) that boolean reads
* `false`, so `os verify` booted a single-org stack and SILENTLY skipped every
* multi-tenant proof. That is the worst place in the codebase for this defect to
* land: the whole purpose of `verify` is to be the thing that notices, and a
* verifier that under-verifies reports success it never established. Third
* recurrence of the shape (cloud#1020, #5233).
*
* REQUESTED posture is the right judge here — this resolves a CLI flag before
* any kernel exists, and the question is literally "what did the operator ask
* this run to prove". `bootStack({ multiTenant: true })` then REQUESTS the
* `isolated` posture for the fixture and hard-fails if the enterprise runtime
* is missing, so an unenforceable request surfaces as an error rather than as a
* quietly single-org pass.
*
* Extracted and exported so the decision is testable on its own, following
* `describeRegisteredDriver` in `serve.ts` — this package's established shape
* for a command-level decision worth pinning.
*/
export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): boolean {
return Boolean(flags['multi-tenant']) || postureEnforcesWall(resolveTenancyPosture());
}

/**
* `objectstack verify` — boot the app in-process and exercise it through the
* real HTTP stack, asserting runtime behavior the static gates can't see:
Expand Down Expand Up @@ -42,7 +74,7 @@ export default class Verify extends Command {
default: false,
}),
'multi-tenant': Flags.boolean({
description: 'Boot org-scoped (register the enterprise @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)',
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)',
default: false,
}),
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
Expand All @@ -53,7 +85,7 @@ export default class Verify extends Command {

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

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

// Data fidelity runs on its own pristine stack.
let crud: VerifyReport;
Expand Down
45 changes: 41 additions & 4 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
*/

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

/**
* [ADR-0093 D4/D5, ADR-0105 D1 / #5262] Is an organization wall actually IN
* FORCE for this boot? Both seeding decisions in this plugin key off it.
*
* ⛔ Never `resolveMultiOrgEnabled()`. ADR-0105 D1 demoted that boolean to a
* back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a
* deployment configured the documented way (`OS_TENANCY_POSTURE=isolated|group`,
* legacy boolean unset) — and a marketplace install on such a deployment wrote
* its sample rows with NO `organization_id` at all, landing them outside the
* wall every subsequent read applies. Same shape as cloud#1020 and #5233.
*
* EFFECTIVE, not requested. Both call sites ask "is the per-org replay going to
* own this seeding instead of me?", and that replay is the enterprise
* `@objectstack/organizations` middleware on `sys_organization` insert. On a
* DEGRADED boot that middleware is absent, so deferring to it would strand the
* data permanently; the `tenancy` service reports the posture in force
* (`single` there), which correctly hands the work back to the inline path.
*
* Falls back to the requested posture when no `tenancy` service is registered
* (a lean embedding without plugin-auth). Read live — never cached.
*/
function organizationWallActive(ctx: PluginContext): boolean {
try {
const tenancy = ctx.getService?.('tenancy') as { posture?: TenancyPosture } | undefined;
if (tenancy?.posture) return postureEnforcesWall(tenancy.posture);
} catch {
/* no `tenancy` service registered — fall through */
}
return postureEnforcesWall(resolveTenancyPosture());
}

export interface MarketplaceInstallLocalPluginConfig {
/** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL
* and then to the public ObjectStack cloud so a fresh `objectstack dev`
Expand Down Expand Up @@ -233,8 +265,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
: [];
if (datasets.length === 0) return;
if (entry.sampleDataPurged === true) return;
if (resolveMultiOrgEnabled()) {
ctx.logger?.info?.(`[MarketplaceInstallLocal] multi-tenant — sample-data heal for ${entry.manifestId} left to per-org replay`);
if (organizationWallActive(ctx)) {
ctx.logger?.info?.(`[MarketplaceInstallLocal] organization wall active — sample-data heal for ${entry.manifestId} left to per-org replay`);
return;
}

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