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
64 changes: 64 additions & 0 deletions .changeset/memory-driver-tenancy-boot-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-memory": minor
---

feat(driver-memory)!: declare the driver single-tenant and refuse to boot multi-tenant (#6915)

`InMemoryDriver` implements **no row-level tenant isolation** — it never reads
`DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not
stamped with a tenant column. The layer the SQL family has (`resolveTenantField`
+ `applyTenantScope`) does not exist here at all, which is why
`scripts/check-tenant-chokepoint.mjs` scans `driver-sql` / `driver-sqlite-wasm` /
`driver-turso` and not this package — and `distinct(object, field, query?)` does
not even accept a `DriverOptions`, so a caller has nowhere to pass a tenant even
deliberately.

Everything above the driver assumes tenant isolation is a *platform* guarantee
(object metadata's `tenancy` block, `applySystemFields` injecting
`organization_id`, the engine threading `tenantId` into every driver call). So a
multi-tenant deployment backed by this driver did not fail — it served
cross-tenant reads, updates and deletes **silently**, the "declared ≠ enforced"
shape Prime Directive #10 forbids.

It now refuses to run there, at startup, on two signals:

- **Deployment posture** — `assertSingleTenantPosture()` reads the shared
`resolveTenancyPosture()` resolver (ADR-0105 D1), the canonical knob which also
subsumes the legacy `OS_MULTI_ORG_ENABLED` boolean, so the driver, auth, the
registry and the CLI can never disagree about the mode. Both walled postures
(`group` and `isolated`) need an organization wall this driver cannot draw, so
both are refused; only `single` passes. Called from the **constructor** and
re-checked in `connect()`. Both seams are load-bearing: `connect()` is what
`ObjectQLEngine.init()` turns into a boot-aborting `DriverConnectError`
(framework#3741), while the constructor is the seam no escape hatch reaches —
`OS_ALLOW_DRIVER_CONNECT_FAILURE=1` downgrades a connect rejection to a warning
and would boot the deployment unisolated again.
- **Object metadata** — `assertObjectsNotTenantScoped()` refuses to sync an
object declaring `tenancy.enabled: true`, naming every offender in one message
so an operator fixes the whole set in one pass. Called from `syncSchema()`,
before the table is allocated.

Both throw `MemoryMultiTenantUnsupportedError` with
`code === 'MEMORY_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected
signal, the knobs that produced it, and `@objectstack/driver-sql` (including
`connection: { filename: ':memory:' }` as the closest in-process drop-in) as the
multi-tenant option.

There is deliberately **no override env var**: an escape hatch would restore
exactly the silent non-isolation this guard removes. Single-tenant deployments —
the dev stack, the example apps, `@objectstack/verify`, and every in-process
embedding, none of which set a tenancy posture — are unaffected.

This is option B of #6915, mirroring the guard #3724 landed on
`@objectstack/driver-mongodb`. Implementing real row-level isolation (option A)
stays behind the #5499 investment freeze; a startup refusal is not an investment
in this driver's capabilities, it is the removal of a silent failure mode
(maintainer ruling, 2026-08-12).

Graded `minor` rather than `patch` for the same reason the sibling guard was: a
deployment that boots today can stop booting. It is a refusal that was always
owed, but it is still a behavior change, and the release notes must be able to
say so.

<!-- adr-0087: not-required (no-migration-prescription) This change retires NO authorable surface. It removes no spec property, no metadata key, no `apiMethods` entry and no field type; `packages/spec` is untouched by this diff, which is confined to `packages/drivers/driver-memory` (a new guard module, one dependency, three call sites) plus the lockfile line that dependency implies. Every object schema that parses today still parses. In particular `tenancy.enabled: true` remains valid, honoured, authorable metadata everywhere it was before — `driver-sql` / `driver-sqlite-wasm` / `driver-turso` enforce it through `applyTenantScope()`, and `scripts/check-tenant-chokepoint.mjs` re-derives that from the AST on every run. So there is nothing for `objectstack migrate meta` to rewrite, and rewriting would be actively WRONG: stripping the `tenancy` block on upgrade would silently disarm a real isolation declaration on the deployments that actually enforce it. Nor is there a FROM/TO rule a ledger entry could state. What this guard refuses is a DEPLOYMENT pairing — this driver together with a walled `OS_TENANCY_POSTURE` — and the correct repair depends on which half is the mistake: a genuinely multi-tenant deployment moves to `@objectstack/driver-sql` (`connection: { filename: ':memory:' }` is the in-process drop-in), while a deployment that never meant to be multi-tenant sets `OS_TENANCY_POSTURE=single`. That is an operator decision about the deployment, not a mechanical transform of any authored metadata, and the ledger has no way to express "pick one of two, based on a fact only you hold". The channel that does reach an affected reader is the refusal itself, which names the detected posture, both env knobs that can produce it, and the driver-sql alternative — shipped with this change and printed at the moment of failure — plus this changeset's own CHANGELOG text. Checked for precedent rather than assumed: #3724 landed the identical guard on `@objectstack/driver-mongodb` and registered nothing (its `17.0.0-rc.0` entry carries no marker — it predates this gate), and neither ADR-0087 registry holds any entry for a driver-level tenancy refusal, so there is no convention here to match or to break. -->

1 change: 1 addition & 0 deletions packages/drivers/driver-memory/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"mingo": "^7.2.2"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions packages/drivers/driver-memory/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,15 @@ export type { MemoryAnalyticsConfig } from './memory-analytics.js';

export { InMemoryStrategy } from './in-memory-strategy.js';

export {
MemoryMultiTenantUnsupportedError,
MULTI_TENANT_UNSUPPORTED_CODE,
assertSingleTenantPosture,
assertObjectsNotTenantScoped,
declaresTenantScope,
} from './memory-tenancy-guard.js';
export type { TenancyAwareSchema } from './memory-tenancy-guard.js';

export default {
id: 'com.objectstack.driver.memory',
version: '1.0.0',
Expand Down
18 changes: 18 additions & 0 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import { hasDanglingLikeEscape, likePatternToRegexSource } from '@objectstack/sp
import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts';
import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core';
import { Query, Aggregator } from 'mingo';
import { assertSingleTenantPosture, assertObjectsNotTenantScoped } from './memory-tenancy-guard.js';
import { getValueByPath } from './memory-matcher.js';
import {
assertFilterConditionShape,
Expand DownExpand Up@@ -156,6 +157,14 @@ export class InMemoryDriver implements IDataDriver {
private persistenceAdapter: PersistenceAdapterInterface | null = null;

constructor(config?: InMemoryDriverConfig) {
// #6915 — this driver has NO row-level tenant isolation, so it refuses a
// multi-tenant deployment outright rather than serving it unisolated.
// Construction is the earliest seam and the one behind no escape hatch:
// `connect()` re-checks (and is what aborts kernel bootstrap with this
// message), but `ObjectQLEngine.init()` downgrades a connect rejection to a
// warning under `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`, which would boot
// unisolated again. See `memory-tenancy-guard.ts`.
assertSingleTenantPosture();
this.config = config || {};
this.logger = config?.logger || createLogger({ level: 'info', format: 'pretty' });
this.logger.debug('InMemory driver instance created');
Expand DownExpand Up@@ -198,6 +207,12 @@ export class InMemoryDriver implements IDataDriver {
// ===================================

async connect() {
// #6915 — re-checked here (not just in the constructor) because a host may
// flip the posture between construction and connect, and because a rejection
// from here is what `ObjectQLEngine.init()` turns into a `DriverConnectError`
// that aborts kernel bootstrap (framework#3741).
assertSingleTenantPosture();

// Initialize persistence adapter if configured
await this.initPersistence();

Expand DownExpand Up@@ -1277,6 +1292,9 @@ export class InMemoryDriver implements IDataDriver {
// ===================================

async syncSchema(object: string, schema: any, options?: DriverOptions) {
// #6915 — metadata-level half of the tenancy guard: an object asking for
// row-level isolation cannot get it here, so the table is never allocated.
assertObjectsNotTenantScoped([{ object, schema }]);
if (!this.db[object]) {
this.db[object] = [];
this.tablesCreatedHere.add(object);
Expand Down
225 changes: 225 additions & 0 deletions packages/drivers/driver-memory/src/memory-tenancy-guard.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Multi-tenancy boot guard (#6915, mirroring driver-mongodb's #3724 guard).
*
* The guard is pure (env posture + object metadata), and this driver holds its
* store in a plain object, so nothing here needs a server. The driver-level
* cases assert two things at once: that the refusal fires at construction and
* at `connect()`, and — the risk this card carries — that the ordinary
* single-tenant in-process path the dogfood suites, `@objectstack/verify` and
* the example apps depend on still boots and serves clean.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
assertSingleTenantPosture,
assertObjectsNotTenantScoped,
declaresTenantScope,
MemoryMultiTenantUnsupportedError,
MULTI_TENANT_UNSUPPORTED_CODE,
} from './memory-tenancy-guard.js';
import { InMemoryDriver } from './memory-driver.js';

const ORIGINAL_MULTI_ORG = process.env.OS_MULTI_ORG_ENABLED;
const ORIGINAL_POSTURE = process.env.OS_TENANCY_POSTURE;

function makeDriver() {
return new InMemoryDriver({ persistence: false });
}

describe('multi-tenancy boot guard (#6915)', () => {
beforeEach(() => {
delete process.env.OS_MULTI_ORG_ENABLED;
delete process.env.OS_TENANCY_POSTURE;
});

afterEach(() => {
if (ORIGINAL_MULTI_ORG === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
else process.env.OS_MULTI_ORG_ENABLED = ORIGINAL_MULTI_ORG;
if (ORIGINAL_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
else process.env.OS_TENANCY_POSTURE = ORIGINAL_POSTURE;
});

describe('assertSingleTenantPosture', () => {
it('passes when nothing is configured (posture derives to `single`)', () => {
expect(() => assertSingleTenantPosture()).not.toThrow();
});

it('passes when OS_MULTI_ORG_ENABLED is explicitly false', () => {
process.env.OS_MULTI_ORG_ENABLED = 'false';
expect(() => assertSingleTenantPosture()).not.toThrow();
});

it('passes for an explicit single posture', () => {
process.env.OS_TENANCY_POSTURE = 'single';
expect(() => assertSingleTenantPosture()).not.toThrow();
});

it('throws a coded error when multi-org mode is on', () => {
process.env.OS_MULTI_ORG_ENABLED = 'true';
try {
assertSingleTenantPosture();
expect.unreachable('expected the guard to throw');
} catch (err) {
expect(err).toBeInstanceOf(MemoryMultiTenantUnsupportedError);
expect((err as any).code).toBe(MULTI_TENANT_UNSUPPORTED_CODE);
// The message must name the knobs and the escape route, not just fail.
expect((err as Error).message).toContain('OS_MULTI_ORG_ENABLED');
expect((err as Error).message).toContain('OS_TENANCY_POSTURE');
expect((err as Error).message).toContain('@objectstack/driver-sql');
expect((err as Error).message).toContain('6915');
}
});

it('treats any non-`false` value as enabled (matches resolveMultiOrgEnabled)', () => {
process.env.OS_MULTI_ORG_ENABLED = '1';
expect(() => assertSingleTenantPosture()).toThrow(MemoryMultiTenantUnsupportedError);
});

// OS_TENANCY_POSTURE (ADR-0105 D1) supersedes the boolean — BOTH walled
// postures need an organization wall this driver cannot draw.
it.each(['isolated', 'group', 'multi'])(
'throws for the `%s` posture even with OS_MULTI_ORG_ENABLED unset',
(posture) => {
process.env.OS_TENANCY_POSTURE = posture;
const err = (() => {
try {
assertSingleTenantPosture();
return null;
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(MemoryMultiTenantUnsupportedError);
// `multi` is the legacy alias, normalized to `isolated` by the resolver.
expect((err as Error).message).toContain(posture === 'multi' ? 'isolated' : posture);
},
);
});

describe('declaresTenantScope', () => {
it('is true only for an explicit tenancy.enabled === true', () => {
expect(declaresTenantScope({ name: 'task', tenancy: { enabled: true } })).toBe(true);
expect(declaresTenantScope({ name: 'task', tenancy: { enabled: false } })).toBe(false);
expect(declaresTenantScope({ name: 'task', tenancy: {} })).toBe(false);
expect(declaresTenantScope({ name: 'task' })).toBe(false);
expect(declaresTenantScope(null)).toBe(false);
expect(declaresTenantScope(undefined)).toBe(false);
});
});

describe('assertObjectsNotTenantScoped', () => {
it('passes for objects that do not declare tenancy', () => {
expect(() =>
assertObjectsNotTenantScoped([
{ object: 'task', schema: { name: 'task' } },
{ object: 'sys_license', schema: { name: 'sys_license', tenancy: { enabled: false } } },
]),
).not.toThrow();
});

it('names every offending object in a single message', () => {
try {
assertObjectsNotTenantScoped([
{ object: 'task', schema: { name: 'task' } },
{ object: 'account', schema: { name: 'account', tenancy: { enabled: true } } },
{ object: 'contact', schema: { name: 'contact', tenancy: { enabled: true } } },
]);
expect.unreachable('expected the guard to throw');
} catch (err) {
expect((err as any).code).toBe(MULTI_TENANT_UNSUPPORTED_CODE);
const message = (err as Error).message;
expect(message).toContain('`account`');
expect(message).toContain('`contact`');
expect(message).not.toContain('`task`');
// Plural remedy when there is more than one offender.
expect(message).toContain('these objects');
}
});

it('stays singular for a lone offender — the shape `syncSchema` actually calls', () => {
try {
assertObjectsNotTenantScoped([
{ object: 'account', schema: { name: 'account', tenancy: { enabled: true } } },
]);
expect.unreachable('expected the guard to throw');
} catch (err) {
const message = (err as Error).message;
expect(message).toContain('object declaring');
expect(message).toContain('this object');
}
});
});

describe('InMemoryDriver wiring', () => {
it('the constructor refuses in multi-tenant mode', () => {
process.env.OS_MULTI_ORG_ENABLED = 'true';
// Construction is the earliest seam, and the only one no escape hatch
// reaches: `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` downgrades a `connect()`
// rejection to a warning, which would boot the deployment unisolated.
expect(() => makeDriver()).toThrow(MemoryMultiTenantUnsupportedError);
});

it.each(['isolated', 'group'])('the constructor refuses the `%s` posture', (posture) => {
process.env.OS_TENANCY_POSTURE = posture;
expect(() => makeDriver()).toThrow(MemoryMultiTenantUnsupportedError);
});

it('connect() refuses when the posture flips after construction', async () => {
const driver = makeDriver(); // built single-tenant
process.env.OS_TENANCY_POSTURE = 'isolated';
await expect(driver.connect()).rejects.toThrow(MemoryMultiTenantUnsupportedError);
});

it('syncSchema() refuses a tenant-scoped object, and allocates no table for it', async () => {
const driver = makeDriver();
await driver.connect();
await expect(
driver.syncSchema('account', { name: 'account', tenancy: { enabled: true } }),
).rejects.toThrow(MemoryMultiTenantUnsupportedError);
// The refusal happens before the store is touched: reading the object back
// finds nothing was created for it.
const stats = driver.getSchemaSyncStats?.();
expect(stats?.created ?? []).not.toContain('account');
});
});

// The risk this card carries is a guard that is too EAGER: `driver-memory` is
// the in-process store behind the dev stack, the example apps and every
// single-tenant embedding. None of those set a posture, so none of them may
// notice this guard exists.
describe('the ordinary single-tenant path still boots clean', () => {
it('constructs, connects, syncs and round-trips a record with no posture set', async () => {
const driver = makeDriver();
await driver.connect();
await driver.syncSchema('task', {
name: 'task',
fields: { title: { type: 'text' } },
});
await driver.create('task', { title: 'hello' });
const rows = await driver.find('task', {});
expect(rows).toHaveLength(1);
expect(rows[0].title).toBe('hello');
await driver.disconnect?.();
});

it('is unaffected by an explicit `single` posture', async () => {
process.env.OS_TENANCY_POSTURE = 'single';
const driver = makeDriver();
await expect(driver.connect()).resolves.not.toThrow();
await expect(
driver.syncSchema('task', { name: 'task', fields: {} }),
).resolves.not.toThrow();
});

it('syncs an object that omits the tenancy block, and one that disables it', async () => {
const driver = makeDriver();
await driver.connect();
await expect(driver.syncSchema('task', { name: 'task' })).resolves.not.toThrow();
await expect(
driver.syncSchema('sys_license', { name: 'sys_license', tenancy: { enabled: false } }),
).resolves.not.toThrow();
});
});
});
Loading
Loading