Skip to content
Open
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ STACKABLE_COCKPIT_BASE_URL=http://localhost:5173
# OIDC discovery URL (Keycloak, Entra ID, or any compliant OIDC provider)
STACKABLE_COCKPIT_OIDC_DISCOVERY_URL=https://your-idp.example.com/realms/your-realm/.well-known/openid-configuration

# The client ID and secret registered in your OIDC provider
# The client ID and secret registered in your OIDC provider. Set all three OIDC
# variables or none; setting only some of them stops the server at startup.
STACKABLE_COCKPIT_OIDC_CLIENT_ID=stackable-cockpit
STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret

Expand Down Expand Up @@ -42,6 +43,7 @@ DATABASE_PASSWORD=change-me
# When STACKABLE_COCKPIT_TRINO_URL is set, the connection form is hidden and
# the specified Trino endpoint is used automatically. By default the logged-in
# user is forwarded to Trino as X-Trino-User (impersonation via the OIDC username).
# Invalid values stop the server at startup.
# STACKABLE_COCKPIT_TRINO_URL=https://trino.example.com:8443
# STACKABLE_COCKPIT_TRINO_PUBLIC_URL=https://trino.example.com # Browser-facing Trino URL for "View in Trino" links (default: the Trino URL used for queries)
# STACKABLE_COCKPIT_TRINO_AUTH_TYPE=basic # "none" (default) or "basic"
Expand Down
6 changes: 4 additions & 2 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { requestLogger, logger } from '$lib/server/logging';
import { getConnectionFromHeader } from '$lib/server/storage/connection.js';
import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js';
import { storageBrowserEnabled } from '$lib/server/feature-flags.js';
// Imported for its side effect: parses the storage and PUBLIC_ client config at
// startup, so invalid values stop the server instead of failing the first request.
// Imported for their side effect: parse the storage, PUBLIC_ client and Trino
// config at startup, so invalid values stop the server instead of failing the
// first request.
import '$lib/server/storage/config.js';
import '$lib/server/trino/config.js';
import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js';
import { notConnected } from '$lib/server/storage/not-connected.js';
import { runMigrations } from '$lib/server/migrate.js';
Expand Down
21 changes: 20 additions & 1 deletion src/lib/env-schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { envBoolean, envInteger, envList, envOptionalInteger, parseEnv } from './env-schema.js';
import {
envBoolean,
envInteger,
envList,
envOptionalInteger,
envUrl,
parseEnv
} from './env-schema.js';

describe('envBoolean', () => {
const schema = envBoolean(false);
Expand Down Expand Up @@ -42,6 +49,18 @@ describe('envOptionalInteger', () => {
});
});

describe('envUrl', () => {
it('accepts http and https URLs and is undefined when blank', () => {
expect(envUrl.parse(' https://trino.example.com:8443 ')).toBe('https://trino.example.com:8443');
expect(envUrl.parse('')).toBeUndefined();
});

it('rejects values without an http or https scheme', () => {
expect(envUrl.safeParse('trino.example.com:8443').success).toBe(false);
expect(envUrl.safeParse('ftp://trino.example.com').success).toBe(false);
});
});

