Skip to content

feat: Warn when multitenancy omits contextToOrchestratorId - #11411

Open
ovr wants to merge 1 commit into
masterfrom
feat/multitenancy-wrong-configuration
Open

feat: Warn when multitenancy omits contextToOrchestratorId#11411
ovr wants to merge 1 commit into
masterfrom
feat/multitenancy-wrong-configuration

Conversation

@ovr

@ovr ovr commented Jul 29, 2026

Copy link
Copy Markdown
Member

A tenant-dependent driverFactory combined with the default global orchestrator id silently shares one tenant's database connection with every other tenant. getOrchestratorApi() returns the cached OrchestratorApi by orchestrator id without consulting the current request context, and the driver factory closure memoizes the driver resolved for whichever tenant queried first, so a second tenant ends up executing its query through the first tenant's connection.

contextToOrchestratorId already prevents this and multitenancy.mdx documents it, but the dedicated "custom data source for each tenant" recipe and its shipped example configured tenant-specific contextToAppId and driverFactory while omitting it, so following the official recipe produced the unsafe configuration.

A tenant-dependent driverFactory combined with the default global
orchestrator id silently shares one tenant's database connection with
every other tenant. getOrchestratorApi() returns the cached
OrchestratorApi by orchestrator id without consulting the current
request context, and the driver factory closure memoizes the driver
resolved for whichever tenant queried first, so a second tenant ends up
executing its query through the first tenant's connection.

contextToOrchestratorId already prevents this and multitenancy.mdx
documents it, but the dedicated "custom data source for each tenant"
recipe and its shipped example configured tenant-specific
contextToAppId and driverFactory while omitting it, so following the
official recipe produced the unsafe configuration.
@ovr
ovr requested review from a team and keydunov as code owners July 29, 2026 12:51
@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Jul 29, 2026
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 3m 5s —— View job


Review complete — 1 high, 1 medium, 2 low. Core diagnosis is correct; the runtime warning's trigger condition is too broad.

Full review

Todos

  • Read PR diff against origin/master
  • Verify the described failure mode against the implementation
  • Review OptsHandler.ts warning logic
  • Review docs + example changes for accuracy and consistency
  • Review test coverage
  • Post inline comments and summary

Verification of the premise

The PR body's mechanism checks out. getOrchestratorApi (packages/cubejs-server-core/src/core/server.ts:572-577) looks up orchestratorStorage purely by contextToOrchestratorId(context), and the driverPromise record it closes over (server.ts:584, 605-607) is created inside that per-orchestrator scope and keyed only by dataSource/@pre_agg. So with the default single global orchestrator id, the first tenant's resolved driver is reused for every subsequent tenant. isCustomDriverFactory() is also set before initializeCoreOptions runs (constructor order at OptsHandler.ts:49-54), so the new check reads a valid flag.

The doc statement that contextToAppId does not key connections is likewise accurate — nothing caches drivers by app id.

Findings

# Severity Location Issue
1 High OptsHandler.ts:458-470 Warning fires for any custom driverFactory + contextToAppId, including static factories that ignore securityContext. Since dbType was removed in v1.7.0, a custom factory is now the standard single-DB setup, so shared-DB multitenancy (row-level isolation via queryRewrite) gets a false alarm — and following the advice fragments the orchestrator and introduces a preAggregationsSchema clash. Suggested narrowing (source-check the user factory for securityContext) or softened, conditional wording in the inline comment.
2 Medium docs-mintlify/reference/configuration/config.mdx:55-62 The new (correct) text contradicts docs-mintlify/embedding/multitenancy.mdx:283, which still lists "connection pool" as keyed by the App ID — exactly the misconception this PR targets. One-word fix, worth doing here.
3 Low config.mdx:329-336 "Required whenever" is stated more absolutely than the mechanism supports; the precise rule is "the connection must not vary within one orchestrator id". Also worth explicitly linking the new paragraph to the pre_aggregations_schema warning directly below it so readers don't apply half the change.
4 Low OptsHandler.test.ts:1253-1262 CUBEJS_DB_TYPE set without cleanup (consistent with the file, but this block is last); missing the static-driverFactory case that finding 1 is about, and the no-contextToAppId case.

Positives

  • The failure mode is real and non-obvious, and attacking it at all three layers — recipe, shipped example, and a boot-time warning — is the right shape. Users following the recipe verbatim were previously landing on the unsafe config.
  • The added code comments on contextToAppId / contextToOrchestratorId in both the recipe and examples/recipes/multiple-data-sources/cube.js explain what each key controls, which is the part users actually get wrong.
  • The warning follows the existing Multitenancy Without ScheduledRefreshContexts convention (same this.core.logger(msg, { warning }) shape, same placement), and the doc URL cube.dev/docs/reference/configuration/config#context_to_orchestrator_id matches the Mintlify route registered at docs-mintlify/docs.json:501.
  • All three new [self-*] link references resolve (config.mdx:1520, 1527, 1528).

Security / performance

No security issue introduced — the change is diagnostic and documentary, and it reduces a cross-tenant data-exposure footgun. No performance concern: the check runs once at option initialization. Note that the remedy the docs now recommend does carry a real resource cost (one connection pool + queue + pre-agg cache per tenant), which is the basis for finding 1.

