Skip to content

Commit 4d2cb22

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(integrations): add Snowflake OAuth block
1 parent 40c0a57 commit 4d2cb22

47 files changed

Lines changed: 2783 additions & 24 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ import {
210210
SlackIcon,
211211
SmartleadIcon,
212212
SmtpIcon,
213+
SnowflakeIcon,
213214
SportmonksIcon,
214215
SQSIcon,
215216
SquareIcon,
@@ -494,6 +495,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
494495
slack: SlackIcon,
495496
smartlead: SmartleadIcon,
496497
smtp: SmtpIcon,
498+
snowflake: SnowflakeIcon,
497499
sportmonks: SportmonksIcon,
498500
sqs: SQSIcon,
499501
square: SquareIcon,

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@
221221
"slack",
222222
"smartlead",
223223
"smtp",
224+
"snowflake",
224225
"sportmonks",
225226
"sqs",
226227
"square",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
---
2+
title: Snowflake
3+
description: Query and inspect data in Snowflake
4+
---
5+
6+
import { BlockInfoCard } from "@/components/ui/block-info-card"
7+
8+
<BlockInfoCard
9+
type="snowflake"
10+
color="#FFFFFF"
11+
/>
12+
13+
{/* MANUAL-CONTENT-START:intro */}
14+
### Configure Snowflake OAuth
15+
16+
An administrator with permission to create account integrations must create a custom Snowflake OAuth security integration for Sim. Replace the callback host and public key below with the values for your Sim deployment:
17+
18+
```sql
19+
CREATE SECURITY INTEGRATION sim_oauth
20+
TYPE = OAUTH
21+
ENABLED = TRUE
22+
OAUTH_CLIENT = CUSTOM
23+
OAUTH_CLIENT_TYPE = 'CONFIDENTIAL'
24+
OAUTH_REDIRECT_URI = 'https://<your-sim-host>/api/auth/oauth2/callback/snowflake'
25+
OAUTH_ENFORCE_PKCE = TRUE
26+
OAUTH_ISSUE_REFRESH_TOKENS = TRUE
27+
OAUTH_CLIENT_RSA_PUBLIC_KEY = '<sim-oauth-public-key>';
28+
```
29+
30+
Run `DESC SECURITY INTEGRATION sim_oauth` to find the generated OAuth client ID and public-key fingerprint. When connecting in Sim, provide:
31+
32+
- Your account URL, such as `https://myorg-myaccount.snowflakecomputing.com`
33+
- Your account locator (not the account name)
34+
- The generated OAuth client ID
35+
36+
The Sim deployment must configure the matching unencrypted PKCS#8 private key as `SNOWFLAKE_OAUTH_PRIVATE_KEY`. The private key is deployment-wide; only its public key is registered in each customer security integration. Sim uses an RS256 client assertion, PKCE, and refresh tokens, so customer OAuth client secrets are not collected or stored.
37+
38+
Sim requests the `refresh_token` scope without a role scope. Snowflake therefore uses the connecting user's default role. Assign that role only the warehouses, databases, schemas, and operations workflows should be able to access. Snowflake blocks privileged roles by default.
39+
40+
For key rotation, register the next public key in `OAUTH_CLIENT_RSA_PUBLIC_KEY_2`, update the Sim deployment to use the matching private key, verify connections, and then replace the old public key. Snowflake supports two active OAuth client public keys so this can be done without interruption.
41+
42+
See Snowflake's [custom OAuth guide](https://docs.snowflake.com/en/user-guide/oauth-custom) and [`CREATE SECURITY INTEGRATION` reference](https://docs.snowflake.com/en/sql-reference/sql/create-security-integration-oauth-snowflake) for the authoritative setup and policy options.
43+
44+
### Result handling
45+
46+
Query, Execute, and Introspect submit exactly one statement and request Snowflake's minimum 16 MB result chunks. If Snowflake returns a pending statement, use **Get Statement** with its handle. Each operation returns at most one result partition; when `hasMore` is true, call **Get Statement** with the next zero-based partition number. Column metadata and the total row count are returned with the first partition and may be absent from later partitions. Sim does not automatically load all partitions into workflow memory.
47+
48+
There is no Snowflake trigger. Snowflake does not document a compatible event or webhook trigger for this integration, so workflows should use schedules when polling is appropriate.
49+
{/* MANUAL-CONTENT-END */}
50+
51+
## Usage Instructions
52+
53+
Connect a Snowflake account with OAuth to run SQL, inspect database schemas, and retrieve asynchronous or partitioned statement results through the Snowflake SQL API.
54+
55+
56+
57+
## Actions
58+
59+
### Snowflake Query
60+
61+
Run a SQL query in Snowflake and return the first bounded result partition
62+
63+
#### Input
64+
65+
| Parameter | Type | Required | Description |
66+
| --------- | ---- | -------- | ----------- |
67+
| `idToken` | string | No | Snowflake account URL stored with the OAuth credential |
68+
| `statement` | string | Yes | A single SQL query to run |
69+
| `warehouse` | string | No | Warehouse to use instead of the user default |
70+
| `database` | string | No | Database to use instead of the user default |
71+
| `schema` | string | No | Schema to use instead of the user default |
72+
| `timeout` | number | No | Statement timeout in seconds \(1-604800\) |
73+
74+
#### Output
75+
76+
| Parameter | Type | Description |
77+
| --------- | ---- | ----------- |
78+
| `status` | string | Statement status \(succeeded or running\) |
79+
| `message` | string | Snowflake status message |
80+
| `statementHandle` | string | Handle for later status/result requests |
81+
| `columns` | json | Result columns \(\[\{name, type, nullable\}\]\) |
82+
| `rows` | json | One bounded result partition as a 2D value array |
83+
| `rowCount` | number | Total rows reported by Snowflake |
84+
| `partitionCount` | number | Number of available result partitions |
85+
| `partition` | number | Partition returned by this operation |
86+
| `hasMore` | boolean | Whether another result partition is available |
87+
### Snowflake Execute
88+
89+
Execute one SQL statement in Snowflake and return its status and bounded results
90+
91+
#### Input
92+
93+
| Parameter | Type | Required | Description |
94+
| --------- | ---- | -------- | ----------- |
95+
| `idToken` | string | No | Snowflake account URL stored with the OAuth credential |
96+
| `statement` | string | Yes | A single SQL statement to execute |
97+
| `warehouse` | string | No | Warehouse to use instead of the user default |
98+
| `database` | string | No | Database to use instead of the user default |
99+
| `schema` | string | No | Schema to use instead of the user default |
100+
| `timeout` | number | No | Statement timeout in seconds \(1-604800\) |
101+
102+
#### Output
103+
104+
| Parameter | Type | Description |
105+
| --------- | ---- | ----------- |
106+
| `status` | string | Statement status \(succeeded or running\) |
107+
| `message` | string | Snowflake status message |
108+
| `statementHandle` | string | Handle for later status/result requests |
109+
| `columns` | json | Result columns \(\[\{name, type, nullable\}\]\) |
110+
| `rows` | json | One bounded result partition as a 2D value array |
111+
| `rowCount` | number | Total rows reported by Snowflake |
112+
| `partitionCount` | number | Number of available result partitions |
113+
| `partition` | number | Partition returned by this operation |
114+
| `hasMore` | boolean | Whether another result partition is available |
115+
116+
### Snowflake Introspect
117+
118+
List tables, views, and columns from a Snowflake database information schema
119+
120+
#### Input
121+
122+
| Parameter | Type | Required | Description |
123+
| --------- | ---- | -------- | ----------- |
124+
| `idToken` | string | No | Snowflake account URL stored with the OAuth credential |
125+
| `database` | string | Yes | Database whose information schema should be inspected |
126+
| `schema` | string | No | Optional exact schema name to inspect |
127+
| `warehouse` | string | No | Warehouse to use instead of the user default |
128+
| `timeout` | number | No | Statement timeout in seconds \(1-604800\) |
129+
130+
#### Output
131+
132+
| Parameter | Type | Description |
133+
| --------- | ---- | ----------- |
134+
| `status` | string | Statement status \(succeeded or running\) |
135+
| `message` | string | Snowflake status message |
136+
| `statementHandle` | string | Handle for later status/result requests |
137+
| `columns` | json | Result columns \(\[\{name, type, nullable\}\]\) |
138+
| `rows` | json | One bounded result partition as a 2D value array |
139+
| `rowCount` | number | Total rows reported by Snowflake |
140+
| `partitionCount` | number | Number of available result partitions |
141+
| `partition` | number | Partition returned by this operation |
142+
| `hasMore` | boolean | Whether another result partition is available |
143+
144+
### Snowflake Get Statement
145+
146+
Check a statement and retrieve one bounded result partition by handle
147+
148+
#### Input
149+
150+
| Parameter | Type | Required | Description |
151+
| --------- | ---- | -------- | ----------- |
152+
| `idToken` | string | No | Snowflake account URL stored with the OAuth credential |
153+
| `statementHandle` | string | Yes | Statement handle returned by Query, Execute, or Introspect |
154+
| `partition` | number | No | Zero-based result partition to retrieve \(default: 0\) |
155+
156+
#### Output
157+
158+
| Parameter | Type | Description |
159+
| --------- | ---- | ----------- |
160+
| `status` | string | Statement status \(succeeded or running\) |
161+
| `message` | string | Snowflake status message |
162+
| `statementHandle` | string | Handle for later status/result requests |
163+
| `columns` | json | Result columns \(\[\{name, type, nullable\}\]\) |
164+
| `rows` | json | One bounded result partition as a 2D value array |
165+
| `rowCount` | number | Total rows reported by Snowflake |
166+
| `partitionCount` | number | Number of available result partitions |
167+
| `partition` | number | Partition returned by this operation |
168+
| `hasMore` | boolean | Whether another result partition is available |

apps/sim/app/api/auth/oauth/utils.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,9 @@ describe('OAuth Utils', () => {
327327
'oauth:refresh:slack:T08CM6ZNYBE'
328328
)
329329
expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][2]).toBe(30)
330-
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'live-rt')
330+
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'live-rt', {
331+
providerAccountId: SLACK_ACCOUNT_ID,
332+
})
331333
expect(mockSet).toHaveBeenCalledWith(
332334
expect.objectContaining({ accessToken: 'new-at', refreshToken: 'new-rt' })
333335
)
@@ -365,7 +367,9 @@ describe('OAuth Utils', () => {
365367

366368
expect(result).toEqual({ accessToken: 'new-at', refreshed: true })
367369
expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][0]).toBe('oauth:refresh:row-1')
368-
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'stale-rt')
370+
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'stale-rt', {
371+
providerAccountId: 'slack-bot-1764756583292',
372+
})
369373
})
370374

