Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cfa2eb1
fix(server-core): rebuild the cached driver when its configuration ch…
MikeNitsenko Aug 2, 2026
23dbff0
fix(server-core): invalidate both pre-aggregation driver keys together
MikeNitsenko Aug 5, 2026
f92891d
fix(server-core): make the driver rebuild path concurrency-safe
MikeNitsenko Aug 5, 2026
6456197
fix(server-core): surface driver rebuilds at the default log level
MikeNitsenko Aug 5, 2026
9c46ab1
fix(server-core): never reuse or re-release a driver once ownership i…
MikeNitsenko Aug 5, 2026
e63ba89
fix(server-core): rate-limit driver rebuilds, and never release the f…
MikeNitsenko Aug 13, 2026
1f8d972
docs: fix the per-user OAuth recipe's fallback when no expiry is adve…
MikeNitsenko Aug 13, 2026
75e0d04
docs: address PR review on the OAuth recipe and driver rebuild scope
MikeNitsenko Aug 13, 2026
b5d9ed9
fix(server-core): replace a driver whose credential stopped rotating
MikeNitsenko Aug 18, 2026
69cc641
docs: state the connection's lifetime in the per-user OAuth recipe
MikeNitsenko Aug 18, 2026
07a25cd
fix(server-core): bound driver replacement to sustained, fixable causes
claude Aug 18, 2026
dc9e97b
fix(server-core): bound the ignored-lifetime warning, and the lifetim…
claude Aug 18, 2026
e099885
fix(server-core): judge a driver lifetime when stated, not as it ages
claude Aug 18, 2026
aea10a5
fix(server-core): keep an accepted lifetime, and count refusal incidents
claude Aug 18, 2026
44fd85b
fix(server-core): bound a refusal incident by its duration, not only …
claude Aug 18, 2026
6ba21b0
docs(server-core): describe both routes to giving a driver up
claude Aug 18, 2026
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
301 changes: 244 additions & 57 deletions docs-mintlify/admin/connect-to-data/oauth-authentication.mdx

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions docs-mintlify/reference/configuration/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,35 @@ in the drivers' [source code][link-github-cube-drivers].

<Info>

The optional `expiresAt` element states when the connection this configuration
describes stops being usable, as epoch milliseconds (or seconds) or an ISO 8601
string — and in JavaScript, a `Date`. In Python, pass `dt.timestamp()` or
`dt.isoformat()`: a `datetime` cannot cross the config bridge and raises
`Unable to represent PyDateTime type as CLR from Python`. It is not passed to
the driver.

A deadline that has already passed is ignored, with a warning: replacing a
driver cannot move a deadline the factory keeps re-asserting. So is one that
falls inside the interval replacements are rate-limited to, since a lifetime
that short could only ever be honoured by replacing the connection on every
window. A credential rotating that fast is picked up by its configuration
changing instead. Both are judged when `driver_factory` states the deadline, so
a lifetime that was accepted is held to whatever it said.

A driver is resolved once and then cached, and replaced when the configuration
`driver_factory` returns changes. A connection built from a credential that
*stops* rotating — an expired token whose refresh is failing — is the case that
leaves behind: the factory keeps resolving an identical configuration while the
connection is already dead. `expiresAt` states the lifetime outright, so the
connection is replaced when it elapses whether or not anything else changed.

It is deliberately excluded from the comparison, so a factory may return a
deadline recomputed on every call without that reading as a new connection.

</Info>

<Info>

A custom `driver_factory` takes precedence over the [`CUBEJS_PRE_AGGREGATIONS_*`
environment variables][ref-preagg-data-source], for every data source rather than only the
ones it handles. If both are configured, the `driver_factory` connection is used for
Expand Down
83 changes: 83 additions & 0 deletions packages/cubejs-server-core/src/core/driver-config-expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* @copyright Cube Dev, Inc.
* @license Apache-2.0
* @fileoverview The optional lifetime a `driverFactory` can put on the
* configuration it returns.
*/

import type { DriverConfig } from './types';

/**
* Above this a number is read as epoch milliseconds, below it as epoch seconds.
* 1e11 milliseconds is 1973 and 1e11 seconds is the year 5138, so nothing
* anyone can mean today is ambiguous. Both spellings are accepted because the
* value is usually copied straight off a credential, and the ecosystems
* disagree: JavaScript counts milliseconds, Python's `time.time()` seconds.
*/
const MILLISECONDS_THRESHOLD = 1e11;

