mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
9a03f3cd9c
commit
9fb173e827
8 files changed
+557
-96
No files matched your search
+20
@@ -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>
|
||||
|
||||
+44
@@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-42
@@ -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,
|
||||
|
||||
+83
@@ -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()
|
||||
})
|
||||
})
|
||||
+113
-44
@@ -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">
|
||||
|
||||
+196
@@ -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()
|
||||
})
|
||||
})
|
||||
+80
-4
@@ -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.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-6
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user