371375
it('dead-flags the installation, not the row, on terminal refresh errors', async () => {

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,10 @@ async function performCoalescedRefresh({
777777
refreshTokenToUse = freshest.refreshToken
778778
}
779779

780-
const result = await refreshOAuthToken(providerId, refreshTokenToUse)
780+
const result =
781+
providerAccountId != null
782+
? await refreshOAuthToken(providerId, refreshTokenToUse, { providerAccountId })
783+
: await refreshOAuthToken(providerId, refreshTokenToUse)
781784

782785
if (!result.ok) {
783786
logger.error('Failed to refresh token', {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockGetSession,
9+
mockExchangeSnowflakeAuthorizationCode,
10+
mockFindFirst,
11+
mockUpdate,
12+
mockSafeAccountInsert,
13+
mockProcessCredentialDraft,
14+
} = vi.hoisted(() => ({
15+
mockGetSession: vi.fn(),
16+
mockExchangeSnowflakeAuthorizationCode: vi.fn(),
17+
mockFindFirst: vi.fn(),
18+
mockUpdate: vi.fn(),
19+
mockSafeAccountInsert: vi.fn(),
20+
mockProcessCredentialDraft: vi.fn(),
21+
}))
22+
23+
vi.mock('@sim/db', () => ({
24+
db: {
25+
query: { account: { findFirst: mockFindFirst } },
26+
update: vi.fn(() => ({ set: vi.fn(() => ({ where: mockUpdate })) })),
27+
},
28+
}))
29+
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
30+
vi.mock('@/lib/credentials/draft-processor', () => ({
31+
processCredentialDraft: mockProcessCredentialDraft,
32+
}))
33+
vi.mock('@/app/api/auth/oauth/utils', () => ({ safeAccountInsert: mockSafeAccountInsert }))
34+
vi.mock('@/lib/oauth/snowflake', () => ({
35+
exchangeSnowflakeAuthorizationCode: mockExchangeSnowflakeAuthorizationCode,
36+
parseSnowflakeOAuthMetadata: vi.fn(() => ({
37+
accountUrl: 'https://myorg-myaccount.snowflakecomputing.com',
38+
accountLocator: 'XY12345',
39+
clientId: 'client-id',
40+
})),
41+
serializeSnowflakeOAuthMetadata: vi.fn(() => 'snowflake:v1:test-metadata'),
42+
}))
43+
44+
import { serializeSnowflakeOAuthMetadata } from '@/lib/oauth/snowflake'
45+
import { GET } from '@/app/api/auth/oauth2/callback/snowflake/route'
46+
47+
const BASE_URL = 'https://sim.test'
48+
const metadata = {
49+
accountUrl: 'https://myorg-myaccount.snowflakecomputing.com',
50+
accountLocator: 'XY12345',
51+
clientId: 'client-id',
52+
}
53+
54+
function callbackRequest(query: Record<string, string>, cookieState = 'expected-state') {
55+
const url = new URL(`${BASE_URL}/api/auth/oauth2/callback/snowflake`)
56+
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)
57+
const cookies = [
58+
`snowflake_oauth_state=${cookieState}`,
59+
'snowflake_oauth_verifier=verifier',
60+
`snowflake_oauth_metadata=${serializeSnowflakeOAuthMetadata(metadata)}`,
61+
`snowflake_return_url=${encodeURIComponent(`${BASE_URL}/workspace/ws-1`)}`,
62+
].join('; ')
63+
return createMockRequest('GET', undefined, { Cookie: cookies }, url.toString())
64+
}
65+
66+
describe('Snowflake OAuth callback route', () => {
67+
afterAll(() => resetEnvMock())
68+
69+
beforeEach(() => {
70+
vi.clearAllMocks()
71+
setEnv({
72+
NEXT_PUBLIC_APP_URL: BASE_URL,
73+
SNOWFLAKE_OAUTH_PRIVATE_KEY: 'private-key',
74+
})
75+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
76+
mockExchangeSnowflakeAuthorizationCode.mockResolvedValue({
77+
accessToken: 'access-token',
78+
refreshToken: 'refresh-token',
79+
expiresIn: 600,
80+
username: 'ADA',
81+
})
82+
})
83+
84+
it('rejects a state mismatch before exchanging the code', async () => {
85+
const response = await GET(
86+
callbackRequest({ code: 'authorization-code', state: 'wrong-state' })
87+
)
88+
expect(response.headers.get('location')).toBe(
89+
`${BASE_URL}/workspace?error=snowflake_state_mismatch`
90+
)
91+
expect(mockExchangeSnowflakeAuthorizationCode).not.toHaveBeenCalled()
92+
})
93+
94+
it('updates an existing account and processes its credential draft', async () => {
95+
mockFindFirst.mockResolvedValue({ id: 'account-row-1' })
96+
const response = await GET(
97+
callbackRequest({ code: 'authorization-code', state: 'expected-state' })
98+
)
99+
expect(mockExchangeSnowflakeAuthorizationCode).toHaveBeenCalledWith(
100+
expect.objectContaining({
101+
code: 'authorization-code',
102+
codeVerifier: 'verifier',
103+
redirectUri: `${BASE_URL}/api/auth/oauth2/callback/snowflake`,
104+
})
105+
)
106+
expect(mockUpdate).toHaveBeenCalled()
107+
expect(mockProcessCredentialDraft).toHaveBeenCalledWith({
108+
userId: 'user-1',
109+
providerId: 'snowflake',
110+
accountId: 'account-row-1',
111+
})
112+
expect(response.headers.get('location')).toContain('snowflake_connected=true')
113+
})
114+
115+
it('inserts a new account and requires the requested refresh token', async () => {
116+
mockFindFirst.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ id: 'account-row-2' })
117+
await GET(callbackRequest({ code: 'authorization-code', state: 'expected-state' }))
118+
expect(mockSafeAccountInsert).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
providerId: 'snowflake',
121+
idToken: metadata.accountUrl,
122+
refreshToken: 'refresh-token',
123+
scope: 'refresh_token',
124+
}),
125+
expect.objectContaining({ provider: 'Snowflake', identifier: 'ADA' })
126+
)
127+
})
128+
129+
it('does not persist an access token when Snowflake omits the refresh token', async () => {
130+
mockExchangeSnowflakeAuthorizationCode.mockResolvedValue({
131+
accessToken: 'access-token',
132+
expiresIn: 600,
133+
})
134+
const response = await GET(
135+
callbackRequest({ code: 'authorization-code', state: 'expected-state' })
136+
)
137+
expect(response.headers.get('location')).toBe(
138+
`${BASE_URL}/workspace?error=snowflake_no_refresh_token`
139+
)
140+
expect(mockSafeAccountInsert).not.toHaveBeenCalled()
141+
})
142+
})

0 commit comments

Comments
 (0)