Skip to content
Draft
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
160 changes: 154 additions & 6 deletions apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 52 additions & 7 deletions apps/api/src/handlers/mcp/snowflake/connection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createPrivateKey } from 'node:crypto';

import { decrypt } from '@roomote/db/encryption';
import type { McpConnectionSnowflakeConfig } from '@roomote/types';
import snowflakeSdk from 'snowflake-sdk';
Expand All @@ -19,6 +21,41 @@ class SnowflakeConfigError extends Error {
}
}

function normalizePrivateKey(
privateKeyPem: string,
passphrase: string | undefined,
): string {
try {
const trimmedPrivateKey = privateKeyPem.trim();
if (
!/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(trimmedPrivateKey)
) {
throw new Error('Unsupported private key format');
}

const privateKey = createPrivateKey({
key: trimmedPrivateKey,
format: 'pem',
passphrase,
});
const modulusLength = privateKey.asymmetricKeyDetails?.modulusLength;

if (
privateKey.asymmetricKeyType !== 'rsa' ||
!modulusLength ||
modulusLength < 2048
) {
throw new Error('Unsupported private key parameters');
}

return privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
} catch {
throw new SnowflakeConfigError(
'Snowflake private key or passphrase is invalid',
);
}
}

function maybeDecryptSecret(value: string | undefined): string | undefined {
if (!value) {
return undefined;
Expand Down Expand Up @@ -46,14 +83,17 @@ export function resolveSnowflakeConnectionConfig(
);
}

const authentication = privateKey
? {
authenticator: 'SNOWFLAKE_JWT' as const,
privateKey: normalizePrivateKey(privateKey, privateKeyPass),
}
: { password };

return {
account: config.account,
username: config.username,
...(privateKey
? { authenticator: 'SNOWFLAKE_JWT' as const }
: { password }),
privateKey,
privateKeyPass,
...authentication,
role: config.role,
...(config.warehouse ? { warehouse: config.warehouse } : {}),
database: config.database,
Expand Down Expand Up @@ -110,9 +150,14 @@ export async function withSnowflakeConnection<T>(
config: ResolvedSnowflakeConnectionConfig,
callback: (connection: Connection) => Promise<T>,
): Promise<T> {
const connection = snowflakeSdk.createConnection(config);
let connection: Connection;

await connect(connection);
try {
connection = snowflakeSdk.createConnection(config);
await connect(connection);
} catch {
throw new Error('Snowflake connection failed');
}

try {
return await callback(connection);
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/integrations/snowflake.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,25 @@ identifier, username, role, and PKCS8 PEM-encoded private key. Add the matching
public key to the Snowflake user first. Supply the private-key passphrase too
when the key is encrypted.

Generate a dedicated encrypted RSA key on a secure operator machine. Keep the
private key out of shell arguments, repositories, chat, and logs. For example,
run `umask 077`, then use `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -aes-256-cbc -out roomote_snowflake_key.p8` and enter the passphrase interactively. Export only the public key with `openssl pkey -in roomote_snowflake_key.p8 -pubout -out roomote_snowflake_key.pub`.

<Warning>
The `execute_sql` tool can run any statement permitted by the configured
Snowflake role, including statements that change data or schema. Use a
dedicated least-privilege role, preferably read-only when tasks only need
warehouse context.
</Warning>

## Rotate from an existing credential

1. Install the new public key in Snowflake's unused `RSA_PUBLIC_KEY_2` slot and verify its fingerprint before changing Roomote.
2. Enter the encrypted PKCS8 private key and passphrase in **Settings > Integrations > Snowflake**. Leave both fields blank on later edits to keep the stored key.
3. Run a Roomote task that calls `list_databases`, `list_schemas`, and `execute_sql` with `SELECT CURRENT_USER(), CURRENT_ROLE(), CURRENT_WAREHOUSE()`. Confirm the configured role can read only the intended data.
4. Review Roomote and Snowflake login logs for a successful JWT login without credential material. A saved connection is not proof that Snowflake accepted it.
5. After an observation window, revoke the previous password, programmatic access token, or public-key slot and verify a fresh Roomote task still connects.

## What to expect

Snowflake provides shared data warehouse context inside Roomote tasks.
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/Integrations.test.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/web/src/components/settings/Integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,7 @@ function SnowflakeConnectionFields({
</Label>
<Input
id="snowflake-private-key-passphrase"
type="password"
value={form.privateKeyPassphrase}
onChange={(event) =>
onFieldChange('privateKeyPassphrase', event.target.value)
Expand Down
Loading