Not done

I could not execute yarn jest test/unit/OptsHandler.test.ts — the command required approval in this environment, so the three new tests are reviewed by reading only, not verified as passing. Same for yarn lint. No prior review threads existed on this PR, so nothing to resolve or dedupe against.

• branch `feat/multitenancy-wrong-configuration`

@mintlify

mintlify Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
cubed3 🟢 Ready View Preview Jul 29, 2026, 12:52 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Comment on lines +458 to +470
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'
),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — this will fire (and give harmful advice) for a large class of safe configurations.

The condition is contextToAppId && !contextToOrchestratorId && isCustomDriverFactory(). Since dbType was removed in v1.7.0, a custom driverFactory is now the normal way to configure a single database — including for tenants that share one physical DB and isolate via queryRewrite / securityContext filters. Those setups are correct today, and:

  • they will now see a scary warning on every boot, and
  • if they follow it, they fragment one orchestrator into N (connection pool + queue + pre-agg cache per tenant) and inherit the preAggregationsSchema clash problem documented in config.mdx — i.e. the warning pushes them toward a worse configuration.

The actual unsafe condition is "driverFactory result depends on securityContext", which isCustomDriverFactory() doesn't approximate. Two options:

  1. Narrow the check with a cheap heuristic on the user-supplied factory (available as this.createOptions.driverFactory), e.g. only warn when its source mentions securityContext:
    const userFactory = this.createOptions.driverFactory;
    const factoryUsesSecurityContext =
      typeof userFactory === 'function' &&
      /securityContext/.test(userFactory.toString());
    Not airtight (a factory could read the context indirectly), but for a warning it removes the bulk of false positives.
  2. If you'd rather keep it broad, at least make the message conditional in tone — "if your driverFactory selects a connection based on securityContext, you must also configure contextToOrchestratorId; if it returns the same configuration for all tenants you can ignore this" — and mention that overriding contextToOrchestratorId also requires overriding preAggregationsSchema. As written the message reads as an unconditional misconfiguration.

Fix this →

Comment on lines 55 to +62
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — this correctly contradicts another docs page, which should be fixed in the same PR.

The new sentence ("It does not key database connections…") matches the implementation: drivers are memoized inside the closure created per orchestratorId in getOrchestratorApi (packages/cubejs-server-core/src/core/server.ts:572-620), never per app id.

But docs-mintlify/embedding/multitenancy.mdx:282-284 still says the opposite:

The App ID (the result of contextToAppId) is used as a caching key for various in-memory structures like data model compilation results, connection pool.

That's exactly the misconception this PR is trying to kill, on the primary multitenancy page. Worth dropping "connection pool" from that sentence here so the two pages agree.

Fix this →

Comment on lines +329 to +336
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — "required" is stated a bit more absolutely than the mechanism warrants.

Two edges where the blanket "required … whenever driver_factory selects a connection based on the security context" isn't quite the whole story:

  • A driver_factory that branches on securityContext but returns configurations that are equivalent for the tenants involved isn't broken; conversely a factory that ignores securityContext entirely is safe even in a heavily multitenant setup. The precise rule is "the connection returned must not vary within one orchestrator id".
  • This paragraph now sits immediately above the existing pre_aggregations_schema <Warning>, which is good, but the causality reads backwards to a reader arriving from the recipe: they're told they must override context_to_orchestrator_id, then told overriding it needs another override. Consider one sentence linking them explicitly ("because you're now creating one orchestrator per tenant, also override pre_aggregations_schema — see below") so nobody applies half the change.

Comment on lines +1253 to +1262
test('must not warn without a custom driverFactory', () => {
process.env.CUBEJS_DB_TYPE = 'postgres';

const warnings = buildCore({
contextToAppId: tenantId,
});

expect(warnings).toHaveLength(0);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — test gaps and env leakage.

  • process.env.CUBEJS_DB_TYPE = 'postgres' is set without restoring it. It's consistent with the rest of this file, so not blocking, but this is the last describe in the file and the assignment is what makes the "no custom driverFactory" path reachable — an afterEach(() => { delete process.env.CUBEJS_DB_TYPE; }) inside this block would keep it self-contained.
  • Missing the case that matters most for false positives: a static driverFactory (one that ignores securityContext) plus contextToAppId. Today that warns; per my comment on OptsHandler.ts it arguably shouldn't. Whichever behaviour you settle on, pinning it with a test would document the intent.
  • Also uncovered: driverFactory present but no contextToAppId (single-tenant) — currently silent, which is intended but untested.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.02%. Comparing base (cad6ce8) to head (41bc02c).

❗ There is a different number of reports uploaded between BASE (cad6ce8) and HEAD (41bc02c). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (cad6ce8) HEAD (41bc02c)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11411       +/-   ##
===========================================
- Coverage   83.92%   59.02%   -24.91%     
===========================================
  Files         257      223       -34     
  Lines       80755    17894    -62861     
  Branches        0     3638     +3638     
===========================================
- Hits        67777    10562    -57215     
+ Misses      12978     6815     -6163     
- Partials        0      517      +517     
Flag Coverage Δ
cube-backend 59.02% <100.00%> (?)
cubesql ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant