Skip to content

Commit a4e2012

Browse files
authored
Merge pull request #93 from keboola/snowflake-keypair-workspace-default
feat: default to key-pair login for Snowflake workspaces
2 parents b8a89ca + ecb6a26 commit a4e2012

4 files changed

Lines changed: 292 additions & 11 deletions

File tree

kbcstorage/workspaces.py

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,45 @@
66
.. _here:
77
http://docs.keboola.apiary.io/#reference/workspaces/
88
"""
9+
from cryptography.hazmat.primitives import serialization
10+
from cryptography.hazmat.primitives.asymmetric import rsa
11+
912
from kbcstorage.base import Endpoint
1013
from kbcstorage.files import Files
1114
from kbcstorage.jobs import Jobs
15+
from kbcstorage.tokens import Tokens
1216
from typing import List # the legacy Workspaces class below unfortunately defines its own method called list
1317

1418

19+
BACKEND_SNOWFLAKE = 'snowflake'
20+
LOGIN_TYPE_DEFAULT = 'default'
21+
LOGIN_TYPE_SNOWFLAKE_SERVICE_KEYPAIR = 'snowflake-service-keypair'
22+
23+
# sentinel distinguishing "not resolved yet" from "project has no default backend"
24+
_DEFAULT_BACKEND_UNRESOLVED = object()
25+
26+
27+
def _generate_rsa_key_pair():
28+
"""
29+
Generate an RSA-2048 key pair for Snowflake key-pair authentication.
30+
31+
Returns:
32+
(private_key_pem, public_key_pem): Both keys PEM-encoded, the private
33+
key in PKCS#8 format as expected by Snowflake drivers.
34+
"""
35+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
36+
private_key_pem = private_key.private_bytes(
37+
encoding=serialization.Encoding.PEM,
38+
format=serialization.PrivateFormat.PKCS8,
39+
encryption_algorithm=serialization.NoEncryption(),
40+
).decode('ascii')
41+
public_key_pem = private_key.public_key().public_bytes(
42+
encoding=serialization.Encoding.PEM,
43+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
44+
).decode('ascii')
45+
return private_key_pem, public_key_pem
46+
47+
1548
def _make_body(mapping, source_key='source', preserve: bool = True):
1649
"""
1750
Given a dict mapping Keboola tables to aliases, construct the body of
@@ -44,6 +77,7 @@ def __init__(self, root_url, token):
4477
token (:obj:`str`): A storage API key.
4578
"""
4679
super().__init__(root_url, 'workspaces', token)
80+
self._default_backend = _DEFAULT_BACKEND_UNRESOLVED
4781

4882
def list(self):
4983
"""
@@ -61,8 +95,9 @@ def detail(self, workspace_id):
6195
"""
6296
Retrieves information about a given workspace.
6397
64-
Note that the password to the workspace can only be retrieved when the
65-
workspace is created.
98+
Note that the workspace credentials (password or private key,
99+
depending on the login type) are only available when the workspace
100+
is created and cannot be retrieved later.
66101
67102
Args:
68103
workspace_id (int or str): The id of the workspace.
@@ -77,24 +112,78 @@ def create(self, backend=None, timeout=None, login_type=None, public_key=None, r
77112
"""
78113
Create a new Workspace and return the credentials.
79114
115+
On the snowflake backend, an omitted (or 'default') login_type would
116+
create a deprecated password-based workspace. The client therefore
117+
defaults to the 'snowflake-service-keypair' login type: when no
118+
public_key is supplied, an RSA key pair is generated locally, the
119+
public key is sent to the API and the private key is returned in
120+
response['connection']['privateKey'] (it never leaves the client
121+
otherwise and cannot be retrieved later). To get the deprecated
122+
password-based workspace, pass login_type='snowflake-legacy-service'
123+
explicitly.
124+
80125
Args:
81126
backend (:obj:`str`): The type of engine for the workspace.
82127
'redshift', 'snowflake' or 'synapse'. Defaults to the project's default backend.
83128
timeout (int): The timeout, in seconds, for SQL statements.
84129
Only supported by snowflake backends.
130+
login_type (:obj:`str`): The login type of the workspace, e.g.
131+
'snowflake-service-keypair', 'snowflake-person-keypair',
132+
'snowflake-legacy-service' or 'none'. Defaults to
133+
'snowflake-service-keypair' on snowflake, otherwise to the
134+
backend's default.
135+
public_key (:obj:`str`): PEM-encoded RSA public key to use with
136+
key-pair login types. When omitted for the default snowflake
137+
key-pair login, a key pair is generated locally.
138+
read_all_objects (bool): Grant the workspace read-only access to
139+
all project data.
85140
86141
Raises:
87142
requests.HTTPError: If the API request fails.
88143
"""
144+
private_key = None
145+
effective_backend = backend or self._get_default_backend()
146+
if effective_backend is None and login_type is not None:
147+
raise ValueError(
148+
"Cannot resolve the project's default backend from the token; "
149+
"pass backend explicitly when using login_type."
150+
)
151+
if effective_backend == BACKEND_SNOWFLAKE:
152+
if login_type in (None, LOGIN_TYPE_DEFAULT):
153+
login_type = LOGIN_TYPE_SNOWFLAKE_SERVICE_KEYPAIR
154+
if login_type == LOGIN_TYPE_SNOWFLAKE_SERVICE_KEYPAIR and public_key is None:
155+
private_key, public_key = _generate_rsa_key_pair()
156+
if login_type is not None:
157+
# the API rejects loginType without an explicit backend
158+
backend = effective_backend
159+
89160
body = {
90-
'backend': backend,
91-
'statementTimeoutSeconds': timeout,
92-
'loginType': login_type,
93-
'publicKey': public_key,
94-
'readOnlyStorageAccess': str(read_all_objects).lower() # convert bool to lowercase true or false
161+
k: v for k, v in {
162+
'backend': backend,
163+
'statementTimeoutSeconds': timeout,
164+
'loginType': login_type,
165+
'publicKey': public_key,
166+
'readOnlyStorageAccess': str(read_all_objects).lower() # convert bool to lowercase true or false
167+
}.items()
168+
if v is not None
95169
}
96170

97-
return self._post(self.base_url, data=body)
171+
response = self._post(self.base_url, data=body)
172+
if private_key is not None:
173+
response.setdefault('connection', {})['privateKey'] = private_key
174+
return response
175+
176+
def _get_default_backend(self):
177+
"""
178+
Resolve the project's default backend from the token detail.
179+
180+
The value is cached on the instance - a project's default backend is
181+
effectively immutable for the client's lifetime.
182+
"""
183+
if self._default_backend is _DEFAULT_BACKEND_UNRESOLVED:
184+
token_info = Tokens(self.root_url, self.token).verify()
185+
self._default_backend = (token_info.get('owner') or {}).get('defaultBackend')
186+
return self._default_backend
98187

99188
def delete(self, workspace_id):
100189
"""
@@ -116,6 +205,11 @@ def reset_password(self, workspace_id):
116205
"""
117206
Generate a new password for the workspace.
118207
208+
Only supported for password-based login types (e.g. the deprecated
209+
'snowflake-legacy-service'). For key-pair workspaces rotate the
210+
credentials with set_public_key() using a freshly generated key pair
211+
instead.
212+
119213
Args:
120214
workspace_id (int or str): The id of the workspace for which the
121215
password should be reset.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ classifiers = [
2121
dependencies = [
2222
"boto3",
2323
"azure-storage-blob",
24+
"cryptography",
2425
"urllib3<2.0.0", # Frozen until fixed: https://github.com/boto/botocore/issues/2926
2526
# Dev dependencies
2627
"requests",

tests/mocks/test_workspaces.py

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
"""
2-
Asses basic functionality of the Workspace endpoint.
2+
Assess basic functionality of the Workspace endpoint.
33
"""
4+
import copy
45
import unittest
6+
from urllib.parse import parse_qs
7+
58
import responses
69
from requests import HTTPError
710

811
from kbcstorage.workspaces import Workspaces
912

13+
from .token_responses import verify_token_response
1014
from .workspace_responses import (list_response, detail_response,
1115
load_tables_response, create_response,
16+
keypair_create_response,
1217
reset_password_response)
1318

1419

@@ -91,18 +96,191 @@ def test_detail_inexsitent_workspace(self):
9196
@responses.activate
9297
def test_create(self):
9398
"""
94-
Workspace endpoint mock creates new workspace
99+
Workspace endpoint mock creates new workspace. With no backend given,
100+
the project default backend (snowflake) is resolved from the token and
101+
a key-pair workspace is created instead of a password one.
95102
"""
103+
responses.add(
104+
responses.Response(
105+
method='GET',
106+
url='https://connection.keboola.com/v2/storage/tokens/verify',
107+
json=verify_token_response
108+
)
109+
)
96110
responses.add(
97111
responses.Response(
98112
method='POST',
99113
url='https://connection.keboola.com/v2/storage/workspaces',
100-
json=create_response
114+
json=keypair_create_response
101115
)
102116
)
103117
created_detail = self.ws.create()
118+
request_body = parse_qs(responses.calls[1].request.body, keep_blank_values=True)
119+
assert request_body['backend'] == ['snowflake']
120+
assert request_body['loginType'] == ['snowflake-service-keypair']
121+
assert 'BEGIN PUBLIC KEY' in request_body['publicKey'][0]
122+
assert 'BEGIN PRIVATE KEY' in created_detail['connection']['privateKey']
123+
124+
@responses.activate
125+
def test_create_snowflake_defaults_to_keypair(self):
126+
"""
127+
With an explicit snowflake backend no token verify is needed and the
128+
login type defaults to snowflake-service-keypair with a locally
129+
generated key pair.
130+
"""
131+
responses.add(
132+
responses.Response(
133+
method='POST',
134+
url='https://connection.keboola.com/v2/storage/workspaces',
135+
json=keypair_create_response
136+
)
137+
)
138+
created_detail = self.ws.create(backend='snowflake')
139+
assert len(responses.calls) == 1
140+
request_body = parse_qs(responses.calls[0].request.body, keep_blank_values=True)
141+
assert request_body['loginType'] == ['snowflake-service-keypair']
142+
assert 'BEGIN PUBLIC KEY' in request_body['publicKey'][0]
143+
assert 'BEGIN PRIVATE KEY' in created_detail['connection']['privateKey']
144+
145+
@responses.activate
146+
def test_create_snowflake_with_own_public_key(self):
147+
"""
148+
A caller-supplied public key is passed through and no private key is
149+
injected into the response.
150+
"""
151+
responses.add(
152+
responses.Response(
153+
method='POST',
154+
url='https://connection.keboola.com/v2/storage/workspaces',
155+
json=keypair_create_response
156+
)
157+
)
158+
created_detail = self.ws.create(backend='snowflake', public_key='my-public-key')
159+
request_body = parse_qs(responses.calls[0].request.body, keep_blank_values=True)
160+
assert request_body['loginType'] == ['snowflake-service-keypair']
161+
assert request_body['publicKey'] == ['my-public-key']
162+
assert 'privateKey' not in created_detail['connection']
163+
164+
@responses.activate
165+
def test_create_snowflake_explicit_legacy_login_type(self):
166+
"""
167+
An explicitly requested password login type is passed through
168+
unchanged and no key pair is generated.
169+
"""
170+
responses.add(
171+
responses.Response(
172+
method='POST',
173+
url='https://connection.keboola.com/v2/storage/workspaces',
174+
json=create_response
175+
)
176+
)
177+
created_detail = self.ws.create(backend='snowflake', login_type='snowflake-legacy-service')
178+
request_body = parse_qs(responses.calls[0].request.body, keep_blank_values=True)
179+
assert request_body['loginType'] == ['snowflake-legacy-service']
180+
assert 'publicKey' not in request_body
104181
assert created_detail['connection']['password'] == 'abc'
105182

183+
@responses.activate
184+
def test_create_non_snowflake_default_backend_unchanged(self):
185+
"""
186+
When the project default backend resolved from the token is not
187+
snowflake, no login type, public key or backend is sent.
188+
"""
189+
bigquery_verify_response = copy.deepcopy(verify_token_response)
190+
bigquery_verify_response['owner']['defaultBackend'] = 'bigquery'
191+
responses.add(
192+
responses.Response(
193+
method='GET',
194+
url='https://connection.keboola.com/v2/storage/tokens/verify',
195+
json=bigquery_verify_response
196+
)
197+
)
198+
responses.add(
199+
responses.Response(
200+
method='POST',
201+
url='https://connection.keboola.com/v2/storage/workspaces',
202+
json=create_response
203+
)
204+
)
205+
self.ws.create()
206+
request_body = parse_qs(responses.calls[1].request.body, keep_blank_values=True)
207+
assert 'backend' not in request_body
208+
assert 'loginType' not in request_body
209+
assert 'publicKey' not in request_body
210+
211+
@responses.activate
212+
def test_create_caches_default_backend(self):
213+
"""
214+
The default backend is resolved via the token verify call only once
215+
per endpoint instance.
216+
"""
217+
responses.add(
218+
responses.Response(
219+
method='GET',
220+
url='https://connection.keboola.com/v2/storage/tokens/verify',
221+
json=verify_token_response
222+
)
223+
)
224+
responses.add(
225+
responses.Response(
226+
method='POST',
227+
url='https://connection.keboola.com/v2/storage/workspaces',
228+
json=keypair_create_response
229+
)
230+
)
231+
responses.add(
232+
responses.Response(
233+
method='POST',
234+
url='https://connection.keboola.com/v2/storage/workspaces',
235+
json=keypair_create_response
236+
)
237+
)
238+
self.ws.create()
239+
self.ws.create()
240+
verify_calls = [c for c in responses.calls if c.request.method == 'GET']
241+
assert len(verify_calls) == 1
242+
243+
@responses.activate
244+
def test_create_login_type_without_resolvable_backend_raises(self):
245+
"""
246+
The API rejects loginType without an explicit backend, so the client
247+
raises a clear error when the default backend cannot be resolved. The
248+
missing value is cached too - verify is not re-queried on retry.
249+
"""
250+
no_backend_verify_response = copy.deepcopy(verify_token_response)
251+
del no_backend_verify_response['owner']['defaultBackend']
252+
responses.add(
253+
responses.Response(
254+
method='GET',
255+
url='https://connection.keboola.com/v2/storage/tokens/verify',
256+
json=no_backend_verify_response
257+
)
258+
)
259+
with self.assertRaises(ValueError):
260+
self.ws.create(login_type='none')
261+
with self.assertRaises(ValueError):
262+
self.ws.create(login_type='none')
263+
assert len(responses.calls) == 1
264+
265+
@responses.activate
266+
def test_create_non_snowflake_backend_unchanged(self):
267+
"""
268+
Non-snowflake backends keep the original behavior - no login type or
269+
public key is sent.
270+
"""
271+
responses.add(
272+
responses.Response(
273+
method='POST',
274+
url='https://connection.keboola.com/v2/storage/workspaces',
275+
json=create_response
276+
)
277+
)
278+
self.ws.create(backend='bigquery')
279+
request_body = parse_qs(responses.calls[0].request.body, keep_blank_values=True)
280+
assert request_body['backend'] == ['bigquery']
281+
assert 'loginType' not in request_body
282+
assert 'publicKey' not in request_body
283+
106284
@responses.activate
107285
def test_delete(self):
108286
"""

0 commit comments

Comments
 (0)