Skip to content

Commit 70d78be

Browse files
feat: Support Snowflake private key as a base64-encoded query parameter (#16)
Signed-off-by: Edgar Ramírez Mondragón <edgarrm358@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3ab3346 commit 70d78be

File tree

5 files changed

+168
-66
lines changed

5 files changed

+168
-66
lines changed

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,49 @@ state_backend:
7878
7979
- **account**: Your Snowflake account identifier (e.g., `myorg-account123`)
8080
- **user**: The username for authentication
81-
- **password**: The password for authentication
81+
- **password**: The password for authentication (required unless using key pair authentication)
8282
- **warehouse**: The compute warehouse to use (required)
8383
- **database**: The database where state will be stored
8484
- **schema**: The schema where state tables will be created (defaults to PUBLIC)
8585
- **role**: Optional role to use for the connection
86+
- **private_key_base64**: Optional base64-encoded DER private key for key pair authentication
87+
88+
#### Key Pair Authentication
89+
90+
Instead of password-based authentication, you can use [Snowflake key pair authentication][snowflake-keypair]. Provide the private key as a base64-encoded DER-format string:
91+
92+
```yaml
93+
state_backend:
94+
uri: snowflake://my_user@my_account/my_database?warehouse=my_warehouse
95+
snowflake:
96+
private_key_base64: MIIEvgIBADANBg... # base64-encoded DER private key
97+
```
98+
99+
The private key can also be passed as a URI query parameter:
100+
101+
```
102+
snowflake://my_user@my_account/my_database?warehouse=my_warehouse&private_key_base64=MIIEvgIBADANBg...
103+
```
104+
105+
Or via an environment variable:
106+
107+
```bash
108+
export MELTANO_STATE_BACKEND_SNOWFLAKE_PRIVATE_KEY_BASE64='MIIEvgIBADANBg...'
109+
```
110+
111+
To generate the base64-encoded DER key from a PEM private key file:
112+
113+
```bash
114+
openssl pkcs8 -topk8 -inform PEM -outform DER -in rsa_key.pem -nocrypt | base64
115+
```
116+
117+
When using key pair authentication, no password is required.
86118

87119
#### Security Considerations
88120

89121
When storing credentials:
90122

91123
- Use environment variables for sensitive values in production
92-
- Consider using Snowflake key-pair authentication (future enhancement)
93124
- Ensure the user has CREATE TABLE, INSERT, UPDATE, DELETE, and SELECT privileges
94125

95126
Example using environment variables:
@@ -129,6 +160,7 @@ gh release create v<new-version>
129160
[meltano]: https://meltano.com
130161
[pipx]: https://github.com/pypa/pipx
131162
[snowflake]: https://www.snowflake.com/
163+
[snowflake-keypair]: https://docs.snowflake.com/en/user-guide/key-pair-auth
132164
[snowflake-sqlalchemy]: https://github.com/snowflakedb/snowflake-sqlalchemy
133165
[state-backend]: https://docs.meltano.com/concepts/state_backends
134166
[uv]: https://docs.astral.sh/uv

fixtures/project/meltano.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
version: 1
1+
requires_meltano: ">=3.7"
22
default_environment: dev
33
project_id: 1ec76bd8-4499-4bad-a974-b27225d75f12
44
send_anonymous_usage_stats: false

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ snowflake_role = "meltano_state_backend_snowflake.backend:SNOWFLAKE_ROLE"
4141
snowflake_schema = "meltano_state_backend_snowflake.backend:SNOWFLAKE_SCHEMA"
4242
snowflake_user = "meltano_state_backend_snowflake.backend:SNOWFLAKE_USER"
4343
snowflake_warehouse = "meltano_state_backend_snowflake.backend:SNOWFLAKE_WAREHOUSE"
44+
snowflake_private_key_base64 = "meltano_state_backend_snowflake.backend:SNOWFLAKE_PRIVATE_KEY_BASE64"
4445

4546
[project.entry-points."meltano.state_backends"]
4647
snowflake = "meltano_state_backend_snowflake.backend:SnowflakeStateStoreManager"

src/meltano_state_backend_snowflake/backend.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
from __future__ import annotations
44

5+
import base64
56
import json
6-
import typing as t
77
from contextlib import contextmanager
88
from functools import cached_property
99
from time import sleep
10+
from typing import TYPE_CHECKING, Any
1011
from urllib.parse import parse_qs, urlparse
1112

1213
import snowflake.connector
@@ -19,7 +20,7 @@
1920
StateStoreManager,
2021
)
2122

