Skip to content

Commit 555a89c

Browse files
os-muskclaude
andauthored
fix(driver-memory): refuse a tenant-scoped call instead of returning cross-organization rows (#17005)
* fix(driver-memory): refuse a call the engine tenant-scoped, instead of answering cross-organization rows The engine scopes an object unless it opts OUT (buildDriverOptions), while the boot guard refuses only an explicit opt-IN (declaresTenantScope). An object that OMITS the tenancy block fell between them: the engine scoped it, the guard never saw it, and the driver discarded the scope and returned every organization's rows. Adds seam 3 to memory-tenancy-guard: assertCallNotTenantScoped judges the scope the engine actually handed over (DriverOptions.tenantId / tenantIds) rather than re-deriving the engine's predicate, and refuses. Called first in every driver door that accepts a DriverOptions, so a refusal leaves no partial effect. Row-level isolation is NOT implemented here and is not the direction: the driver declines to answer. Also records declaresTenantScope's false closing sentence as superseded -- a `single` posture constrains the wall, not the number of organizations. Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg Co-authored-by: Claude <noreply@anthropic.com> * test(driver-memory): three-way acceptance fixture + changeset for the tenant-scope refusal Seeds three organizations with counts 2/3/7 so that "returns nothing" (0), "the correct subset" (2), "a widened union" (5) and "everything" (12) are four distinct numbers -- the closed round of this card seeded only two orgs, where a widened union covered the whole table and "the scope widened" was indistinguish- able from "no scope ran". The refusal is pinned as none of those readings, which makes the control failable in BOTH wrong directions: a revert to silent non-isolation answers 12, and an implementation of row-level isolation answers 2, and each reds the file. Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 775e5ec commit 555a89c

5 files changed

Lines changed: 568 additions & 16 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589)
6+
7+
**BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used.
8+
9+
Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read.
10+
11+
This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it.
12+
13+
**⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing.
14+
15+
What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all.
16+
17+
The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn.
18+
19+
Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test.
20+
21+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: no Zod schema, no spec declaration, no stored representation and no published export changes shape, so `objectstack migrate meta` has nothing to rewrite. The change is a runtime refusal inside one driver, reached through a deployment's choice of driver rather than through authored metadata, and it is delivered to the operator by the refusal itself — which names the isolating driver and the ADR-0066 posture in its own message, at the moment the unsupported call is made. -->

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export {
1818
MULTI_TENANT_UNSUPPORTED_CODE,
1919
assertSingleTenantPosture,
2020
assertObjectsNotTenantScoped,
21+
// [#16589] Seam 3 — the per-call refusal. Exported on the same reasoning as
22+
// the two boot seams above: a consumer asserting this driver's behaviour under
23+
// a tenant scope needs the refusal's identity, not its message text.
24+
assertCallNotTenantScoped,
2125
declaresTenantScope,
2226
} from './memory-tenancy-guard.js';
2327
export type { TenancyAwareSchema } from './memory-tenancy-guard.js';

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ 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';
16+
import {
17+
assertSingleTenantPosture,
18+
assertObjectsNotTenantScoped,
19+
assertCallNotTenantScoped,
20+
} from './memory-tenancy-guard.js';
1721
import { getValueByPath } from './memory-matcher.js';
1822
import {
1923
assertFilterConditionShape,
@@ -562,6 +566,9 @@ export class InMemoryDriver implements IDataDriver {
562566
* result was unchecked. Same repair shape as `update`/`upsert` (#13878).
563567
*/
564568
async find(object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
569+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
570+
// store access or delegation, so a refusal leaves no partial effect.
571+
assertCallNotTenantScoped('find', object, options);
565572
this.logger.debug('Find operation', { object, query });
566573

567574
const table = this.getTable(object);
@@ -644,6 +651,9 @@ export class InMemoryDriver implements IDataDriver {
644651
* to narrow. The same shape `update()` was repaired with (#13878).
645652
*/
646653
async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown> | null> {
654+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
655+
// store access or delegation, so a refusal leaves no partial effect.
656+
assertCallNotTenantScoped('findOne', object, options);
647657
this.logger.debug('FindOne operation', { object, query });
648658

649659
const results = await this.find(object, { ...query, limit: 1 }, options);
@@ -669,6 +679,9 @@ export class InMemoryDriver implements IDataDriver {
669679
// breaking change, and method parameters compare bivariantly against the
670680
// contract's `Record<string, unknown>`, so the declaration is satisfied.
671681
async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
682+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
683+
// store access or delegation, so a refusal leaves no partial effect.
684+
assertCallNotTenantScoped('create', object, options);
672685
this.logger.debug('Create operation', { object, hasData: !!data });
673686

674687
const table = this.getTable(object);
@@ -701,6 +714,9 @@ export class InMemoryDriver implements IDataDriver {
701714
* `Promise<any>` and no caller was ever asked to narrow.
702715
*/
703716
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
717+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
718+
// store access or delegation, so a refusal leaves no partial effect.
719+
assertCallNotTenantScoped('update', object, options);
704720
this.logger.debug('Update operation', { object, id });
705721

706722
const table = this.getTable(object);
@@ -733,6 +749,9 @@ export class InMemoryDriver implements IDataDriver {
733749
}
734750

735751
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
752+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
753+
// store access or delegation, so a refusal leaves no partial effect.
754+
assertCallNotTenantScoped('upsert', object, options);
736755
this.logger.debug('Upsert operation', { object, conflictKeys });
737756

738757
const table = this.getTable(object);
@@ -763,6 +782,9 @@ export class InMemoryDriver implements IDataDriver {
763782
}
764783

765784
async delete(object: string, id: string | number, options?: DriverOptions) {
785+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
786+
// store access or delegation, so a refusal leaves no partial effect.
787+
assertCallNotTenantScoped('delete', object, options);
766788
this.logger.debug('Delete operation', { object, id });
767789

768790
const table = this.getTable(object);
@@ -783,6 +805,9 @@ export class InMemoryDriver implements IDataDriver {
783805
}
784806

785807
async count(object: string, query?: DriverQuery, options?: DriverOptions) {
808+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
809+
// store access or delegation, so a refusal leaves no partial effect.
810+
assertCallNotTenantScoped('count', object, options);
786811
let records = this.getTable(object);
787812
if (query?.where) {
788813
const mongoQuery = this.convertToMongoQuery(query.where, object);
@@ -801,6 +826,9 @@ export class InMemoryDriver implements IDataDriver {
801826
// ===================================
802827

803828
async bulkCreate(object: string, dataArray: Record<string, any>[], options?: DriverOptions): Promise<Record<string, any>[]> {
829+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
830+
// store access or delegation, so a refusal leaves no partial effect.
831+
assertCallNotTenantScoped('bulkCreate', object, options);
804832
this.logger.debug('BulkCreate operation', { object, count: dataArray.length });
805833

806834
const table = this.getTable(object);
@@ -845,6 +873,9 @@ export class InMemoryDriver implements IDataDriver {
845873
}
846874

847875
async updateMany(object: string, query: DriverQuery, data: Record<string, any>, options?: DriverOptions): Promise<number> {
876+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
877+
// store access or delegation, so a refusal leaves no partial effect.
878+
assertCallNotTenantScoped('updateMany', object, options);
848879
this.logger.debug('UpdateMany operation', { object, query });
849880

850881
const table = this.getTable(object);
@@ -887,6 +918,9 @@ export class InMemoryDriver implements IDataDriver {
887918
}
888919

889920
async deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise<number> {
921+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
922+
// store access or delegation, so a refusal leaves no partial effect.
923+
assertCallNotTenantScoped('deleteMany', object, options);
890924
this.logger.debug('DeleteMany operation', { object, query });
891925

892926
const table = this.getTable(object);
@@ -947,6 +981,9 @@ export class InMemoryDriver implements IDataDriver {
947981
* follows that established convention rather than inventing a second one.
948982
*/
949983
async bulkUpdate(object: string, updates: { id: string | number, data: Record<string, any> }[], options?: DriverOptions) {
984+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
985+
// store access or delegation, so a refusal leaves no partial effect.
986+
assertCallNotTenantScoped('bulkUpdate', object, options);
950987
this.logger.debug('BulkUpdate operation', { object, count: updates.length });
951988

952989
const table = this.getTable(object);
@@ -1176,6 +1213,9 @@ export class InMemoryDriver implements IDataDriver {
11761213
* ]);
11771214
*/
11781215
async aggregate(object: string, pipeline: Record<string, any>[] | DriverQuery, options?: DriverOptions): Promise<any[]> {
1216+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
1217+
// store access or delegation, so a refusal leaves no partial effect.
1218+
assertCallNotTenantScoped('aggregate', object, options);
11791219
// ObjectQL's engine calls driver.aggregate(object, AST) with the SAME
11801220
// DriverQuery shape find() consumes ({ where, groupBy, aggregations }) — not
11811221
// a MongoDB pipeline. Passing that object into Mingo's Aggregator crashed
@@ -1902,6 +1942,9 @@ export class InMemoryDriver implements IDataDriver {
19021942
// ===================================
19031943

19041944
async syncSchema(object: string, schema: any, options?: DriverOptions) {
1945+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
1946+
// store access or delegation, so a refusal leaves no partial effect.
1947+
assertCallNotTenantScoped('syncSchema', object, options);
19051948
// #6915 — metadata-level half of the tenancy guard: an object asking for
19061949
// row-level isolation cannot get it here, so the table is never allocated.
19071950
assertObjectsNotTenantScoped([{ object, schema }]);
@@ -1949,6 +1992,9 @@ export class InMemoryDriver implements IDataDriver {
19491992
}
19501993

19511994
async dropTable(object: string, options?: DriverOptions) {
1995+
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
1996+
// store access or delegation, so a refusal leaves no partial effect.
1997+
assertCallNotTenantScoped('dropTable', object, options);
19521998
if (this.db[object]) {
19531999
const recordCount = this.db[object].length;
19542000
delete this.db[object];

0 commit comments

Comments
 (0)