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/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; + } }); }); }); 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 : [])