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/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) --- 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)