Skip to content

Commit d1557d9

Browse files
authored
feat(driver-mongodb)!: 显式单租户 —— 检测到多租户模式即拒绝启动 (#3724) (#3734)
`MongoDBDriver` 没有任何行级租户隔离:从不读 `DriverOptions.tenantId`,读路径不加租户谓词,写路径不打租户戳。多租户部署把 datasource 指向 Mongo 时,每个查询都能静默地读/改/删别的租户的文档。 采用 #3724 的方案 B —— 声明为单租户 driver 并硬失败: - `assertSingleTenantPosture()` 拒绝任何非 `single` 的租户 posture(`OS_TENANCY_POSTURE=group|isolated`,含由 `OS_MULTI_ORG_ENABLED=true` 推导出的),经共享的 `resolveTenancyPosture()`(ADR-0105 D1)解析。 - 守卫放在**构造函数**而不只是 `connect()`:`ObjectQLEngine.init()` 会 catch driver 的 connect 失败并照常启动,只放 connect 会把"拒绝启动"退化成"查询时炸"(该行为本身已另行跟踪:#3741)。 - `assertObjectsNotTenantScoped()` 在 `syncSchema` / `syncSchemasBatch` 拒绝声明 `tenancy.enabled: true` 的对象,一次列出全部违规项。 - CLI `serve.ts` 不再吞掉这个错误(按 `code` duck-type 匹配),启动 exit 1。 刻意不提供 override 环境变量:逃生阀会把这次消除的静默不隔离原样放回来。单租户部署不受影响。
1 parent 5e55739 commit d1557d9

13 files changed

Lines changed: 504 additions & 5 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/driver-mongodb": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(driver-mongodb)!: declare the driver single-tenant and refuse to boot multi-tenant (#3724)
7+
8+
`MongoDBDriver` implements **no row-level tenant isolation** — it never reads
9+
`DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not
10+
stamped with a tenant column. The layer the SQL driver has (`resolveTenantField`
11+
+ `applyTenantScope`) simply does not exist here, while everything above the
12+
driver — object metadata's `tenancy` block, `applySystemFields` injecting
13+
`organization_id`, the engine threading `tenantId` into every driver call —
14+
operates on the assumption that tenant isolation is a platform guarantee. Point
15+
a multi-tenant deployment's datasource at Mongo and every query read, updated
16+
and deleted other tenants' documents, silently.
17+
18+
Rather than serve unisolated, the driver now fails fast at startup:
19+
20+
- The **constructor** and `connect()` call `assertSingleTenantPosture()`, which
21+
refuses any tenancy posture other than `single` (`OS_TENANCY_POSTURE=group` /
22+
`isolated`, including the posture derived from `OS_MULTI_ORG_ENABLED=true`),
23+
resolved through the shared `resolveTenancyPosture()` so the driver can never
24+
disagree with auth / the registry / the CLI about the mode. The check sits in
25+
the constructor and not only in `connect()` because `ObjectQLEngine.init()`
26+
*catches* a driver's connect rejection and boots anyway — a connect-only guard
27+
would have degraded "refuses to start" into "starts, then throws at query
28+
time". `connect()` re-checks in case a host flips the posture in between.
29+
- `syncSchema()` / `syncSchemasBatch()` call `assertObjectsNotTenantScoped()` and
30+
refuse objects declaring `tenancy.enabled: true`, naming every offender in one
31+
message.
32+
- `objectstack serve` / `dev` (CLI) now re-throw this error out of the
33+
auto-driver-registration block instead of swallowing it, so boot exits 1 with
34+
the actionable message — the same treatment `UnsupportedDriverError` already
35+
gets. Matched duck-typed by `code`, so the CLI takes no dependency on the
36+
driver package.
37+
38+
Both throw `MongoDBMultiTenantUnsupportedError` with
39+
`code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected
40+
signal, the remedy, and `@objectstack/driver-sql` as the multi-tenant option.
41+
42+
There is deliberately **no override env var**: an escape hatch would restore
43+
exactly the silent non-isolation this guard removes. Single-tenant deployments —
44+
every currently-working Mongo deployment — are unaffected.
45+
46+
This is option B of #3724. Implementing real row-level isolation (option A)
47+
remains open; the `unique` index shape stays single-field until then, which is
48+
now correct by construction rather than by omission.

content/docs/data-modeling/drivers.mdx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ actually connect to Turso.
6262
| **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` |
6363
| **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` |
6464
| **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` |
65-
| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` |
65+
| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) |
6666
| **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | `memory` |
6767