describe('envList', () => {
it('splits, trims and drops empty entries', () => {
expect(envList(['a']).parse(' x , ,y ')).toEqual(['x', 'y']);
Expand Down
3 changes: 3 additions & 0 deletions src/lib/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const envOptionalInteger = z.preprocess(
/** A trimmed string, or `undefined` when unset. */
export const envString = z.preprocess(blankAsUnset, z.string().optional());

/** An `http` or `https` URL, or `undefined` when unset. */
export const envUrl = z.preprocess(blankAsUnset, z.url({ protocol: /^https?$/ }).optional());

/** A list of non-empty entries separated by `separator`. */
export function envList(defaultValue: string[], separator: RegExp | string = ',') {
return z.preprocess(
Expand Down
17 changes: 16 additions & 1 deletion src/lib/server/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { parseAdditionalUserInputFromProviderProfile } from 'better-auth/db';
import { auth } from './auth.js';

Expand Down Expand Up @@ -37,3 +37,18 @@ describe('user.additionalFields.trinoUser', () => {
await expect(update({ emailVerified: true }, '/update-user')).resolves.toBeUndefined();
});
});

describe('OIDC configuration', () => {
it('fails at startup when only some of the OIDC variables are set', async () => {
vi.resetModules();
vi.doMock('$env/dynamic/private', () => ({
env: { STACKABLE_COCKPIT_OIDC_CLIENT_ID: 'cockpit' }
}));
try {
await expect(import('./auth.js')).rejects.toThrow(/set all or none of/);
} finally {
vi.doUnmock('$env/dynamic/private');
vi.resetModules();
}
});
});
51 changes: 35 additions & 16 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import { genericOAuth, openAPI } from 'better-auth/plugins';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import type { RequestEvent } from '@sveltejs/kit';
import { z } from 'zod';
import { db } from './db.js';
import * as schema from './auth-schema.js';
import { envList, envString, envUrl, parseEnv } from '../env-schema.js';

// Dynamic imports with fallbacks for non-SvelteKit contexts.
const envModule = await import('$env/dynamic/private').catch(() => null);
Expand All @@ -28,23 +30,40 @@ function guardTrinoUserWrite(data: Record<string, unknown>, context: { path?: st
throw new APIError('FORBIDDEN', { message: 'trinoUser is not allowed to be set' });
}

const trinoUserClaim = env.STACKABLE_COCKPIT_TRINO_USER_CLAIM ?? 'preferred_username';
const OIDC_REQUIRED = [
'STACKABLE_COCKPIT_OIDC_DISCOVERY_URL',
'STACKABLE_COCKPIT_OIDC_CLIENT_ID',
'STACKABLE_COCKPIT_OIDC_CLIENT_SECRET'
] as const;

const defaultScopes = ['openid', 'profile', 'email'];
// OIDC is enabled only when all required variables are set. Setting only some
// of them is a startup error, not a silently disabled login.
const config = parseEnv(
z
.object({
STACKABLE_COCKPIT_OIDC_DISCOVERY_URL: envUrl,
STACKABLE_COCKPIT_OIDC_CLIENT_ID: envString,
STACKABLE_COCKPIT_OIDC_CLIENT_SECRET: envString,
STACKABLE_COCKPIT_OIDC_EXTRA_SCOPES: envList([], /[\s,]+/),
STACKABLE_COCKPIT_TRINO_USER_CLAIM: envString.pipe(z.string().default('preferred_username'))
})
.refine(
(values) => {
const set = OIDC_REQUIRED.filter((name) => values[name]).length;
return set === 0 || set === OIDC_REQUIRED.length;
},
{ message: `set all or none of ${OIDC_REQUIRED.join(', ')}` }
),
env
);

const extraScopes = (env.STACKABLE_COCKPIT_OIDC_EXTRA_SCOPES ?? '')
.split(/[\s,]+/)
.map((scope) => scope.trim())
.filter(Boolean);
const trinoUserClaim = config.STACKABLE_COCKPIT_TRINO_USER_CLAIM;

const oidcScopes = [...new Set([...defaultScopes, ...extraScopes])];
const defaultScopes = ['openid', 'profile', 'email'];

// OIDC is enabled only when all required OIDC env vars are present.
export const oidcEnabled = !!(
env.STACKABLE_COCKPIT_OIDC_DISCOVERY_URL &&
env.STACKABLE_COCKPIT_OIDC_CLIENT_ID &&
env.STACKABLE_COCKPIT_OIDC_CLIENT_SECRET
);
const oidcScopes = [...new Set([...defaultScopes, ...config.STACKABLE_COCKPIT_OIDC_EXTRA_SCOPES])];

export const oidcEnabled = OIDC_REQUIRED.every((name) => config[name]);

export const auth = betterAuth({
secret: env.STACKABLE_COCKPIT_SESSION_SECRET,
Expand Down Expand Up @@ -89,9 +108,9 @@ export const auth = betterAuth({
config: [
{
providerId: 'oidc',
discoveryUrl: env.STACKABLE_COCKPIT_OIDC_DISCOVERY_URL,
clientId: env.STACKABLE_COCKPIT_OIDC_CLIENT_ID!,
clientSecret: env.STACKABLE_COCKPIT_OIDC_CLIENT_SECRET!,
discoveryUrl: config.STACKABLE_COCKPIT_OIDC_DISCOVERY_URL!,
clientId: config.STACKABLE_COCKPIT_OIDC_CLIENT_ID!,
clientSecret: config.STACKABLE_COCKPIT_OIDC_CLIENT_SECRET!,
scopes: oidcScopes,
pkce: true,
// Refresh the user from the IdP on every login, so changes to the
Expand Down
26 changes: 11 additions & 15 deletions src/lib/server/trino/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { env } from '$env/dynamic/private';
import { readFileSync } from 'node:fs';
import { Agent } from 'undici';
import { logger } from '$lib/server/logging';
import { trinoConfig } from './config.js';
import { getUserTrinoClient, getUserTrinoUrl } from './user-clients.js';

const log = logger.child({ module: 'trino-client' });
Expand Down Expand Up @@ -168,28 +168,24 @@ export function buildBasicAuthHeader(username: string, password: string): string

// --- Singleton & env config ---

const trinoUrl = env.STACKABLE_COCKPIT_TRINO_URL;
const trinoPublicUrl = env.STACKABLE_COCKPIT_TRINO_PUBLIC_URL;
const authType = (env.STACKABLE_COCKPIT_TRINO_AUTH_TYPE ?? 'none') as 'none' | 'basic';
const authUsername = env.STACKABLE_COCKPIT_TRINO_AUTH_USERNAME;
const authPassword = env.STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD;
const tlsInsecure = env.STACKABLE_COCKPIT_TRINO_TLS_INSECURE === 'true';
const tlsCaCertPath = env.STACKABLE_COCKPIT_TRINO_TLS_CA_CERT;
const {
url: trinoUrl,
publicUrl: trinoPublicUrl,
authType,
authUsername,
authPassword,
tlsInsecure,
tlsCaCertPath
} = trinoConfig;

export const trinoUserImpersonation = env.STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION !== 'false';
export const trinoUserImpersonation = trinoConfig.userImpersonation;

/** True when the Trino connection is pre-configured via environment variables. */
export const trinoConfigured = !!trinoUrl;

let singleton: TrinoClient | undefined;

if (trinoConfigured) {
if (authType === 'basic' && (!authUsername || !authPassword)) {
throw new Error(
'STACKABLE_COCKPIT_TRINO_AUTH_TYPE is "basic" but STACKABLE_COCKPIT_TRINO_AUTH_USERNAME or STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD is missing'
);
}

let dispatcher: Agent | undefined;
if (tlsInsecure || tlsCaCertPath) {
dispatcher = new Agent({
Expand Down
46 changes: 46 additions & 0 deletions src/lib/server/trino/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';

vi.mock('$env/dynamic/private', () => ({ env: {} }));

import { parseTrinoConfig } from './config.js';

const TRINO_URL = 'https://trino.example.com:8443';

describe('parseTrinoConfig', () => {
it('uses the defaults when nothing is set', () => {
expect(parseTrinoConfig({})).toEqual({
url: undefined,
publicUrl: undefined,
authType: 'none',
authUsername: undefined,
authPassword: undefined,
tlsInsecure: false,
tlsCaCertPath: undefined,
userImpersonation: true,
queryTtlSeconds: 1800
});
});

it.each([
['STACKABLE_COCKPIT_TRINO_URL', 'trino.example.com:8443'],
['STACKABLE_COCKPIT_TRINO_AUTH_TYPE', 'oauth2'],
['STACKABLE_COCKPIT_TRINO_TLS_INSECURE', 'yes'],
['STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION', 'off'],
['STACKABLE_COCKPIT_QUERY_TTL', '30m']
])('rejects an invalid %s', (name, value) => {
expect(() =>
parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_URL: TRINO_URL, [name]: value })
).toThrow(`${name}:`);
});

it('requires credentials for basic auth when a Trino URL is set', () => {
const basic = {
STACKABLE_COCKPIT_TRINO_AUTH_TYPE: 'basic',
STACKABLE_COCKPIT_TRINO_AUTH_USERNAME: 'svc'
};
expect(() => parseTrinoConfig({ ...basic, STACKABLE_COCKPIT_TRINO_URL: TRINO_URL })).toThrow(
/STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD is missing/
);
expect(parseTrinoConfig(basic).authType).toBe('basic');
});
});
51 changes: 51 additions & 0 deletions src/lib/server/trino/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Trino connection and query settings. Parsed once at module init so a typo
// fails at startup instead of silently changing TLS or impersonation behaviour.

import { z } from 'zod';
import { env } from '$env/dynamic/private';
import { envBoolean, envInteger, envString, envUrl, parseEnv } from '$lib/env-schema.js';

const schema = z
.object({
STACKABLE_COCKPIT_TRINO_URL: envUrl,
STACKABLE_COCKPIT_TRINO_PUBLIC_URL: envUrl,
STACKABLE_COCKPIT_TRINO_AUTH_TYPE: envString.pipe(z.enum(['none', 'basic']).default('none')),
STACKABLE_COCKPIT_TRINO_AUTH_USERNAME: envString,
STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD: envString,
STACKABLE_COCKPIT_TRINO_TLS_INSECURE: envBoolean(false),
STACKABLE_COCKPIT_TRINO_TLS_CA_CERT: envString,
STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION: envBoolean(true),
STACKABLE_COCKPIT_QUERY_TTL: envInteger(1800)
})
.refine(
(config) =>
!config.STACKABLE_COCKPIT_TRINO_URL ||
config.STACKABLE_COCKPIT_TRINO_AUTH_TYPE !== 'basic' ||
(config.STACKABLE_COCKPIT_TRINO_AUTH_USERNAME &&
config.STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD),
{
path: ['STACKABLE_COCKPIT_TRINO_AUTH_TYPE'],
message:
'is "basic" but STACKABLE_COCKPIT_TRINO_AUTH_USERNAME or STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD is missing'
}
);

export function parseTrinoConfig(env: Record<string, string | undefined>) {
const config = parseEnv(schema, env);
return {
url: config.STACKABLE_COCKPIT_TRINO_URL,
publicUrl: config.STACKABLE_COCKPIT_TRINO_PUBLIC_URL,
authType: config.STACKABLE_COCKPIT_TRINO_AUTH_TYPE,
authUsername: config.STACKABLE_COCKPIT_TRINO_AUTH_USERNAME,
authPassword: config.STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD,
tlsInsecure: config.STACKABLE_COCKPIT_TRINO_TLS_INSECURE,
tlsCaCertPath: config.STACKABLE_COCKPIT_TRINO_TLS_CA_CERT,
userImpersonation: config.STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION,
queryTtlSeconds: config.STACKABLE_COCKPIT_QUERY_TTL
};
}

/** Trino settings from the environment. The connection fields are only used
* when `STACKABLE_COCKPIT_TRINO_URL` is set; otherwise users configure their
* own connection. Impersonation also applies to those per-user connections. */
export const trinoConfig = parseTrinoConfig(env);
4 changes: 2 additions & 2 deletions src/lib/server/trino/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { env } from '$env/dynamic/private';
import { logger } from '$lib/server/logging';
import { trinoActiveQueries, trinoQueryTotal } from '$lib/server/metrics.js';
import {
Expand All @@ -10,14 +9,15 @@ import {
type QueryState
} from '$lib/types/query.js';
import { type TrinoClient, type TrinoQueryStats, resolveTrinoPublicUrl } from './client.js';
import { trinoConfig } from './config.js';
import { collectResults } from './result-collector.js';

const log = logger.child({ module: 'trino-queries' });

// --- TTL & eviction configuration ---

/** Time (seconds) before completed, unaccessed tab queries are evicted. */
const QUERY_TTL = Number(env.STACKABLE_COCKPIT_QUERY_TTL) || 1800; // 30 min
const QUERY_TTL = trinoConfig.queryTtlSeconds;
const SWEEP_INTERVAL_MS = 60_000;

// --- TrinoQuery model ---
Expand Down
Loading