From dda8af0fc441ec984d167da20fa62fc33d40a012 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:20:46 +0000 Subject: [PATCH 1/2] fix(tenancy): eight sites read the demoted OS_MULTI_ORG_ENABLED as "is this multi-org?" (#5262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .changeset/posture-misreads-sweep.md | 61 ++++ .../commands/verify-tenancy-posture.test.ts | 97 ++++++ packages/cli/src/commands/verify.ts | 38 ++- .../src/marketplace-install-local-plugin.ts | 45 ++- ...lace-install-local-tenancy-posture.test.ts | 295 ++++++++++++++++++ .../src/registry-tenancy-posture.test.ts | 127 ++++++++ packages/objectql/src/registry.ts | 28 +- .../sql-driver-tenant-audit-posture.test.ts | 201 ++++++++++++ .../src/sql-driver-tenant-scope.test.ts | 14 +- packages/plugins/driver-sql/src/sql-driver.ts | 52 ++- .../src/dev-plugin-tenancy-posture.test.ts | 156 +++++++++ packages/plugins/plugin-dev/src/dev-plugin.ts | 26 +- .../src/app-plugin.tenancy-posture.test.ts | 233 ++++++++++++++ packages/runtime/src/app-plugin.ts | 55 +++- 14 files changed, 1395 insertions(+), 33 deletions(-) create mode 100644 .changeset/posture-misreads-sweep.md create mode 100644 packages/cli/src/commands/verify-tenancy-posture.test.ts create mode 100644 packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts create mode 100644 packages/objectql/src/registry-tenancy-posture.test.ts create mode 100644 packages/plugins/driver-sql/src/sql-driver-tenant-audit-posture.test.ts create mode 100644 packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts create mode 100644 packages/runtime/src/app-plugin.tenancy-posture.test.ts diff --git a/.changeset/posture-misreads-sweep.md b/.changeset/posture-misreads-sweep.md new file mode 100644 index 0000000000..4138c0c4bf --- /dev/null +++ b/.changeset/posture-misreads-sweep.md @@ -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. diff --git a/packages/cli/src/commands/verify-tenancy-posture.test.ts b/packages/cli/src/commands/verify-tenancy-posture.test.ts new file mode 100644 index 0000000000..5a9fd1a78e --- /dev/null +++ b/packages/cli/src/commands/verify-tenancy-posture.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 4886919ea9..ed20975d7e 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -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, @@ -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: @@ -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 }), @@ -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; diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 3852a5c039..1740917ed3 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -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'; @@ -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` @@ -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; } @@ -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; diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts new file mode 100644 index 0000000000..532ed69a68 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5262 — the marketplace local-install plugin's two seeding decisions ask +// whether an organization wall is IN FORCE, never the demoted +// `OS_MULTI_ORG_ENABLED` boolean. +// +// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted +// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`. +// Both sites kept calling `resolveMultiOrgEnabled()`, so on a deployment +// configured the documented way (posture knob only): +// +// • the INSTALL-TIME seed (`applySideEffects`, the write path) never resolved +// an active organization and wrote every sample row with NO +// `organization_id` — landing the whole app's data outside the wall that +// every subsequent read applies. The rows exist and nobody can see them. +// • the REHYDRATE-TIME heal re-ran the bundled datasets on a walled +// deployment instead of leaving them to the per-org replay, adding more of +// the same NULL-org rows on every cold boot. +// +// Same shape as cloud#1020 and #5233, two sites over. +// +// The judge is the EFFECTIVE posture (the `tenancy` service), not the requested +// one: both sites are really asking "will the per-org replay own this instead +// of me?", and on a DEGRADED boot that replay does not exist. The degraded +// scenarios below pin that, and they are what separate this from a +// requested-posture fix. +// +// Real plugin, real routes, real ledger on a real temp dir, real env resolution. +// Only `SeedLoaderService` is substituted (the same seam the sibling suites in +// this package use) so the assertions can read what the seeder was ASKED to do. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let seedResult: any = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; +let loadCalls: any[] = []; + +vi.mock('@objectstack/runtime', () => ({ + SeedLoaderService: class { + async load(request: any) { loadCalls.push(request); return seedResult; } + }, + recordSeedOutcome: vi.fn(), +})); +vi.mock('@objectstack/spec/data', () => ({ + SeedLoaderRequestSchema: { parse: (x: any) => x }, +})); + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +/** + * @param effectivePosture the `tenancy` service's posture IN FORCE, as + * plugin-auth registers it, or `undefined` for a lean embedding with no such + * service — which exercises the requested-posture fallback. + */ +function makeCtx(rawApp: any, services: Record, effectivePosture?: string) { + const hooks = new Map(); + return { + ctx: { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name === 'tenancy') { + if (!effectivePosture) throw new Error('no tenancy'); + return { posture: effectivePosture }; + } + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }, + fire: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(body: any, manifestId?: string) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: (k: string) => (k === 'manifestId' ? manifestId : undefined), + header: () => undefined, + }, + json, + }; +} + +const MANIFEST = { + id: 'app.test.crm', + version: '1.0.0', + objects: [{ name: 'crm_x', fields: { name: { type: 'text' } } }], + data: [{ object: 'crm_x', records: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }] }], +}; + +/** No `sys_organization` rows and no active org on the request. */ +const SERVICES = (findRows: Record = {}) => ({ + manifest: { register: vi.fn() }, + auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, + objectql: { + syncSchemas: async () => undefined, + find: vi.fn(async (object: string) => findRows[object] ?? []), + }, + metadata: {}, + driver: { delete: vi.fn(async () => true) }, +}); + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-posture-')); + seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + loadCalls = []; + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + 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; + vi.restoreAllMocks(); +}); + +const setEnv = (env: { posture?: string; legacy?: string }) => { + 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; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Site B — install-time seeding (the WRITE path) +// ─────────────────────────────────────────────────────────────────────────── + +/** Install the manifest with `seedNow`, and report what the seeder was asked. */ +async function installUnder( + env: { posture?: string; legacy?: string }, + effectivePosture?: string, +) { + setEnv(env); + const rawApp = makeRawApp(); + const { ctx, fire } = makeCtx(rawApp, SERVICES(), effectivePosture); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await fire(); + + const res = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!( + makeC({ manifest: MANIFEST }), + ); + return { res, ctx, seeded: res.payload?.data?.seeded ?? res.payload?.data }; +} + +describe('#5262 — install-time seeding respects the organization wall in force', () => { + it('posture-only walled deployment does NOT inline-seed NULL-org rows', async () => { + // THE regression on the write path. With no active organization + // resolvable, a walled deployment must SKIP rather than write rows that + // land outside its own wall. Before the fix the demoted boolean read + // false and these rows were written org-less. + const { ctx } = await installUnder({ posture: 'isolated' }, 'isolated'); + + expect(loadCalls).toHaveLength(0); + expect( + (ctx.logger.warn as any).mock.calls.some((c: any[]) => + String(c[0]).includes('no active org'), + ), + ).toBe(true); + }); + + it('`group` is a walled posture too', async () => { + await installUnder({ posture: 'group' }, 'group'); + expect(loadCalls).toHaveLength(0); + }); + + it('falls back to the requested posture with no tenancy service wired', async () => { + await installUnder({ posture: 'isolated' }); + expect(loadCalls).toHaveLength(0); + }); + + it('DEGRADED boot seeds inline — there is no per-org replay to defer to', async () => { + // A wall was requested, the enterprise runtime is absent, the operator + // opted in. Nothing isolates this deployment's data, so the rows land + // inline with no organization — which is what every row looks like on an + // unwalled stack. Deferring would leave the installed app permanently + // empty. This is the assertion a requested-posture fix fails. + await installUnder({ posture: 'isolated' }, 'single'); + + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].config.organizationId).toBeUndefined(); + }); + + it('legacy-boolean-only deployment keeps working — back-compat', async () => { + await installUnder({ legacy: 'true' }); + expect(loadCalls).toHaveLength(0); + }); + + it('single-org deployments still seed inline, org-less', async () => { + await installUnder({ posture: 'single' }, 'single'); + expect(loadCalls).toHaveLength(1); + expect(loadCalls[0].config.organizationId).toBeUndefined(); + }); + + it('an explicit legacy `false` does not veto the authoritative posture', async () => { + await installUnder({ posture: 'isolated', legacy: 'false' }); + expect(loadCalls).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Site A — rehydrate-time sample-data heal +// ─────────────────────────────────────────────────────────────────────────── + +/** Pre-write a ledger entry, then boot a fresh plugin so rehydrate runs. */ +async function rehydrateUnder( + env: { posture?: string; legacy?: string }, + effectivePosture?: string, +) { + setEnv(env); + new LocalManifestSource(dir).write({ + packageId: 'pkg_1', + versionId: 'pkgv_1', + manifestId: MANIFEST.id, + version: MANIFEST.version, + manifest: MANIFEST, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'admin', + withSampleData: false, + } as any); + const rawApp = makeRawApp(); + // Every seeded object empty → the healer WOULD run, if the posture let it. + const { ctx, fire } = makeCtx(rawApp, SERVICES({ crm_x: [] }), effectivePosture); + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await fire(); + return { ctx }; +} + +describe('#5262 — rehydrate heal leaves walled deployments to the per-org replay', () => { + it('posture-only walled deployment does NOT heal', async () => { + // THE regression on the heal path: before the fix a walled posture-only + // deployment re-seeded org-less rows on every cold boot. + const { ctx } = await rehydrateUnder({ posture: 'isolated' }, 'isolated'); + + expect(loadCalls).toHaveLength(0); + expect( + (ctx.logger.info as any).mock.calls.some((c: any[]) => + String(c[0]).includes('left to per-org replay'), + ), + ).toBe(true); + }); + + it('`group` is a walled posture too', async () => { + await rehydrateUnder({ posture: 'group' }, 'group'); + expect(loadCalls).toHaveLength(0); + }); + + it('falls back to the requested posture with no tenancy service wired', async () => { + await rehydrateUnder({ posture: 'isolated' }); + expect(loadCalls).toHaveLength(0); + }); + + it('DEGRADED boot DOES heal — same reasoning as the install path', async () => { + await rehydrateUnder({ posture: 'isolated' }, 'single'); + expect(loadCalls).toHaveLength(1); + }); + + it('legacy-boolean-only deployment keeps working — back-compat', async () => { + await rehydrateUnder({ legacy: 'true' }); + expect(loadCalls).toHaveLength(0); + }); + + it('single-org deployments still heal', async () => { + await rehydrateUnder({ posture: 'single' }, 'single'); + expect(loadCalls).toHaveLength(1); + }); +}); diff --git a/packages/objectql/src/registry-tenancy-posture.test.ts b/packages/objectql/src/registry-tenancy-posture.test.ts new file mode 100644 index 0000000000..8dd1984b3f --- /dev/null +++ b/packages/objectql/src/registry-tenancy-posture.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5262 — `SchemaRegistry`'s env-derived multi-tenant default reads 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 registry kept calling `resolveMultiOrgEnabled()`, so a deployment +// configured the documented way — the posture knob and nothing else — built its +// schemas as single-tenant while SecurityPlugin, reading the posture, compiled a +// Layer 0 wall that filters EVERY read by `organization_id`. Two layers, one +// fact, two answers. Third recurrence of the shape (cloud#1020, #5233). +// +// What actually diverges is the INDEX: since the column/flag decoupling the +// `organization_id` column is provisioned unconditionally, and `multiTenant` +// governs only whether it is indexed. So the defect is not a missing column — +// it is the wall's hottest predicate running unindexed on every posture-only +// deployment. That is the fact these tests pin, deliberately and narrowly, +// rather than the broader "columns go missing" reading. +// +// Driven through the REAL `SchemaRegistry` constructor + `registerObject` +// pipeline, reading the stored definition back out — the same path the kernel +// takes at boot. Nothing about the resolver is stubbed: the tests set the real +// environment variables and let the real `resolveTenancyPosture()` fold them. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SchemaRegistry } from './registry'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; + +/** + * Configure the deployment's tenancy env exactly as an operator would, then + * register a plain business object through the real registry and hand back the + * stored definition. `undefined` means "leave the variable UNSET" — which is + * the whole point of the regression: a posture-only deployment sets one knob. + */ +const registerUnder = (env: { posture?: string; legacy?: string }) => { + 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; + + // No `multiTenant` option — this is the env-derived default under test. + const registry = new SchemaRegistry(); + registry.registerObject( + { name: 'lead', fields: { first_name: { type: 'text' } } } as any, + 'crm', + 'crm', + 'own', + ); + const stored = (registry as any).objectContributors.get('lead')[0].definition; + return stored.fields.organization_id; +}; + +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 — SchemaRegistry keys its multi-tenant default off OS_TENANCY_POSTURE', () => { + it('posture-only deployment (OS_TENANCY_POSTURE=isolated, legacy boolean UNSET) indexes organization_id', () => { + // THE regression. Configured exactly as the v17 docs say: the authoritative + // knob and nothing else. Before the fix `resolveMultiOrgEnabled()` returned + // false here and the column landed UNINDEXED on a fully walled deployment. + const field = registerUnder({ posture: 'isolated' }); + + expect(field).toBeDefined(); + expect(field.reference).toBe('sys_organization'); + expect(field.indexed).toBe(true); + }); + + it('`group` is a walled posture too — not just `isolated`', () => { + // The registry asks `postureEnforcesWall`, the spec's own vocabulary, + // instead of comparing against `'isolated'`. `group` walls organizations + // just as much (ADR-0105 D1); it only widens READ scope to the membership + // set, and `organization_id IN (...)` needs the index every bit as much as + // `organization_id = ?` does. + expect(registerUnder({ posture: 'group' }).indexed).toBe(true); + }); + + it('legacy-boolean-only deployment keeps working — back-compat via the posture resolver', () => { + // Nothing already deployed changes behaviour: `resolveTenancyPosture()` + // falls back to `OS_MULTI_ORG_ENABLED` when the posture knob is unset, so + // the pre-ADR-0105 configuration still resolves to `isolated`. + expect(registerUnder({ legacy: 'true' }).indexed).toBe(true); + }); + + it('single-org deployments still leave the column unindexed', () => { + // Intent unchanged — only the knob is corrected. Nothing filters by + // organization on an unwalled stack, so the index would be dead weight. + expect(registerUnder({ posture: 'single' }).indexed).toBe(false); + expect(registerUnder({ legacy: 'false' }).indexed).toBe(false); + expect(registerUnder({}).indexed).toBe(false); + }); + + it('an explicit legacy `false` does not veto the authoritative posture', () => { + // The precise inversion the demotion created: the canonical knob asks for a + // wall, the superseded one says "no multi-org". The canonical knob wins — + // otherwise the legacy flag would still be authoritative in disguise. + expect(registerUnder({ posture: 'isolated', legacy: 'false' }).indexed).toBe(true); + }); + + it('an explicit `multiTenant` option still overrides the env entirely', () => { + // The option is how embedders and the bulk of this package's own suite pin + // a mode; the posture read is only the DEFAULT when they say nothing. + process.env.OS_TENANCY_POSTURE = 'isolated'; + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerObject({ name: 'lead', fields: {} } as any, 'crm', 'crm', 'own'); + const stored = (registry as any).objectContributors.get('lead')[0].definition; + expect(stored.fields.organization_id.indexed).toBe(false); + }); + + it('the column itself is provisioned either way — only the index moves', () => { + // Guards the claim this file is scoped on. If a future change makes the + // COLUMN conditional again, the blast radius of a posture misread grows + // from "slow" to "the wall has nothing to filter on", and this fails. + expect(registerUnder({ posture: 'isolated' })).toBeDefined(); + expect(registerUnder({ posture: 'single' })).toBeDefined(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 04c0141690..633835c6b1 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, type AuditProvenanceField } from '@objectstack/spec/data'; -import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types'; +import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/types'; +import { postureEnforcesWall } from '@objectstack/spec/security'; import { provisionSearchCompanion } from './search-companion.js'; import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, checkFieldCompleteness } from '@objectstack/spec/kernel'; import { AppSchema } from '@objectstack/spec/ui'; @@ -731,8 +732,29 @@ export class SchemaRegistry { if (options.multiTenant !== undefined) { this.multiTenant = options.multiTenant; } else { - // Mirror the SecurityPlugin / CLI banner default (env-driven, off by default). - this.multiTenant = resolveMultiOrgEnabled(); + // Mirror the SecurityPlugin / CLI wiring: key off the deployment's + // resolved tenancy POSTURE (env-driven, single-org by default). + // + // [ADR-0105 D1 / #5262] ⛔ Never `resolveMultiOrgEnabled()`. That boolean + // was DEMOTED 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 this + // registry then disagreed with SecurityPlugin about the same deployment: + // the security layer compiled a Layer 0 wall that filters every read by + // `organization_id`, while the schema layer left that column UNINDEXED + // because it believed the stack was single-org. Third recurrence of the + // shape (cloud#1020, #5233). + // + // REQUESTED posture, deliberately — not the `tenancy` service's effective + // answer. This class is constructed below the kernel (no service registry + // to ask), and the fact it needs is "will anything ever filter by + // organization_id here", which the request settles: a degraded boot + // (ADR-0093 D5) that later gains the enterprise runtime must already have + // the index. The two errors are not symmetric — a spare index on a + // single-org stack is dead weight, a missing one on a walled stack is a + // full scan on the hottest predicate in the system — so this fails toward + // provisioning it. + this.multiTenant = postureEnforcesWall(resolveTenancyPosture()); } // Pinyin-search companion column (#2486). Env-driven like multiTenant; diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-audit-posture.test.ts b/packages/plugins/driver-sql/src/sql-driver-tenant-audit-posture.test.ts new file mode 100644 index 0000000000..f65260d8e1 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-tenant-audit-posture.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5262 — the SQL driver's tenant-audit gate reads the AUTHORITATIVE tenancy +// posture, never the demoted `OS_MULTI_ORG_ENABLED` boolean, and reads it LIVE. +// +// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted +// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`. +// `isMultiTenantMode()` kept calling `resolveMultiOrgEnabled()`, so on a +// deployment configured the documented way (posture knob only) the tenant-audit +// warning was silently off — on exactly the walled deployments it exists for. +// That warning is the one signal that catches a system/seed/sudo write landing +// outside its tenant, which is "easy to miss in code review and impossible to +// find after a breach", in the words of the function it gates. +// +// The judge is the REQUESTED posture. The driver is constructed from connection +// config with no kernel or service registry, so the `tenancy` service's +// effective answer is not reachable here at all — and the asymmetry runs the +// right way for a WARNING anyway: a spurious line on a degraded stack costs a +// log entry, a suppressed one on a walled stack is this defect. +// +// The second half of the file pins the LIVENESS of that read. The gate used to +// memoise into `_multiTenantMode` on whichever write happened to land first — +// a startup reading recorded as a permanent judgment — which made it unable to +// see anything a later boot phase established. +// +// Real `SqlDriver` against a real in-memory SQLite throughout: real +// `initObjects`, real `create`/`update`, real env resolution. The only +// substitution is the driver's logger, which is the assertion surface. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; + +const objects = [ + { + name: 'account', + fields: { + organization_id: { type: 'string' }, + name: { type: 'string' }, + }, + }, +]; + +let driver: SqlDriver; +let warns: Array<{ msg: string; meta: any }>; + +const bootDriver = async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + warns = []; + (driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) }; + await driver.initObjects(objects); +}; + +/** Writes WITHOUT a tenantId — the case the audit exists to catch. */ +const writeWithoutTenant = async (id: string) => { + await driver.create('account', { id, organization_id: 'org_a', name: id }); +}; + +const tenantAuditWarned = () => warns.some((w) => w.msg.includes('[tenant-audit]')); + +const setEnv = (env: { posture?: string; legacy?: string }) => { + 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; +}; + +beforeEach(async () => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + await bootDriver(); +}); +afterEach(async () => { + await driver.disconnect(); + 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 — tenant-audit fires on a posture-only walled deployment', () => { + it('OS_TENANCY_POSTURE=isolated with the legacy boolean UNSET warns', async () => { + // THE regression: before the fix this deployment got NO tenant-audit + // warning at all, because the demoted boolean read false. + setEnv({ posture: 'isolated' }); + await writeWithoutTenant('x1'); + + expect(tenantAuditWarned()).toBe(true); + expect(warns[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' }); + }); + + it('`group` is a walled posture too', async () => { + setEnv({ posture: 'group' }); + await writeWithoutTenant('x1'); + expect(tenantAuditWarned()).toBe(true); + }); + + it('legacy-boolean-only deployment keeps working — back-compat', async () => { + setEnv({ legacy: 'true' }); + await writeWithoutTenant('x1'); + expect(tenantAuditWarned()).toBe(true); + }); + + it('an explicit legacy `false` does not veto the authoritative posture', async () => { + setEnv({ posture: 'isolated', legacy: 'false' }); + await writeWithoutTenant('x1'); + expect(tenantAuditWarned()).toBe(true); + }); + + it('single-org deployments stay quiet — intent unchanged', async () => { + // Single-tenant stacks still get an `organization_id` column but no + // isolation, so every sudo write would otherwise spam a meaningless warning. + setEnv({ posture: 'single' }); + await writeWithoutTenant('x1'); + expect(tenantAuditWarned()).toBe(false); + + setEnv({}); + await writeWithoutTenant('x2'); + expect(tenantAuditWarned()).toBe(false); + }); + + it('a write that DOES carry its tenant is never audited, on any posture', async () => { + // The early-out this fix moved ahead of the posture read. Reordering two + // pure guards must not change the answer for anyone. + setEnv({ posture: 'isolated' }); + await driver.create( + 'account', + { id: 'x1', name: 'X1' }, + { tenantId: 'org_a' } as any, + ); + expect(tenantAuditWarned()).toBe(false); + }); + + it('bypassTenantAudit still silences a walled deployment', async () => { + setEnv({ posture: 'isolated' }); + await driver.create( + 'account', + { id: 'x1', organization_id: 'org_a', name: 'X1' }, + { bypassTenantAudit: true } as any, + ); + expect(tenantAuditWarned()).toBe(false); + }); + + it('OS_TENANT_AUDIT=0 still silences a walled deployment', async () => { + setEnv({ posture: 'isolated' }); + process.env.OS_TENANT_AUDIT = '0'; + try { + await writeWithoutTenant('x1'); + expect(tenantAuditWarned()).toBe(false); + } finally { + delete process.env.OS_TENANT_AUDIT; + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5262 — the verdict is taken LIVE, never frozen at first write', () => { + it('a posture established AFTER the first write is still seen', async () => { + // The `_multiTenantMode` memo this replaced was filled by whichever write + // landed first and never re-read. A driver constructed during boot could + // therefore record "single-org" from a pre-boot environment and keep + // answering that for the life of the process — the recorded-verdict shape + // AGENTS.md's startup-registry rule names. + setEnv({ posture: 'single' }); + await writeWithoutTenant('before'); + expect(tenantAuditWarned()).toBe(false); + + // Same driver instance, same connection. + setEnv({ posture: 'isolated' }); + await writeWithoutTenant('after'); + expect(tenantAuditWarned()).toBe(true); + }); + + it('and the reverse — a posture relaxed after a warning stops warning', async () => { + setEnv({ posture: 'isolated' }); + await writeWithoutTenant('first'); + expect(tenantAuditWarned()).toBe(true); + + warns = []; + setEnv({ posture: 'single' }); + // A DIFFERENT object:op, so the per-key throttle cannot be what quiets it. + await driver.update('account', 'first', { name: 'renamed' }); + expect(tenantAuditWarned()).toBe(false); + }); + + it('no cached field survives on the instance', async () => { + // Direct tombstone for the memo: if someone reintroduces `_multiTenantMode` + // (or any sibling cache) the liveness assertions above would keep passing + // only by luck of ordering, so pin its absence explicitly. + setEnv({ posture: 'isolated' }); + await writeWithoutTenant('x1'); + expect((driver as any)._multiTenantMode).toBeUndefined(); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts index ff14678de2..40688c4cbf 100644 --- a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -341,13 +341,25 @@ describe('SqlDriver tenant scope (organization_id)', () => { (driver as any).logger = { warn: (msg: string, meta: any) => warnSpy.push({ msg, meta }) }; // The tenant-audit warning only fires in multi-tenant mode (single-tenant // stacks now always have an organization_id column but no isolation). - (driver as any)._multiTenantMode = true; + // + // [#5262] Configured through the real knob rather than by poking the old + // `_multiTenantMode` memo, which no longer exists: that field froze a + // process-level fact into a per-instance verdict, and the gate now + // resolves the tenancy posture live. Setting the env exercises the same + // resolution a real deployment does; restored in the `finally` below. + const priorPosture = process.env.OS_TENANCY_POSTURE; + process.env.OS_TENANCY_POSTURE = 'isolated'; + try { await driver.initObjects(objects); await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' }); await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' }); // Second create on same object:op should NOT add another warn (throttle). expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1); + } finally { + if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = priorPosture; + } }); it('does not warn when bypassTenantAudit is set', async () => { diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index ef0cf19fe5..e1a797456d 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -20,7 +20,8 @@ import type { IDataDriver } from '@objectstack/spec/contracts'; import { StandardErrorCode } from '@objectstack/spec/api'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; -import { resolveMultiOrgEnabled } from '@objectstack/types'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { postureEnforcesWall } from '@objectstack/spec/security'; import { nextUtcCalendarDay } from '@objectstack/core'; import { applyIndexKeyParts, @@ -5065,20 +5066,36 @@ export class SqlDriver implements IDataDriver { } /** - * Whether the host kernel runs in multi-tenant mode — read once from - * `OS_MULTI_ORG_ENABLED`, matching how - * the SchemaRegistry / SecurityPlugin pick the mode. Used to gate the - * tenant-audit warning: it's only meaningful where tenant isolation is - * actually enforced (org-scoping installed). + * Whether this deployment requested an organization wall — the same resolved + * POSTURE the SchemaRegistry and SecurityPlugin key off. Gates the + * tenant-audit warning, which is only meaningful where tenant isolation is + * something the deployment actually asked for. + * + * [ADR-0105 D1 / #5262] ⛔ Never `resolveMultiOrgEnabled()`. That boolean was + * DEMOTED to a back-compat input of `resolveTenancyPosture()` and reads + * `false` on a deployment configured the documented way + * (`OS_TENANCY_POSTURE=isolated|group`, legacy boolean unset), so the + * tenant-audit warning — the one signal that catches a sudo/seed write + * landing outside its tenant — was silently off on exactly the walled + * deployments it exists for. + * + * REQUESTED posture, not the `tenancy` service's effective answer, for two + * reasons. This class is a driver: it is constructed from connection config + * with no kernel or service registry to ask, so the effective posture is not + * reachable here at all. And the asymmetry runs the right way for a WARNING — + * a spurious line on a degraded stack costs a log entry, while a suppressed + * one on a walled stack is the defect being fixed. + * + * ⚠️ Read LIVE on every call, never memoised. The previous `_multiTenantMode` + * field froze a process-level fact into a per-instance verdict on whichever + * write happened to land first — the "startup reading recorded as a judgment" + * shape AGENTS.md warns about — which made the gate unable to see anything a + * later boot phase (or a test) established. It is affordable because + * {@link auditMissingTenant} now consults it only AFTER the cheap `tenantId` + * early-out, so a normal tenant-scoped write never reaches this at all. */ - private _multiTenantMode?: boolean; protected isMultiTenantMode(): boolean { - if (this._multiTenantMode === undefined) { - // Single source of truth (shared with auth/registry/CLI) — previously - // this read `process.env` inline instead of the shared resolver. - this._multiTenantMode = resolveMultiOrgEnabled(); - } - return this._multiTenantMode; + return postureEnforcesWall(resolveTenancyPosture()); } /** @@ -5173,6 +5190,13 @@ export class SqlDriver implements IDataDriver { ): void { if (process.env.OS_TENANT_AUDIT === '0') return; if (options?.bypassTenantAudit === true) return; + // A write that DID carry its tenant is the case this audit has nothing to + // say about, and it is the overwhelmingly common one — so it exits here, + // before the posture read below. Ordering only (both guards are pure + // predicates over independent facts); it is what makes `isMultiTenantMode()` + // affordable as a live read now that it no longer memoises (#5262). + const tenantId = options?.tenantId; + if (tenantId !== undefined && tenantId !== null && tenantId !== '') return; // Only meaningful in multi-tenant deployments. Single-tenant stacks have no // tenant isolation, yet the kernel now ALWAYS provisions an `organization_id` // column (its existence is decoupled from the tenant flag). Column presence @@ -5180,8 +5204,6 @@ export class SqlDriver implements IDataDriver { // system/sudo write (e.g. the notification/http delivery dispatchers' claim // updates) would spam a meaningless warning on single-tenant boots. if (!this.isMultiTenantMode()) return; - const tenantId = options?.tenantId; - if (tenantId !== undefined && tenantId !== null && tenantId !== '') return; const field = this.resolveTenantField(object); if (!field) return; const key = `${object}:${op}`; diff --git a/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts new file mode 100644 index 0000000000..bc59d1c64f --- /dev/null +++ b/packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5262 — DevPlugin decides whether to load the enterprise multi-org runtime +// from the AUTHORITATIVE tenancy posture, never the demoted +// `OS_MULTI_ORG_ENABLED` boolean. +// +// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted +// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`. +// DevPlugin kept calling `resolveMultiOrgEnabled()`, so a dev stack configured +// the documented way (posture knob only) never even ATTEMPTED to load +// `@objectstack/organizations`. SecurityPlugin then probed an absent +// `org-scoping` service, stripped the wildcard `tenant_isolation` RLS, and the +// stack served traffic with no organization wall at all — while the `tenancy` +// service reported the wall as requested. Straight into the ADR-0093 D5 +// degraded state, with no D5 fail-fast on this path to catch it. Third +// recurrence of the shape (cloud#1020, #5233). +// +// The judge is the REQUESTED posture, matching `serve.ts`'s own wiring exactly. +// It has to be: this branch is what MOUNTS the wall, so asking the `tenancy` +// service "is the wall up?" would be circular. +// +// ── What is observed, and why it is honest ────────────────────────────────── +// `@objectstack/organizations` is a cloud-private enterprise package that is +// genuinely absent from this workspace, so the dynamic import genuinely fails +// and the real catch branch runs. That makes the emitted warning a faithful +// witness for "the multi-org branch was ENTERED": under the bug there is no +// warning at all, because the `if` was never taken. The assertions therefore +// key on branch ENTRY, not on a successfully mounted plugin — the latter is +// unobservable in open-source CI, and pretending otherwise would need a fake +// enterprise package, i.e. stubbing the very thing under test. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// #3060 — the same treatment the sibling suite uses: init() dynamically imports +// ~10 real workspace packages, whose vite transforms alone can blow the test +// timeout under a parallel `pnpm test`. Each factory throws the shape an absent +// package produces, so the graceful-degradation branches run for real with zero +// module resolution on the hot path. `@objectstack/organizations` is +// deliberately NOT listed: it is really absent, and its real failure is the +// signal this file reads. +vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/driver-memory', () => { throw Object.assign(new Error("Cannot find package '@objectstack/driver-memory'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-auth', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-auth'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); + +import { DevPlugin } from './dev-plugin'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; +const OLD_NODE_ENV = process.env.NODE_ENV; + +/** Boot DevPlugin under a tenancy configuration and report what it tried. */ +const initUnder = async (env: { posture?: string; legacy?: string }) => { + 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; + + const registered = new Map(); + const ctx: any = { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn((name: string) => { + if (registered.has(name)) return registered.get(name); + throw new Error('not found'); + }), + getServices: vi.fn(() => new Map()), + registerService: vi.fn((name: string, svc: unknown) => registered.set(name, svc)), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; + + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const lines = [ + ...ctx.logger.warn.mock.calls, + ...ctx.logger.info.mock.calls, + ].map((c: unknown[]) => String(c[0])); + + return { + /** Did the multi-org branch run at all? */ + attemptedOrganizationsLoad: lines.some((l) => l.includes('@objectstack/organizations')), + lines, + }; +}; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + process.env.NODE_ENV = 'development'; +}); +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; + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + vi.restoreAllMocks(); +}); + +describe('#5262 — DevPlugin loads the multi-org runtime from OS_TENANCY_POSTURE', () => { + it('posture-only dev stack (OS_TENANCY_POSTURE=isolated, legacy boolean UNSET) tries to load it', async () => { + // THE regression. Before the fix `resolveMultiOrgEnabled()` read false, the + // branch was skipped entirely, and this stack ran with no organization wall + // while believing it had asked for one. + const run = await initUnder({ posture: 'isolated' }); + expect(run.attemptedOrganizationsLoad).toBe(true); + }); + + it('`group` requests the runtime too — gating on the legacy boolean let it skip', async () => { + // Worth its own case: `OS_TENANCY_POSTURE=group` has NO legacy-boolean + // spelling at all, so under the bug a group deployment could never load the + // package by any configuration — it silently degraded to unwalled + // single-org, the exact ADR-0049 class the D5 guard exists to close. + const run = await initUnder({ posture: 'group' }); + expect(run.attemptedOrganizationsLoad).toBe(true); + }); + + it('names the POSTURE that was requested, not one knob’s spelling of it', async () => { + // The old text asserted `OS_MULTI_ORG_ENABLED=true` at an operator who may + // well have set only `OS_TENANCY_POSTURE` — sending them to check a + // variable they never set. A diagnostic that misreports the operator's own + // configuration costs every later investigation a lap (cf. cloud#1020's + // banner). + const run = await initUnder({ posture: 'group' }); + const warning = run.lines.find((l) => l.includes('@objectstack/organizations')); + expect(warning).toContain("posture 'group'"); + expect(warning).not.toContain('OS_MULTI_ORG_ENABLED=true'); + }); + + it('legacy-boolean-only dev stack keeps working — back-compat via the posture resolver', async () => { + const run = await initUnder({ legacy: 'true' }); + expect(run.attemptedOrganizationsLoad).toBe(true); + }); + + it('single-org dev stacks still skip the enterprise runtime entirely', async () => { + // Intent unchanged — only the knob is corrected. + expect((await initUnder({ posture: 'single' })).attemptedOrganizationsLoad).toBe(false); + expect((await initUnder({ legacy: 'false' })).attemptedOrganizationsLoad).toBe(false); + expect((await initUnder({})).attemptedOrganizationsLoad).toBe(false); + }); + + it('an explicit legacy `false` does not veto the authoritative posture', async () => { + const run = await initUnder({ posture: 'isolated', legacy: 'false' }); + expect(run.attemptedOrganizationsLoad).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index c4001ed842..af22323b75 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext } from '@objectstack/core'; -import { resolveAllowDevPlugin, resolveMultiOrgEnabled } from '@objectstack/types'; +import { resolveAllowDevPlugin, resolveTenancyPosture } from '@objectstack/types'; +import { postureEnforcesWall } from '@objectstack/spec/security'; /** * Dev Plugin Options @@ -445,15 +446,32 @@ export class DevPlugin implements Plugin { // name the enterprise plugin keeps registering) and caches the result for // the lifetime of the plugin. if (enabled('security')) { - const multiTenant = resolveMultiOrgEnabled(); + // [ADR-0105 D1 / #5262] Key off the resolved POSTURE, exactly as + // `serve.ts` does — ⛔ never `resolveMultiOrgEnabled()`. That boolean was + // DEMOTED to a back-compat input of `resolveTenancyPosture()`, so a dev + // stack configured the documented way (`OS_TENANCY_POSTURE=isolated|group`, + // legacy boolean unset) read `false` here and never loaded the enterprise + // runtime at all — SecurityPlugin then probed an absent `org-scoping`, + // stripped the wildcard `tenant_isolation` RLS, and the stack served + // traffic in the ADR-0093 D5 degraded state while the `tenancy` service + // reported the wall as requested. Both walled postures need this package: + // gating on the legacy boolean also let `OS_TENANCY_POSTURE=group` skip it. + // + // REQUESTED posture is the only coherent judge here — this branch is what + // MOUNTS the wall, so asking "is the wall up?" would be circular. + const tenancyPosture = resolveTenancyPosture(); + const multiTenant = postureEnforcesWall(tenancyPosture); if (multiTenant) { try { const organizationsPkg = '@objectstack/organizations'; const mod: any = await import(/* webpackIgnore: true */ organizationsPkg); this.childPlugins.push(new mod.OrganizationsPlugin()); - ctx.logger.info(' ✔ Organizations plugin enabled (multi-org: organization_id auto-stamp, per-org seed)'); + ctx.logger.info(` ✔ Organizations plugin enabled (posture '${tenancyPosture}': organization_id auto-stamp, per-org seed)`); } catch { - ctx.logger.warn(' ✘ OS_MULTI_ORG_ENABLED=true but @objectstack/organizations (enterprise) not installed — running single-org'); + // Names the posture that was actually requested, not one knob's + // spelling of it: the old text asserted `OS_MULTI_ORG_ENABLED=true` + // at an operator who may well have set only `OS_TENANCY_POSTURE`. + ctx.logger.warn(` ✘ tenancy posture '${tenancyPosture}' requested but @objectstack/organizations (enterprise) not installed — running single-org, organization wall INACTIVE (ADR-0093 D5)`); } } try { diff --git a/packages/runtime/src/app-plugin.tenancy-posture.test.ts b/packages/runtime/src/app-plugin.tenancy-posture.test.ts new file mode 100644 index 0000000000..089f086895 --- /dev/null +++ b/packages/runtime/src/app-plugin.tenancy-posture.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5262 — AppPlugin's two seeder decisions ask whether an organization wall is +// IN FORCE, never the demoted `OS_MULTI_ORG_ENABLED` boolean. +// +// ADR-0105 D1 made `OS_TENANCY_POSTURE` the canonical knob and demoted +// `OS_MULTI_ORG_ENABLED` to a back-compat INPUT of `resolveTenancyPosture()`. +// Both seeder sites kept calling `resolveMultiOrgEnabled()`, so a deployment +// configured the documented way (posture knob only) took the SINGLE-TENANT +// branch on a fully walled stack and inline-seeded exactly the NULL-organization +// rows the code's own comment exists to avoid — rows that then sit behind the +// wall, unreadable, needing a separate claim step. Third recurrence of the +// shape (cloud#1020, #5233). +// +// The judge here is the EFFECTIVE posture (the `tenancy` service), not the +// requested one, and the degraded scenario below is why. What these decisions +// actually turn on is "will the per-org replay run INSTEAD of me?" — and that +// replay is enterprise `@objectstack/organizations` middleware. On a degraded +// boot it does not exist, so keying on the REQUEST would defer to a replay that +// can never happen and leave the stack with no seed data at all. That case is +// pinned explicitly, because it is the one a requested-posture fix would get +// wrong while still passing every other assertion in this file. +// +// Driven through the real `AppPlugin.start()` with a real `tenancy` service +// object of the same shape plugin-auth registers, and real env vars folded by +// the real resolver. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppPlugin } from './app-plugin'; +import type { PluginContext } from '@objectstack/core'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; +const OLD_NODE_ENV = process.env.NODE_ENV; + +interface Scenario { + /** `OS_TENANCY_POSTURE`, or `undefined` to leave it UNSET. */ + posture?: string; + /** `OS_MULTI_ORG_ENABLED`, or `undefined` to leave it UNSET. */ + legacy?: string; + /** + * The `tenancy` service's EFFECTIVE posture, as plugin-auth would register + * it, or `undefined` for a lean embedding with no such service (AppPlugin + * mounted without plugin-auth) — which is what exercises the fallback. + */ + effectivePosture?: 'single' | 'group' | 'isolated'; +} + +/** + * Boot AppPlugin under a scenario and report what the seeder did. + * + * No `metadata` service, so the seed takes the basic-insert fallback — the + * settle plumbing is identical on both branches and `ql.insert` is the cleanest + * witness for "did inline seeding actually happen". + */ +const runSeeder = async (scenario: Scenario) => { + if (scenario.posture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = scenario.posture; + if (scenario.legacy === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = scenario.legacy; + + const insert = vi.fn(async () => ({ id: 'x' })); + const hooks = new Map(); + const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + + const ctx = { + logger, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'objectql') return { insert }; + if (name === 'tenancy' && scenario.effectivePosture) { + // The shape plugin-auth registers (createTenancyService). Only + // `posture` — the posture IN FORCE — is consulted by AppPlugin. + return { posture: scenario.effectivePosture }; + } + // `metadata` absent → basic-insert fallback; everything else absent too. + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((event: string, handler: unknown) => hooks.set(event, handler)), + trigger: vi.fn(), + } as unknown as PluginContext; + + const plugin = new AppPlugin({ + id: 'posture-seed-app', + data: [{ object: 'crm_lead', records: [{ id: 'seeded_1' }] }], + }); + await plugin.start(ctx); + + return { + /** Did the INLINE seed run? (site 3) */ + inlineSeeded: insert.mock.calls.length > 0, + /** Was the hot-reload seeder installed? (site 4) */ + hotReloadSeederRegistered: hooks.has('metadata:reloaded'), + logger, + }; +}; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + // Site 4 is dev-only; NODE_ENV=development is a precondition for it to be + // reachable at all, so the posture is the only variable under test. + process.env.NODE_ENV = 'development'; +}); +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; + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + vi.restoreAllMocks(); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5262 — inline seed (site 3) defers to per-org replay on a walled deployment', () => { + it('posture-only deployment with a wall in force skips the inline seed', async () => { + // THE regression, with a real `tenancy` service as a kernel boot has. + // Before the fix `resolveMultiOrgEnabled()` read false and this stack + // inline-seeded NULL-org rows behind its own wall. + const run = await runSeeder({ posture: 'isolated', effectivePosture: 'isolated' }); + + expect(run.inlineSeeded).toBe(false); + expect( + run.logger.info.mock.calls.some((c: unknown[]) => + String(c[0]).includes('skipping inline seed'), + ), + ).toBe(true); + }); + + it('posture-only deployment with NO tenancy service falls back to the requested posture', async () => { + // A lean embedding that mounts AppPlugin without plugin-auth. The fallback + // must still read the POSTURE, not the demoted boolean — otherwise the + // defect simply relocates into the fallback. + expect((await runSeeder({ posture: 'isolated' })).inlineSeeded).toBe(false); + expect((await runSeeder({ posture: 'group' })).inlineSeeded).toBe(false); + }); + + it('`group` is a walled posture too — not just `isolated`', async () => { + const run = await runSeeder({ posture: 'group', effectivePosture: 'group' }); + expect(run.inlineSeeded).toBe(false); + }); + + it('DEGRADED boot seeds INLINE — the effective posture is what makes this right', async () => { + // ADR-0093 D5: a wall was REQUESTED, the enterprise runtime is absent, and + // the operator opted in via OS_ALLOW_DEGRADED_TENANCY. The `tenancy` + // service reports the posture in force — `single` — because nothing + // isolates this deployment's data. + // + // There is no per-org replay here to defer to, so deferring would strand + // the app with NO seed data whatsoever. Reading the EFFECTIVE posture hands + // the work back to the inline path, and the rows land NULL-org, which is + // exactly what every row on an unwalled stack looks like. + // + // This is the assertion that separates a correct fix from a + // requested-posture fix: the latter passes everything else in this file and + // fails here. + const run = await runSeeder({ posture: 'isolated', effectivePosture: 'single' }); + + expect(run.inlineSeeded).toBe(true); + }); + + it('legacy-boolean-only deployment keeps working — back-compat via the posture resolver', async () => { + expect((await runSeeder({ legacy: 'true' })).inlineSeeded).toBe(false); + }); + + it('single-org deployments still seed inline', async () => { + expect((await runSeeder({ posture: 'single', effectivePosture: 'single' })).inlineSeeded).toBe(true); + expect((await runSeeder({ legacy: 'false' })).inlineSeeded).toBe(true); + expect((await runSeeder({})).inlineSeeded).toBe(true); + }); + + it('an explicit legacy `false` does not veto the authoritative posture', async () => { + expect((await runSeeder({ posture: 'isolated', legacy: 'false' })).inlineSeeded).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5262 — hot-reload seeder (site 4) is not installed on a walled deployment', () => { + it('posture-only walled deployment does NOT register the hot-reload seeder', async () => { + // Before the fix this seeder WAS installed on a walled posture-only dev + // stack, and every row it wrote for a newly hot-reloaded object landed with + // a NULL organization. + const run = await runSeeder({ posture: 'isolated', effectivePosture: 'isolated' }); + expect(run.hotReloadSeederRegistered).toBe(false); + }); + + it('falls back to the requested posture with no tenancy service wired', async () => { + expect((await runSeeder({ posture: 'group' })).hotReloadSeederRegistered).toBe(false); + }); + + it('DEGRADED boot DOES register it — same reasoning as the inline seed', async () => { + const run = await runSeeder({ posture: 'isolated', effectivePosture: 'single' }); + expect(run.hotReloadSeederRegistered).toBe(true); + }); + + it('single-org dev stacks still get the hot-reload seeder', async () => { + expect((await runSeeder({})).hotReloadSeederRegistered).toBe(true); + expect((await runSeeder({ legacy: 'false' })).hotReloadSeederRegistered).toBe(true); + }); + + it('legacy-boolean-only deployment keeps working — back-compat', async () => { + expect((await runSeeder({ legacy: 'true' })).hotReloadSeederRegistered).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#5262 — the two seeder sites can never disagree', () => { + // They are two halves of one policy: "on a walled deployment the per-org + // replay owns seeding". A change that fixes one and forgets the other leaves + // a dev stack that skips the boot seed and then hot-reload-seeds NULL-org + // rows anyway — strictly worse than either mistake alone. Nothing compared + // them before, which is a large part of why #5262 sat unnoticed. + const scenarios: Array<{ name: string; scenario: Scenario; walled: boolean }> = [ + { name: 'posture=isolated, wall in force', scenario: { posture: 'isolated', effectivePosture: 'isolated' }, walled: true }, + { name: 'posture=group, wall in force', scenario: { posture: 'group', effectivePosture: 'group' }, walled: true }, + { name: 'posture=isolated, no tenancy service', scenario: { posture: 'isolated' }, walled: true }, + { name: 'posture=isolated, DEGRADED', scenario: { posture: 'isolated', effectivePosture: 'single' }, walled: false }, + { name: 'posture=single', scenario: { posture: 'single', effectivePosture: 'single' }, walled: false }, + { name: 'legacy=true only', scenario: { legacy: 'true' }, walled: true }, + { name: 'legacy=false only', scenario: { legacy: 'false' }, walled: false }, + { name: 'nothing set', scenario: {}, walled: false }, + ]; + + it.each(scenarios)('$name', async ({ scenario, walled }) => { + const run = await runSeeder(scenario); + expect(run.inlineSeeded).toBe(!walled); + expect(run.hotReloadSeederRegistered).toBe(!walled); + // The invariant, not the two booleans: both sites read one fact. + expect(run.inlineSeeded).toBe(run.hotReloadSeederRegistered); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 465c02bddb..dc82c062ff 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -2,7 +2,8 @@ import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core'; import { assertProtocolCompat } from '@objectstack/metadata-core'; -import { resolveMultiOrgEnabled } from '@objectstack/types'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; @@ -1042,7 +1043,7 @@ export class AppPlugin implements Plugin { // step. So we skip it. Single-tenant deployments keep the // legacy behaviour: seed immediately at boot so there's // always demo data without needing an org insert. - const multiTenant = resolveMultiOrgEnabled(); + const multiTenant = this.organizationWallActive(ctx); if (this.skipSeedData) { // #3917: this boot exists to READ metadata (os migrate // plan/apply). It must not write to the target database. @@ -1202,6 +1203,49 @@ export class AppPlugin implements Plugin { this.registerHotReloadSeeder(ctx, ql); } + /** + * [ADR-0093 D4/D5, ADR-0105 D1 / #5262] Is an organization wall actually IN + * FORCE for this boot? The judge for both seeder decisions below. + * + * ⛔ 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). Both seeder sites then took the single-tenant branch + * on a fully walled deployment and inline-seeded exactly the NULL-organization + * rows their own comments exist to avoid — rows that sit behind the wall + * unreadable and need a separate claim step. Third recurrence of the shape + * (cloud#1020, #5233). + * + * EFFECTIVE, not requested — and this site is the reason the distinction is + * worth the extra hop. What these decisions actually turn on is "will the + * per-org replay run instead of me?", and that replay is the enterprise + * `@objectstack/organizations` middleware on `sys_organization` insert. On a + * DEGRADED boot (a wall requested, the enterprise runtime absent, operator + * opted in via `OS_ALLOW_DEGRADED_TENANCY`) that middleware does not exist, + * so keying on the REQUEST would skip the inline seed in favour of a replay + * that can never happen — a stack with no seed data at all. The `tenancy` + * service reports the posture in force (`single` + `degraded` there), which + * makes the seeded rows land inline, exactly as they should on a deployment + * with no wall to isolate them behind. + * + * AppPlugin starts in kernel Phase 2 and plugin-auth registers `tenancy` in + * Phase 1, so the service is present on any real boot; the fallback covers + * lean embeddings that mount AppPlugin without plugin-auth, where the + * requested posture is the best fact available. Read live per call — never + * cached — so nothing freezes a verdict a later boot phase can still change. + */ + private organizationWallActive(ctx: PluginContext): boolean { + try { + const tenancy = ctx.getService?.('tenancy') as + | { posture?: TenancyPosture } + | undefined; + if (tenancy?.posture) return postureEnforcesWall(tenancy.posture); + } catch { + /* service registry has no `tenancy` — fall through */ + } + return postureEnforcesWall(resolveTenancyPosture()); + } + /** * 15.1 third-party eval — dev hot-reload of a NEW object registered its * metadata (and, via ObjectQL's `metadata:reloaded` hook, created its @@ -1225,7 +1269,12 @@ export class AppPlugin implements Plugin { const hook = (ctx as any).hook; if (typeof hook !== 'function') return; if (process.env.NODE_ENV !== 'development') return; - if (resolveMultiOrgEnabled()) return; + // Same judge as the inline seed above, for the same reason — see + // `organizationWallActive`. Keying this on the demoted boolean installed + // the hot-reload seeder on posture-only walled dev stacks, where every + // row it wrote for a newly-registered object landed with a NULL + // organization (#5262). + if (this.organizationWallActive(ctx)) return; const knownObjects = new Set( (Array.isArray(this.bundle.objects) ? this.bundle.objects : []) From 5ea48bbfd83ef1c955b5fbfe64e50ef820230e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:31:50 +0000 Subject: [PATCH 2/2] test(driver-sqlite-wasm): open the tenant audit with the real posture knob, not the deleted memo (#5262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from this branch's own driver-sql change, caught by CI (Test Core 2/3): `SqliteWasmDriver extends SqlDriver`, and this suite's tenant-audit test switched multi-tenant mode on by poking the private `_multiTenantMode` memo: (driver as any)._multiTenantMode = true; That memo is gone — `isMultiTenantMode()` now resolves the tenancy posture live — so the poke silently stopped doing anything, the gate read single-org, no warning was emitted, and the assertion failed with "expected [] to have a length of 1 but got +0". Reproduced locally against the pre-fix test before changing it. Fixed the way the sibling driver-sql suite already was: set the real `OS_TENANCY_POSTURE=isolated` and restore it in a `finally`. This is strictly better than what it replaces — the old poke reached into an implementation detail, so it began lying the moment that detail changed, which is exactly how it failed. Driving the documented knob cannot go quietly stale that way. Also strengthened the neighbouring `bypassTenantAudit` case in the same block, which was VACUOUS: it never set a posture, so the audit returned at the multi-tenant gate and `warnSpy` was empty regardless of the flag under test — it would have passed with the flag deleted. (Vacuous before this branch too; it simply never poked the memo the way its sibling did.) It now sets the posture, so the flag is the only thing that can keep the log quiet; verified by deleting the flag and watching it go red. Repo-wide grep confirms no third package pokes `_multiTenantMode`; the only remaining mentions are this branch's own comments and the tombstone assertion in sql-driver-tenant-audit-posture.test.ts. Full suites (not just the touched file): driver-sqlite-wasm 16 files / 232 tests passed; driver-sql 63 files (59 passed, 4 skipped) / 740 passed + 44 skipped. typecheck Done for both; eslint clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .../sqlite-wasm-driver-tenant-scope.test.ts | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts index 9374164d68..38a995e985 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts @@ -287,13 +287,29 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => { (driver as any).logger = { warn: (msg: string, meta: any) => warnSpy.push({ msg, meta }) }; // The tenant-audit warning only fires in multi-tenant mode (single-tenant // stacks now always have an organization_id column but no isolation). - (driver as any)._multiTenantMode = true; - await driver.initObjects(objects); - - await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' }); - await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' }); - // Second create on same object:op should NOT add another warn (throttle). - expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1); + // + // [#5262] Configured through the real knob rather than by poking the old + // `_multiTenantMode` memo, which no longer exists: `SqliteWasmDriver + // extends SqlDriver`, and that memo froze a process-level fact into a + // per-instance verdict, so the gate now resolves the tenancy posture live + // on every call. Setting the env exercises the same resolution a real + // deployment does — and unlike the old poke it cannot silently stop + // meaning anything, because a wrong posture makes the assertion fail + // rather than quietly disabling the branch under test. Restored in the + // `finally` below. Mirrors the same fix in driver-sql's suite. + const priorPosture = process.env.OS_TENANCY_POSTURE; + process.env.OS_TENANCY_POSTURE = 'isolated'; + try { + await driver.initObjects(objects); + + await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' }); + await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' }); + // Second create on same object:op should NOT add another warn (throttle). + expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1); + } finally { + if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = priorPosture; + } }); it('does not warn when bypassTenantAudit is set', async () => { @@ -301,13 +317,28 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => { const warnSpy: any[] = []; driver = new SqliteWasmDriver({ filename: ':memory:' }); (driver as any).logger = { warn: (msg: string, meta: any) => warnSpy.push({ msg, meta }) }; - await driver.initObjects(objects); - await driver.create( - 'account', - { id: 'x1', organization_id: 'org_a', name: 'X1' }, - { bypassTenantAudit: true } as any, - ); - expect(warnSpy).toHaveLength(0); + // [#5262] A walled posture is a PRECONDITION of this assertion, not + // decoration. Without it the audit returns at the multi-tenant gate and + // `warnSpy` is empty no matter what `bypassTenantAudit` does — the test + // passed while proving nothing about the flag it names. (That was equally + // true before this change, where the absent `_multiTenantMode` memo + // resolved to single-org; the sibling test above happened to poke the memo + // and this one never did.) With the posture set, the flag is the only + // thing that can keep the log quiet. + const priorPosture = process.env.OS_TENANCY_POSTURE; + process.env.OS_TENANCY_POSTURE = 'isolated'; + try { + await driver.initObjects(objects); + await driver.create( + 'account', + { id: 'x1', organization_id: 'org_a', name: 'X1' }, + { bypassTenantAudit: true } as any, + ); + expect(warnSpy).toHaveLength(0); + } finally { + if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = priorPosture; + } }); }); });