Skip to content

Commit 91e3100

Browse files
committed
feat: key-pair authentication for Snowflake
JIRA: PSDK-193 risk: low
1 parent e12164a commit 91e3100

File tree

8 files changed

+413
-8
lines changed

8 files changed

+413
-8
lines changed

docs/Dockerfile

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
FROM node:20.11.1-bookworm-slim
22

3-
RUN npm install -g [email protected]
3+
RUN apt-get update && \
4+
apt-get install -y git make golang-go curl && \
5+
npm install -g [email protected] && \
6+
apt-get clean && \
7+
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
48

59
COPY docs docs
610

7-
RUN apt-get update && \
8-
apt-get install -y git make golang-go curl
9-
1011
WORKDIR docs
1112
RUN npm install
1213

docs/content/en/latest/data/data-source/_index.md

+21-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ See [Connect Data](https://www.gooddata.com/docs/cloud/connect-data/) to learn h
4040

4141
## Example
4242

43-
Since there are multiple data source types, here are examples, how to initialize each of them:
43+
Since there are multiple data source types, here are examples of how to initialize each of them:
4444

4545
### Postgres
4646

@@ -79,6 +79,7 @@ CatalogDataSourceRedshift(
7979
```
8080
### Snowflake
8181

82+
Using basic credentials (username + password):
8283
```python
8384
CatalogDataSourceSnowflake(
8485
id=data_source_id,
@@ -95,6 +96,25 @@ CatalogDataSourceSnowflake(
9596
),
9697
)
9798
```
99+
100+
Using key-pair authentication (username + private_key, optionally private_key_passphrase):
101+
```python
102+
CatalogDataSourceSnowflake(
103+
id=data_source_id,
104+
name=data_source_name,
105+
db_specific_attributes=SnowflakeAttributes(
106+
account=os.environ["SNOWFLAKE_ACCOUNT"],
107+
warehouse=os.environ["SNOWFLAKE_WAREHOUSE"],
108+
db_name=os.environ["SNOWFLAKE_DBNAME"]
109+
),
110+
schema=os.environ["SNOWFLAKE_SCHEMA"],
111+
credentials=KeyPairCredentials(
112+
username=os.environ["SNOWFLAKE_USER"],
113+
private_key=os.environ["SNOWFLAKE_PRIVATE_KEY"],
114+
),
115+
)
116+
```
117+
98118
### Vertica
99119

100120
```python

gooddata-sdk/gooddata_sdk/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from gooddata_sdk.catalog.entity import (
5757
AttrCatalogEntity,
5858
BasicCredentials,
59+
KeyPairCredentials,
5960
TokenCredentialsFromEnvVar,
6061
TokenCredentialsFromFile,
6162
)

gooddata-sdk/gooddata_sdk/catalog/data_source/declarative_model/data_source.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ def to_test_request(
107107
self,
108108
password: Optional[str] = None,
109109
token: Optional[str] = None,
110+
private_key: Optional[str] = None,
111+
private_key_passphrase: Optional[str] = None,
110112
) -> TestDefinitionRequest:
111113
kwargs: dict[str, Any] = {"schema": self.schema}
112114
if password is not None:
@@ -115,6 +117,10 @@ def to_test_request(
115117
kwargs["token"] = token
116118
if self.username is not None:
117119
kwargs["username"] = self.username
120+
if private_key is not None:
121+
kwargs["private_key"] = private_key
122+
if private_key_passphrase is not None:
123+
kwargs["private_key_passphrase"] = private_key
118124
return TestDefinitionRequest(type=self.type, url=self.url, **kwargs)
119125

120126
@staticmethod
@@ -127,12 +133,22 @@ def data_source_folder(data_sources_folder: Path, data_source_id: str) -> Path:
127133
create_directory(data_source_folder)
128134
return data_source_folder
129135

130-
def to_api(self, password: Optional[str] = None, token: Optional[str] = None) -> DeclarativeDataSource:
136+
def to_api(
137+
self,
138+
password: Optional[str] = None,
139+
token: Optional[str] = None,
140+
private_key: Optional[str] = None,
141+
private_key_passphrase: Optional[str] = None,
142+
) -> DeclarativeDataSource:
131143
dictionary = self._get_snake_dict()
132144
if password is not None:
133145
dictionary["password"] = password
134146
if token is not None:
135147
dictionary["token"] = token
148+
if private_key is not None:
149+
dictionary["private_key"] = private_key
150+
if private_key_passphrase is not None:
151+
dictionary["private_key_passphrase"] = private_key_passphrase
136152
return self.client_class().from_dict(dictionary)
137153

138154
def store_to_disk(self, data_sources_folder: Path) -> None:

gooddata-sdk/gooddata_sdk/catalog/data_source/entity_model/data_source.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
from gooddata_api_client.model.json_api_data_source_patch_document import JsonApiDataSourcePatchDocument
1414

1515
from gooddata_sdk.catalog.base import Base, value_in_allowed
16-
from gooddata_sdk.catalog.entity import BasicCredentials, Credentials, TokenCredentials, TokenCredentialsFromFile
16+
from gooddata_sdk.catalog.entity import (
17+
BasicCredentials,
18+
Credentials,
19+
KeyPairCredentials,
20+
TokenCredentials,
21+
TokenCredentialsFromFile,
22+
)
1723

1824
U = TypeVar("U", bound="CatalogDataSourceBase")
1925

@@ -29,6 +35,7 @@ class CatalogDataSourceBase(Base):
2935
BasicCredentials,
3036
TokenCredentials,
3137
TokenCredentialsFromFile,
38+
KeyPairCredentials,
3239
]
3340
_DELIMITER: ClassVar[str] = "&"
3441
_ATTRIBUTES: ClassVar[List[str]] = [

gooddata-sdk/gooddata_sdk/catalog/entity.py

+23
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ class Credentials(Base):
118118
TOKEN_KEY: ClassVar[str] = "token"
119119
USER_KEY: ClassVar[str] = "username"
120120
PASSWORD_KEY: ClassVar[str] = "password"
121+
PRIVATE_KEY: ClassVar[str] = "private_key"
122+
PRIVATE_KEY_PASSPHRASE: ClassVar[str] = "private_key_passphrase"
121123

122124
def to_api_args(self) -> dict[str, Any]:
123125
return attr.asdict(self)
@@ -228,3 +230,24 @@ def from_api(cls, attributes: dict[str, Any]) -> BasicCredentials:
228230
# You have to fill it to keep it or update it
229231
password="",
230232
)
233+
234+
235+
@attr.s(auto_attribs=True, kw_only=True)
236+
class KeyPairCredentials(Credentials):
237+
username: str
238+
private_key: str = attr.field(repr=lambda value: "***")
239+
private_key_passphrase: Optional[str] = attr.field(repr=lambda value: "***", default=None)
240+
241+
@classmethod
242+
def is_part_of_api(cls, entity: dict[str, Any]) -> bool:
243+
return cls.USER_KEY in entity and cls.PRIVATE_KEY in entity
244+
245+
@classmethod
246+
def from_api(cls, attributes: dict[str, Any]) -> KeyPairCredentials:
247+
# Credentials are not returned for security reasons
248+
return cls(
249+
username=attributes[cls.USER_KEY],
250+
# Private key is not returned from API (security)
251+
# You have to fill it to keep it or update it
252+
private_key="",
253+
)

0 commit comments

Comments
 (0)