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
21 changes: 21 additions & 0 deletions .changeset/memory-driver-tenant-scope-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/driver-memory": minor
---

fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589)

**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.

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.

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.

**⚠️ 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.

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.

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.

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.

<!-- 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. -->
4 changes: 4 additions & 0 deletions packages/drivers/driver-memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export {
MULTI_TENANT_UNSUPPORTED_CODE,
assertSingleTenantPosture,
assertObjectsNotTenantScoped,
// [#16589] Seam 3 — the per-call refusal. Exported on the same reasoning as
// the two boot seams above: a consumer asserting this driver's behaviour under
// a tenant scope needs the refusal's identity, not its message text.
assertCallNotTenantScoped,
declaresTenantScope,
} from './memory-tenancy-guard.js';
export type { TenancyAwareSchema } from './memory-tenancy-guard.js';
Expand Down
48 changes: 47 additions & 1 deletion packages/drivers/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ 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 {
assertSingleTenantPosture,
assertObjectsNotTenantScoped,
assertCallNotTenantScoped,
} from './memory-tenancy-guard.js';
import { getValueByPath } from './memory-matcher.js';
import {
assertFilterConditionShape,
Expand Down Expand Up @@ -562,6 +566,9 @@ export class InMemoryDriver implements IDataDriver {
* result was unchecked. Same repair shape as `update`/`upsert` (#13878).
*/
async find(object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
// store access or delegation, so a refusal leaves no partial effect.
assertCallNotTenantScoped('find', object, options);
this.logger.debug('Find operation', { object, query });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

async syncSchema(object: string, schema: any, options?: DriverOptions) {
// [#16589] Seam 3: refuse a call the engine scoped — FIRST, before any
// store access or delegation, so a refusal leaves no partial effect.
assertCallNotTenantScoped('syncSchema', object, options);
// #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 }]);
Expand Down Expand Up @@ -1949,6 +1992,9 @@ export class InMemoryDriver implements IDataDriver {
}

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