Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/verify-multitenant-requests-isolated-posture.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions packages/verify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -36,7 +37,8 @@
},
"devDependencies": {
"@types/node": "^26.1.1",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
"keywords": [
"objectstack",
Expand Down
116 changes: 116 additions & 0 deletions packages/verify/src/harness.posture.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<TenancyShape>('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<TenancyShape>('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,
);
});
25 changes: 25 additions & 0 deletions packages/verify/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -117,6 +121,25 @@ export async function bootStack(
): Promise<VerifyStack> {
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).
Expand Down Expand Up @@ -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})`,
Expand Down Expand Up @@ -309,6 +333,7 @@ export async function bootStack(
} catch {
/* best-effort */
}
restoreTenancyPosture();
};

return { kernel, api, raw, signIn, signUp, apiAs, stop };
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.