22-
if t.TYPE_CHECKING:
23+
if TYPE_CHECKING:
2324
from collections.abc import Generator, Iterable
2425

2526

@@ -85,6 +86,15 @@ class SnowflakeStateBackendError(MeltanoError):
8586
env_specific=True,
8687
)
8788

89+
SNOWFLAKE_PRIVATE_KEY_BASE64 = SettingDefinition(
90+
name="state_backend.snowflake.private_key_base64",
91+
label="Snowflake Private Key (Base64-encoded DER)",
92+
description="Snowflake private key to use, in base64-encoded DER format",
93+
kind=SettingKind.STRING, # ty: ignore[invalid-argument-type]
94+
sensitive=True,
95+
env_specific=True,
96+
)
97+
8898

8999
class SnowflakeStateStoreManager(StateStoreManager):
90100
"""State backend for Snowflake."""
@@ -104,7 +114,8 @@ def __init__(
104114
database: str | None = None,
105115
schema: str | None = None,
106116
role: str | None = None,
107-
**kwargs: t.Any,
117+
private_key_base64: str | None = None,
118+
**kwargs: Any,
108119
) -> None:
109120
"""Initialize the SnowflakeStateStoreManager.
110121
@@ -117,6 +128,7 @@ def __init__(
117128
database: Snowflake database name
118129
schema: Snowflake schema name (default: PUBLIC)
119130
role: Optional Snowflake role to use
131+
private_key_base64: Optional Snowflake private key to use, in base64-encoded DER format
120132
kwargs: Additional keyword args to pass to parent
121133
122134
"""
@@ -137,8 +149,12 @@ def __init__(
137149
raise MissingStateBackendSettingsError(msg)
138150

139151
self.password = password or parsed.password
140-
if not self.password:
141-
msg = "Snowflake password is required"
152+
if (
153+
not self.password
154+
and not private_key_base64
155+
and not query_params.get("private_key_base64")
156+
):
157+
msg = "Snowflake password or private key is required"
142158
raise MissingStateBackendSettingsError(msg)
143159

144160
self.warehouse = warehouse or query_params.get("warehouse", [None])[0]
@@ -156,8 +172,18 @@ def __init__(
156172
self.schema = schema or (path_parts[1] if len(path_parts) > 1 else "PUBLIC")
157173
self.role = role or query_params.get("role", [None])[0]
158174

175+
pkey_base64 = private_key_base64 or query_params.get("private_key_base64", [None])[0]
176+
self.private_key = self._load_private_key(pkey_base64) if pkey_base64 else None
177+
159178
self._ensure_tables()
160179

180+
def _load_private_key(self, pkey_base64: str) -> bytes:
181+
# Restore '+' chars that parse_qs decodes as spaces
182+
pkey_base64 = pkey_base64.replace(" ", "+")
183+
# Add padding if stripped (e.g. from URL query params)
184+
pkey_base64 += "=" * (-len(pkey_base64) % 4)
185+
return base64.b64decode(pkey_base64)
186+
161187
@cached_property
162188
def connection(self) -> snowflake.connector.SnowflakeConnection:
163189
"""Get a Snowflake connection.
@@ -166,17 +192,21 @@ def connection(self) -> snowflake.connector.SnowflakeConnection:
166192
A Snowflake connection object.
167193
168194
"""
169-
conn_params = {
195+
conn_params: dict[str, Any] = {
170196
"account": self.account,
171197
"user": self.user,
172-
"password": self.password,
173198
"warehouse": self.warehouse,
174199
"database": self.database,
175200
"schema": self.schema,
176201
}
177202
if self.role:
178203
conn_params["role"] = self.role
179204

205+
if self.private_key:
206+
conn_params["private_key"] = self.private_key
207+
elif self.password: # pragma: no branch
208+
conn_params["password"] = self.password
209+
180210
return snowflake.connector.connect(**conn_params)
181211

182212
def _ensure_tables(self) -> None:

tests/test_backend.py

Lines changed: 95 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
import base64
34
import shutil
45
from decimal import Decimal
56
from typing import TYPE_CHECKING
67
from unittest import mock
78

89
import pytest
10+
from cryptography.hazmat.primitives import serialization
11+
from cryptography.hazmat.primitives.asymmetric import rsa
912
from meltano.core.project import Project
1013
from meltano.core.state_store import MeltanoState, state_store_manager_from_project_settings
1114
from meltano.core.state_store.base import (
@@ -31,6 +34,17 @@ def project(tmp_path: Path) -> Project:
3134
return Project.find(path.resolve()) # type: ignore[no-any-return]
3235

3336

37+
@pytest.fixture
38+
def pkey_base64() -> str:
39+
dummy_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
40+
dummy_private_key = dummy_key.private_bytes(
41+
encoding=serialization.Encoding.DER,
42+
format=serialization.PrivateFormat.PKCS8,
43+
encryption_algorithm=serialization.NoEncryption(),
44+
)
45+
return base64.b64encode(dummy_private_key).decode("utf-8")
46+
47+
3448
def test_get_manager(project: Project) -> None:
3549
with mock.patch(
3650
"meltano_state_backend_snowflake.backend.SnowflakeStateStoreManager._ensure_tables",
@@ -379,64 +393,78 @@ def test_acquire_lock_retry(
379393
assert mock_cursor.execute.call_count >= 2
380394

381395

396+
# class TestURIQueryParams:
397+
# """Tests for URI query parameter parsing."""
398+
399+
400+
@pytest.fixture
401+
def base_uri() -> str:
402+
return "snowflake://myuser:mypass@myaccount/mydb/myschema?warehouse=mywh"
403+
404+
382405
@pytest.mark.usefixtures("mock_connection")
383-
class TestURIQueryParams:
384-
"""Tests for URI query parameter parsing."""
406+
def test_full_sqlalchemy_uri(base_uri: str) -> None:
407+
"""Test full SQLAlchemy-style URI with all params."""
408+
manager = SnowflakeStateStoreManager(uri=f"{base_uri}&role=myrole")
409+
assert manager.account == "myaccount"
410+
assert manager.user == "myuser"
411+
assert manager.password == "mypass" # noqa: S105
412+
assert manager.database == "mydb"
413+
assert manager.schema == "myschema"
414+
assert manager.warehouse == "mywh"
415+
assert manager.role == "myrole"
385416

386-
def test_full_sqlalchemy_uri(self) -> None:
387-
"""Test full SQLAlchemy-style URI with all params."""
388-
manager = SnowflakeStateStoreManager(
389-
uri="snowflake://myuser:mypass@myaccount/mydb/myschema?warehouse=mywh&role=myrole",
390-
)
391-
assert manager.account == "myaccount"
392-
assert manager.user == "myuser"
393-
assert manager.password == "mypass" # noqa: S105
394-
assert manager.database == "mydb"
395-
assert manager.schema == "myschema"
396-
assert manager.warehouse == "mywh"
397-
assert manager.role == "myrole"
398-
399-
def test_warehouse_from_query_param(self) -> None:
400-
"""Test warehouse extracted from query parameter."""
401-
manager = SnowflakeStateStoreManager(
402-
uri="snowflake://myuser:mypass@myaccount/mydb?warehouse=mywh",
403-
)
404-
assert manager.warehouse == "mywh"
405-
assert manager.schema == "PUBLIC"
406417

407-
def test_role_from_query_param(self) -> None:
408-
"""Test role extracted from query parameter."""
409-
manager = SnowflakeStateStoreManager(
410-
uri="snowflake://myuser:mypass@myaccount/mydb?warehouse=mywh&role=analyst",
411-
)
412-
assert manager.role == "analyst"
418+
@pytest.mark.usefixtures("mock_connection")
419+
def test_warehouse_from_query_param(base_uri: str) -> None:
420+
"""Test warehouse extracted from query parameter."""
421+
manager = SnowflakeStateStoreManager(uri=base_uri)
422+
assert manager.warehouse == "mywh"
423+
assert manager.schema == "myschema"
413424

414-
def test_explicit_settings_override_query_params(self) -> None:
415-
"""Test that explicit settings take precedence over query params."""
416-
manager = SnowflakeStateStoreManager(
417-
uri="snowflake://uri_user:uri_pass@uri_account/uri_db/uri_schema?warehouse=uri_wh&role=uri_role",
418-
account="explicit_account",
419-
user="explicit_user",
420-
password="explicit_pass", # noqa: S106
421-
warehouse="explicit_wh",
422-
database="explicit_db",
423-
schema="explicit_schema",
424-
role="explicit_role",
425-
)
426-
assert manager.account == "explicit_account"
427-
assert manager.user == "explicit_user"
428-
assert manager.password == "explicit_pass" # noqa: S105
429-
assert manager.warehouse == "explicit_wh"
430-
assert manager.database == "explicit_db"
431-
assert manager.schema == "explicit_schema"
432-
assert manager.role == "explicit_role"
433-
434-
def test_role_defaults_to_none(self) -> None:
435-
"""Test role defaults to None when not provided."""
436-
manager = SnowflakeStateStoreManager(
437-
uri="snowflake://myuser:mypass@myaccount/mydb?warehouse=mywh",
438-
)
439-
assert manager.role is None
425+
426+
@pytest.mark.usefixtures("mock_connection")
427+
def test_role_from_query_param(base_uri: str) -> None:
428+
"""Test role extracted from query parameter."""
429+
manager = SnowflakeStateStoreManager(uri=f"{base_uri}&role=analyst")
430+
assert manager.role == "analyst"
431+
432+
433+
@pytest.mark.usefixtures("mock_connection")
434+
def test_explicit_settings_override_query_params(base_uri: str) -> None:
435+
"""Test that explicit settings take precedence over query params."""
436+
manager = SnowflakeStateStoreManager(
437+
uri=f"{base_uri}&role=uri_role",
438+
account="explicit_account",
439+
user="explicit_user",
440+
password="explicit_pass", # noqa: S106
441+
warehouse="explicit_wh",
442+
database="explicit_db",
443+
schema="explicit_schema",
444+
role="explicit_role",
445+
)
446+
assert manager.account == "explicit_account"
447+
assert manager.user == "explicit_user"
448+
assert manager.password == "explicit_pass" # noqa: S105
449+
assert manager.warehouse == "explicit_wh"
450+
assert manager.database == "explicit_db"
451+
assert manager.schema == "explicit_schema"
452+
assert manager.role == "explicit_role"
453+
454+
455+
@pytest.mark.usefixtures("mock_connection")
456+
def test_role_defaults_to_none(base_uri: str) -> None:
457+
"""Test role defaults to None when not provided."""
458+
manager = SnowflakeStateStoreManager(uri=base_uri)
459+
assert manager.role is None
460+
461+
462+
@pytest.mark.usefixtures("mock_connection")
463+
def test_private_key_from_query_param(base_uri: str, pkey_base64: str) -> None:
464+
"""Test private key extracted from query parameter."""
465+
manager = SnowflakeStateStoreManager(uri=f"{base_uri}&private_key_base64={pkey_base64}")
466+
assert manager.private_key is not None
467+
assert manager.private_key == base64.b64decode(pkey_base64)
440468

441469

442470
def test_missing_account_validation() -> None:
@@ -470,10 +498,10 @@ def test_missing_user_validation() -> None:
470498

471499

472500
def test_missing_password_validation() -> None:
473-
"""Test missing password validation."""
501+
"""Test missing password validation when no private key is provided."""
474502
with pytest.raises(
475503
MissingStateBackendSettingsError,
476-
match="Snowflake password is required",
504+
match="Snowflake password or private key is required",
477505
):
478506
SnowflakeStateStoreManager(
479507
uri="snowflake://user@account/db", # No password in URI
@@ -484,6 +512,17 @@ def test_missing_password_validation() -> None:
484512
)
485513

486514

515+
@pytest.mark.usefixtures("mock_connection")
516+
def test_private_key_without_password(pkey_base64: str) -> None:
517+
"""Test that private key auth works without a password."""
518+
manager = SnowflakeStateStoreManager(
519+
uri="snowflake://myuser@myaccount/mydb?warehouse=mywh",
520+
private_key_base64=pkey_base64,
521+
)
522+
assert manager.private_key == base64.b64decode(pkey_base64)
523+
assert manager.password is None
524+
525+
487526
def test_missing_warehouse_validation() -> None:
488527
"""Test missing warehouse validation."""
489528
with pytest.raises(

0 commit comments

Comments
 (0)