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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ DATABASE_PASSWORD=change-me
# STACKABLE_COCKPIT_TRINO_AUTH_USERNAME=service-account
# STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD=secret
# STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION=false # Disable impersonation: run all queries as the auth user (requires basic auth). Default: true
# STACKABLE_COCKPIT_TRINO_TLS_INSECURE=false # Set to "true" to skip TLS certificate verification
# STACKABLE_COCKPIT_TRINO_TLS_INSECURE=false # Set to "true" to skip TLS certificate verification. Requires STACKABLE_COCKPIT_TRINO_URL
# STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE=false # Set to "true" to let users skip TLS certificate verification for connections entered in the UI. Requires STACKABLE_COCKPIT_TRINO_URL to be unset
# STACKABLE_COCKPIT_QUERY_TTL=1800 # Seconds before completed, unaccessed tab queries are evicted (default: 1800)

# Feature flags
Expand Down
4 changes: 3 additions & 1 deletion deploy/helm/cockpit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ At startup, the cockpit fetches the OIDC discovery document once and logs the ca
### Trino Connection Parameters

Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app connection form is hidden.
Otherwise users enter their own connection, and `trino.userConnection.tls.allowInsecure` lets them skip TLS certificate verification.

| Parameter | Description | Default |
| --- | --- | --- |
Expand All @@ -179,7 +180,8 @@ Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app conn
| `trino.auth.username` | Username for basic auth. | `""` |
| `trino.auth.password.secretKeyRef.name` | Name of the Secret holding the Trino password. Required when `type=basic`. | `""` |
| `trino.auth.password.secretKeyRef.key` | Key inside the Secret. | `trino-auth-password` |
| `trino.tls.insecure` | Skip TLS certificate verification. Logs a warning in production; for a private CA, use `tls.caCerts` or `tls.secretClasses` instead. | `false` |
| `trino.tls.insecure` | Skip TLS certificate verification for `trino.url`. Requires `trino.url`. Logs a warning in production; for a private CA, use `tls.caCerts` or `tls.secretClasses` instead. | `false` |
| `trino.userConnection.tls.allowInsecure` | Let users skip TLS certificate verification for the connection they enter in the UI. Requires an empty `trino.url`. | `false` |
| `trino.queryTtl` | Query result TTL in seconds. | `1800` (application default) |

### Security Parameters
Expand Down
10 changes: 10 additions & 0 deletions deploy/helm/cockpit/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,19 @@ spec:
- name: STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION
value: {{ .Values.trino.userImpersonation.enabled | quote }}
{{- if .Values.trino.tls.insecure }}
{{- if not .Values.trino.url }}
{{- fail "trino.tls.insecure only applies to the pre-configured connection and requires trino.url" }}
{{- end }}
- name: STACKABLE_COCKPIT_TRINO_TLS_INSECURE
value: "true"
{{- end }}
{{- if .Values.trino.userConnection.tls.allowInsecure }}
{{- if .Values.trino.url }}
{{- fail "trino.userConnection.tls.allowInsecure only applies to connections users enter and requires an empty trino.url" }}
{{- end }}
- name: STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE
value: "true"
{{- end }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
Expand Down
10 changes: 8 additions & 2 deletions deploy/helm/cockpit/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,14 @@ trino:
name: ""
key: trino-auth-password
tls:
# Skip TLS certificate verification. A private CA goes into tls.caCerts or
# tls.secretClasses instead.
# Skip TLS certificate verification for the connection to trino.url. Requires trino.url.
# A private CA goes into tls.caCerts or tls.secretClasses instead.
insecure: false
# Connections users enter in the UI. Only used when trino.url is empty.
userConnection:
tls:
# Let users skip TLS certificate verification for their connection. Requires an
# empty trino.url.
allowInsecure: false
# Query result TTL in seconds. Default: 1800 (application default).
# queryTtl: 1800
1 change: 1 addition & 0 deletions docs/modules/cockpit/pages/modules/trino.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ For a Trino cluster with a private CA, see xref:installation/helm.adoc#_ca_certi

Without `trino.url`, each user enters a connection in the UI.
These connections are kept in memory and are lost when Cockpit restarts.
With `trino.userConnection.tls.allowInsecure: true`, users can skip certificate verification for their connection.

== User identity

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ Cockpit does not start if a value is invalid, and the error names every invalid

|`STACKABLE_COCKPIT_TRINO_TLS_INSECURE`
|`false`
|Skip TLS certificate verification.
|Skip TLS certificate verification for `STACKABLE_COCKPIT_TRINO_URL`. Requires `STACKABLE_COCKPIT_TRINO_URL`.

|`STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE`
|`false`
|Let users skip TLS certificate verification for the connection they enter in the UI. Requires `STACKABLE_COCKPIT_TRINO_URL` to be unset.

|`STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION`
|`true`
Expand Down
82 changes: 70 additions & 12 deletions e2e/trino/connection-form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ function interceptSaveSuccess(page: import('@playwright/test').Page) {
connectionUrl: MOCK_TRINO_URL,
authType: 'none',
authUsername: '',
authPassword: ''
authPassword: '',
tlsInsecure: false
},
message: { type: 'success' }
};
Expand All @@ -50,24 +51,27 @@ function interceptSaveSuccess(page: import('@playwright/test').Page) {
}