6868
> 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://`)
139139
URL scheme automatically — no `OS_DATABASE_DRIVER` needed. Set
140140
`OS_DATABASE_DRIVER=mongodb` only if you want to be explicit.
141141

142+
### Multi-tenancy: not supported
143+
144+
<Callout type="warn">
145+
The MongoDB driver is **single-tenant only**. It implements no row-level tenant
146+
isolation — unlike `SqlDriver`, it ignores `DriverOptions.tenantId`, so reads
147+
carry no tenant predicate and writes are not stamped with a tenant column.
148+
</Callout>
149+
150+
Rather than serve a multi-tenant deployment without isolation, the driver
151+
**refuses to start** in one ([#3724](https://github.com/objectstack-ai/objectstack/issues/3724)):
152+
153+
| Signal | Checked in | Result |
154+
| :--- | :--- | :--- |
155+
| 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 |
156+
| An object declares `tenancy.enabled: true` | `syncSchema()` / `syncSchemasBatch()` | throws, naming every offending object |
157+
158+
The error carries `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`. There is no
159+
override flag — one would restore exactly the silent cross-tenant access the
160+
guard prevents. For multi-tenant deployments use `@objectstack/driver-sql`
161+
(PostgreSQL / MySQL / SQLite), which enforces tenant scoping at the driver
162+
level.
163+
142164
## SQLite (via `@objectstack/driver-sql`)
143165

144166
SQLite is ideal for local-first development and embedded applications.

content/docs/deployment/self-hosting.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ workable default:
3232

3333
| Variable | Why it must be set |
3434
|:---|:---|
35-
| `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). |
35+
| `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). |
3636
| `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. |
3737
| `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. |
3838
| `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. |

content/docs/plugins/packages.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ const driver = new SqliteWasmDriver({ filename: ':memory:' });
185185
**MongoDB Driver** — Document-oriented storage backend.
186186

187187
- **Purpose**: Native MongoDB driver for ObjectQL with document-flavored objects
188-
- **When to use**: Existing MongoDB infrastructure, document-shaped data
188+
- **When to use**: Existing MongoDB infrastructure, document-shaped data — **single-tenant deployments only**
189+
- **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)
189190
- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-mongodb/README.md)
190191

191192
---

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,14 @@ export default class Serve extends Command {
939939
// actionable message, and exits 1 (in dev AND prod). All OTHER driver
940940
// construction errors keep the prior best-effort silent behavior.
941941
if (e instanceof UnsupportedDriverError) throw e;
942+
// Same class of fatal (#3724): a driver that refuses to run in this
943+
// deployment's tenancy mode — driver-mongodb has no row-level tenant
944+
// isolation and rejects a non-`single` posture. Swallowing it would
945+
// boot the server with NO driver at all, burying "your database
946+
// cannot isolate tenants" under a later, unrelated failure. Matched
947+
// by `code` (duck-typed) so the CLI keeps no dependency on the
948+
// driver package and cross-realm `instanceof` can't bite.
949+
if (e?.code === 'MONGODB_MULTI_TENANT_UNSUPPORTED') throw e;
942950
// silent
943951
}
944952
}

packages/plugins/driver-mongodb/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
MongoDB driver for ObjectStack — native document database support via the official MongoDB Node.js driver.
44

5+
> ### ⚠️ Single-tenant only
6+
>
7+
> This driver has **no row-level tenant isolation**: it ignores
8+
> `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not
9+
> stamped with a tenant column. Rather than serve a multi-tenant deployment
10+
> without isolation, it **refuses to start** in one — see
11+
> [Multi-tenancy](#multi-tenancy) below. Use
12+
> [`@objectstack/driver-sql`](../driver-sql) (PostgreSQL / MySQL / SQLite) for
13+
> multi-tenant deployments.
14+
515
## Installation
616

717
```bash
@@ -66,6 +76,39 @@ export default defineStack({
6676
| **Advanced** | Full-text search, JSON queries, geospatial ||
6777
| **Joins** | Cross-collection joins ($lookup) ||
6878
| **Window Functions** | ROW_NUMBER, RANK, etc. ||
79+
| **Multi-tenancy** | Row-level tenant isolation | ❌ (boots single-tenant only) |
80+
81+
### Multi-tenancy
82+
83+
The driver is a **single-tenant / embedded** driver. Unlike
84+
`@objectstack/driver-sql` — which resolves a tenant column per object, injects a
85+
`WHERE tenant_field = ?` predicate on reads and stamps that column on writes —
86+
this driver implements none of that layer. In a multi-tenant deployment every
87+
query would read, update and delete other tenants' documents.
88+
89+
So instead of running unisolated, it fails fast at startup
90+
([#3724](https://github.com/objectstack-ai/objectstack/issues/3724)):
91+
92+
| Signal | Where it is checked | Result |
93+
|--------|--------------------|--------|
94+
| 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` |
95+
| An object declares `tenancy.enabled: true` | `syncSchema()` / `syncSchemasBatch()` | throws, naming every offending object |
96+
97+
The error carries `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'` so a host can
98+
recognise it without matching on the message:
99+
100+
```typescript
101+
import {
102+
MongoDBMultiTenantUnsupportedError,
103+
MULTI_TENANT_UNSUPPORTED_CODE,
104+
} from '@objectstack/driver-mongodb';
105+
```
106+
107+
There is deliberately **no override flag** — one would restore exactly the
108+
silent cross-tenant access the guard exists to prevent. To run MongoDB, keep the
109+
deployment single-tenant (`OS_TENANCY_POSTURE=single` or unset, `OS_MULTI_ORG_ENABLED`
110+
unset or `false`, no object declaring `tenancy.enabled: true`); to go
111+
multi-tenant, switch to `@objectstack/driver-sql`.
69112

70113
### ID Handling
71114

packages/plugins/driver-mongodb/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"dependencies": {
2121
"@objectstack/core": "workspace:*",
2222
"@objectstack/spec": "workspace:*",
23+
"@objectstack/types": "workspace:*",
2324
"mongodb": "^7.5.0",
2425
"nanoid": "^6.0.0"
2526
},

packages/plugins/driver-mongodb/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ export type { MongoDBDriverConfig } from './mongodb-driver.js';
77
export { translateFilter } from './mongodb-filter.js';
88
export { buildAggregationPipeline, postProcessAggregation } from './mongodb-aggregation.js';
99
export type { AggregationInput } from './mongodb-aggregation.js';
10+
export {
11+
MongoDBMultiTenantUnsupportedError,
12+
MULTI_TENANT_UNSUPPORTED_CODE,
13+
assertSingleTenantPosture,
14+
assertObjectsNotTenantScoped,
15+
declaresTenantScope,
16+
} from './mongodb-tenancy-guard.js';
1017

1118
export default {
1219
id: 'com.objectstack.driver.mongodb',

packages/plugins/driver-mongodb/src/mongodb-driver.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import {
2727
postProcessAggregation,
2828
} from './mongodb-aggregation.js';
2929
import { syncCollectionSchema, dropCollection } from './mongodb-schema.js';
30+
import {
31+
assertSingleTenantPosture,
32+
assertObjectsNotTenantScoped,
33+
} from './mongodb-tenancy-guard.js';
3034

3135
const DEFAULT_ID_LENGTH = 16;
3236

@@ -59,6 +63,14 @@ export interface MongoDBDriverConfig {
5963
*
6064
* Implements the IDataDriver contract via the official MongoDB driver.
6165
* Uses native MongoDB queries, aggregation pipelines, and transactions.
66+
*
67+
* **Single-tenant only (#3724).** This driver implements no row-level tenant
68+
* isolation — it ignores `DriverOptions.tenantId`, so reads carry no tenant
69+
* predicate and writes are not stamped with a tenant column. Rather than serve
70+
* a multi-tenant deployment unisolated, it **refuses to start** in one: see
71+
* `mongodb-tenancy-guard.ts`, wired into the constructor + {@link connect}
72+
* (deployment posture) and {@link syncSchema} / {@link syncSchemasBatch}
73+
* (object metadata).
6274
*/
6375
export class MongoDBDriver implements IDataDriver {
6476
public readonly name: string = 'com.objectstack.driver.mongodb';
@@ -116,6 +128,14 @@ export class MongoDBDriver implements IDataDriver {
116128
private config: MongoDBDriverConfig;
117129

118130
constructor(config: MongoDBDriverConfig) {
131+
// Refuse to even EXIST in a multi-tenant deployment (#3724). The check is
132+
// repeated in `connect()`, but construction is the only seam guaranteed to
133+
// fail loudly: `ObjectQLEngine.init()` catches a driver's connect rejection
134+
// and logs it, then boots anyway ("may recover via lazy reconnection"), so
135+
// a connect-only guard would degrade from "refuses to start" to "starts,
136+
// then throws at query time".
137+
assertSingleTenantPosture();
138+
119139
this.config = config;
120140
const clientOptions: MongoClientOptions = {
121141
maxPoolSize: config.maxPoolSize ?? 10,
@@ -132,6 +152,12 @@ export class MongoDBDriver implements IDataDriver {
132152
// ===========================================================================
133153

134154
async connect(): Promise<void> {
155+
// Fail before a socket is opened: this driver cannot isolate tenants, so a
156+
// multi-tenant deployment must never get a usable connection out of it.
157+
// Re-checked here (not just in the constructor) because a host may flip the
158+
// posture between construction and boot.
159+
assertSingleTenantPosture();
160+
135161
await this.client.connect();
136162
const dbName = this.config.database || this.extractDatabaseName(this.config.url);
137163
this.db = this.client.db(dbName);
@@ -498,11 +524,19 @@ export class MongoDBDriver implements IDataDriver {
498524
// ===========================================================================
499525

500526
async syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise<void> {
527+
// An object asking for row-level tenant isolation gets none here (#3724) —
528+
// refuse rather than materialise a collection that silently mixes tenants.
529+
assertObjectsNotTenantScoped([{ object, schema }]);
530+
501531
const objectDef = schema as { name: string; fields?: Record<string, any> };
502532
await syncCollectionSchema(this.db, object, objectDef);
503533
}
504534

505535
async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise<void> {
536+
// Pre-scan so the failure names every tenant-scoped object at once instead
537+
// of surfacing them one boot at a time.
538+
assertObjectsNotTenantScoped(schemas);
539+
506540
for (const { object, schema } of schemas) {
507541
await this.syncSchema(object, schema, options);
508542
}

packages/plugins/driver-mongodb/src/mongodb-schema.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ interface FieldDef {
2323
* tenant stamp on write), so there is no tenant column to compose with. A
2424
* `(tenant, field)` index would advertise an isolation this driver does not
2525
* deliver — worse than the single-field index, because it would read as
26-
* fixed. The tenancy gap itself is tracked separately; when it lands, this
27-
* must adopt the same scoping rule as `SqlDriver.uniqueIndexesFromFields`.
26+
* fixed.
27+
*
28+
* Settled by #3724: the driver is now explicitly single-tenant and refuses to
29+
* boot into a multi-tenant deployment (see `mongodb-tenancy-guard.ts`), so a
30+
* single-field unique index is exactly right — there is no second tenant for
31+
* it to over-constrain. Should row-level tenancy ever land here (option A of
32+
* that issue), this must adopt the same scoping rule as
33+
* `SqlDriver.uniqueIndexesFromFields`.
2834
*/
2935
unique?: boolean | 'global';
3036
indexed?: boolean;

0 commit comments

Comments
 (0)