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+
912from kbcstorage .base import Endpoint
1013from kbcstorage .files import Files
1114from kbcstorage .jobs import Jobs
15+ from kbcstorage .tokens import Tokens
1216from 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+
1548def _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.
0 commit comments