From 579f41b20b9f08cfdff4c7e7b971d98b692e1198 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:45:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(verify):=20multiTenant=20boot=20requests=20?= =?UTF-8?q?the=20isolated=20tenancy=20posture=20=E2=80=94=20ADR-0105=20D1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #3559 a walled posture is an explicit operator request resolved from env when AuthPlugin registers the `tenancy` service; mounting the enterprise organizations plugin only ENTITLES it. bootStack's multiTenant opt-in predates that split and only mounted the plugin, so multi-org fixtures silently booted `single` — no Layer 0 wall, default-org write stamping — and every cross-tenant proof asserted against the wrong posture. First surfaced by cloud's security-enterprise multi-org integration test (the licensed path open-core CI cannot run), which fails 3/4 against any framework checkout at or past #3559. bootStack({multiTenant: true}) now sets OS_TENANCY_POSTURE=isolated for the boot — before AuthPlugin snapshots the requested posture — unless the caller already provided one; the request is restored on stop() and on a failed multi-tenant boot, so later single-tenant boots in the same worker are unaffected. The verify package also gains a `test` script: `turbo run test` never ran its suite before, so the new posture regression pin (and the existing derive tests) now actually gate. Co-Authored-By: Claude Fable 5 --- ...y-multitenant-requests-isolated-posture.md | 19 +++ packages/verify/package.json | 6 +- packages/verify/src/harness.posture.test.ts | 116 ++++++++++++++++++ packages/verify/src/harness.ts | 25 ++++ pnpm-lock.yaml | 3 + 5 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 .changeset/verify-multitenant-requests-isolated-posture.md create mode 100644 packages/verify/src/harness.posture.test.ts diff --git a/.changeset/verify-multitenant-requests-isolated-posture.md b/.changeset/verify-multitenant-requests-isolated-posture.md new file mode 100644 index 0000000000..2edaa2de17 --- /dev/null +++ b/.changeset/verify-multitenant-requests-isolated-posture.md @@ -0,0 +1,19 @@ +--- +'@objectstack/verify': patch +--- + +`bootStack({ multiTenant: true })` now REQUESTS the `isolated` tenancy posture +for the boot (ADR-0105 D1), restoring the request on `stop()` and respecting an +explicit caller-provided `OS_TENANCY_POSTURE`. + +Since #3559 a walled posture is an explicit operator request resolved from env +when AuthPlugin registers the `tenancy` service — mounting the enterprise +organizations plugin only ENTITLES it. The harness's multi-tenant opt-in +predates that split and only mounted the plugin, so multi-org fixtures silently +booted `single`: no Layer 0 wall, D3 default-org write stamping, and every +cross-tenant proof asserting against the wrong posture (first surfaced by +cloud's security-enterprise multi-org integration test, which runs the licensed +path open-core CI cannot). + +The verify package also gains a `test` script so its suite actually runs under +`turbo run test`, including the new regression pin for this contract. diff --git a/packages/verify/package.json b/packages/verify/package.json index 43f034d172..6d7306c196 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -16,7 +16,8 @@ "scripts": { "build": "tsup --config ../../tsup.config.ts", "dev": "tsc -w", - "lint": "eslint src" + "lint": "eslint src", + "test": "vitest run" }, "dependencies": { "@objectstack/core": "workspace:*", @@ -36,7 +37,8 @@ }, "devDependencies": { "@types/node": "^26.1.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "keywords": [ "objectstack", diff --git a/packages/verify/src/harness.posture.test.ts b/packages/verify/src/harness.posture.test.ts new file mode 100644 index 0000000000..414dc5c326 --- /dev/null +++ b/packages/verify/src/harness.posture.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// bootStack({ multiTenant: true }) must REQUEST the `isolated` tenancy posture +// (ADR-0105 D1). Since #3559, mounting the enterprise organizations plugin only +// ENTITLES a walled posture — activation is an explicit operator request, read +// from env when AuthPlugin registers the `tenancy` service. A harness that +// mounts the plugin without requesting a posture silently boots `single` (no +// wall, default-org write stamping) and every multi-org fixture asserts against +// the wrong posture — the regression this file pins. The enterprise package is +// not installable in this workspace, so a fake stands in for it, registering +// the same `org-scoping` service + entitlement surface; the proof that the REAL +// plugin walls tenants lives in cloud's security-enterprise multi-org +// integration test. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { bootStack } from './harness'; + +class FakeOrganizationsPlugin { + readonly name = 'fake-organizations'; + readonly version = '0.0.0'; + readonly supportedPostures = ['group', 'isolated'] as const; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async init(ctx: any): Promise { + ctx.registerService('org-scoping', this); + } +} + +// The factory runs lazily, on the harness's dynamic import — after this module +// body has executed, so referencing the class above is safe. +vi.mock('@objectstack/organizations', () => ({ + OrganizationsPlugin: FakeOrganizationsPlugin, +})); + +const app = { + manifest: { + id: 'com.example.posture', + namespace: 'posture', + version: '0.0.1', + type: 'app', + name: 'Posture Fixture', + }, + objects: [], +}; + +interface TenancyShape { + posture: string; + requestedPosture: string; + isolationActive: boolean; +} + +afterEach(() => { + delete process.env.OS_TENANCY_POSTURE; +}); + +// Each case boots the full in-process stack — well beyond the 5s default, and a +// timed-out boot keeps running past its case, so its deferred stop() would race +// the NEXT case's env expectations. Generous per-case timeouts keep the boots +// inside their own cases. +const BOOT_TIMEOUT = 120_000; + +describe('bootStack multiTenant — tenancy posture request (ADR-0105 D1)', () => { + it( + 'requests `isolated` for the boot and restores the env on stop()', + async () => { + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + const stack = await bootStack(app, { multiTenant: true }); + try { + expect(process.env.OS_TENANCY_POSTURE).toBe('isolated'); + const tenancy = await stack.kernel.getServiceAsync('tenancy'); + expect(tenancy.requestedPosture).toBe('isolated'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + } finally { + await stack.stop(); + } + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + }, + BOOT_TIMEOUT, + ); + + it( + 'never overrides an explicit caller-provided OS_TENANCY_POSTURE', + async () => { + process.env.OS_TENANCY_POSTURE = 'group'; + const stack = await bootStack(app, { multiTenant: true }); + try { + const tenancy = await stack.kernel.getServiceAsync('tenancy'); + expect(tenancy.requestedPosture).toBe('group'); + } finally { + await stack.stop(); + } + // stop() must not clear a posture the harness did not set. + expect(process.env.OS_TENANCY_POSTURE).toBe('group'); + }, + BOOT_TIMEOUT, + ); + + it( + 'restores the env when the enterprise package is missing and the boot throws', + async () => { + // `vi.resetModules()` alone cannot evict a `vi.mock` factory result, so + // re-mock with `vi.doMock` (re-evaluated on the next import) and clear the + // module cache: the harness's dynamic import now rejects like a genuinely + // missing package. + vi.resetModules(); + vi.doMock('@objectstack/organizations', () => { + throw new Error("Cannot find module '@objectstack/organizations'"); + }); + await expect(bootStack(app, { multiTenant: true })).rejects.toThrow( + /requires the enterprise @objectstack\/organizations/, + ); + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + }, + BOOT_TIMEOUT, + ); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index d1700a2f86..738b0ae73f 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -81,6 +81,10 @@ export interface BootOptions { * `collectRLSPolicies`). This exercises the org-scoped isolation real apps * rely on, rather than the single-tenant default where every tenant policy is * stripped and a member sees every row. Default `false`. + * + * Also REQUESTS the `isolated` tenancy posture (ADR-0105 D1) for the boot, + * unless the caller already set `OS_TENANCY_POSTURE` — mounting the plugin + * entitles a walled posture but no longer activates one by itself. */ multiTenant?: boolean; /** @@ -117,6 +121,25 @@ export async function bootStack( ): Promise { process.env.NODE_ENV = 'development'; + // [ADR-0105 D1] `multiTenant: true` REQUESTS the hard organization wall — + // posture `isolated`, what `OS_MULTI_ORG_ENABLED=true` historically meant. + // Since #3559 a walled posture is an explicit operator request resolved from + // env when AuthPlugin registers the `tenancy` service; mounting the + // enterprise plugin only ENTITLES it. Without the request the fixture + // silently boots `single` — no wall, default-org write stamping — and every + // multi-org proof asserts against the wrong posture. Must be set BEFORE + // AuthPlugin snapshots the requested posture; an explicit caller-provided + // OS_TENANCY_POSTURE wins; restored on stop() (and on a failed multi-tenant + // boot) so later single-tenant boots in the same worker are unaffected. + const prevTenancyPosture = process.env.OS_TENANCY_POSTURE; + const requestIsolatedPosture = !!opts.multiTenant && !prevTenancyPosture; + if (requestIsolatedPosture) process.env.OS_TENANCY_POSTURE = 'isolated'; + const restoreTenancyPosture = () => { + if (!requestIsolatedPosture) return; + if (prevTenancyPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = prevTenancyPosture; + }; + const kernel = new ObjectKernel(); // Data engine + in-memory SQLite (pure-JS WASM driver — no native build, CI-safe). @@ -181,6 +204,7 @@ export async function bootStack( try { mod = await import(/* webpackIgnore: true */ organizationsPkg); } catch (e) { + restoreTenancyPosture(); throw new Error( 'verify: multiTenant=true requires the enterprise @objectstack/organizations package (migrated from plugin-org-scoping, ADR-0081 D2). ' + `Install/link it in this workspace to run multi-org fixtures. (${(e as Error).message})`, @@ -309,6 +333,7 @@ export async function bootStack( } catch { /* best-effort */ } + restoreTenancyPosture(); }; return { kernel, api, raw, signIn, signUp, apiAs, stop }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b6cdc7fd8..94fe6ab2a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2326,6 +2326,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.1)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/vscode-objectstack: devDependencies: