Skip to content

Commit 45d5bd2

Browse files
huangyiireneclaude
andauthored
feat(driver-memory)!: refuse to boot into a multi-tenant deployment (#6915) (#7924)
* feat(driver-memory)!: refuse to boot into a multi-tenant deployment (#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 (`distinct()` does not even accept a `DriverOptions`). Everything above the driver assumes tenant isolation is a platform guarantee, so a multi-tenant deployment backed by this driver did not fail — it served cross-tenant reads, updates and deletes SILENTLY. Route B of #6915, mirroring the guard #3724 landed on driver-mongodb: * `assertSingleTenantPosture()` reads `resolveTenancyPosture()` (ADR-0105 D1) and refuses both walled postures. 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`, 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. * `assertObjectsNotTenantScoped()` refuses object schemas declaring `tenancy.enabled: true`, naming every offender in one message. Called from `syncSchema()` before the table is allocated. Both throw `MemoryMultiTenantUnsupportedError` (`code === 'MEMORY_MULTI_TENANT_UNSUPPORTED'`) with a message naming the detected signal, the knobs that produced it, and the multi-tenant alternative. No override env var: an escape hatch would restore exactly the silent non-isolation this removes. Route A (real row-level isolation) stays behind the #5499 investment freeze per the maintainer ruling of 2026-08-12 — a startup refusal is not an investment in the driver's capabilities, it is the removal of a silent failure mode. `driver-memory` stays outside `scripts/check-tenant-chokepoint.mjs`'s scan set: a driver that refuses multi-tenant has no read-side chokepoint to re-derive. `@objectstack/types` becomes a dependency of the package — the posture resolver is the only correct way to read the mode, and it was not previously reachable from here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7aqcZXdqX6yPMtbQWfASy * chore(changeset): answer the ADR-0087 ledger question for the driver-memory tenancy guard (#6915) `no-migration-prescription`: the guard retires no authorable surface, and `tenancy.enabled: true` stays valid, honoured metadata everywhere the SQL family enforces it — so `objectstack migrate meta` has nothing to rewrite, and rewriting would silently disarm a real isolation declaration on the deployments that do enforce it. What the guard refuses is a DEPLOYMENT pairing, whose repair (switch drivers, or set the posture to `single`) depends on a fact only the operator holds and no ledger entry can state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7aqcZXdqX6yPMtbQWfASy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59ac0c6 commit 45d5bd2

8 files changed

Lines changed: 486 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
feat(driver-memory)!: declare the driver single-tenant and refuse to boot multi-tenant (#6915)
6+
7+
`InMemoryDriver` implements **no row-level tenant isolation** — it never reads
8+
`DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not
9+
stamped with a tenant column. The layer the SQL family has (`resolveTenantField`
10+
+ `applyTenantScope`) does not exist here at all, which is why
11+
`scripts/check-tenant-chokepoint.mjs` scans `driver-sql` / `driver-sqlite-wasm` /
12+
`driver-turso` and not this package — and `distinct(object, field, query?)` does
13+
not even accept a `DriverOptions`, so a caller has nowhere to pass a tenant even
14+
deliberately.
15+
16+
Everything above the driver assumes tenant isolation is a *platform* guarantee
17+
(object metadata's `tenancy` block, `applySystemFields` injecting
18+
`organization_id`, the engine threading `tenantId` into every driver call). So a
19+
multi-tenant deployment backed by this driver did not fail — it served
20+
cross-tenant reads, updates and deletes **silently**, the "declared ≠ enforced"
21+
shape Prime Directive #10 forbids.
22+
23+
It now refuses to run there, at startup, on two signals:
24+
25+
- **Deployment posture**`assertSingleTenantPosture()` reads the shared
26+
`resolveTenancyPosture()` resolver (ADR-0105 D1), the canonical knob which also
27+
subsumes the legacy `OS_MULTI_ORG_ENABLED` boolean, so the driver, auth, the
28+
registry and the CLI can never disagree about the mode. Both walled postures
29+
(`group` and `isolated`) need an organization wall this driver cannot draw, so
30+
both are refused; only `single` passes. Called from the **constructor** and
31+
re-checked in `connect()`. Both seams are load-bearing: `connect()` is what
32+
`ObjectQLEngine.init()` turns into a boot-aborting `DriverConnectError`
33+
(framework#3741), while the constructor is the seam no escape hatch reaches —
34+
`OS_ALLOW_DRIVER_CONNECT_FAILURE=1` downgrades a connect rejection to a warning
35+
and would boot the deployment unisolated again.
36+
- **Object metadata**`assertObjectsNotTenantScoped()` refuses to sync an
37+
object declaring `tenancy.enabled: true`, naming every offender in one message
38+
so an operator fixes the whole set in one pass. Called from `syncSchema()`,
39+
before the table is allocated.
40+
41+
Both throw `MemoryMultiTenantUnsupportedError` with
42+
`code === 'MEMORY_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected
43+
signal, the knobs that produced it, and `@objectstack/driver-sql` (including
44+
`connection: { filename: ':memory:' }` as the closest in-process drop-in) as the
45+
multi-tenant option.
46+
47+
There is deliberately **no override env var**: an escape hatch would restore
48+
exactly the silent non-isolation this guard removes. Single-tenant deployments —
49+
the dev stack, the example apps, `@objectstack/verify`, and every in-process
50+
embedding, none of which set a tenancy posture — are unaffected.
51+
52+
This is option B of #6915, mirroring the guard #3724 landed on
53+
`@objectstack/driver-mongodb`. Implementing real row-level isolation (option A)
54+
stays behind the #5499 investment freeze; a startup refusal is not an investment
55+
in this driver's capabilities, it is the removal of a silent failure mode
56+
(maintainer ruling, 2026-08-12).
57+
58+
Graded `minor` rather than `patch` for the same reason the sibling guard was: a
59+
deployment that boots today can stop booting. It is a refusal that was always
60+
owed, but it is still a behavior change, and the release notes must be able to
61+
say so.
62+
63+
<!-- 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. -->
64+

packages/drivers/driver-memory/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"dependencies": {
2222
"@objectstack/core": "workspace:*",
2323
"@objectstack/spec": "workspace:*",
24+
"@objectstack/types": "workspace:*",
2425
"mingo": "^7.2.2"
2526
},
2627
"devDependencies": {

packages/drivers/driver-memory/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export type { MemoryAnalyticsConfig } from './memory-analytics.js';
1313

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

16+
export {
17+
MemoryMultiTenantUnsupportedError,
18+
MULTI_TENANT_UNSUPPORTED_CODE,
19+
assertSingleTenantPosture,
20+
assertObjectsNotTenantScoped,
21+
declaresTenantScope,
22+
} from './memory-tenancy-guard.js';
23+
export type { TenancyAwareSchema } from './memory-tenancy-guard.js';
24+
1625
export default {
1726
id: 'com.objectstack.driver.memory',
1827
version: '1.0.0',

packages/drivers/driver-memory/src/memory-driver.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { hasDanglingLikeEscape, likePatternToRegexSource } from '@objectstack/sp
1313
import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts';
1414
import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core';
1515
import { Query, Aggregator } from 'mingo';
16+
import { assertSingleTenantPosture, assertObjectsNotTenantScoped } from './memory-tenancy-guard.js';
1617
import { getValueByPath } from './memory-matcher.js';
1718
import {
1819
assertFilterConditionShape,
@@ -156,6 +157,14 @@ export class InMemoryDriver implements IDataDriver {
156157
private persistenceAdapter: PersistenceAdapterInterface | null = null;
157158

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

200209
async connect() {
210+
// #6915 — re-checked here (not just in the constructor) because a host may
211+
// flip the posture between construction and connect, and because a rejection
212+
// from here is what `ObjectQLEngine.init()` turns into a `DriverConnectError`
213+
// that aborts kernel bootstrap (framework#3741).
214+
assertSingleTenantPosture();
215+
201216
// Initialize persistence adapter if configured
202217
await this.initPersistence();
203218

@@ -1277,6 +1292,9 @@ export class InMemoryDriver implements IDataDriver {
12771292
// ===================================
12781293

12791294
async syncSchema(object: string, schema: any, options?: DriverOptions) {
1295+
// #6915 — metadata-level half of the tenancy guard: an object asking for
1296+
// row-level isolation cannot get it here, so the table is never allocated.
1297+
assertObjectsNotTenantScoped([{ object, schema }]);
12801298
if (!this.db[object]) {
12811299
this.db[object] = [];
12821300
this.tablesCreatedHere.add(object);
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Multi-tenancy boot guard (#6915, mirroring driver-mongodb's #3724 guard).
5+
*
6+
* The guard is pure (env posture + object metadata), and this driver holds its
7+
* store in a plain object, so nothing here needs a server. The driver-level
8+
* cases assert two things at once: that the refusal fires at construction and
9+
* at `connect()`, and — the risk this card carries — that the ordinary
10+
* single-tenant in-process path the dogfood suites, `@objectstack/verify` and
11+
* the example apps depend on still boots and serves clean.
12+
*/
13+
14+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
15+
import {
16+
assertSingleTenantPosture,
17+
assertObjectsNotTenantScoped,
18+
declaresTenantScope,
19+
MemoryMultiTenantUnsupportedError,
20+
MULTI_TENANT_UNSUPPORTED_CODE,
21+
} from './memory-tenancy-guard.js';
22+
import { InMemoryDriver } from './memory-driver.js';
23+
24+
const ORIGINAL_MULTI_ORG = process.env.OS_MULTI_ORG_ENABLED;
25+
const ORIGINAL_POSTURE = process.env.OS_TENANCY_POSTURE;
26+
27+
function makeDriver() {
28+
return new InMemoryDriver({ persistence: false });
29+
}
30+
31+
describe('multi-tenancy boot guard (#6915)', () => {
32+
beforeEach(() => {
33+
delete process.env.OS_MULTI_ORG_ENABLED;
34+
delete process.env.OS_TENANCY_POSTURE;
35+
});
36+
37+
afterEach(() => {
38+
if (ORIGINAL_MULTI_ORG === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
39+
else process.env.OS_MULTI_ORG_ENABLED = ORIGINAL_MULTI_ORG;
40+
if (ORIGINAL_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
41+
else process.env.OS_TENANCY_POSTURE = ORIGINAL_POSTURE;
42+
});
43+
44+
describe('assertSingleTenantPosture', () => {
45+
it('passes when nothing is configured (posture derives to `single`)', () => {
46+
expect(() => assertSingleTenantPosture()).not.toThrow();
47+
});
48+
49+
it('passes when OS_MULTI_ORG_ENABLED is explicitly false', () => {
50+
process.env.OS_MULTI_ORG_ENABLED = 'false';
51+
expect(() => assertSingleTenantPosture()).not.toThrow();
52+
});
53+
54+
it('passes for an explicit single posture', () => {
55+
process.env.OS_TENANCY_POSTURE = 'single';
56+
expect(() => assertSingleTenantPosture()).not.toThrow();
57+
});
58+
59+
it('throws a coded error when multi-org mode is on', () => {
60+
process.env.OS_MULTI_ORG_ENABLED = 'true';
61+
try {
62+
assertSingleTenantPosture();
63+
expect.unreachable('expected the guard to throw');
64+
} catch (err) {
65+
expect(err).toBeInstanceOf(MemoryMultiTenantUnsupportedError);
66+
expect((err as any).code).toBe(MULTI_TENANT_UNSUPPORTED_CODE);
67+
// The message must name the knobs and the escape route, not just fail.
68+
expect((err as Error).message).toContain('OS_MULTI_ORG_ENABLED');
69+
expect((err as Error).message).toContain('OS_TENANCY_POSTURE');
70+
expect((err as Error).message).toContain('@objectstack/driver-sql');
71+
expect((err as Error).message).toContain('6915');
72+
}
73+
});
74+
75+
it('treats any non-`false` value as enabled (matches resolveMultiOrgEnabled)', () => {
76+
process.env.OS_MULTI_ORG_ENABLED = '1';
77+
expect(() => assertSingleTenantPosture()).toThrow(MemoryMultiTenantUnsupportedError);
78+
});
79+
80+
// OS_TENANCY_POSTURE (ADR-0105 D1) supersedes the boolean — BOTH walled
81+
// postures need an organization wall this driver cannot draw.
82+
it.each(['isolated', 'group', 'multi'])(
83+
'throws for the `%s` posture even with OS_MULTI_ORG_ENABLED unset',
84+
(posture) => {
85+
process.env.OS_TENANCY_POSTURE = posture;
86+
const err = (() => {
87+
try {
88+
assertSingleTenantPosture();
89+
return null;
90+
} catch (e) {
91+
return e;
92+
}
93+
})();
94+
expect(err).toBeInstanceOf(MemoryMultiTenantUnsupportedError);
95+
// `multi` is the legacy alias, normalized to `isolated` by the resolver.
96+
expect((err as Error).message).toContain(posture === 'multi' ? 'isolated' : posture);
97+
},
98+
);
99+
});
100+
101+
describe('declaresTenantScope', () => {
102+
it('is true only for an explicit tenancy.enabled === true', () => {
103+
expect(declaresTenantScope({ name: 'task', tenancy: { enabled: true } })).toBe(true);
104+
expect(declaresTenantScope({ name: 'task', tenancy: { enabled: false } })).toBe(false);
105+
expect(declaresTenantScope({ name: 'task', tenancy: {} })).toBe(false);
106+
expect(declaresTenantScope({ name: 'task' })).toBe(false);
107+
expect(declaresTenantScope(null)).toBe(false);
108+
expect(declaresTenantScope(undefined)).toBe(false);
109+
});
110+
});
111+
112+
describe('assertObjectsNotTenantScoped', () => {
113+
it('passes for objects that do not declare tenancy', () => {
114+
expect(() =>
115+
assertObjectsNotTenantScoped([
116+
{ object: 'task', schema: { name: 'task' } },
117+
{ object: 'sys_license', schema: { name: 'sys_license', tenancy: { enabled: false } } },
118+
]),
119+
).not.toThrow();
120+
});
121+
122+
it('names every offending object in a single message', () => {
123+
try {
124+
assertObjectsNotTenantScoped([
125+
{ object: 'task', schema: { name: 'task' } },
126+
{ object: 'account', schema: { name: 'account', tenancy: { enabled: true } } },
127+
{ object: 'contact', schema: { name: 'contact', tenancy: { enabled: true } } },
128+
]);
129+
expect.unreachable('expected the guard to throw');
130+
} catch (err) {
131+
expect((err as any).code).toBe(MULTI_TENANT_UNSUPPORTED_CODE);
132+
const message = (err as Error).message;
133+
expect(message).toContain('`account`');
134+
expect(message).toContain('`contact`');
135+
expect(message).not.toContain('`task`');
136+
// Plural remedy when there is more than one offender.
137+
expect(message).toContain('these objects');
138+
}
139+
});
140+
141+
it('stays singular for a lone offender — the shape `syncSchema` actually calls', () => {
142+
try {
143+
assertObjectsNotTenantScoped([
144+
{ object: 'account', schema: { name: 'account', tenancy: { enabled: true } } },
145+
]);
146+
expect.unreachable('expected the guard to throw');
147+
} catch (err) {
148+
const message = (err as Error).message;
149+
expect(message).toContain('object declaring');
150+
expect(message).toContain('this object');
151+
}
152+
});
153+
});
154+
155+
describe('InMemoryDriver wiring', () => {
156+
it('the constructor refuses in multi-tenant mode', () => {
157+
process.env.OS_MULTI_ORG_ENABLED = 'true';
158+
// Construction is the earliest seam, and the only one no escape hatch
159+
// reaches: `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` downgrades a `connect()`
160+
// rejection to a warning, which would boot the deployment unisolated.
161+
expect(() => makeDriver()).toThrow(MemoryMultiTenantUnsupportedError);
162+
});
163+
164+
it.each(['isolated', 'group'])('the constructor refuses the `%s` posture', (posture) => {
165+
process.env.OS_TENANCY_POSTURE = posture;
166+
expect(() => makeDriver()).toThrow(MemoryMultiTenantUnsupportedError);
167+
});
168+
169+
it('connect() refuses when the posture flips after construction', async () => {
170+
const driver = makeDriver(); // built single-tenant
171+
process.env.OS_TENANCY_POSTURE = 'isolated';
172+
await expect(driver.connect()).rejects.toThrow(MemoryMultiTenantUnsupportedError);
173+
});
174+
175+
it('syncSchema() refuses a tenant-scoped object, and allocates no table for it', async () => {
176+
const driver = makeDriver();
177+
await driver.connect();
178+
await expect(
179+
driver.syncSchema('account', { name: 'account', tenancy: { enabled: true } }),
180+
).rejects.toThrow(MemoryMultiTenantUnsupportedError);
181+
// The refusal happens before the store is touched: reading the object back
182+
// finds nothing was created for it.
183+
const stats = driver.getSchemaSyncStats?.();
184+
expect(stats?.created ?? []).not.toContain('account');
185+
});
186+
});
187+
188+
// The risk this card carries is a guard that is too EAGER: `driver-memory` is
189+
// the in-process store behind the dev stack, the example apps and every
190+
// single-tenant embedding. None of those set a posture, so none of them may
191+
// notice this guard exists.
192+
describe('the ordinary single-tenant path still boots clean', () => {
193+
it('constructs, connects, syncs and round-trips a record with no posture set', async () => {
194+
const driver = makeDriver();
195+
await driver.connect();
196+
await driver.syncSchema('task', {
197+
name: 'task',
198+
fields: { title: { type: 'text' } },
199+
});
200+
await driver.create('task', { title: 'hello' });
201+
const rows = await driver.find('task', {});
202+
expect(rows).toHaveLength(1);
203+
expect(rows[0].title).toBe('hello');
204+
await driver.disconnect?.();
205+
});
206+
207+
it('is unaffected by an explicit `single` posture', async () => {
208+
process.env.OS_TENANCY_POSTURE = 'single';
209+
const driver = makeDriver();
210+
await expect(driver.connect()).resolves.not.toThrow();
211+
await expect(
212+
driver.syncSchema('task', { name: 'task', fields: {} }),
213+
).resolves.not.toThrow();
214+
});
215+
216+
it('syncs an object that omits the tenancy block, and one that disables it', async () => {
217+
const driver = makeDriver();
218+
await driver.connect();
219+
await expect(driver.syncSchema('task', { name: 'task' })).resolves.not.toThrow();
220+
await expect(
221+
driver.syncSchema('sys_license', { name: 'sys_license', tenancy: { enabled: false } }),
222+
).resolves.not.toThrow();
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)