Skip to content

Commit 8adeba8

Browse files
committed
wip: gate fixes (runtime string id, query-options erasure)
1 parent 2b076c6 commit 8adeba8

3 files changed

Lines changed: 42 additions & 14 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
---
4+
5+
The in-memory driver now honours `DriverOptions.tenantId` / `tenantIds` instead of discarding them, so a scoped read no longer returns other organizations' rows.
6+
7+
Two predicates decided "is this object tenant-scoped" and they disagreed on the default case. `Engine.buildDriverOptions` scopes unless the object opts OUT (`tenantId !== undefined && !isTenancyDisabled(schema) && !isFederated`); this driver's boot guard refuses only an explicit opt-IN (`tenancy.enabled === true`). An object that omits the `tenancy` block — the common case — was therefore scoped by the engine and invisible to the guard, and the driver did nothing with the scope: `tenantId`, `tenantIds` and `organization_id` occurred nowhere in `memory-driver.ts`. The read path knew nothing about tenants; the unique-constraint path did.
8+
9+
Measured on one app across two drivers, same build, same seed, same account: the four objects that omit the block returned 12 / 30 / 40 / 14 rows on this driver against **0** on sqlite, and the three that declare `tenancy.enabled: false` agreed exactly. The split line was the declaration. Neither driver said a word about the disagreement.
10+
11+
⚠️ **Every isolation measurement previously taken on this driver is void and must be re-taken.** The failure direction was toward exposure in the place where isolation is tested: a suite asserting "tenant A cannot see tenant B's rows" passed here not because isolation worked, but because both tenants' rows came back to everyone and the assertion had been written against a single tenant's fixture.
12+
13+
The semantics are `driver-sql`'s, read off `applyTenantScope` and reproduced arm for arm rather than invented — `col = :tenantId OR col IS NULL` for the equality path, `col IN (…) OR col IS NULL` under the ADR-0105 D2 union posture, and the NULL arm keeps the #2734 global-row carve-out so a platform row that belongs to no organization stays visible to all of them. Every door that accepts a `DriverOptions` routes through one chokepoint: `find`, `findOne`, `count`, `aggregate` (both arms), `update`, `upsert`, `delete`, `updateMany`, `deleteMany`, `bulkUpdate` and `bulkDelete`. `distinct()` accepts no `DriverOptions` at all and is therefore still unscoped — the one door named rather than left to be discovered.
14+
15+
**What changes for an existing consumer.** A caller that passes no `tenantId` — every seed script, admin path and legacy call — is unaffected down to the array it allocates. A caller that does pass one, on an object carrying a tenant column and no `tenancy` declaration, now sees its own organization's rows plus organization-less rows, where it previously saw everything. That is the fix, and it is the reason a `single`-posture deployment is affected at all: `single` constrains the wall, not the number of organizations — one measured run held 13 `sys_organization` rows.
16+
17+
Write-side tenancy is deliberately not included: nothing stamps a tenant column on insert the way `SqlDriver.injectTenantOnInsert` does, so the boot guard still refuses a walled posture and still refuses an object declaring `tenancy.enabled: true`. `declaresTenantScope`'s docstring is corrected in the same change — its load-bearing sentence, "every object in a single-tenant deployment omits the block", was false.

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,18 @@ export class MemoryMultiTenantUnsupportedError extends Error {
8585

8686
constructor(detected: string, remedy: string) {
8787
super(
88-
`[driver-memory] Refusing to start: this driver has NO row-level tenant isolation.\n` +
88+
// ⛔ No tracker id in this text: it is a RUNTIME string an operator reads,
89+
// and `#NNNN` resolves to nothing for them (`pnpm check:doc-authoring`).
90+
// The anchor for this driver's tenancy work is `Tracking:` below.
91+
`[driver-memory] Refusing to start: this driver has only HALF of row-level tenant isolation.\n` +
8992
`\n` +
9093
` Detected: ${detected}\n` +
9194
`\n` +
92-
` InMemoryDriver scopes reads, updates and deletes by \`DriverOptions.tenantId\`\n` +
93-
` (#16589), but it does NOT stamp a tenant column on writes: a record created\n` +
94-
` without an explicit organization lands with none, and a record with no\n` +
95-
` organization is visible to EVERY tenant. Rather than run half-isolated, the\n` +
96-
` driver fails at startup.\n` +
95+
` InMemoryDriver scopes reads, updates and deletes by \`DriverOptions.tenantId\`,\n` +
96+
` but it does NOT stamp a tenant column on writes: a record created without an\n` +
97+
` explicit organization lands with none, and a record with no organization is\n` +
98+
` visible to EVERY tenant. Rather than run half-isolated, the driver fails at\n` +
99+
` startup.\n` +
97100
`\n` +
98101
` Fix one of:\n` +
99102
` • Use @objectstack/driver-sql (PostgreSQL / MySQL / SQLite) for multi-tenant\n` +

packages/drivers/driver-memory/src/memory-tenant-scope.test.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,22 @@
3030
*/
3131

3232
import { describe, it, expect } from 'vitest';
33+
import type { DriverQuery } from '@objectstack/spec/contracts';
3334
import { InMemoryDriver } from './memory-driver.js';
3435
import { tenantScopePredicate } from './memory-tenant-scope.js';
3536

37+
/** The shape the fixtures below declare — no wider than they need. */
38+
interface SeedSchema {
39+
name: string;
40+
fields: Record<string, { type: string }>;
41+
tenancy?: { enabled?: boolean; tenantField?: string };
42+
}
43+
3644
const ORG_A = 'org_a';
3745
const ORG_B = 'org_b';
3846

3947
/** The card's case exactly: a tenant column, and NO `tenancy` block at all. */
40-
const EMPLOYER_SCHEMA = {
48+
const EMPLOYER_SCHEMA: SeedSchema = {
4149
name: 'ats_employer',
4250
fields: {
4351
id: { type: 'string' },
@@ -48,7 +56,7 @@ const EMPLOYER_SCHEMA = {
4856
};
4957

5058
/** ADR-0066's platform-global posture — the objects that AGREED across drivers. */
51-
const LICENSE_SCHEMA = {
59+
const LICENSE_SCHEMA: SeedSchema = {
5260
name: 'sys_license',
5361
fields: {
5462
id: { type: 'string' },
@@ -58,13 +66,13 @@ const LICENSE_SCHEMA = {
5866
};
5967

6068
/** No tenant column at all: nothing to scope by, on any driver. */
61-
const NOTE_SCHEMA = {
69+
const NOTE_SCHEMA: SeedSchema = {
6270
name: 'note',
6371
fields: { id: { type: 'string' }, body: { type: 'string' } },
6472
};
6573

6674
/** A wall drawn by a column that deliberately is not the platform's. */
67-
const WORKSPACE_ITEM_SCHEMA = {
75+
const WORKSPACE_ITEM_SCHEMA: SeedSchema = {
6876
name: 'workspace_item',
6977
fields: {
7078
id: { type: 'string' },
@@ -82,10 +90,10 @@ const WORKSPACE_ITEM_SCHEMA = {
8290
* without the global carve-out answers `[a1, a2]`; a broken scope answers `[]`.
8391
* All four are distinguishable, which is the point.
8492
*/
85-
async function seed(driver: InMemoryDriver, schema: Record<string, unknown> = EMPLOYER_SCHEMA) {
86-
const object = schema.name as string;
93+
async function seed(driver: InMemoryDriver, schema: SeedSchema = EMPLOYER_SCHEMA) {
94+
const object = schema.name;
8795
await driver.syncSchema(object, schema);
88-
const tenantField = (schema as any).tenancy?.tenantField ?? 'organization_id';
96+
const tenantField = schema.tenancy?.tenantField ?? 'organization_id';
8997
await driver.bulkCreate(object, [
9098
{ id: 'a1', name: 'A one', [tenantField]: ORG_A },
9199
{ id: 'a2', name: 'A two', [tenantField]: ORG_A },
@@ -244,7 +252,7 @@ describe('#16589 — the in-memory driver honours DriverOptions.tenantId', () =>
244252
// AST arm — what objectql's engine sends.
245253
const ast = await driver.aggregate(
246254
object,
247-
{ aggregations: [{ function: 'count', field: 'id', alias: 'n' }] } as any,
255+
{ aggregations: [{ function: 'count', field: 'id', alias: 'n' }] } satisfies DriverQuery,
248256
{ tenantId: ORG_A },
249257
);
250258
expect(ast[0]?.n).toBe(3);

0 commit comments

Comments
 (0)