diff --git a/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx b/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx index c789b2d60b79c..0b76372919c6a 100644 --- a/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx +++ b/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx @@ -17,19 +17,39 @@ the local database (bootstrapped in the `docker-compose.yml` file) which has the same data model. To enable multitenancy, use the -[`contextToAppId`](/reference/configuration/config#context_to_app_id) function to -provide distinct identifiers for each tenant. Also, implement the +[`contextToAppId`](/reference/configuration/config#context_to_app_id) and +[`contextToOrchestratorId`](/reference/configuration/config#context_to_orchestrator_id) +functions to provide distinct identifiers for each tenant. Also, implement the [`driverFactory`](/reference/configuration/config#driver_factory) function where you can select a data source based on the tenant name. [JSON Web Token](/docs/data-modeling/access-control) includes information about the tenant name in the `tenant` property of the `securityContext`. + + +Whenever `driverFactory` picks a connection based on the `securityContext`, you +must also define `contextToOrchestratorId` and derive it from the same tenant +identifier. `driverFactory` is called +[once per data source for every orchestrator id](/reference/configuration/config#driver_factory), +and the orchestrator id defaults to a single global value for all tenants — so +without it, every tenant reuses the database connection resolved for whichever +tenant queried first, and one tenant receives another tenant's data. + + + ```javascript module.exports = { // Provides distinct identifiers for each tenant which are used as caching keys + // for the data model compilation results contextToAppId: ({ securityContext }) => `CUBE_APP_${securityContext.tenant}`, + // Caching key for database connections, execution queues and pre-aggregation + // table caches. Must be tenant-specific because driverFactory below selects a + // connection based on the security context + contextToOrchestratorId: ({ securityContext }) => + `CUBE_APP_${securityContext.tenant}`, + // Selects the database connection configuration based on the tenant name driverFactory: ({ securityContext }) => { if (!securityContext.tenant) { diff --git a/docs-mintlify/reference/configuration/config.mdx b/docs-mintlify/reference/configuration/config.mdx index 26b1a7fa2fd2e..7ad426651ea76 100644 --- a/docs-mintlify/reference/configuration/config.mdx +++ b/docs-mintlify/reference/configuration/config.mdx @@ -55,6 +55,11 @@ It's a [multitenancy][ref-multitenancy] option. caching key for various in-memory structures like data model compilation results, etc. +It does not key database connections, execution queues, or pre-aggregation table +caches — those use [`context_to_orchestrator_id`][self-orchestrator-id], which +you must also define if [`driver_factory`][self-driver-factory] selects a +connection based on the security context. + Called on each request. @@ -321,6 +326,15 @@ execution queues, pre-aggregation table caches. By default, the same instance is used for **all** tenants; override this property in situations where each tenant requires their own Query Orchestrator. +Overriding it is **required** whenever [`driver_factory`][self-driver-factory] +selects a connection based on the security context, and the id must be derived +from the same tenant identifier. Setting +[`context_to_app_id`][self-opts-ctx-to-appid] alone is not enough: the app id keys +the data model compilation cache, while the driver is resolved once per data +source per *orchestrator* id. With the default single global orchestrator id, +every tenant reuses the connection resolved for whichever tenant queried first, +so one tenant receives another tenant's data. + Please remember to override @@ -1511,6 +1525,7 @@ module.exports = { [ref-rest-scopes]: /reference/core-data-apis/rest-api#api-scopes [ref-config-options]: /admin/connect-to-data#configuration-options [self-orchestrator-id]: #context_to_orchestrator_id +[self-driver-factory]: #driver_factory [ref-multiple-data-sources]: /admin/connect-to-data/multiple-data-sources [ref-websockets]: /recipes/core-data-api/real-time-data-fetch [ref-matching-preaggs]: /docs/pre-aggregations/matching-pre-aggregations diff --git a/examples/recipes/multiple-data-sources/cube.js b/examples/recipes/multiple-data-sources/cube.js index dd87548280244..fadc264c22731 100644 --- a/examples/recipes/multiple-data-sources/cube.js +++ b/examples/recipes/multiple-data-sources/cube.js @@ -2,9 +2,17 @@ const PostgresDriver = require('@cubejs-backend/postgres-driver'); module.exports = { // Provides distinct identifiers for each tenant which are used as caching keys + // for the data model compilation results contextToAppId: ({ securityContext }) => `CUBEJS_APP_${securityContext.tenant}`, + // Caching key for database connections, execution queues and pre-aggregation + // table caches. It must be tenant-specific whenever driverFactory selects a + // connection based on the security context, otherwise every tenant shares the + // driver resolved for whichever tenant queried first. + contextToOrchestratorId: ({ securityContext }) => + `CUBEJS_APP_${securityContext.tenant}`, + // Selects the database connection configuration based on the tenant name driverFactory: ({ securityContext }) => { diff --git a/packages/cubejs-server-core/src/core/OptsHandler.ts b/packages/cubejs-server-core/src/core/OptsHandler.ts index 8021bb08bf7b7..66f7edfa0c7e0 100644 --- a/packages/cubejs-server-core/src/core/OptsHandler.ts +++ b/packages/cubejs-server-core/src/core/OptsHandler.ts @@ -455,6 +455,20 @@ export class OptsHandler { }); } + if ( + opts.contextToAppId && + !opts.contextToOrchestratorId && + this.isCustomDriverFactory() + ) { + this.core.logger('Multitenancy Without ContextToOrchestratorId', { + warning: ( + 'You are using multitenancy with a custom driverFactory but without ' + + 'configuring contextToOrchestratorId: ' + + 'https://cube.dev/docs/reference/configuration/config#context_to_orchestrator_id' + ), + }); + } + if (options.devServer && !options.apiSecret) { options.apiSecret = crypto.randomBytes(16).toString('hex'); displayCLIWarning( diff --git a/packages/cubejs-server-core/test/unit/OptsHandler.test.ts b/packages/cubejs-server-core/test/unit/OptsHandler.test.ts index db97854bbe5a5..de61dba2da29e 100644 --- a/packages/cubejs-server-core/test/unit/OptsHandler.test.ts +++ b/packages/cubejs-server-core/test/unit/OptsHandler.test.ts @@ -1202,4 +1202,62 @@ describe('OptsHandler class', () => { expect(Array.isArray(permissions)).toBeTruthy(); expect(permissions).toEqual(['graphql', 'meta', 'data', 'jobs']); }); + + describe('contextToOrchestratorId warning', () => { + const WARNING_MSG = 'Multitenancy Without ContextToOrchestratorId'; + + const tenantId = ({ securityContext }: any) => ( + `CUBE_APP_${securityContext.tenant}` + ); + + const buildCore = (opts: CreateOptions) => { + const logger = jest.fn(); + const core = new CubejsServerCoreExposed({ + ...conf, + logger, + scheduledRefreshTimer: false, + ...opts, + }); + + expect(core.options).toBeDefined(); + + return logger.mock.calls.filter((call) => call[0] === WARNING_MSG); + }; + + test('must warn on tenant-specific driverFactory without contextToOrchestratorId', () => { + const warnings = buildCore({ + contextToAppId: tenantId, + driverFactory: ({ securityContext }: any) => ({ + type: 'postgres', + database: `tenant_${securityContext.tenant}`, + }), + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0][1].warning).toContain('contextToOrchestratorId'); + }); + + test('must not warn when contextToOrchestratorId is configured', () => { + const warnings = buildCore({ + contextToAppId: tenantId, + contextToOrchestratorId: tenantId, + driverFactory: ({ securityContext }: any) => ({ + type: 'postgres', + database: `tenant_${securityContext.tenant}`, + }), + }); + + expect(warnings).toHaveLength(0); + }); + + test('must not warn without a custom driverFactory', () => { + process.env.CUBEJS_DB_TYPE = 'postgres'; + + const warnings = buildCore({ + contextToAppId: tenantId, + }); + + expect(warnings).toHaveLength(0); + }); + }); });