/**
* Intercept SvelteKit responses to override the trinoConfigured boolean.
* Intercept SvelteKit responses to override boolean page data fields such as
* trinoConfigured.
*
* SvelteKit embeds page data in two formats:
* - SSR HTML uses devalue.uneval with unquoted keys: `trinoConfigured:true`
* - __data.json uses devalue.stringify with indexed arrays where booleans
* are stored at positions referenced by the shape object at index 0
*/
function interceptTrinoConfigured(page: import('@playwright/test').Page, value: boolean) {
function interceptPageData(
page: import('@playwright/test').Page,
overrides: Record<string, boolean>
) {
page.route('**/trino', async (route, request) => {
if (request.resourceType() !== 'document') return route.continue();
const response = await route.fetch();
let html = await response.text();
// devalue.uneval uses literal booleans; minified builds may use !0 / !1
html = html.replaceAll(`trinoConfigured:${!value}`, `trinoConfigured:${value}`);
html = html.replaceAll(
`trinoConfigured:${!value ? '!0' : '!1'}`,
`trinoConfigured:${value ? '!0' : '!1'}`
);
for (const [key, value] of Object.entries(overrides)) {
// devalue.uneval uses literal booleans; minified builds may use !0 / !1
html = html.replaceAll(`${key}:${!value}`, `${key}:${value}`);
html = html.replaceAll(`${key}:${!value ? '!0' : '!1'}`, `${key}:${value ? '!0' : '!1'}`);
}
await route.fulfill({ response, body: html });
});

Expand All @@ -77,15 +81,61 @@ function interceptTrinoConfigured(page: import('@playwright/test').Page, value:
for (const node of json.nodes ?? []) {
if (node?.type === 'data' && Array.isArray(node.data)) {
const shape = node.data[0];
if (typeof shape === 'object' && shape !== null && 'trinoConfigured' in shape) {
node.data[shape.trinoConfigured as number] = value;
if (typeof shape !== 'object' || shape === null) continue;
for (const [key, value] of Object.entries(overrides)) {
if (key in shape) node.data[shape[key] as number] = value;
}
}
}
await route.fulfill({ response, body: JSON.stringify(json) });
});
}

/** Capture the fields posted by the next `?/save` request and abort it. */
function captureSave(page: import('@playwright/test').Page): Promise<URLSearchParams> {
return new Promise((resolve) => {
page.route(
(url) => url.pathname === '/trino' && url.search === '?/save',
async (route, request) => {
resolve(new URLSearchParams(request.postData() ?? ''));
await route.abort();
}
);
});
}

// ---------------------------------------------------------------------------
// 0. Per-user connections that may skip TLS verification
// ---------------------------------------------------------------------------
test.describe('Connection form (insecure TLS allowed)', () => {
test.use({ locale: 'en-US' });

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.removeItem('trino_url');
localStorage.removeItem('trino_tls_insecure');
});
interceptPageData(page, { trinoConfigured: false, allowInsecureTls: true });
});

test('skipping TLS verification is posted, persisted and shown in the summary', async ({
page
}) => {
await page.goto('/trino');
await waitForHydration(page);

await page.getByLabel('URL').fill(MOCK_TRINO_URL);
await page.getByLabel('Skip TLS certificate verification').check();
await expect(page.getByText('TLS unverified')).toBeVisible();

const posted = captureSave(page);
await page.getByRole('button', { name: 'Save' }).click();

expect((await posted).get('tlsInsecure')).toBe('on');
expect(await page.evaluate(() => localStorage.getItem('trino_tls_insecure'))).toBe('true');
});
});

// ---------------------------------------------------------------------------
// 1. When Trino IS env-configured, the form must be hidden
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -116,7 +166,7 @@ test.describe('Connection form (manual mode)', () => {
localStorage.removeItem('trino_password');
});

interceptTrinoConfigured(page, false);
interceptPageData(page, { trinoConfigured: false });
});

test('connection form is visible', async ({ page }) => {
Expand Down Expand Up @@ -171,6 +221,14 @@ test.describe('Connection form (manual mode)', () => {
await expect(page.getByLabel('Password')).not.toBeVisible();
});

test('TLS verification option is hidden unless allowed', async ({ page }) => {
await page.goto('/trino');
await waitForHydration(page);

await expect(page.getByLabel('URL')).toBeVisible();
await expect(page.getByLabel('Skip TLS certificate verification')).not.toBeVisible();
});

test('submitting without a URL shows a validation error', async ({ page }) => {
await page.goto('/trino');
await waitForHydration(page);
Expand Down
4 changes: 4 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
"trino_connection_test_failed": "Verbindung zu Trino fehlgeschlagen – bitte URL und Zugangsdaten prüfen.",
"trino_connection_save_failed": "Die Verbindung konnte nicht gespeichert werden. Bitte erneut versuchen.",
"trino_connection_env_managed": "Die Verbindung wird über Umgebungsvariablen verwaltet.",
"trino_connection_tls_insecure": "TLS-Zertifikatsprüfung überspringen",
"trino_connection_tls_insecure_hint": "Unsicher: Das Zertifikat des Trino-Servers wird nicht geprüft. Nur für Testumgebungen verwenden.",
"trino_connection_tls_insecure_not_allowed": "Das Überspringen der TLS-Zertifikatsprüfung ist auf diesem Server nicht erlaubt.",
"trino_connection_tls_unverified": "TLS ungeprüft",
"trino_user_missing": "Ihrem Konto ist kein Trino-Benutzername zugeordnet. Melden Sie sich ab und erneut an; besteht das Problem weiterhin, wenden Sie sich an Ihre Administration.",
"trino_cancel_query": "Abbrechen",
"trino_state_submitting": "Wird eingereicht",
Expand Down
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
"trino_connection_test_failed": "Could not connect to Trino — check the URL and credentials.",
"trino_connection_save_failed": "Could not save the connection. Please try again.",
"trino_connection_env_managed": "The connection is managed via environment variables.",
"trino_connection_tls_insecure": "Skip TLS certificate verification",
"trino_connection_tls_insecure_hint": "Insecure: the Trino server certificate is not checked. Only use this for test setups.",
"trino_connection_tls_insecure_not_allowed": "Skipping TLS certificate verification is not allowed on this server.",
"trino_connection_tls_unverified": "TLS unverified",
"trino_user_missing": "Your account has no Trino username. Sign out and sign in again; if the problem persists, contact your administrator.",
"trino_cancel_query": "Cancel",
"trino_state_submitting": "Submitting",
Expand Down
31 changes: 31 additions & 0 deletions src/lib/server/trino/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('parseTrinoConfig', () => {
authUsername: undefined,
authPassword: undefined,
tlsInsecure: false,
userConnectionAllowInsecureTls: false,
userImpersonation: true,
queryTtlSeconds: 1800
});
Expand Down Expand Up @@ -42,4 +43,34 @@ describe('parseTrinoConfig', () => {
);
expect(parseTrinoConfig(basic).authType).toBe('basic');
});

it('rejects an invalid STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE', () => {
expect(() =>
parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: 'yes' })
).toThrow('STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE:');
});

it('applies each TLS setting to its own kind of connection', () => {
expect(
parseTrinoConfig({
STACKABLE_COCKPIT_TRINO_URL: TRINO_URL,
STACKABLE_COCKPIT_TRINO_TLS_INSECURE: 'true'
})
).toMatchObject({ tlsInsecure: true, userConnectionAllowInsecureTls: false });
expect(
parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: 'true' })
).toMatchObject({ tlsInsecure: false, userConnectionAllowInsecureTls: true });
});