/**
* A driver lifetime as a POSIX timestamp in milliseconds, or undefined when
* none was given or the value cannot be read as a moment in time.
*
* Unreadable input is dropped rather than rejected: an expiry is an
* optimisation over comparing configurations, and failing a deployment's
* queries over a malformed one would be a worse outcome than the behaviour it
* had before the field existed.
*/
export function parseDriverExpiry(value: unknown): number | undefined {
if (value === undefined || value === null) {
return undefined;
}

if (value instanceof Date) {
const time = value.getTime();

return Number.isNaN(time) ? undefined : time;
}

if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}

return value > MILLISECONDS_THRESHOLD ? value : value * 1000;
}

if (typeof value === 'string') {
const text = value.trim();

if (!text) {
return undefined;
}

// A stringified timestamp, which `Date.parse` would read as a year.
if (/^\d+(\.\d+)?$/.test(text)) {
return parseDriverExpiry(Number(text));
}

const parsed = Date.parse(text);

return Number.isNaN(parsed) ? undefined : parsed;
}

return undefined;
}

/**
* The same configuration without its lifetime, for the two places that must not
* see it: the fingerprint that decides whether the connection changed, and the
* options handed to the driver's own constructor.
*
* Returns the input untouched when there is nothing to strip, so the common
* case allocates nothing.
*/
export function withoutDriverExpiry(config: DriverConfig): DriverConfig {
if (!config || typeof config !== 'object' || !('expiresAt' in config)) {
return config;
}

const { expiresAt: _lifetime, ...rest } = config;

return <DriverConfig>rest;
}
105 changes: 105 additions & 0 deletions packages/cubejs-server-core/src/core/driver-config-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @copyright Cube Dev, Inc.
* @license Apache-2.0
* @fileoverview Fingerprinting for driver configurations and security contexts.
*/

import crypto from 'crypto';

/**
* Deterministic JSON used for fingerprinting. Object keys are emitted in sorted
* order so two structures that differ only in property order hash the same, and
* values JSON cannot represent are reduced to stable placeholders rather than
* silently disappearing. Throws on a circular structure, which callers treat as
* "not fingerprintable".
*/
function stableStringify(value: unknown, seen: Set<unknown>): string {
if (value === undefined || value === null) {
return 'null';
}

const type = typeof value;

if (type === 'string' || type === 'number' || type === 'boolean') {
return JSON.stringify(value);
}

if (type === 'bigint') {
return JSON.stringify((value as bigint).toString());
}

// A closure's identity cannot be compared meaningfully across calls, so it
// contributes a constant. Two configs differing only in a function body are
// therefore treated as equal — deliberately conservative: it can only lead to
// reusing a connection, never to swapping one out unnecessarily.
//
// The practical consequence is that a config carrying its credential as a
// provider callback rather than a resolved value fingerprints identically
// however the credential rotates, so such a driver is never rebuilt. A
// `driverFactory` that needs rotation to be noticed has to return the
// resolved value.
if (type === 'function' || type === 'symbol') {
return JSON.stringify(`[${type}]`);
}
Comment thread
claude[bot] marked this conversation as resolved.

if (value instanceof Date) {
return JSON.stringify(value.toISOString());
}

if (seen.has(value)) {
throw new Error('Circular structure cannot be fingerprinted');
}

seen.add(value);

try {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item, seen)).join(',')}]`;
}

// Own enumerable keys only, so a class instance holding its values behind
// prototype accessors fingerprints as `{}` — constant, and therefore another
// shape whose rotation goes unnoticed. Plain configs are unaffected.
const entries = Object.keys(value as Record<string, unknown>)
.sort()
.reduce<string[]>((acc, key) => {
const entry = (value as Record<string, unknown>)[key];

// Match JSON.stringify: undefined-valued properties are absent, so
// `{ a: undefined }` and `{}` fingerprint the same.
if (entry !== undefined) {
acc.push(`${JSON.stringify(key)}:${stableStringify(entry, seen)}`);
}

return acc;
}, []);

return `{${entries.join(',')}}`;
} finally {
seen.delete(value);
}
}

/**
* A short, stable digest of `value`, or `null` when it cannot be fingerprinted.
*
* Hashed rather than kept verbatim because the values being compared include
* database passwords and OAuth access tokens: a raw copy would live for the
* lifetime of the process and surface in any heap dump. `null` means "cannot
* tell whether this changed", and every caller must treat that as "assume it
* did not" so behaviour falls back to the previous resolve-once semantics.
*/
export function fingerprint(value: unknown): string | null {
try {
return crypto
.createHash('sha256')
.update(stableStringify(value, new Set()))
// 32 hex chars = 128 bits, which is far more than an equality check over
// the handful of configurations one process resolves needs, and keeps the
// digest short enough to sit in a log line.
.digest('hex')
.slice(0, 32);
} catch (e) {
return null;
}
}
Loading
Loading