From f6654f5de4b5055812920e53e93096088550eeea Mon Sep 17 00:00:00 2001 From: Michael Toy Date: Wed, 21 Oct 2026 15:06:29 -0700 Subject: [PATCH 1/3] Snowflake: key-pair auth from a Malloy config file Declare the authenticator property, and default it to SNOWFLAKE_JWT when a private key is given without one. Fixes #3046 --- packages/malloy-db-snowflake/src/index.ts | 9 ++ .../src/snowflake_config.unit.spec.ts | 106 ++++++++++++++++++ .../src/snowflake_connection.ts | 19 +++- packages/malloy/src/connection/CONTEXT.md | 4 +- 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts diff --git a/packages/malloy-db-snowflake/src/index.ts b/packages/malloy-db-snowflake/src/index.ts index fb30d70235..9aaa7911f7 100644 --- a/packages/malloy-db-snowflake/src/index.ts +++ b/packages/malloy-db-snowflake/src/index.ts @@ -104,6 +104,15 @@ registerConnectionType('snowflake', { type: 'password', optional: true, }, + { + name: 'authenticator', + displayName: 'Authenticator', + type: 'string', + optional: true, + advanced: true, + description: + 'snowflake-sdk authenticator, e.g. SNOWFLAKE_JWT, EXTERNALBROWSER, OAUTH. Defaults to SNOWFLAKE_JWT when a private key is set, otherwise password authentication.', + }, { name: 'timeoutMs', displayName: 'Timeout (ms)', diff --git a/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts b/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts new file mode 100644 index 0000000000..b9aa6a7be4 --- /dev/null +++ b/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts @@ -0,0 +1,106 @@ +/* + * Copyright Contributors to the Malloy project + * SPDX-License-Identifier: MIT + */ + +import {MalloyConfig} from '@malloydata/malloy'; +import snowflake from 'snowflake-sdk'; +import { + SnowflakeConnection, + withKeyPairAuthenticator, +} from './snowflake_connection'; +// Registers the 'snowflake' connection type for the MalloyConfig tests. +import './index'; + +const PEM = + '-----BEGIN PRIVATE KEY-----\nMIIBVQ==\n-----END PRIVATE KEY-----\n'; + +describe('withKeyPairAuthenticator', () => { + it('selects SNOWFLAKE_JWT for a private key with no authenticator', () => { + expect( + withKeyPairAuthenticator({account: 'a', privateKey: PEM}).authenticator + ).toBe('SNOWFLAKE_JWT'); + }); + + it('selects SNOWFLAKE_JWT for a private key path with no authenticator', () => { + expect( + withKeyPairAuthenticator({account: 'a', privateKeyPath: '/k.p8'}) + .authenticator + ).toBe('SNOWFLAKE_JWT'); + }); + + it('leaves an explicit authenticator alone', () => { + expect( + withKeyPairAuthenticator({ + account: 'a', + privateKey: PEM, + authenticator: 'OAUTH', + }).authenticator + ).toBe('OAUTH'); + }); + + it('leaves password auth alone', () => { + expect( + withKeyPairAuthenticator({account: 'a', password: 'p'}).authenticator + ).toBeUndefined(); + }); +}); + +describe('Snowflake key-pair auth from a Malloy config', () => { + const KEY_ENV = 'TEST_SNOWFLAKE_PRIVATE_KEY'; + const PASS_ENV = 'TEST_SNOWFLAKE_KEY_PASSPHRASE'; + let createPool: jest.SpyInstance; + let conn: SnowflakeConnection | undefined; + + beforeEach(() => { + process.env[KEY_ENV] = PEM; + process.env[PASS_ENV] = 'hunter2'; + createPool = jest.spyOn(snowflake, 'createPool'); + }); + + afterEach(async () => { + await conn?.close(); + conn = undefined; + createPool.mockRestore(); + delete process.env[KEY_ENV]; + delete process.env[PASS_ENV]; + }); + + async function connect( + entry: Record + ): Promise> { + const config = new MalloyConfig({ + connections: { + // poolMin 0 so the pool opens no connection to the fake account. + sf: {is: 'snowflake', account: 'acme', poolMin: 0, ...entry}, + }, + }); + expect(config.log).toEqual([]); + const found = await config.connections.lookupConnection('sf'); + expect(found).toBeInstanceOf(SnowflakeConnection); + if (found instanceof SnowflakeConnection) conn = found; + expect(createPool).toHaveBeenCalledTimes(1); + return createPool.mock.calls[0][0]; + } + + it('reaches the driver as key-pair auth with no authenticator written', async () => { + const opts = await connect({ + username: 'malloy', + privateKey: {env: KEY_ENV}, + privateKeyPass: {env: PASS_ENV}, + }); + expect(opts).toMatchObject({ + authenticator: 'SNOWFLAKE_JWT', + privateKey: PEM, + privateKeyPass: 'hunter2', + }); + }); + + it('passes a written authenticator through to the driver', async () => { + const opts = await connect({ + username: 'malloy', + authenticator: 'EXTERNALBROWSER', + }); + expect(opts['authenticator']).toBe('EXTERNALBROWSER'); + }); +}); diff --git a/packages/malloy-db-snowflake/src/snowflake_connection.ts b/packages/malloy-db-snowflake/src/snowflake_connection.ts index fb5dffc866..28a9f530a7 100644 --- a/packages/malloy-db-snowflake/src/snowflake_connection.ts +++ b/packages/malloy-db-snowflake/src/snowflake_connection.ts @@ -120,6 +120,22 @@ export async function sampleVariantBlocks( return undefined; } +/** + * snowflake-sdk uses a private key only when `authenticator` is + * `SNOWFLAKE_JWT`; with no authenticator it falls back to password auth and + * fails asking for a password. A key supplied with no authenticator can only + * mean key-pair auth, so say so. An explicit authenticator is left alone. + */ +export function withKeyPairAuthenticator( + opts: ConnectionOptions +): ConnectionOptions { + if (opts.authenticator !== undefined) return opts; + if (opts.privateKey === undefined && opts.privateKeyPath === undefined) { + return opts; + } + return {...opts, authenticator: 'SNOWFLAKE_JWT'}; +} + export interface SnowflakeConnectionOptions { // snowflake sdk connection options connOptions?: ConnectionOptions; @@ -190,7 +206,8 @@ export class SnowflakeConnection // try to get connection options from ~/.snowflake/connections.toml connOptions = SnowflakeExecutor.getConnectionOptionsFromToml(); } - this.connOptions = connOptions ?? {}; + connOptions = withKeyPairAuthenticator(connOptions); + this.connOptions = connOptions; this.setupSQL = options?.setupSQL; this.executor = new SnowflakeExecutor( connOptions, diff --git a/packages/malloy/src/connection/CONTEXT.md b/packages/malloy/src/connection/CONTEXT.md index 4c05322049..8c0b06b2d3 100644 --- a/packages/malloy/src/connection/CONTEXT.md +++ b/packages/malloy/src/connection/CONTEXT.md @@ -174,8 +174,8 @@ A service account key can arrive three ways, in this order of precedence: `servi `host` (string), `port` (number), `username` (string), `password` (password), `databaseName` (string), `connectionString` (string, advanced), `setupSQL` (text, advanced), `ssl` (json, advanced) **Snowflake** (`displayName: "Snowflake"`): -`account` (string, required), `username` (string), `password` (password), `role` (string), `warehouse` (string), `database` (string), `schema` (string), `privateKeyPath` (file), `privateKeyPass` (password), `timeoutMs` (number, advanced), `schemaSampleTimeoutMs` (number, advanced), `schemaSampleRowLimit` (number, advanced), `schemaSampleFullScanMaxBytes` (number, advanced), `setupSQL` (text, advanced), `poolMin` (number, advanced), `poolMax` (number, advanced), `poolTestOnBorrow` (boolean, advanced) -Factory extracts `name`, `setupSQL`, `timeoutMs`, and the three pool fields; passes remaining properties as snowflake-sdk `ConnectionOptions`. The pool fields are assembled into a `generic-pool` options object via `buildPoolOptions()` and shallow-merged with `SnowflakeExecutor.defaultPoolOptions_` (`{min: 1, max: 1, testOnBorrow: true, testOnReturn: true}`); omitting all three preserves the defaults. +`account` (string, required), `username` (string), `password` (password), `role` (string), `warehouse` (string), `database` (string), `schema` (string), `privateKeyPath` (file), `privateKey` (password), `privateKeyPass` (password), `authenticator` (string, advanced), `timeoutMs` (number, advanced), `schemaSampleTimeoutMs` (number, advanced), `schemaSampleRowLimit` (number, advanced), `schemaSampleFullScanMaxBytes` (number, advanced), `setupSQL` (text, advanced), `poolMin` (number, advanced), `poolMax` (number, advanced), `poolTestOnBorrow` (boolean, advanced) +Factory extracts `name`, `setupSQL`, `timeoutMs`, and the three pool fields; passes remaining properties as snowflake-sdk `ConnectionOptions`. The pool fields are assembled into a `generic-pool` options object via `buildPoolOptions()` and shallow-merged with `SnowflakeExecutor.defaultPoolOptions_` (`{min: 1, max: 1, testOnBorrow: true, testOnReturn: true}`); omitting all three preserves the defaults. The connection sets `authenticator: "SNOWFLAKE_JWT"` when a private key is given without an authenticator, since snowflake-sdk otherwise ignores the key and demands a password (`withKeyPairAuthenticator()`). **Trino** (`displayName: "Trino"`): `server` (string), `port` (number), `catalog` (string), `schema` (string), `user` (string), `password` (password), `setupSQL` (text, advanced), `source` (string, advanced), `ssl` (json, advanced), `session` (json, advanced), `extraCredential` (json, advanced), `extraHeaders` (json, advanced) From 47c24154d3d326ee6cb2b7a142c692bf8afa5a5e Mon Sep 17 00:00:00 2001 From: Michael Toy Date: Wed, 21 Oct 2026 18:09:49 -0700 Subject: [PATCH 2/3] Snowflake: document that a private key takes precedence over a password Also type the createPool spy in the config test. --- packages/malloy-db-snowflake/src/index.ts | 2 +- .../malloy-db-snowflake/src/snowflake_config.unit.spec.ts | 7 ++++--- packages/malloy-db-snowflake/src/snowflake_connection.ts | 5 +++-- packages/malloy/src/connection/CONTEXT.md | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/malloy-db-snowflake/src/index.ts b/packages/malloy-db-snowflake/src/index.ts index 9aaa7911f7..cccd494e1c 100644 --- a/packages/malloy-db-snowflake/src/index.ts +++ b/packages/malloy-db-snowflake/src/index.ts @@ -111,7 +111,7 @@ registerConnectionType('snowflake', { optional: true, advanced: true, description: - 'snowflake-sdk authenticator, e.g. SNOWFLAKE_JWT, EXTERNALBROWSER, OAUTH. Defaults to SNOWFLAKE_JWT when a private key is set, otherwise password authentication.', + 'snowflake-sdk authenticator, e.g. SNOWFLAKE_JWT, EXTERNALBROWSER, OAUTH. Defaults to SNOWFLAKE_JWT when a private key is set, even if a password is also set; otherwise password authentication.', }, { name: 'timeoutMs', diff --git a/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts b/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts index b9aa6a7be4..c0978060cd 100644 --- a/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts +++ b/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts @@ -5,6 +5,7 @@ import {MalloyConfig} from '@malloydata/malloy'; import snowflake from 'snowflake-sdk'; +import type {ConnectionOptions} from 'snowflake-sdk'; import { SnowflakeConnection, withKeyPairAuthenticator, @@ -49,7 +50,7 @@ describe('withKeyPairAuthenticator', () => { describe('Snowflake key-pair auth from a Malloy config', () => { const KEY_ENV = 'TEST_SNOWFLAKE_PRIVATE_KEY'; const PASS_ENV = 'TEST_SNOWFLAKE_KEY_PASSPHRASE'; - let createPool: jest.SpyInstance; + let createPool: jest.SpiedFunction; let conn: SnowflakeConnection | undefined; beforeEach(() => { @@ -68,7 +69,7 @@ describe('Snowflake key-pair auth from a Malloy config', () => { async function connect( entry: Record - ): Promise> { + ): Promise { const config = new MalloyConfig({ connections: { // poolMin 0 so the pool opens no connection to the fake account. @@ -101,6 +102,6 @@ describe('Snowflake key-pair auth from a Malloy config', () => { username: 'malloy', authenticator: 'EXTERNALBROWSER', }); - expect(opts['authenticator']).toBe('EXTERNALBROWSER'); + expect(opts.authenticator).toBe('EXTERNALBROWSER'); }); }); diff --git a/packages/malloy-db-snowflake/src/snowflake_connection.ts b/packages/malloy-db-snowflake/src/snowflake_connection.ts index 28a9f530a7..4ac6645ae4 100644 --- a/packages/malloy-db-snowflake/src/snowflake_connection.ts +++ b/packages/malloy-db-snowflake/src/snowflake_connection.ts @@ -123,8 +123,9 @@ export async function sampleVariantBlocks( /** * snowflake-sdk uses a private key only when `authenticator` is * `SNOWFLAKE_JWT`; with no authenticator it falls back to password auth and - * fails asking for a password. A key supplied with no authenticator can only - * mean key-pair auth, so say so. An explicit authenticator is left alone. + * fails asking for a password. A key supplied with no authenticator means + * key-pair auth, even when a password is also present; the password is then + * unused. An explicit authenticator is left alone. */ export function withKeyPairAuthenticator( opts: ConnectionOptions diff --git a/packages/malloy/src/connection/CONTEXT.md b/packages/malloy/src/connection/CONTEXT.md index 8c0b06b2d3..eba5e713d8 100644 --- a/packages/malloy/src/connection/CONTEXT.md +++ b/packages/malloy/src/connection/CONTEXT.md @@ -175,7 +175,7 @@ A service account key can arrive three ways, in this order of precedence: `servi **Snowflake** (`displayName: "Snowflake"`): `account` (string, required), `username` (string), `password` (password), `role` (string), `warehouse` (string), `database` (string), `schema` (string), `privateKeyPath` (file), `privateKey` (password), `privateKeyPass` (password), `authenticator` (string, advanced), `timeoutMs` (number, advanced), `schemaSampleTimeoutMs` (number, advanced), `schemaSampleRowLimit` (number, advanced), `schemaSampleFullScanMaxBytes` (number, advanced), `setupSQL` (text, advanced), `poolMin` (number, advanced), `poolMax` (number, advanced), `poolTestOnBorrow` (boolean, advanced) -Factory extracts `name`, `setupSQL`, `timeoutMs`, and the three pool fields; passes remaining properties as snowflake-sdk `ConnectionOptions`. The pool fields are assembled into a `generic-pool` options object via `buildPoolOptions()` and shallow-merged with `SnowflakeExecutor.defaultPoolOptions_` (`{min: 1, max: 1, testOnBorrow: true, testOnReturn: true}`); omitting all three preserves the defaults. The connection sets `authenticator: "SNOWFLAKE_JWT"` when a private key is given without an authenticator, since snowflake-sdk otherwise ignores the key and demands a password (`withKeyPairAuthenticator()`). +Factory extracts `name`, `setupSQL`, `timeoutMs`, and the three pool fields; passes remaining properties as snowflake-sdk `ConnectionOptions`. The pool fields are assembled into a `generic-pool` options object via `buildPoolOptions()` and shallow-merged with `SnowflakeExecutor.defaultPoolOptions_` (`{min: 1, max: 1, testOnBorrow: true, testOnReturn: true}`); omitting all three preserves the defaults. The connection sets `authenticator: "SNOWFLAKE_JWT"` when a private key is given without an authenticator, even if a password is also set, since snowflake-sdk otherwise ignores the key and demands a password (`withKeyPairAuthenticator()`). **Trino** (`displayName: "Trino"`): `server` (string), `port` (number), `catalog` (string), `schema` (string), `user` (string), `password` (password), `setupSQL` (text, advanced), `source` (string, advanced), `ssl` (json, advanced), `session` (json, advanced), `extraCredential` (json, advanced), `extraHeaders` (json, advanced) From e096fee5be2b08b490cc8faff69caad2e47a1e41 Mon Sep 17 00:00:00 2001 From: Michael Toy Date: Wed, 21 Oct 2026 18:36:01 -0700 Subject: [PATCH 3/3] Snowflake: an empty private key or key path is not a key The driver ignores an empty privateKeyPath, so it must not switch a password login to key-pair auth. --- .../src/snowflake_config.unit.spec.ts | 9 +++++++++ packages/malloy-db-snowflake/src/snowflake_connection.ts | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts b/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts index c0978060cd..2f729fb5ee 100644 --- a/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts +++ b/packages/malloy-db-snowflake/src/snowflake_config.unit.spec.ts @@ -45,6 +45,15 @@ describe('withKeyPairAuthenticator', () => { withKeyPairAuthenticator({account: 'a', password: 'p'}).authenticator ).toBeUndefined(); }); + + it('does not take an empty key or key path as a key', () => { + for (const key of [{privateKey: ''}, {privateKeyPath: ''}]) { + expect( + withKeyPairAuthenticator({account: 'a', password: 'p', ...key}) + .authenticator + ).toBeUndefined(); + } + }); }); describe('Snowflake key-pair auth from a Malloy config', () => { diff --git a/packages/malloy-db-snowflake/src/snowflake_connection.ts b/packages/malloy-db-snowflake/src/snowflake_connection.ts index 4ac6645ae4..703c4fc040 100644 --- a/packages/malloy-db-snowflake/src/snowflake_connection.ts +++ b/packages/malloy-db-snowflake/src/snowflake_connection.ts @@ -125,15 +125,15 @@ export async function sampleVariantBlocks( * `SNOWFLAKE_JWT`; with no authenticator it falls back to password auth and * fails asking for a password. A key supplied with no authenticator means * key-pair auth, even when a password is also present; the password is then - * unused. An explicit authenticator is left alone. + * unused. An empty string is not a key: the driver ignores an empty + * `privateKeyPath`, so it must not switch a password login to key-pair. An + * explicit authenticator is left alone. */ export function withKeyPairAuthenticator( opts: ConnectionOptions ): ConnectionOptions { if (opts.authenticator !== undefined) return opts; - if (opts.privateKey === undefined && opts.privateKeyPath === undefined) { - return opts; - } + if (!opts.privateKey && !opts.privateKeyPath) return opts; return {...opts, authenticator: 'SNOWFLAKE_JWT'}; }