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
48 changes: 48 additions & 0 deletions .changeset/mongodb-single-tenant-boot-guard.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 23 additions & 1 deletion content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

<Callout type="warn">
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.
</Callout>

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.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/.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 `<cwd>/.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. |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
43 changes: 43 additions & 0 deletions packages/plugins/driver-mongodb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/driver-mongodb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"mongodb": "^7.5.0",
"nanoid": "^6.0.0"
},
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/driver-mongodb/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
34 changes: 34 additions & 0 deletions packages/plugins/driver-mongodb/src/mongodb-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -132,6 +152,12 @@ export class MongoDBDriver implements IDataDriver {
// ===========================================================================

async connect(): Promise<void> {
// 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);
Expand Down Expand Up @@ -498,11 +524,19 @@ export class MongoDBDriver implements IDataDriver {
// ===========================================================================

async syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise<void> {
// 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<string, any> };
await syncCollectionSchema(this.db, object, objectDef);
}

async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise<void> {
// 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);
}
Expand Down
10 changes: 8 additions & 2 deletions packages/plugins/driver-mongodb/src/mongodb-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading