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
25 changes: 23 additions & 2 deletions docs-site/content/docs/integrations/primary-sources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ This helps **ktx** understand how your team actually queries the data.

## Snowflake

Connects via the Snowflake SDK. Supports multi-schema scanning, RSA key authentication, and query-history configuration for Snowflake query history.
Connects via the Snowflake SDK. Supports multi-schema scanning, password, RSA key pair, OAuth token, programmatic access token, and browser-based SSO authentication, and query-history configuration for Snowflake query history.

### Connection config

Expand Down Expand Up @@ -154,8 +154,29 @@ single schema, `schema_name: PUBLIC` is accepted as an equivalent shorthand.

| Method | Config |
|--------|--------|
| Password | `password: env:SNOWFLAKE_PASSWORD` |
| Password (default) | `password: env:SNOWFLAKE_PASSWORD` |
| RSA key pair | `authMethod: rsa`, `privateKey: file:~/.ssh/snowflake_key.pem`, optional `passphrase` |
| OAuth token | `authMethod: oauth`, `token: env:SNOWFLAKE_OAUTH_TOKEN` |
| Programmatic access token | `authMethod: pat`, `token: env:SNOWFLAKE_PAT` |
| Browser SSO | `authMethod: externalbrowser` — opens a browser window for SSO/MFA login |

For `oauth`, `pat`, and `externalbrowser`, `username` is optional — identity is carried by the token or resolved by the IDP.

```yaml title="ktx.yaml — OAuth example"
connections:
my-snowflake:
driver: snowflake
authMethod: oauth
account: xy12345
warehouse: ANALYTICS_WH
database: PROD
token: env:SNOWFLAKE_OAUTH_TOKEN
role: ANALYST
```

`token` follows the same reference syntax as other secrets: `env:VAR` reads from an environment variable, `file:/path/to/token` reads from a file, or a literal value can be supplied directly. <!-- pragma: allowlist secret -->

Browser SSO authentication is interactive — running `ktx connection test` or `ktx setup` will open a browser window to complete the Snowflake SSO flow. Not suitable for headless or CI environments. `ktx setup`'s Snowflake wizard supports all five auth methods, both when creating a new connection and when editing an existing one.

### Features

Expand Down
87 changes: 71 additions & 16 deletions packages/cli/src/connectors/snowflake/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { configureSnowflakeSdkLogger } from './sdk-logger.js';

