feat(studio): improve Snowflake destination setup (#50719)

## Problem

Snowflake setup makes the public and private key files easy to confuse,
treats the optional SQL role like a primary connection field, and
requires users to paste private-key contents manually. Password managers
can also mistake the Snowflake user field for a sign-in field.

This PR is based on #50708 so its later Docs-button follow-up can use
the nested destination-guide URLs.

## Solution

Clarifies the Snowflake field copy and example values, moves **Role**
into Advanced settings, opts the service-user field out of
password-manager overlays, and adds drag-and-drop or button upload for
P8 and PEM private-key files. Public key files are rejected without
replacing the current field value.

| Before | After |
| --- | --- |
| <img width="1280" height="1323" alt="Pipelines Database Agua Basket
Supabase"
src="https://github.com/user-attachments/assets/83d91b03-34f0-479f-ba65-ad9275b28431"
/> | <img width="1280" height="1323" alt="Replication Database Agua
Basket Supabase"
src="https://github.com/user-attachments/assets/a722722c-d518-4c60-94b8-e79bab6de2ca"
/> |

## Review instructions

1. Open **[Database >
Replication](https://studio-staging-git-dnywh-studioimprove-snowflake-form-supabase.vercel.app/project/_/database/replication)**,
click **Add pipeline**, and select **Snowflake**.
2. Confirm **Role** appears under **Advanced settings** and explains the
default-role behaviour.
3. Upload or drop a valid P8 or PEM private key and confirm its contents
appear in **Private key**.
4. Select a public key file and confirm the form rejects it without
replacing the existing value.
5. Confirm 1Password (or Bitwarden etc) does _not_ add its widget to
**User**.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which references
[WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md)
and the docs
[CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md)
guide


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added Snowflake private key upload via file picker or drag-and-drop.
- Supports P8 and PEM private key files, with validation and clear error
messages.
  - Added an optional Snowflake role field in Advanced Settings.
- **Usability Improvements**
  - Preserves manual edits made while a private key file is processing.
- Improved drag-and-drop feedback and updated field guidance and
placeholders.
- Prevents password managers from automatically filling Snowflake
credentials.
- Validates private keys when submitting the Snowflake destination form.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-09-28 10:38:57 +10:00
1 parent 9a03f3cd9c
commit 9fb173e827
8 files changed
+557 -96

No files matched your search

@@ -8,6 +8,7 @@ import {
FormControl,
FormField,
FormInputGroupInput,
Input,
InputGroup,
InputGroupAddon,
InputGroupText,
@@ -251,6 +252,25 @@ export const AdvancedSettings = ({
</div>
</>
)}
{type === 'Snowflake' && (
<FormField
control={form.control}
name="snowflakeRole"
render={({ field }) => (
<FormItemLayout
label="Role"
labelOptional="Optional"
layout="horizontal"
description="Role for SQL requests. Leave blank to use the service user’s default role."
>
<FormControl>
<Input {...field} placeholder="PIPELINES_ROLE" value={field.value ?? ''} />
</FormControl>
</FormItemLayout>
)}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
@@ -1,3 +1,5 @@
import type { UseFormReturn } from 'react-hook-form'
import { type DestinationPanelSchemaType } from '../DestinationForm.schema'
import { BigQueryPartitionBy } from '@/data/replication/types'
@@ -97,3 +99,45 @@ export const getBigQueryValidationIssues = (
return issues
}
export const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000
export const readServiceAccountFile = async (
file: File,
form: UseFormReturn<DestinationPanelSchemaType>,
isCurrentRequest: () => boolean
) => {
if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
if (isCurrentRequest()) {
form.setError('serviceAccountKey', {
message: 'Service account key must be 5,000 characters or fewer.',
})
}
return
}
try {
const contents = await file.text()
if (!isCurrentRequest()) return
if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
form.setError('serviceAccountKey', {
message: 'Service account key must be 5,000 characters or fewer.',
})
return
}
form.setValue('serviceAccountKey', contents, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
form.clearErrors('serviceAccountKey')
} catch {
if (isCurrentRequest()) {
form.setError('serviceAccountKey', {
message: 'Could not read the selected JSON file.',
})
}
}
}
@@ -6,48 +6,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000
const readServiceAccountFile = async (
file: File,
form: UseFormReturn<DestinationPanelSchemaType>,
isCurrentRequest: () => boolean
) => {
if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
if (isCurrentRequest()) {
form.setError('serviceAccountKey', {
message: 'Service account key must be 5,000 characters or fewer.',
})
}
return
}
try {
const contents = await file.text()
if (!isCurrentRequest()) return
if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
form.setError('serviceAccountKey', {
message: 'Service account key must be 5,000 characters or fewer.',
})
return
}
form.setValue('serviceAccountKey', contents, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
form.clearErrors('serviceAccountKey')
} catch {
if (isCurrentRequest()) {
form.setError('serviceAccountKey', {
message: 'Could not read the selected JSON file.',
})
}
}
}
import { MAX_SERVICE_ACCOUNT_KEY_LENGTH, readServiceAccountFile } from './BigQuery.utils'
export const BigQueryFields = ({
form,
@@ -0,0 +1,83 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { useForm } from 'react-hook-form'
import { Form } from 'ui'
import { describe, expect, it, vi } from 'vitest'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
import { SnowflakeFields } from './Fields'
import { customRender } from '@/tests/lib/custom-render'
const PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nprivate-key\n-----END PRIVATE KEY-----'
const TestForm = ({ snowflakePrivateKey = '' }: { snowflakePrivateKey?: string }) => {
const form = useForm<DestinationPanelSchemaType>({
defaultValues: { snowflakePrivateKey },
})
return (
<Form {...form}>
<SnowflakeFields form={form} editMode={false} />
</Form>
)
}
describe('SnowflakeFields', () => {
it('imports a P8 file into an editable private key field', async () => {
const { container } = customRender(<TestForm />)
const file = new File([PRIVATE_KEY], 'rsa_key.p8', { type: 'application/x-pem-file' })
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) })
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
expect(fileInput).not.toBeNull()
fireEvent.change(fileInput!, { target: { files: [file] } })
const textarea = screen.getByRole('textbox', { name: 'Private key' })
await waitFor(() => expect(textarea).toHaveValue(PRIVATE_KEY))
fireEvent.change(textarea, { target: { value: `${PRIVATE_KEY}\n` } })
expect(textarea).toHaveValue(`${PRIVATE_KEY}\n`)
})
it('does not overwrite a manual edit when a file read resolves late', async () => {
const { container } = customRender(<TestForm />)
let resolveFileText!: (contents: string) => void
const fileText = new Promise<string>((resolve) => {
resolveFileText = resolve
})
const file = new File([PRIVATE_KEY], 'rsa_key.p8', { type: 'application/x-pem-file' })
Object.defineProperty(file, 'text', { value: vi.fn(() => fileText) })
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
fireEvent.change(fileInput!, { target: { files: [file] } })
const textarea = screen.getByRole('textbox', { name: 'Private key' })
fireEvent.change(textarea, { target: { value: 'manual private key' } })
resolveFileText(PRIVATE_KEY)
await waitFor(() => expect(textarea).toHaveValue('manual private key'))
})
it('imports a dropped PEM private key file', async () => {
customRender(<TestForm />)
const file = new File([PRIVATE_KEY], 'rsa_key.pem', { type: 'application/x-pem-file' })
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) })
const textarea = screen.getByRole('textbox', { name: 'Private key' })
fireEvent.drop(textarea.closest('div')!, { dataTransfer: { files: [file] } })
await waitFor(() => expect(textarea).toHaveValue(PRIVATE_KEY))
})
it('rejects a public key file without replacing the private key', async () => {
const { container } = customRender(<TestForm snowflakePrivateKey="existing-key" />)
const publicKey = '-----BEGIN PUBLIC KEY-----\npublic-key\n-----END PUBLIC KEY-----'
const file = new File([publicKey], 'rsa_key.pub', { type: 'application/x-pem-file' })
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(publicKey) })
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
fireEvent.change(fileInput!, { target: { files: [file] } })
expect(await screen.findByText('Select a P8 or PEM private key.')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
})
})
@@ -1,12 +1,13 @@
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { Eye, EyeOff, Upload } from 'lucide-react'
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { Button, FormControl, FormField, Input, TextArea } from 'ui'
import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants'
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
import { MAX_PRIVATE_KEY_LENGTH, readPrivateKeyFile } from './Snowflake.utils'
export const SnowflakeFields = ({
form,
@@ -16,6 +17,40 @@ export const SnowflakeFields = ({
editMode: boolean
}) => {
const [showPrivateKeyPassphrase, setShowPrivateKeyPassphrase] = useState(false)
const privateKeyFileInputRef = useRef<HTMLInputElement>(null)
const fileReadRequestIdRef = useRef(0)
const [isDraggingPrivateKey, setIsDraggingPrivateKey] = useState(false)
const handlePrivateKeyFile = async (file: File | undefined) => {
if (!file) return
const requestId = ++fileReadRequestIdRef.current
await readPrivateKeyFile(file, form, () => requestId === fileReadRequestIdRef.current)
}
const handlePrivateKeyFileInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
await handlePrivateKeyFile(file)
}
const handlePrivateKeyDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingPrivateKey(true)
}
const handlePrivateKeyDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingPrivateKey(false)
}
const handlePrivateKeyDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingPrivateKey(false)
await handlePrivateKeyFile(event.dataTransfer.files?.[0])
}
return (
<div className="flex flex-col gap-y-6 p-5">
@@ -24,7 +59,7 @@ export const SnowflakeFields = ({
<div className="flex flex-col gap-y-1">
<p className="text-sm font-medium text-foreground">Connection</p>
<p className="text-sm text-foreground-light">
Configure the Snowflake account, user, and target namespace for replicated data.
Enter the Snowflake account and destination details.
</p>
</div>
@@ -36,7 +71,7 @@ export const SnowflakeFields = ({
<FormItemLayout
layout="horizontal"
label="Account ID"
description="Snowflake account identifier, for example ORGNAME-ACCOUNTNAME"
description="Snowflake organization and account identifiers joined with a hyphen."
>
<FormControl>
<Input {...field} placeholder="MYORG-MYACCOUNT" value={field.value ?? ''} />
@@ -52,10 +87,19 @@ export const SnowflakeFields = ({
<FormItemLayout
layout="horizontal"
label="User"
description="Snowflake user configured for key-pair authentication"
description="Snowflake service user with key-pair authentication."
>
<FormControl>
<Input {...field} placeholder="ETL_USER" value={field.value ?? ''} />
<Input
{...field}
placeholder="PIPELINES_USER"
value={field.value ?? ''}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
/>
</FormControl>
</FormItemLayout>
)}
@@ -68,10 +112,10 @@ export const SnowflakeFields = ({
<FormItemLayout
layout="horizontal"
label="Database"
description="Snowflake database where replicated tables will be created"
description="Snowflake database where replicated tables are created."
>
<FormControl>
<Input {...field} placeholder="ANALYTICS" value={field.value ?? ''} />
<Input {...field} placeholder="PIPELINES_DB" value={field.value ?? ''} />
</FormControl>
</FormItemLayout>
)}
@@ -84,26 +128,10 @@ export const SnowflakeFields = ({
<FormItemLayout
layout="horizontal"
label="Schema"
description="Snowflake schema where replicated tables will be created"
description="An empty Snowflake schema where replicated tables are created."
>
<FormControl>
<Input {...field} placeholder="PUBLIC" value={field.value ?? ''} />
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="snowflakeRole"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Role"
description="Optional Snowflake role to assume after connecting"
>
<FormControl>
<Input {...field} placeholder="ETL_ROLE" value={field.value ?? ''} />
<Input {...field} placeholder="REPLICATED" value={field.value ?? ''} />
</FormControl>
</FormItemLayout>
)}
@@ -128,23 +156,63 @@ export const SnowflakeFields = ({
description={
editMode
? 'Stored private key is hidden. Enter a new private key to replace it.'
: 'RSA private key PEM contents in PKCS#8 or PKCS#1 format'
: 'Paste or upload a complete RSA private key (PKCS #8 or PKCS #1 PEM).'
}
>
<FormControl>
<TextArea
{...field}
rows={8}
maxLength={10000}
placeholder={
editMode
? STORED_SECRET_PLACEHOLDER
: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
}
value={field.value ?? ''}
className="font-mono text-xs"
/>
</FormControl>
<div
className="relative"
onDragOver={handlePrivateKeyDragOver}
onDragLeave={handlePrivateKeyDragLeave}
onDrop={handlePrivateKeyDrop}
>
<div
className={cn(
'space-y-2 transition-opacity',
isDraggingPrivateKey && 'opacity-40'
)}
>
<FormControl>
<TextArea
{...field}
onChange={(event) => {
fileReadRequestIdRef.current += 1
field.onChange(event)
}}
rows={8}
maxLength={MAX_PRIVATE_KEY_LENGTH}
placeholder={
editMode
? STORED_SECRET_PLACEHOLDER
: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
}
value={field.value ?? ''}
className="font-mono text-xs"
/>
</FormControl>
<input
ref={privateKeyFileInputRef}
type="file"
accept=".p8,.pem,application/x-pem-file"
aria-label="Upload private key"
className="hidden"
onChange={handlePrivateKeyFileInputChange}
/>
<Button
type="button"
size="tiny"
icon={<Upload size={14} />}
onClick={() => privateKeyFileInputRef.current?.click()}
>
Upload private key
</Button>
</div>
{isDraggingPrivateKey ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-md ring-2 ring-brand ring-offset-2 ring-offset-background"
/>
) : null}
</div>
</FormItemLayout>
)}
/>
@@ -156,17 +224,18 @@ export const SnowflakeFields = ({
<FormItemLayout
layout="horizontal"
label="Private key passphrase"
labelOptional="Optional"
description={
editMode
? 'Stored passphrase setting is hidden. Enter a new passphrase to replace it.'
: 'Optional passphrase for encrypted private keys'
: 'Passphrase for an encrypted PKCS #8 private key.'
}
>
<FormControl>
<PasswordInput
value={field.value ?? ''}
type={showPrivateKeyPassphrase ? 'text' : 'password'}
placeholder={editMode ? STORED_SECRET_PLACEHOLDER : 'Optional'}
placeholder={editMode ? STORED_SECRET_PLACEHOLDER : undefined}
onChange={(event) => field.onChange(event.target.value)}
actions={
<div className="flex items-center justify-center">
@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from 'vitest'
import {
getSnowflakeValidationIssues,
isPrivateKey,
MAX_PRIVATE_KEY_LENGTH,
readPrivateKeyFile,
SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE,
} from './Snowflake.utils'
describe('isPrivateKey', () => {
it.each([
['a plain PKCS#8 key', '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----', true],
[
'a PKCS#1 RSA key',
'-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----',
true,
],
[
'an encrypted PKCS#8 key',
'-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----',
true,
],
[
'a legacy encrypted RSA key with headers',
'-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,ABC\n\nabc\n-----END RSA PRIVATE KEY-----',
true,
],
[
'a key using CRLF line endings',
'-----BEGIN PRIVATE KEY-----\r\nabc\r\n-----END PRIVATE KEY-----',
true,
],
['a public key', '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----', false],
[
'mismatched BEGIN/END markers',
'-----BEGIN PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----',
false,
],
[
'a body with no newline before the END marker',
'-----BEGIN PRIVATE KEY-----\nabc-----END PRIVATE KEY-----',
false,
],
[
'trailing content after the END marker',
'-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\nextra',
false,
],
[
'a body that is only whitespace',
'-----BEGIN PRIVATE KEY-----\n \n-----END PRIVATE KEY-----',
false,
],
['plain text', 'not a private key', false],
])('returns %s as %s', (_, contents, expected) => {
expect(isPrivateKey(contents)).toBe(expected)
})
})
describe('getSnowflakeValidationIssues', () => {
const VALID_DATA = {
snowflakeAccountId: 'MYORG-MYACCOUNT',
snowflakeUser: 'PIPELINES_USER',
snowflakePrivateKey: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----',
snowflakeDatabase: 'PIPELINES_DB',
snowflakeSchema: 'REPLICATED',
}
it('flags a pasted private key that is not a real key', () => {
const issues = getSnowflakeValidationIssues({
...VALID_DATA,
snowflakePrivateKey: 'asdasdasda',
})
expect(issues).toContainEqual({
path: 'snowflakePrivateKey',
message: SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE,
})
})
it('does not flag a valid private key', () => {
expect(getSnowflakeValidationIssues(VALID_DATA)).toEqual([])
})
it('skips the format check when validatePrivateKeyFormat is false', () => {
const issues = getSnowflakeValidationIssues(
{ ...VALID_DATA, snowflakePrivateKey: 'asdasdasda' },
{ validatePrivateKeyFormat: false }
)
expect(issues).toEqual([])
})
it('does not flag an empty private key left blank while editing', () => {
const issues = getSnowflakeValidationIssues(
{ ...VALID_DATA, snowflakePrivateKey: '' },
{ secretsOptional: true }
)
expect(issues).toEqual([])
})
})
describe('readPrivateKeyFile', () => {
const PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nprivate-key\n-----END PRIVATE KEY-----'
const createForm = () => ({
setError: vi.fn(),
setValue: vi.fn(),
clearErrors: vi.fn(),
})
const isCurrent = () => true
it('sets a size error when the file is larger than the limit', async () => {
const file = new File([PRIVATE_KEY], 'rsa_key.p8')
Object.defineProperty(file, 'size', { value: MAX_PRIVATE_KEY_LENGTH + 1 })
const form = createForm()
await readPrivateKeyFile(file, form, isCurrent)
expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
message: 'Private key must be 10,000 characters or fewer.',
})
expect(form.setValue).not.toHaveBeenCalled()
})
it('sets a size error when the file contents exceed the limit after reading', async () => {
const file = new File([PRIVATE_KEY], 'rsa_key.p8')
const longContents = 'a'.repeat(MAX_PRIVATE_KEY_LENGTH + 1)
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(longContents) })
const form = createForm()
await readPrivateKeyFile(file, form, isCurrent)
expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
message: 'Private key must be 10,000 characters or fewer.',
})
expect(form.setValue).not.toHaveBeenCalled()
})
it('sets a format error for a file that is not a private key', async () => {
const publicKey = '-----BEGIN PUBLIC KEY-----\npublic-key\n-----END PUBLIC KEY-----'
const file = new File([publicKey], 'rsa_key.pub')
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(publicKey) })
const form = createForm()
await readPrivateKeyFile(file, form, isCurrent)
expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
message: 'Select a P8 or PEM private key.',
})
expect(form.setValue).not.toHaveBeenCalled()
})
it('applies the contents of a valid private key file', async () => {
const file = new File([PRIVATE_KEY], 'rsa_key.p8')
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) })
const form = createForm()
await readPrivateKeyFile(file, form, isCurrent)
expect(form.setValue).toHaveBeenCalledWith('snowflakePrivateKey', PRIVATE_KEY, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
expect(form.clearErrors).toHaveBeenCalledWith('snowflakePrivateKey')
expect(form.setError).not.toHaveBeenCalled()
})
it('sets a read error when the file cannot be read', async () => {
const file = new File([PRIVATE_KEY], 'rsa_key.p8')
Object.defineProperty(file, 'text', { value: vi.fn().mockRejectedValue(new Error('boom')) })
const form = createForm()
await readPrivateKeyFile(file, form, isCurrent)
expect(form.setError).toHaveBeenCalledWith('snowflakePrivateKey', {
message: 'Could not read the selected private key.',
})
})
it('discards the result when the request is no longer current', async () => {
const file = new File([PRIVATE_KEY], 'rsa_key.p8')
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) })
const form = createForm()
await readPrivateKeyFile(file, form, () => false)
expect(form.setValue).not.toHaveBeenCalled()
expect(form.setError).not.toHaveBeenCalled()
expect(form.clearErrors).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,5 @@
import type { UseFormReturn } from 'react-hook-form'
import { type DestinationPanelSchemaType } from '../DestinationForm.schema'
export type SnowflakeApiConfig = {
@@ -30,12 +32,86 @@ const SNOWFLAKE_REQUIRED_FIELDS: { path: SnowflakeFieldPath; message: string }[]
{ path: 'snowflakeSchema', message: 'Schema is required.' },
]
export const SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE =
'Enter a valid RSA private key in PKCS #8 or PKCS #1 PEM format.'
export const isPrivateKey = (contents: string) => {
const match = contents.match(
/^\s*-----BEGIN ((?:ENCRYPTED |RSA )?PRIVATE KEY)-----\r?\n([\s\S]*?)\r?\n-----END \1-----\s*$/
)
return match !== null && match[2].trim().length > 0
}
export const getSnowflakeValidationIssues = (
data: Pick<DestinationPanelSchemaType, SnowflakeFieldPath>,
options: { secretsOptional?: boolean } = {}
): SnowflakeValidationIssue[] =>
SNOWFLAKE_REQUIRED_FIELDS.filter(({ path }) => {
if (options.secretsOptional && path === 'snowflakePrivateKey') return false
options: { secretsOptional?: boolean; validatePrivateKeyFormat?: boolean } = {}
): SnowflakeValidationIssue[] => {
const { secretsOptional = false, validatePrivateKeyFormat = true } = options
const issues: SnowflakeValidationIssue[] = SNOWFLAKE_REQUIRED_FIELDS.filter(({ path }) => {
if (secretsOptional && path === 'snowflakePrivateKey') return false
return !data[path]?.trim().length
})
const privateKey = data.snowflakePrivateKey?.trim() ?? ''
// Format is checked on submit only. Live onChange validation would fail on every
// keystroke while the user is still pasting or typing a key.
if (privateKey && validatePrivateKeyFormat && !isPrivateKey(privateKey)) {
issues.push({ path: 'snowflakePrivateKey', message: SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE })
}
return issues
}
export const MAX_PRIVATE_KEY_LENGTH = 10000
type PrivateKeyForm = Pick<
UseFormReturn<DestinationPanelSchemaType>,
'setError' | 'setValue' | 'clearErrors'
>
export const readPrivateKeyFile = async (
file: File,
form: PrivateKeyForm,
isCurrentRequest: () => boolean
) => {
if (file.size > MAX_PRIVATE_KEY_LENGTH) {
if (isCurrentRequest()) {
form.setError('snowflakePrivateKey', {
message: 'Private key must be 10,000 characters or fewer.',
})
}
return
}
try {
const contents = await file.text()
if (!isCurrentRequest()) return
if (contents.length > MAX_PRIVATE_KEY_LENGTH) {
form.setError('snowflakePrivateKey', {
message: 'Private key must be 10,000 characters or fewer.',
})
return
}
if (!isPrivateKey(contents)) {
form.setError('snowflakePrivateKey', { message: 'Select a P8 or PEM private key.' })
return
}
form.setValue('snowflakePrivateKey', contents, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
form.clearErrors('snowflakePrivateKey')
} catch {
if (isCurrentRequest()) {
form.setError('snowflakePrivateKey', { message: 'Could not read the selected private key.' })
}
}
}
@@ -45,7 +45,10 @@ import { PipelineCostDialog } from './PipelineCostDialog'
import { PipelineRegionField } from './PipelineRegionField'
import { PublicationSelection } from './PublicationSelection'
import { SnowflakeFields } from './Snowflake/Fields'
import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils'
import {
getSnowflakeValidationIssues,
SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE,
} from './Snowflake/Snowflake.utils'
import { TableCopySelection } from './TableCopySelection'
import { useDestinationForm } from './useDestinationForm'
import { ValidationFailuresSection } from './ValidationFailuresSection'
@@ -223,11 +226,12 @@ export const DestinationForm = ({
}
)
} else if (selectedType === 'Snowflake') {
getSnowflakeValidationIssues(data, { secretsOptional: editMode }).forEach(
({ path, message }) => {
addRequiredFieldError(path, message)
}
)
getSnowflakeValidationIssues(data, {
secretsOptional: editMode,
validatePrivateKeyFormat: false,
}).forEach(({ path, message }) => {
addRequiredFieldError(path, message)
})
} else if (selectedType === 'ClickHouse') {
getClickHouseValidationIssues(data).forEach(({ path, message }) => {
addRequiredFieldError(path, message)
@@ -345,6 +349,16 @@ export const DestinationForm = ({
}
}
if (selectedType === 'Snowflake') {
const privateKeyIssue = getSnowflakeValidationIssues(data, {
secretsOptional: editMode,
}).find((issue) => issue.message === SNOWFLAKE_PRIVATE_KEY_FORMAT_MESSAGE)
if (privateKeyIssue) {
form.setError(privateKeyIssue.path, { message: privateKeyIssue.message })
return
}
}
// Pipeline prerequisite validation models a new pipeline and cannot
// account for resources already owned by an existing pipeline. Edits keep
// the established direct-update flow after pruning stale table ids.