From 33a423dc852314f48ba8f35752c87c5eb01234e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:58:20 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(driver-mongodb)!:=20=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E5=8D=95=E7=A7=9F=E6=88=B7=20=E2=80=94=E2=80=94=20=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E5=88=B0=E5=A4=9A=E7=A7=9F=E6=88=B7=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=8D=B3=E6=8B=92=E7=BB=9D=E5=90=AF=E5=8A=A8=20(#3724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MongoDBDriver` has NO row-level tenant isolation: it never reads `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are never stamped with a tenant column. The layer the SQL driver has (`resolveTenantField` + `applyTenantScope`) does not exist here, while everything above the driver — the object metadata `tenancy` block, `applySystemFields` injecting `organization_id`, the engine threading `tenantId` into every driver call — runs on the assumption that tenant isolation is a platform guarantee. Point a multi-tenant deployment's datasource at Mongo and every query read, updated and deleted OTHER tenants' documents, silently. This is option B of #3724: rather than gamble that Mongo multi-tenancy is really wanted, declare the driver single-tenant and fail fast. - `assertSingleTenantPosture()` refuses any tenancy posture other than `single` — `OS_TENANCY_POSTURE=group|isolated`, including the posture derived from `OS_MULTI_ORG_ENABLED=true` — resolved through the shared `resolveTenancyPosture()` (ADR-0105 D1) so the driver can never disagree with auth / the registry / the CLI about the mode. - It runs in the **constructor**, not only in `connect()`, because `ObjectQLEngine.init()` catches a driver's connect rejection and boots anyway ("may recover via lazy reconnection"). A connect-only guard would have degraded "refuses to start" into "starts, then throws at query time". `connect()` re-checks in case a host flips the posture in between. - `assertObjectsNotTenantScoped()` refuses objects declaring `tenancy.enabled: true` at `syncSchema` / `syncSchemasBatch`, naming every offender in one message. Both throw `MongoDBMultiTenantUnsupportedError` with `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`; the message names the detected signal, the remedy, and `@objectstack/driver-sql` as the multi-tenant option. `serve.ts` swallowed every driver-registration error except `UnsupportedDriverError`, which would have buried this one and booted with no driver at all — it now re-throws the coded error too (duck-typed by `code`, so the CLI keeps no dependency on the driver package) and exits 1. There is deliberately NO override env var: an escape hatch would restore exactly the silent non-isolation this removes. Single-tenant deployments — every currently-working Mongo deployment — are untouched; the real-Mongo integration suite passes unchanged. Also closes the loop on the `unique` index comment left by #3717: a single-field unique index is now correct by construction here, not by omission. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Hk4hSCirLNWLkgkNkj8YT --- .../mongodb-single-tenant-boot-guard.md | 48 +++++ content/docs/data-modeling/drivers.mdx | 24 ++- content/docs/deployment/self-hosting.mdx | 2 +- packages/cli/src/commands/serve.ts | 8 + packages/plugins/driver-mongodb/README.md | 43 ++++ packages/plugins/driver-mongodb/package.json | 1 + packages/plugins/driver-mongodb/src/index.ts | 7 + .../driver-mongodb/src/mongodb-driver.ts | 34 ++++ .../driver-mongodb/src/mongodb-schema.ts | 10 +- .../src/mongodb-tenancy-guard.test.ts | 187 ++++++++++++++++++ .../src/mongodb-tenancy-guard.ts | 139 +++++++++++++ pnpm-lock.yaml | 3 + 12 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 .changeset/mongodb-single-tenant-boot-guard.md create mode 100644 packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts create mode 100644 packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts diff --git a/.changeset/mongodb-single-tenant-boot-guard.md b/.changeset/mongodb-single-tenant-boot-guard.md new file mode 100644 index 0000000000..222246dadb --- /dev/null +++ b/.changeset/mongodb-single-tenant-boot-guard.md @@ -0,0 +1,48 @@ +--- +"@objectstack/driver-mongodb": minor +"@objectstack/cli": patch +--- + +feat(driver-mongodb)!: declare the driver single-tenant and refuse to boot multi-tenant (#3724) + +`MongoDBDriver` 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 driver has (`resolveTenantField` ++ `applyTenantScope`) simply does not exist here, while everything above the +driver — object metadata's `tenancy` block, `applySystemFields` injecting +`organization_id`, the engine threading `tenantId` into every driver call — +operates on the assumption that tenant isolation is a platform guarantee. Point +a multi-tenant deployment's datasource at Mongo and every query read, updated +and deleted other tenants' documents, silently. + +Rather than serve unisolated, the driver now fails fast at startup: + +- The **constructor** and `connect()` call `assertSingleTenantPosture()`, which + refuses any tenancy posture other than `single` (`OS_TENANCY_POSTURE=group` / + `isolated`, including the posture derived from `OS_MULTI_ORG_ENABLED=true`), + resolved through the shared `resolveTenancyPosture()` so the driver can never + disagree with auth / the registry / the CLI about the mode. The check sits in + the constructor and not only in `connect()` because `ObjectQLEngine.init()` + *catches* a driver's connect rejection and boots anyway — a connect-only guard + would have degraded "refuses to start" into "starts, then throws at query + time". `connect()` re-checks in case a host flips the posture in between. +- `syncSchema()` / `syncSchemasBatch()` call `assertObjectsNotTenantScoped()` and + refuse objects declaring `tenancy.enabled: true`, naming every offender in one + message. +- `objectstack serve` / `dev` (CLI) now re-throw this error out of the + auto-driver-registration block instead of swallowing it, so boot exits 1 with + the actionable message — the same treatment `UnsupportedDriverError` already + gets. Matched duck-typed by `code`, so the CLI takes no dependency on the + driver package. + +Both throw `MongoDBMultiTenantUnsupportedError` with +`code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected +signal, the remedy, and `@objectstack/driver-sql` 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 — +every currently-working Mongo deployment — are unaffected. + +This is option B of #3724. Implementing real row-level isolation (option A) +remains open; the `unique` index shape stays single-field until then, which is +now correct by construction rather than by omission. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 1819f57c97..6b1d7cbe9b 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -62,7 +62,7 @@ actually connect to Turso. | **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` | | **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` | | **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` | -| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` | +| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) | | **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | `memory` | > All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single @@ -139,6 +139,28 @@ The CLI infers the MongoDB driver from the `mongodb://` (or `mongodb+srv://`) URL scheme automatically — no `OS_DATABASE_DRIVER` needed. Set `OS_DATABASE_DRIVER=mongodb` only if you want to be explicit. +### Multi-tenancy: not supported + + +The MongoDB driver is **single-tenant only**. It implements no row-level tenant +isolation — unlike `SqlDriver`, it ignores `DriverOptions.tenantId`, so reads +carry no tenant predicate and writes are not stamped with a tenant column. + + +Rather than serve a multi-tenant deployment without isolation, the driver +**refuses to start** in one ([#3724](https://github.com/objectstack-ai/objectstack/issues/3724)): + +| Signal | Checked in | Result | +| :--- | :--- | :--- | +| Tenancy posture is not `single` — `OS_TENANCY_POSTURE=group`/`isolated`, or derived from `OS_MULTI_ORG_ENABLED=true` | `new MongoDBDriver()`, re-checked in `connect()` | throws `MongoDBMultiTenantUnsupportedError`; `objectstack serve` exits 1 | +| An object declares `tenancy.enabled: true` | `syncSchema()` / `syncSchemasBatch()` | throws, naming every offending object | + +The error carries `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`. There is no +override flag — one would restore exactly the silent cross-tenant access the +guard prevents. For multi-tenant deployments use `@objectstack/driver-sql` +(PostgreSQL / MySQL / SQLite), which enforces tenant scoping at the driver +level. + ## SQLite (via `@objectstack/driver-sql`) SQLite is ideal for local-first development and embedded applications. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 4d11e9eb81..7331466bc7 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -32,7 +32,7 @@ workable default: | Variable | Why it must be set | |:---|:---| -| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** supported by the open framework — that driver ships in ObjectStack Cloud). | +| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** supported by the open framework — that driver ships in ObjectStack Cloud). `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). | | `OS_AUTH_SECRET` | Session secret for the auth plugin (`AUTH_SECRET` is the legacy alias). Without it, `/api/v1/auth/*` is **silently skipped** — the server runs unauthenticated. | | `OS_SECRET_KEY` | 32-byte master key encrypting every stored secret (`openssl rand -hex 32`). On a container's ephemeral filesystem the auto-minted key is **lost on restart**, making previously-encrypted secrets undecryptable. | | `OS_PORT` | `os start` **fails loudly** if the port is busy (it never auto-shifts like `os dev`). Pin it and keep your reverse-proxy upstream in sync. | diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index bf2be31696..eef867ff08 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -939,6 +939,14 @@ export default class Serve extends Command { // actionable message, and exits 1 (in dev AND prod). All OTHER driver // construction errors keep the prior best-effort silent behavior. if (e instanceof UnsupportedDriverError) throw e; + // Same class of fatal (#3724): a driver that refuses to run in this + // deployment's tenancy mode — driver-mongodb has no row-level tenant + // isolation and rejects a non-`single` posture. Swallowing it would + // boot the server with NO driver at all, burying "your database + // cannot isolate tenants" under a later, unrelated failure. Matched + // by `code` (duck-typed) so the CLI keeps no dependency on the + // driver package and cross-realm `instanceof` can't bite. + if (e?.code === 'MONGODB_MULTI_TENANT_UNSUPPORTED') throw e; // silent } } diff --git a/packages/plugins/driver-mongodb/README.md b/packages/plugins/driver-mongodb/README.md index 6f5a643ffa..a59598376c 100644 --- a/packages/plugins/driver-mongodb/README.md +++ b/packages/plugins/driver-mongodb/README.md @@ -2,6 +2,16 @@ MongoDB driver for ObjectStack — native document database support via the official MongoDB Node.js driver. +> ### ⚠️ Single-tenant only +> +> This driver has **no row-level tenant isolation**: it ignores +> `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not +> stamped with a tenant column. Rather than serve a multi-tenant deployment +> without isolation, it **refuses to start** in one — see +> [Multi-tenancy](#multi-tenancy) below. Use +> [`@objectstack/driver-sql`](../driver-sql) (PostgreSQL / MySQL / SQLite) for +> multi-tenant deployments. + ## Installation ```bash @@ -66,6 +76,39 @@ export default defineStack({ | **Advanced** | Full-text search, JSON queries, geospatial | ✅ | | **Joins** | Cross-collection joins ($lookup) | ❌ | | **Window Functions** | ROW_NUMBER, RANK, etc. | ❌ | +| **Multi-tenancy** | Row-level tenant isolation | ❌ (boots single-tenant only) | + +### Multi-tenancy + +The driver is a **single-tenant / embedded** driver. Unlike +`@objectstack/driver-sql` — which resolves a tenant column per object, injects a +`WHERE tenant_field = ?` predicate on reads and stamps that column on writes — +this driver implements none of that layer. In a multi-tenant deployment every +query would read, update and delete other tenants' documents. + +So instead of running unisolated, it fails fast at startup +([#3724](https://github.com/objectstack-ai/objectstack/issues/3724)): + +| Signal | Where it is checked | Result | +|--------|--------------------|--------| +| Tenancy posture is not `single` — `OS_TENANCY_POSTURE=group\|isolated`, or derived from `OS_MULTI_ORG_ENABLED=true` | `new MongoDBDriver()`, re-checked in `connect()` before a socket is opened | throws `MongoDBMultiTenantUnsupportedError` | +| An object declares `tenancy.enabled: true` | `syncSchema()` / `syncSchemasBatch()` | throws, naming every offending object | + +The error carries `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'` so a host can +recognise it without matching on the message: + +```typescript +import { + MongoDBMultiTenantUnsupportedError, + MULTI_TENANT_UNSUPPORTED_CODE, +} from '@objectstack/driver-mongodb'; +``` + +There is deliberately **no override flag** — one would restore exactly the +silent cross-tenant access the guard exists to prevent. To run MongoDB, keep the +deployment single-tenant (`OS_TENANCY_POSTURE=single` or unset, `OS_MULTI_ORG_ENABLED` +unset or `false`, no object declaring `tenancy.enabled: true`); to go +multi-tenant, switch to `@objectstack/driver-sql`. ### ID Handling diff --git a/packages/plugins/driver-mongodb/package.json b/packages/plugins/driver-mongodb/package.json index 4a8ea50799..409123d743 100644 --- a/packages/plugins/driver-mongodb/package.json +++ b/packages/plugins/driver-mongodb/package.json @@ -20,6 +20,7 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "mongodb": "^7.5.0", "nanoid": "^6.0.0" }, diff --git a/packages/plugins/driver-mongodb/src/index.ts b/packages/plugins/driver-mongodb/src/index.ts index 15f0122bfd..06b9cc2890 100644 --- a/packages/plugins/driver-mongodb/src/index.ts +++ b/packages/plugins/driver-mongodb/src/index.ts @@ -7,6 +7,13 @@ export type { MongoDBDriverConfig } from './mongodb-driver.js'; export { translateFilter } from './mongodb-filter.js'; export { buildAggregationPipeline, postProcessAggregation } from './mongodb-aggregation.js'; export type { AggregationInput } from './mongodb-aggregation.js'; +export { + MongoDBMultiTenantUnsupportedError, + MULTI_TENANT_UNSUPPORTED_CODE, + assertSingleTenantPosture, + assertObjectsNotTenantScoped, + declaresTenantScope, +} from './mongodb-tenancy-guard.js'; export default { id: 'com.objectstack.driver.mongodb', diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.ts b/packages/plugins/driver-mongodb/src/mongodb-driver.ts index 67ecc4338a..b85d0ed48e 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-driver.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-driver.ts @@ -27,6 +27,10 @@ import { postProcessAggregation, } from './mongodb-aggregation.js'; import { syncCollectionSchema, dropCollection } from './mongodb-schema.js'; +import { + assertSingleTenantPosture, + assertObjectsNotTenantScoped, +} from './mongodb-tenancy-guard.js'; const DEFAULT_ID_LENGTH = 16; @@ -59,6 +63,14 @@ export interface MongoDBDriverConfig { * * Implements the IDataDriver contract via the official MongoDB driver. * Uses native MongoDB queries, aggregation pipelines, and transactions. + * + * **Single-tenant only (#3724).** This driver implements no row-level tenant + * isolation — it ignores `DriverOptions.tenantId`, so reads carry no tenant + * predicate and writes are not stamped with a tenant column. Rather than serve + * a multi-tenant deployment unisolated, it **refuses to start** in one: see + * `mongodb-tenancy-guard.ts`, wired into the constructor + {@link connect} + * (deployment posture) and {@link syncSchema} / {@link syncSchemasBatch} + * (object metadata). */ export class MongoDBDriver implements IDataDriver { public readonly name: string = 'com.objectstack.driver.mongodb'; @@ -116,6 +128,14 @@ export class MongoDBDriver implements IDataDriver { private config: MongoDBDriverConfig; constructor(config: MongoDBDriverConfig) { + // Refuse to even EXIST in a multi-tenant deployment (#3724). The check is + // repeated in `connect()`, but construction is the only seam guaranteed to + // fail loudly: `ObjectQLEngine.init()` catches a driver's connect rejection + // and logs it, then boots anyway ("may recover via lazy reconnection"), so + // a connect-only guard would degrade from "refuses to start" to "starts, + // then throws at query time". + assertSingleTenantPosture(); + this.config = config; const clientOptions: MongoClientOptions = { maxPoolSize: config.maxPoolSize ?? 10, @@ -132,6 +152,12 @@ export class MongoDBDriver implements IDataDriver { // =========================================================================== async connect(): Promise { + // Fail before a socket is opened: this driver cannot isolate tenants, so a + // multi-tenant deployment must never get a usable connection out of it. + // Re-checked here (not just in the constructor) because a host may flip the + // posture between construction and boot. + assertSingleTenantPosture(); + await this.client.connect(); const dbName = this.config.database || this.extractDatabaseName(this.config.url); this.db = this.client.db(dbName); @@ -498,11 +524,19 @@ export class MongoDBDriver implements IDataDriver { // =========================================================================== async syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise { + // An object asking for row-level tenant isolation gets none here (#3724) — + // refuse rather than materialise a collection that silently mixes tenants. + assertObjectsNotTenantScoped([{ object, schema }]); + const objectDef = schema as { name: string; fields?: Record }; await syncCollectionSchema(this.db, object, objectDef); } async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise { + // Pre-scan so the failure names every tenant-scoped object at once instead + // of surfacing them one boot at a time. + assertObjectsNotTenantScoped(schemas); + for (const { object, schema } of schemas) { await this.syncSchema(object, schema, options); } diff --git a/packages/plugins/driver-mongodb/src/mongodb-schema.ts b/packages/plugins/driver-mongodb/src/mongodb-schema.ts index c526468a7c..0f1a7a2e97 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-schema.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-schema.ts @@ -23,8 +23,14 @@ interface FieldDef { * tenant stamp on write), so there is no tenant column to compose with. A * `(tenant, field)` index would advertise an isolation this driver does not * deliver — worse than the single-field index, because it would read as - * fixed. The tenancy gap itself is tracked separately; when it lands, this - * must adopt the same scoping rule as `SqlDriver.uniqueIndexesFromFields`. + * fixed. + * + * Settled by #3724: the driver is now explicitly single-tenant and refuses to + * boot into a multi-tenant deployment (see `mongodb-tenancy-guard.ts`), so a + * single-field unique index is exactly right — there is no second tenant for + * it to over-constrain. Should row-level tenancy ever land here (option A of + * that issue), this must adopt the same scoping rule as + * `SqlDriver.uniqueIndexesFromFields`. */ unique?: boolean | 'global'; indexed?: boolean; diff --git a/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts new file mode 100644 index 0000000000..f9ea6990dd --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Multi-tenancy boot guard (#3724). + * + * These tests need no MongoDB binary: the guard is pure (env flag + object + * metadata), and the driver-level assertions exercise `connect()` / schema sync + * on an unreachable URL — the refusal must happen *before* any I/O. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + assertSingleTenantPosture, + assertObjectsNotTenantScoped, + declaresTenantScope, + MongoDBMultiTenantUnsupportedError, + MULTI_TENANT_UNSUPPORTED_CODE, +} from './mongodb-tenancy-guard.js'; +import { MongoDBDriver } from './mongodb-driver.js'; + +const ORIGINAL_MULTI_ORG = process.env.OS_MULTI_ORG_ENABLED; +const ORIGINAL_POSTURE = process.env.OS_TENANCY_POSTURE; + +/** A URL nothing listens on — reaching the network at all fails these tests. */ +const UNREACHABLE_URL = 'mongodb://127.0.0.1:1/never_connected'; + +function makeDriver() { + return new MongoDBDriver({ + url: UNREACHABLE_URL, + database: 'guard_test', + serverSelectionTimeoutMS: 50, + connectTimeoutMS: 50, + }); +} + +describe('multi-tenancy boot guard (#3724)', () => { + 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(MongoDBMultiTenantUnsupportedError); + 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('3724'); + } + }); + + it('treats any non-`false` value as enabled (matches resolveMultiOrgEnabled)', () => { + process.env.OS_MULTI_ORG_ENABLED = '1'; + expect(() => assertSingleTenantPosture()).toThrow(MongoDBMultiTenantUnsupportedError); + }); + + // 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(MongoDBMultiTenantUnsupportedError); + // `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`'); + } + }); + }); + + describe('MongoDBDriver wiring', () => { + it('the constructor refuses in multi-tenant mode', () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + // Construction is the seam that fails loudly: ObjectQLEngine.init() + // catches a connect() rejection and boots anyway. + expect(() => makeDriver()).toThrow(MongoDBMultiTenantUnsupportedError); + }); + + it('connect() refuses without opening a connection when the posture flips after construction', async () => { + const driver = makeDriver(); // built single-tenant + process.env.OS_TENANCY_POSTURE = 'isolated'; + // An unreachable URL would surface a MongoServerSelectionError if the + // guard let the connection attempt through — the coded error proves the + // refusal happens first. + await expect(driver.connect()).rejects.toThrow(MongoDBMultiTenantUnsupportedError); + }); + + it('connect() is not gated when the deployment is single-tenant', async () => { + const driver = makeDriver(); + // Still fails — the server is unreachable — but NOT with the tenancy guard. + await expect(driver.connect()).rejects.not.toBeInstanceOf( + MongoDBMultiTenantUnsupportedError, + ); + }); + + it('syncSchema() refuses a tenant-scoped object before touching the db', async () => { + const driver = makeDriver(); + await expect( + driver.syncSchema('account', { name: 'account', tenancy: { enabled: true } }), + ).rejects.toThrow(MongoDBMultiTenantUnsupportedError); + }); + + it('syncSchemasBatch() refuses when any object is tenant-scoped', async () => { + const driver = makeDriver(); + await expect( + driver.syncSchemasBatch([ + { object: 'task', schema: { name: 'task' } }, + { object: 'account', schema: { name: 'account', tenancy: { enabled: true } } }, + ]), + ).rejects.toThrow(MongoDBMultiTenantUnsupportedError); + }); + }); +}); diff --git a/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts new file mode 100644 index 0000000000..233c0a9309 --- /dev/null +++ b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MongoDB Driver — multi-tenancy boot guard (#3724) + * + * This driver implements **no row-level tenant isolation**: it never reads + * `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are + * never stamped with a tenant column. The SQL driver's `resolveTenantField()` + * + `applyTenantScope()` layer simply does not exist here. + * + * The platform 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). + * Booting this driver into a multi-tenant deployment therefore produces + * **silent** cross-tenant reads, updates and deletes — the exact + * "declared ≠ enforced" shape Prime Directive #10 forbids. + * + * So the driver refuses to run there. It is positioned as a **single-tenant / + * embedded** driver and fails fast — loudly, at startup — the moment it detects + * multi-tenant mode: + * + * 1. The deployment's tenancy posture is not `single` (deployment-level signal) + * → {@link assertSingleTenantPosture}, called from the `MongoDBDriver` + * **constructor** and re-checked in `connect()` before a socket is opened. + * The constructor is the seam that fails loudly: `ObjectQLEngine.init()` + * catches a driver's connect rejection and boots anyway, so a connect-only + * guard would degrade into a query-time failure. + * 2. An object declares `tenancy.enabled: true` (metadata-level signal) → + * {@link assertObjectsNotTenantScoped}, called from the `syncSchema` / + * `syncSchemasBatch` paths. + * + * There is deliberately **no escape-hatch env var**: an override would restore + * exactly the silent non-isolation this guard exists to remove. Multi-tenant + * deployments use `@objectstack/driver-sql`, which implements driver-level + * tenant scoping. When real demand for Mongo multi-tenancy appears, the fix is + * to implement the isolation (option A in #3724) — not to weaken this gate. + */ + +import { resolveTenancyPosture } from '@objectstack/types'; + +/** Stable, matchable error code for the boot refusal. */ +export const MULTI_TENANT_UNSUPPORTED_CODE = 'MONGODB_MULTI_TENANT_UNSUPPORTED'; + +const ISSUE_URL = 'https://github.com/objectstack-ai/objectstack/issues/3724'; + +/** + * Thrown when the MongoDB driver is asked to run in a multi-tenant deployment. + * + * Carries {@link MULTI_TENANT_UNSUPPORTED_CODE} as `code` so hosts (CLI boot, + * runtime plugin loader, tests) can recognise it without string-matching the + * message or relying on cross-realm `instanceof`. + */ +export class MongoDBMultiTenantUnsupportedError extends Error { + public readonly code = MULTI_TENANT_UNSUPPORTED_CODE; + + constructor(detected: string, remedy: string) { + super( + `[driver-mongodb] Refusing to start: this driver has NO row-level tenant isolation.\n` + + `\n` + + ` Detected: ${detected}\n` + + `\n` + + ` MongoDBDriver never reads \`DriverOptions.tenantId\` — reads carry no tenant\n` + + ` predicate and writes are not stamped with a tenant column, so queries would\n` + + ` read, update and delete OTHER tenants' documents. Rather than run unisolated,\n` + + ` the driver fails at startup.\n` + + `\n` + + ` Fix one of:\n` + + ` • Use @objectstack/driver-sql (PostgreSQL / MySQL / SQLite) for multi-tenant\n` + + ` deployments — it enforces tenant scoping at the driver level.\n` + + ` ${remedy}\n` + + `\n` + + ` Tracking: ${ISSUE_URL}`, + ); + this.name = 'MongoDBMultiTenantUnsupportedError'; + } +} + +/** Minimal shape of an object definition this guard inspects. */ +export interface TenancyAwareSchema { + tenancy?: { enabled?: boolean } | null; +} + +/** + * Whether an object definition asks for row-level tenant isolation. + * + * Only an **explicit** `tenancy.enabled === true` counts. An absent `tenancy` + * block is not treated as a multi-tenant signal here: platform-wide tenant + * scoping is driven by the deployment flag (checked separately by + * {@link assertMultiOrgDisabled}), and every object in a single-tenant + * deployment omits the block. + */ +export function declaresTenantScope(schema: unknown): boolean { + return (schema as TenancyAwareSchema | null | undefined)?.tenancy?.enabled === true; +} + +/** + * Refuse to run unless the deployment's tenancy posture is `single`. + * + * Reads the posture through 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. + */ +export function assertSingleTenantPosture(): void { + const posture = resolveTenancyPosture(); + if (posture === 'single') return; + throw new MongoDBMultiTenantUnsupportedError( + `tenancy posture \`${posture}\` — a multi-tenant deployment ` + + '(from `OS_TENANCY_POSTURE`, or derived from `OS_MULTI_ORG_ENABLED`)', + '• Run this deployment single-tenant: `OS_TENANCY_POSTURE=single` (and unset\n' + + ' `OS_MULTI_ORG_ENABLED`, or set it to `false`).', + ); +} + +/** + * Refuse to sync object schemas that declare row-level tenant isolation. + * + * Reports **every** offending object in one message so an operator fixes the + * whole set in one pass instead of rediscovering them one boot at a time. + */ +export function assertObjectsNotTenantScoped( + schemas: Array<{ object: string; schema: unknown }>, +): void { + const offenders = schemas + .filter(({ schema }) => declaresTenantScope(schema)) + .map(({ object }) => object); + + if (offenders.length === 0) return; + + const list = offenders.map((name) => `\`${name}\``).join(', '); + throw new MongoDBMultiTenantUnsupportedError( + `object${offenders.length > 1 ? 's' : ''} declaring \`tenancy.enabled: true\`: ${list}`, + '• Drop the `tenancy` block from ' + + (offenders.length > 1 ? 'these objects' : 'this object') + + ' if the data is genuinely single-tenant.', + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13d1025287..38c1ab908a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1076,6 +1076,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types mongodb: specifier: ^7.5.0 version: 7.5.0(socks@2.8.9) From a26651492b7f8bcb32ca97ebdb0bea71dd367171 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:00:28 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(plugins):=20=E6=A0=87=E6=B3=A8=20drive?= =?UTF-8?q?r-mongodb=20=E4=BB=85=E6=94=AF=E6=8C=81=E5=8D=95=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=20(#3724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package catalog's "When to use" line advertised the Mongo driver for any "existing MongoDB infrastructure" with no mention that it cannot isolate tenants and now refuses to boot outside a `single` tenancy posture. Flagged by the docs-drift check on this PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Hk4hSCirLNWLkgkNkj8YT --- content/docs/plugins/packages.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 1ae906ed16..94a676beac 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -185,7 +185,8 @@ const driver = new SqliteWasmDriver({ filename: ':memory:' }); **MongoDB Driver** — Document-oriented storage backend. - **Purpose**: Native MongoDB driver for ObjectQL with document-flavored objects -- **When to use**: Existing MongoDB infrastructure, document-shaped data +- **When to use**: Existing MongoDB infrastructure, document-shaped data — **single-tenant deployments only** +- **Not supported**: row-level tenant isolation. The driver refuses to boot when the tenancy posture is not `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported) - **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-mongodb/README.md) ---