export interface KtxSnowflakeConnectionConfig {
driver?: string;
authMethod?: 'password' | 'rsa';
authMethod?: 'password' | 'rsa' | 'oauth' | 'pat' | 'externalbrowser';
account?: string;
warehouse?: string;
database?: string;
Expand All @@ -44,21 +44,25 @@ export interface KtxSnowflakeConnectionConfig {
password?: string;
privateKey?: string;
passphrase?: string;
token?: string;
role?: string;
maxConnections?: number;
[key: string]: unknown;
}

export interface KtxSnowflakeResolvedConnectionConfig {
authMethod: 'password' | 'rsa';
authMethod: 'password' | 'rsa' | 'oauth' | 'pat' | 'externalbrowser';
account: string;
warehouse: string;
database: string;
schemas: string[];
username: string;
username?: string;
password?: string;
privateKey?: string;
passphrase?: string;
token?: string;
/** Re-reads the token source (env var or file) so a rotated short-lived token is picked up on each new pooled connection. */
refreshToken?: () => string;
role?: string;
maxConnections: number;
deadlineMs: number;
Expand Down Expand Up @@ -265,6 +269,12 @@ export function snowflakeConnectionConfigFromConfig(input: {
}
const env = input.env ?? process.env;
const authMethod = input.connection?.authMethod ?? 'password';
const knownAuthMethods = ['password', 'rsa', 'oauth', 'pat', 'externalbrowser'];
if (!knownAuthMethods.includes(authMethod)) {
throw new Error(
`connections.${input.connectionId}.authMethod "${authMethod}" is not supported; use one of ${knownAuthMethods.join(', ')}`,
);
}
const account = stringConfigValue(input.connection, 'account', env);
const warehouse = stringConfigValue(input.connection, 'warehouse', env);
const database = stringConfigValue(input.connection, 'database', env);
Expand All @@ -278,8 +288,11 @@ export function snowflakeConnectionConfigFromConfig(input: {
if (!database) {
throw new Error(`Native Snowflake connector requires connections.${input.connectionId}.database`);
}
if (!username) {
throw new Error(`Native Snowflake connector requires connections.${input.connectionId}.username`);
// username is required for password/rsa; optional for oauth/pat/externalbrowser.
if (authMethod === 'password' || authMethod === 'rsa') {
if (!username) {
throw new Error(`Native Snowflake connector requires connections.${input.connectionId}.username`);
}
}
assertSafeSnowflakeIdentifier(warehouse, 'warehouse');
assertSafeSnowflakeIdentifier(database, 'database');
Expand All @@ -293,7 +306,7 @@ export function snowflakeConnectionConfigFromConfig(input: {
warehouse,
database,
schemas: resolvedSchemas,
username,
...(username ? { username } : {}),
maxConnections: positiveIntegerConfigValue({
connection: input.connection,
key: 'maxConnections',
Expand All @@ -316,7 +329,19 @@ export function snowflakeConnectionConfigFromConfig(input: {
if (!resolved.privateKey) {
throw new Error(`Native Snowflake connector requires connections.${input.connectionId}.privateKey for RSA auth`);
}
} else {
} else if (authMethod === 'oauth' || authMethod === 'pat') {
const connection = input.connection;
const connectionId = input.connectionId;
const refreshToken = (): string => {
const token = stringConfigValue(connection, 'token', env);
if (!token) {
throw new Error(`Native Snowflake connector requires connections.${connectionId}.token for ${authMethod} auth`);
}
return token;
};
resolved.token = refreshToken();
resolved.refreshToken = refreshToken;
} else if (authMethod !== 'externalbrowser') {
Comment thread
mattsenicksigma marked this conversation as resolved.
resolved.password = stringConfigValue(input.connection, 'password', env);
if (!resolved.password) {
throw new Error(`Native Snowflake connector requires connections.${input.connectionId}.password`);
Expand Down Expand Up @@ -487,16 +512,27 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {

private async getPool(): Promise<ReturnType<typeof snowflake.createPool>> {
if (!this.pool) {
this.pool = snowflake.createPool(await this.resolveConnectionOptions(), {
min: 0,
max: this.resolved.maxConnections,
evictionRunIntervalMillis: 30_000,
acquireTimeoutMillis: 60_000,
});
this.pool = snowflake.createPool(await this.resolveConnectionOptions(), this.poolPolicy());
}
return this.pool;
}

private poolPolicy(): { min: number; max: number; evictionRunIntervalMillis: number; idleTimeoutMillis?: number; acquireTimeoutMillis: number } {
if (this.resolved.authMethod !== 'externalbrowser') {
return { min: 0, max: this.resolved.maxConnections, evictionRunIntervalMillis: 30_000, acquireTimeoutMillis: 60_000 };
}
// externalbrowser opens a system browser for SSO/MFA (default 120s per attempt, see
// WAIT_FOR_BROWSER_ACTION_TIMEOUT in the SDK). Keep at least one connection warm and
// avoid idle eviction so a scan doesn't force a repeat login mid-run, and give acquire
// more headroom than the SDK's own browser timeout so the pool never gives up first.
return {
min: 1,
max: this.resolved.maxConnections,
evictionRunIntervalMillis: 0,
acquireTimeoutMillis: 150_000,
};
}

private async resolveConnectionOptions(): Promise<snowflake.ConnectionOptions> {
const patch = await this.sdkOptionsProvider?.resolve({
account: this.resolved.account,
Expand All @@ -517,9 +553,28 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
clientSessionKeepAliveHeartbeatFrequency: 900,
...patch?.sdkOptions,
};
return this.resolved.authMethod === 'rsa'
? { ...baseConfig, authenticator: 'SNOWFLAKE_JWT', privateKey: this.decryptPrivateKey() }
: { ...baseConfig, password: this.resolved.password };
if (this.resolved.authMethod === 'rsa') {
return { ...baseConfig, authenticator: 'SNOWFLAKE_JWT', privateKey: this.decryptPrivateKey() };
}
if (this.resolved.authMethod === 'oauth' || this.resolved.authMethod === 'pat') {
const authenticator = this.resolved.authMethod === 'oauth' ? 'OAUTH' : 'PROGRAMMATIC_ACCESS_TOKEN';
const refreshToken = this.resolved.refreshToken;
const fallbackToken = this.resolved.token;
return {
...baseConfig,
authenticator,
// The pool reuses this options object for every new physical connection it opens
// (min: 0 plus idle eviction means that can happen well after construction), so
// re-read a short-lived token each time rather than baking in the value from setup.
get token() {
return refreshToken ? refreshToken() : fallbackToken;
},
};
}
if (this.resolved.authMethod === 'externalbrowser') {
return { ...baseConfig, authenticator: 'EXTERNALBROWSER' };
Comment thread
mattsenicksigma marked this conversation as resolved.
}
return { ...baseConfig, password: this.resolved.password };
}

private async executeSnowflakeQuery(
Expand Down
78 changes: 61 additions & 17 deletions packages/cli/src/setup-databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,25 +895,32 @@ async function buildConnectionConfig(input: {
stringConfigField(input.existingConnection, 'database'),
);
if (database === undefined) return 'back';
const username = await promptText(
prompts,
'Snowflake username',
stringConfigField(input.existingConnection, 'username'),
);
if (username === undefined) return 'back';
const authChoice = await prompts.select({
message: 'Snowflake authentication method',
options: [
{ value: 'password', label: 'Password' },
{ value: 'rsa', label: 'Key-pair (RSA / JWT)' },
{ value: 'oauth', label: 'OAuth token' },
{ value: 'pat', label: 'Programmatic access token' },
{ value: 'externalbrowser', label: 'Browser SSO' },
{ value: 'back', label: 'Back' },
],
});
if (authChoice === 'back') return 'back';
const authMethod: 'password' | 'rsa' = authChoice === 'rsa' ? 'rsa' : 'password';
const authMethod = authChoice as 'password' | 'rsa' | 'oauth' | 'pat' | 'externalbrowser';
const usernameRequired = authMethod === 'password' || authMethod === 'rsa';
const username = await promptText(
prompts,
usernameRequired
? 'Snowflake username'
: 'Snowflake username (optional)\nPress Enter to skip — identity is carried by the token or resolved by the IDP.',
stringConfigField(input.existingConnection, 'username'),
);
if (username === undefined) return 'back';
let passwordRef: string | null = null;
let privateKeyInput: string | undefined;
let passphraseRef: string | null = null;
let tokenRef: string | null = null;
if (authMethod === 'password') {
const ref = await promptCredential({
prompts,
Expand All @@ -924,7 +931,7 @@ async function buildConnectionConfig(input: {
});
if (ref === 'back') return 'back'; // pragma: allowlist secret
passwordRef = ref;
} else {
} else if (authMethod === 'rsa') {
privateKeyInput = await promptText(
prompts,
'Path to Snowflake private key (PEM)\nFor example ~/.ssh/snowflake_rsa_key.p8, or $ENV_VAR / env:NAME / file:/abs/path.',
Expand All @@ -940,7 +947,18 @@ async function buildConnectionConfig(input: {
});
if (phr === 'back') return 'back';
passphraseRef = phr;
} else if (authMethod === 'oauth' || authMethod === 'pat') {
const ref = await promptCredential({
prompts,
message: authMethod === 'oauth' ? 'Snowflake OAuth token' : 'Snowflake programmatic access token (PAT)',
projectDir: args.projectDir,
connectionId: input.connectionId,
secretName: 'token', // pragma: allowlist secret
});
if (ref === 'back') return 'back';
tokenRef = ref;
}
// externalbrowser needs no credential prompt — the SDK opens a browser for SSO/MFA.
const role = await promptText(
prompts,
'Snowflake role (optional)\nPress Enter to skip.',
Expand All @@ -961,20 +979,46 @@ async function buildConnectionConfig(input: {
...(role ? { role } : {}),
};
}
const resolvedPrivateKey = privateKeyInput
? normalizeFileReference(privateKeyInput)
: stringConfigField(input.existingConnection, 'privateKey');
if (!account || !warehouse || !database || !username || !resolvedPrivateKey) return null;
const resolvedPassphrase = passphraseRef ?? stringConfigField(input.existingConnection, 'passphrase');
if (authMethod === 'rsa') {
const resolvedPrivateKey = privateKeyInput
? normalizeFileReference(privateKeyInput)
: stringConfigField(input.existingConnection, 'privateKey');
if (!account || !warehouse || !database || !username || !resolvedPrivateKey) return null;
const resolvedPassphrase = passphraseRef ?? stringConfigField(input.existingConnection, 'passphrase');
return {
driver: 'snowflake',
authMethod: 'rsa',
account,
warehouse,
database,
username,
privateKey: resolvedPrivateKey,
...(resolvedPassphrase ? { passphrase: resolvedPassphrase } : {}),
...(role ? { role } : {}),
};
}
if (authMethod === 'oauth' || authMethod === 'pat') {
const resolvedToken = tokenRef ?? stringConfigField(input.existingConnection, 'token');
if (!account || !warehouse || !database || !resolvedToken) return null;
return {
driver: 'snowflake',
authMethod,
account,
warehouse,
database,
...(username ? { username } : {}),
token: resolvedToken,
...(role ? { role } : {}),
};
}
if (!account || !warehouse || !database) return null;
return {
driver: 'snowflake',
authMethod: 'rsa',
authMethod: 'externalbrowser',
account,
warehouse,
database,
username,
privateKey: resolvedPrivateKey,
...(resolvedPassphrase ? { passphrase: resolvedPassphrase } : {}),
...(username ? { username } : {}),
...(role ? { role } : {}),
};
}
Expand Down
Loading
Loading