it('rejects a TLS setting that has no effect', () => {
expect(() => parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_TLS_INSECURE: 'true' })).toThrow(
/STACKABLE_COCKPIT_TRINO_TLS_INSECURE: only applies to the pre-configured connection/
);
expect(() =>
parseTrinoConfig({
STACKABLE_COCKPIT_TRINO_URL: TRINO_URL,
STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: 'true'
})
).toThrow(/STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: only applies to/);
});
});
27 changes: 26 additions & 1 deletion src/lib/server/trino/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const schema = z
STACKABLE_COCKPIT_TRINO_AUTH_USERNAME: envString,
STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD: envString,
STACKABLE_COCKPIT_TRINO_TLS_INSECURE: envBoolean(false),
STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: envBoolean(false),
STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION: envBoolean(true),
STACKABLE_COCKPIT_QUERY_TTL: envInteger(1800)
})
Expand All @@ -27,6 +28,26 @@ const schema = z
message:
'is "basic" but STACKABLE_COCKPIT_TRINO_AUTH_USERNAME or STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD is missing'
}
)
// Each TLS setting applies to one kind of connection only; reject a setting that would
// silently have no effect.
.refine(
(config) => !config.STACKABLE_COCKPIT_TRINO_TLS_INSECURE || config.STACKABLE_COCKPIT_TRINO_URL,
{
path: ['STACKABLE_COCKPIT_TRINO_TLS_INSECURE'],
message:
'only applies to the pre-configured connection, but STACKABLE_COCKPIT_TRINO_URL is not set'
}
)
.refine(
(config) =>
!config.STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE ||
!config.STACKABLE_COCKPIT_TRINO_URL,
{
path: ['STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE'],
message:
'only applies to connections users enter, but STACKABLE_COCKPIT_TRINO_URL is set and hides the connection form'
}
);

export function parseTrinoConfig(env: Record<string, string | undefined>) {
Expand All @@ -38,12 +59,16 @@ export function parseTrinoConfig(env: Record<string, string | undefined>) {
authUsername: config.STACKABLE_COCKPIT_TRINO_AUTH_USERNAME,
authPassword: config.STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD,
tlsInsecure: config.STACKABLE_COCKPIT_TRINO_TLS_INSECURE,
userConnectionAllowInsecureTls:
config.STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE,
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. */
* own connection. Impersonation also applies to those per-user connections.
* `tlsInsecure` skips certificate verification for the pre-configured connection;
* `userConnectionAllowInsecureTls` lets users skip it for their own. */
export const trinoConfig = parseTrinoConfig(env);
Loading
Loading