hyperlake 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hyperlake/__init__.py +40 -0
- hyperlake/_version.py +7 -0
- hyperlake/auth.py +609 -0
- hyperlake/client.py +1324 -0
- hyperlake/constants.py +67 -0
- hyperlake/dbapi.py +798 -0
- hyperlake/exceptions.py +164 -0
- hyperlake/logging.py +30 -0
- hyperlake/mapper.py +374 -0
- hyperlake/sqlalchemy/__init__.py +16 -0
- hyperlake/sqlalchemy/compiler.py +272 -0
- hyperlake/sqlalchemy/datatype.py +291 -0
- hyperlake/sqlalchemy/dialect.py +465 -0
- hyperlake/sqlalchemy/error.py +26 -0
- hyperlake/sqlalchemy/util.py +111 -0
- hyperlake/transaction.py +105 -0
- hyperlake/types.py +134 -0
- hyperlake-0.1.1.dist-info/METADATA +659 -0
- hyperlake-0.1.1.dist-info/RECORD +22 -0
- hyperlake-0.1.1.dist-info/WHEEL +5 -0
- hyperlake-0.1.1.dist-info/entry_points.txt +2 -0
- hyperlake-0.1.1.dist-info/top_level.txt +1 -0
hyperlake/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
from . import auth
|
|
13
|
+
from . import client
|
|
14
|
+
from . import constants
|
|
15
|
+
from . import dbapi
|
|
16
|
+
from . import exceptions
|
|
17
|
+
from . import logging
|
|
18
|
+
from ._version import __author__
|
|
19
|
+
from ._version import __author_email__
|
|
20
|
+
from ._version import __description__
|
|
21
|
+
from ._version import __license__
|
|
22
|
+
from ._version import __title__
|
|
23
|
+
from ._version import __url__
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"auth",
|
|
28
|
+
"client",
|
|
29
|
+
"constants",
|
|
30
|
+
"dbapi",
|
|
31
|
+
"exceptions",
|
|
32
|
+
"logging",
|
|
33
|
+
"__author__",
|
|
34
|
+
"__author_email__",
|
|
35
|
+
"__description__",
|
|
36
|
+
"__license__",
|
|
37
|
+
"__title__",
|
|
38
|
+
"__url__",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
hyperlake/_version.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
__title__ = "hyperlake"
|
|
2
|
+
__description__ = "Python Client for the Hyperlake distributed SQL Engine"
|
|
3
|
+
__url__ = "https://github.com/ockhamlabs/hyperlake-python-client"
|
|
4
|
+
__version__ = "0.1.1"
|
|
5
|
+
__author__ = "Ockham Labs Team"
|
|
6
|
+
__author_email__ = "pythonclient+hello@ockhamlabs.ai"
|
|
7
|
+
__license__ = "Apache 2.0"
|
hyperlake/auth.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
import abc
|
|
13
|
+
import importlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import webbrowser
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
from typing import Callable
|
|
22
|
+
from typing import Dict
|
|
23
|
+
from typing import List
|
|
24
|
+
from typing import Optional
|
|
25
|
+
from typing import Tuple
|
|
26
|
+
from urllib.parse import urlparse
|
|
27
|
+
|
|
28
|
+
from requests import PreparedRequest
|
|
29
|
+
from requests import Request
|
|
30
|
+
from requests import Response
|
|
31
|
+
from requests import Session
|
|
32
|
+
from requests.auth import AuthBase
|
|
33
|
+
from requests.auth import extract_cookies_to_jar
|
|
34
|
+
|
|
35
|
+
import hyperlake.logging
|
|
36
|
+
from hyperlake import exceptions
|
|
37
|
+
from hyperlake.constants import HEADER_USER
|
|
38
|
+
from hyperlake.constants import MAX_NT_PASSWORD_SIZE
|
|
39
|
+
|
|
40
|
+
logger = hyperlake.logging.get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Authentication(metaclass=abc.ABCMeta):
|
|
44
|
+
@abc.abstractmethod
|
|
45
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
49
|
+
return tuple()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class KerberosAuthentication(Authentication):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
config: Optional[str] = None,
|
|
56
|
+
service_name: Optional[str] = None,
|
|
57
|
+
mutual_authentication: bool = False,
|
|
58
|
+
force_preemptive: bool = False,
|
|
59
|
+
hostname_override: Optional[str] = None,
|
|
60
|
+
sanitize_mutual_error_response: bool = True,
|
|
61
|
+
principal: Optional[str] = None,
|
|
62
|
+
delegate: bool = False,
|
|
63
|
+
ca_bundle: Optional[str] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._config = config
|
|
66
|
+
self._service_name = service_name
|
|
67
|
+
self._mutual_authentication = mutual_authentication
|
|
68
|
+
self._force_preemptive = force_preemptive
|
|
69
|
+
self._hostname_override = hostname_override
|
|
70
|
+
self._sanitize_mutual_error_response = sanitize_mutual_error_response
|
|
71
|
+
self._principal = principal
|
|
72
|
+
self._delegate = delegate
|
|
73
|
+
self._ca_bundle = ca_bundle
|
|
74
|
+
|
|
75
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
76
|
+
try:
|
|
77
|
+
import requests_kerberos
|
|
78
|
+
except ImportError:
|
|
79
|
+
raise RuntimeError("unable to import requests_kerberos")
|
|
80
|
+
|
|
81
|
+
if self._config:
|
|
82
|
+
os.environ["KRB5_CONFIG"] = self._config
|
|
83
|
+
http_session.trust_env = False
|
|
84
|
+
http_session.auth = requests_kerberos.HTTPKerberosAuth(
|
|
85
|
+
mutual_authentication=self._mutual_authentication,
|
|
86
|
+
force_preemptive=self._force_preemptive,
|
|
87
|
+
hostname_override=self._hostname_override,
|
|
88
|
+
sanitize_mutual_error_response=self._sanitize_mutual_error_response,
|
|
89
|
+
principal=self._principal,
|
|
90
|
+
delegate=self._delegate,
|
|
91
|
+
service=self._service_name,
|
|
92
|
+
)
|
|
93
|
+
if self._ca_bundle:
|
|
94
|
+
http_session.verify = self._ca_bundle
|
|
95
|
+
return http_session
|
|
96
|
+
|
|
97
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
98
|
+
try:
|
|
99
|
+
from requests_kerberos.exceptions import KerberosExchangeError
|
|
100
|
+
|
|
101
|
+
return KerberosExchangeError,
|
|
102
|
+
except ImportError:
|
|
103
|
+
raise RuntimeError("unable to import requests_kerberos")
|
|
104
|
+
|
|
105
|
+
def __eq__(self, other: object) -> bool:
|
|
106
|
+
if not isinstance(other, KerberosAuthentication):
|
|
107
|
+
return False
|
|
108
|
+
return (self._config == other._config
|
|
109
|
+
and self._service_name == other._service_name
|
|
110
|
+
and self._mutual_authentication == other._mutual_authentication
|
|
111
|
+
and self._force_preemptive == other._force_preemptive
|
|
112
|
+
and self._hostname_override == other._hostname_override
|
|
113
|
+
and self._sanitize_mutual_error_response == other._sanitize_mutual_error_response
|
|
114
|
+
and self._principal == other._principal
|
|
115
|
+
and self._delegate == other._delegate
|
|
116
|
+
and self._ca_bundle == other._ca_bundle)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class GSSAPIAuthentication(Authentication):
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
config: Optional[str] = None,
|
|
123
|
+
service_name: Optional[str] = None,
|
|
124
|
+
mutual_authentication: bool = False,
|
|
125
|
+
force_preemptive: bool = False,
|
|
126
|
+
hostname_override: Optional[str] = None,
|
|
127
|
+
sanitize_mutual_error_response: bool = True,
|
|
128
|
+
principal: Optional[str] = None,
|
|
129
|
+
delegate: bool = False,
|
|
130
|
+
ca_bundle: Optional[str] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._config = config
|
|
133
|
+
self._service_name = service_name
|
|
134
|
+
self._mutual_authentication = mutual_authentication
|
|
135
|
+
self._force_preemptive = force_preemptive
|
|
136
|
+
self._hostname_override = hostname_override
|
|
137
|
+
self._sanitize_mutual_error_response = sanitize_mutual_error_response
|
|
138
|
+
self._principal = principal
|
|
139
|
+
self._delegate = delegate
|
|
140
|
+
self._ca_bundle = ca_bundle
|
|
141
|
+
|
|
142
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
143
|
+
try:
|
|
144
|
+
import requests_gssapi
|
|
145
|
+
except ImportError:
|
|
146
|
+
raise RuntimeError("unable to import requests_gssapi")
|
|
147
|
+
|
|
148
|
+
if self._config:
|
|
149
|
+
os.environ["KRB5_CONFIG"] = self._config
|
|
150
|
+
http_session.trust_env = False
|
|
151
|
+
http_session.auth = requests_gssapi.HTTPSPNEGOAuth(
|
|
152
|
+
mutual_authentication=self._mutual_authentication,
|
|
153
|
+
opportunistic_auth=self._force_preemptive,
|
|
154
|
+
target_name=self._get_target_name(self._hostname_override, self._service_name),
|
|
155
|
+
sanitize_mutual_error_response=self._sanitize_mutual_error_response,
|
|
156
|
+
creds=self._get_credentials(self._principal),
|
|
157
|
+
delegate=self._delegate,
|
|
158
|
+
)
|
|
159
|
+
if self._ca_bundle:
|
|
160
|
+
http_session.verify = self._ca_bundle
|
|
161
|
+
return http_session
|
|
162
|
+
|
|
163
|
+
def _get_credentials(self, principal: Optional[str] = None) -> Any:
|
|
164
|
+
if principal:
|
|
165
|
+
try:
|
|
166
|
+
import gssapi
|
|
167
|
+
except ImportError:
|
|
168
|
+
raise RuntimeError("unable to import gssapi")
|
|
169
|
+
|
|
170
|
+
name = gssapi.Name(principal, gssapi.NameType.user)
|
|
171
|
+
return gssapi.Credentials(name=name, usage="initiate")
|
|
172
|
+
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _get_target_name(
|
|
176
|
+
self,
|
|
177
|
+
hostname_override: Optional[str] = None,
|
|
178
|
+
service_name: Optional[str] = None,
|
|
179
|
+
) -> Any:
|
|
180
|
+
if service_name is not None:
|
|
181
|
+
try:
|
|
182
|
+
import gssapi
|
|
183
|
+
except ImportError:
|
|
184
|
+
raise RuntimeError("unable to import gssapi")
|
|
185
|
+
|
|
186
|
+
if hostname_override is None:
|
|
187
|
+
raise ValueError("service name must be used together with hostname_override")
|
|
188
|
+
|
|
189
|
+
kerb_spn = "{0}@{1}".format(service_name, hostname_override)
|
|
190
|
+
return gssapi.Name(kerb_spn, gssapi.NameType.hostbased_service)
|
|
191
|
+
|
|
192
|
+
return hostname_override
|
|
193
|
+
|
|
194
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
195
|
+
try:
|
|
196
|
+
from requests_gssapi.exceptions import SPNEGOExchangeError
|
|
197
|
+
|
|
198
|
+
return SPNEGOExchangeError,
|
|
199
|
+
except ImportError:
|
|
200
|
+
raise RuntimeError("unable to import requests_kerberos")
|
|
201
|
+
|
|
202
|
+
def __eq__(self, other: object) -> bool:
|
|
203
|
+
if not isinstance(other, GSSAPIAuthentication):
|
|
204
|
+
return False
|
|
205
|
+
return (self._config == other._config
|
|
206
|
+
and self._service_name == other._service_name
|
|
207
|
+
and self._mutual_authentication == other._mutual_authentication
|
|
208
|
+
and self._force_preemptive == other._force_preemptive
|
|
209
|
+
and self._hostname_override == other._hostname_override
|
|
210
|
+
and self._sanitize_mutual_error_response == other._sanitize_mutual_error_response
|
|
211
|
+
and self._principal == other._principal
|
|
212
|
+
and self._delegate == other._delegate
|
|
213
|
+
and self._ca_bundle == other._ca_bundle)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class BasicAuthentication(Authentication):
|
|
217
|
+
def __init__(self, username: str, password: str):
|
|
218
|
+
self._username = username
|
|
219
|
+
self._password = password
|
|
220
|
+
|
|
221
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
222
|
+
try:
|
|
223
|
+
import requests.auth
|
|
224
|
+
except ImportError:
|
|
225
|
+
raise RuntimeError("unable to import requests.auth")
|
|
226
|
+
|
|
227
|
+
http_session.auth = requests.auth.HTTPBasicAuth(self._username, self._password)
|
|
228
|
+
return http_session
|
|
229
|
+
|
|
230
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
231
|
+
return ()
|
|
232
|
+
|
|
233
|
+
def __eq__(self, other: object) -> bool:
|
|
234
|
+
if not isinstance(other, BasicAuthentication):
|
|
235
|
+
return False
|
|
236
|
+
return self._username == other._username and self._password == other._password
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _BearerAuth(AuthBase):
|
|
240
|
+
"""
|
|
241
|
+
Custom implementation of Authentication class for bearer token
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, token: str):
|
|
245
|
+
self.token = token
|
|
246
|
+
|
|
247
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
248
|
+
r.headers["Authorization"] = "Bearer " + self.token
|
|
249
|
+
return r
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class JWTAuthentication(Authentication):
|
|
253
|
+
|
|
254
|
+
def __init__(self, token: str):
|
|
255
|
+
self.token = token
|
|
256
|
+
|
|
257
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
258
|
+
http_session.auth = _BearerAuth(self.token)
|
|
259
|
+
return http_session
|
|
260
|
+
|
|
261
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
262
|
+
return ()
|
|
263
|
+
|
|
264
|
+
def __eq__(self, other: object) -> bool:
|
|
265
|
+
if not isinstance(other, JWTAuthentication):
|
|
266
|
+
return False
|
|
267
|
+
return self.token == other.token
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class RedirectHandler(metaclass=abc.ABCMeta):
|
|
271
|
+
"""
|
|
272
|
+
Abstract class for OAuth redirect handlers, inherit from this class to implement your own redirect handler.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
@abc.abstractmethod
|
|
276
|
+
def __call__(self, url: str) -> None:
|
|
277
|
+
raise NotImplementedError()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ConsoleRedirectHandler(RedirectHandler):
|
|
281
|
+
"""
|
|
282
|
+
Handler for OAuth redirections to log to console.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __call__(self, url: str) -> None:
|
|
286
|
+
print("Open the following URL in browser for the external authentication:")
|
|
287
|
+
print(url)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class WebBrowserRedirectHandler(RedirectHandler):
|
|
291
|
+
"""
|
|
292
|
+
Handler for OAuth redirections to open in web browser.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
def __call__(self, url: str) -> None:
|
|
296
|
+
webbrowser.open_new(url)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class CompositeRedirectHandler(RedirectHandler):
|
|
300
|
+
"""
|
|
301
|
+
Composite handler for OAuth redirect handlers.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
def __init__(self, handlers: List[Callable[[str], None]]):
|
|
305
|
+
self.handlers = handlers
|
|
306
|
+
|
|
307
|
+
def __call__(self, url: str) -> None:
|
|
308
|
+
for handler in self.handlers:
|
|
309
|
+
handler(url)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class _OAuth2TokenCache(metaclass=abc.ABCMeta):
|
|
313
|
+
"""
|
|
314
|
+
Abstract class for OAuth token cache, inherit from this class to implement your own token cache.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
@abc.abstractmethod
|
|
318
|
+
def get_token_from_cache(self, key: Optional[str]) -> Optional[str]:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
@abc.abstractmethod
|
|
322
|
+
def store_token_to_cache(self, key: Optional[str], token: str) -> None:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class _OAuth2TokenInMemoryCache(_OAuth2TokenCache):
|
|
327
|
+
"""
|
|
328
|
+
Multiple clients can share the same cache only if each connection explicitly specifies
|
|
329
|
+
a user otherwise the first cached token will be used to authenticate all other users.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def __init__(self) -> None:
|
|
333
|
+
self._cache: Dict[Optional[str], str] = {}
|
|
334
|
+
|
|
335
|
+
def get_token_from_cache(self, key: Optional[str]) -> Optional[str]:
|
|
336
|
+
return self._cache.get(key)
|
|
337
|
+
|
|
338
|
+
def store_token_to_cache(self, key: Optional[str], token: str) -> None:
|
|
339
|
+
self._cache[key] = token
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class _OAuth2KeyRingTokenCache(_OAuth2TokenCache):
|
|
343
|
+
"""
|
|
344
|
+
Keyring token cache implementation
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def __init__(self) -> None:
|
|
348
|
+
super().__init__()
|
|
349
|
+
try:
|
|
350
|
+
self._keyring = importlib.import_module("keyring")
|
|
351
|
+
except ImportError:
|
|
352
|
+
self._keyring = None # type: ignore
|
|
353
|
+
logger.info("keyring module not found. OAuth2 token will not be stored in keyring.")
|
|
354
|
+
|
|
355
|
+
def is_keyring_available(self) -> bool:
|
|
356
|
+
return self._keyring is not None \
|
|
357
|
+
and not isinstance(self._keyring.get_keyring(), self._keyring.backends.fail.Keyring)
|
|
358
|
+
|
|
359
|
+
def get_token_from_cache(self, key: Optional[str]) -> Optional[str]:
|
|
360
|
+
password = self._keyring.get_password(key, "token")
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
password_as_dict = json.loads(str(password))
|
|
364
|
+
if password_as_dict.get("sharded_password"):
|
|
365
|
+
# if password was stored shared, reconstruct it
|
|
366
|
+
shard_count = int(password_as_dict.get("shard_count"))
|
|
367
|
+
|
|
368
|
+
password = ""
|
|
369
|
+
for i in range(shard_count):
|
|
370
|
+
password += str(self._keyring.get_password(key, f"token__{i}"))
|
|
371
|
+
|
|
372
|
+
except self._keyring.errors.NoKeyringError as e:
|
|
373
|
+
raise hyperlake.exceptions.NotSupportedError("Although keyring module is installed no backend has been "
|
|
374
|
+
"detected, check https://pypi.org/project/keyring/ for more "
|
|
375
|
+
"information.") from e
|
|
376
|
+
except ValueError:
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
return password
|
|
380
|
+
|
|
381
|
+
def store_token_to_cache(self, key: Optional[str], token: str) -> None:
|
|
382
|
+
# keyring is installed, so we can store the token for reuse within multiple threads
|
|
383
|
+
try:
|
|
384
|
+
# if not Windows or "small" password, stick to the default
|
|
385
|
+
if os.name != "nt" or len(token) < MAX_NT_PASSWORD_SIZE:
|
|
386
|
+
self._keyring.set_password(key, "token", token)
|
|
387
|
+
else:
|
|
388
|
+
logger.debug(f"password is {len(token)} characters, sharding it.")
|
|
389
|
+
|
|
390
|
+
password_shards = [
|
|
391
|
+
token[i: i + MAX_NT_PASSWORD_SIZE] for i in range(0, len(token), MAX_NT_PASSWORD_SIZE)
|
|
392
|
+
]
|
|
393
|
+
shard_info = {
|
|
394
|
+
"sharded_password": True,
|
|
395
|
+
"shard_count": len(password_shards),
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
# store the "shard info" as the "base" password
|
|
399
|
+
self._keyring.set_password(key, "token", json.dumps(shard_info))
|
|
400
|
+
# then store all shards with the shard number as postfix
|
|
401
|
+
for i, s in enumerate(password_shards):
|
|
402
|
+
self._keyring.set_password(key, f"token__{i}", s)
|
|
403
|
+
except self._keyring.errors.NoKeyringError as e:
|
|
404
|
+
raise hyperlake.exceptions.NotSupportedError("Although keyring module is installed no backend has been "
|
|
405
|
+
"detected, check https://pypi.org/project/keyring/ for more "
|
|
406
|
+
"information.") from e
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class _OAuth2TokenBearer(AuthBase):
|
|
410
|
+
"""
|
|
411
|
+
Custom implementation of hyperlake Trino OAuth2 based authentication to get the token
|
|
412
|
+
"""
|
|
413
|
+
MAX_OAUTH_ATTEMPTS = 5
|
|
414
|
+
_BEARER_PREFIX = re.compile(r"bearer", flags=re.IGNORECASE)
|
|
415
|
+
|
|
416
|
+
def __init__(self, redirect_auth_url_handler: Callable[[str], None]):
|
|
417
|
+
self._redirect_auth_url = redirect_auth_url_handler
|
|
418
|
+
keyring_cache = _OAuth2KeyRingTokenCache()
|
|
419
|
+
self._token_cache = keyring_cache if keyring_cache.is_keyring_available() else _OAuth2TokenInMemoryCache()
|
|
420
|
+
self._token_lock = threading.Lock()
|
|
421
|
+
self._inside_oauth_attempt_lock = threading.Lock()
|
|
422
|
+
self._inside_oauth_attempt_blocker = threading.Event()
|
|
423
|
+
|
|
424
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
425
|
+
host = self._determine_host(r.url)
|
|
426
|
+
user = self._determine_user(r.headers)
|
|
427
|
+
key = self._construct_cache_key(host, user)
|
|
428
|
+
token = self._get_token_from_cache(key)
|
|
429
|
+
|
|
430
|
+
if token is not None:
|
|
431
|
+
r.headers['Authorization'] = "Bearer " + token
|
|
432
|
+
|
|
433
|
+
r.register_hook('response', self._authenticate)
|
|
434
|
+
|
|
435
|
+
return r
|
|
436
|
+
|
|
437
|
+
def _authenticate(self, response: Response, **kwargs: Any) -> Optional[Response]:
|
|
438
|
+
if not 400 <= response.status_code < 500:
|
|
439
|
+
return response
|
|
440
|
+
|
|
441
|
+
acquired = self._inside_oauth_attempt_lock.acquire(blocking=False)
|
|
442
|
+
if acquired:
|
|
443
|
+
try:
|
|
444
|
+
# Lock is acquired, attempt the OAuth2 flow
|
|
445
|
+
self._attempt_oauth(response, **kwargs)
|
|
446
|
+
self._inside_oauth_attempt_blocker.set()
|
|
447
|
+
finally:
|
|
448
|
+
self._inside_oauth_attempt_lock.release()
|
|
449
|
+
self._inside_oauth_attempt_blocker.clear()
|
|
450
|
+
else:
|
|
451
|
+
# Lock is not acquired, we are already in the OAuth2 flow, so we block until OAuth2 flow is finished.
|
|
452
|
+
self._inside_oauth_attempt_blocker.wait()
|
|
453
|
+
|
|
454
|
+
return self._retry_request(response, **kwargs)
|
|
455
|
+
|
|
456
|
+
def _attempt_oauth(self, response: Response, **kwargs: Any) -> None:
|
|
457
|
+
# we have to handle the authentication, may be token the token expired, or it wasn't there at all
|
|
458
|
+
auth_info = response.headers.get('WWW-Authenticate')
|
|
459
|
+
if not auth_info:
|
|
460
|
+
raise exceptions.HyperlakeAuthError("Error: header WWW-Authenticate not available in the response.")
|
|
461
|
+
|
|
462
|
+
if not _OAuth2TokenBearer._BEARER_PREFIX.search(auth_info):
|
|
463
|
+
raise exceptions.HyperlakeAuthError(f"Error: header info didn't match {auth_info}")
|
|
464
|
+
|
|
465
|
+
# Example www-authenticate header value:
|
|
466
|
+
# 'Basic realm="Trino", Bearer realm="Trino", token_type="JWT",
|
|
467
|
+
# Bearer x_redirect_server="https://trino.com/oauth2/token/uuid4",
|
|
468
|
+
# x_token_server="https://trino.com/oauth2/token/uuid4"'
|
|
469
|
+
auth_info_headers = self._parse_authenticate_header(auth_info)
|
|
470
|
+
|
|
471
|
+
auth_server = auth_info_headers.get('bearer x_redirect_server', auth_info_headers.get('x_redirect_server'))
|
|
472
|
+
token_server = auth_info_headers.get('bearer x_token_server', auth_info_headers.get('x_token_server'))
|
|
473
|
+
if token_server is None:
|
|
474
|
+
raise exceptions.HyperlakeAuthError("Error: header info didn't have x_token_server")
|
|
475
|
+
|
|
476
|
+
if auth_server is not None:
|
|
477
|
+
# tell app that use this url to proceed with the authentication
|
|
478
|
+
self._redirect_auth_url(auth_server)
|
|
479
|
+
|
|
480
|
+
# Consume content and release the original connection
|
|
481
|
+
# to allow our new request to reuse the same one.
|
|
482
|
+
response.content
|
|
483
|
+
response.close()
|
|
484
|
+
|
|
485
|
+
token = self._get_token(token_server, response, **kwargs)
|
|
486
|
+
|
|
487
|
+
request = response.request
|
|
488
|
+
host = self._determine_host(request.url)
|
|
489
|
+
user = self._determine_user(request.headers)
|
|
490
|
+
key = self._construct_cache_key(host, user)
|
|
491
|
+
self._store_token_to_cache(key, token)
|
|
492
|
+
|
|
493
|
+
def _retry_request(self, response: Response, **kwargs: Any) -> Optional[Response]:
|
|
494
|
+
request = response.request.copy()
|
|
495
|
+
extract_cookies_to_jar(request._cookies, response.request, response.raw)
|
|
496
|
+
request.prepare_cookies(request._cookies)
|
|
497
|
+
|
|
498
|
+
host = self._determine_host(response.request.url)
|
|
499
|
+
user = self._determine_user(request.headers)
|
|
500
|
+
key = self._construct_cache_key(host, user)
|
|
501
|
+
token = self._get_token_from_cache(key)
|
|
502
|
+
if token is not None:
|
|
503
|
+
request.headers['Authorization'] = "Bearer " + token
|
|
504
|
+
retry_response = response.connection.send(request, **kwargs)
|
|
505
|
+
retry_response.history.append(response)
|
|
506
|
+
retry_response.request = request
|
|
507
|
+
return retry_response
|
|
508
|
+
|
|
509
|
+
def _get_token(self, token_server: str, response: Response, **kwargs: Any) -> str:
|
|
510
|
+
attempts = 0
|
|
511
|
+
while attempts < self.MAX_OAUTH_ATTEMPTS:
|
|
512
|
+
attempts += 1
|
|
513
|
+
with response.connection.send(Request(
|
|
514
|
+
method='GET', url=token_server).prepare(), **kwargs) as response:
|
|
515
|
+
if response.status_code == 200:
|
|
516
|
+
token_response = json.loads(response.text)
|
|
517
|
+
token = token_response.get('token')
|
|
518
|
+
if token:
|
|
519
|
+
return token
|
|
520
|
+
error = token_response.get('error')
|
|
521
|
+
if error:
|
|
522
|
+
raise exceptions.HyperlakeAuthError(f"Error while getting the token: {error}")
|
|
523
|
+
else:
|
|
524
|
+
token_server = token_response.get('nextUri')
|
|
525
|
+
logger.debug(f"nextURi auth token server: {token_server}")
|
|
526
|
+
else:
|
|
527
|
+
raise exceptions.HyperlakeAuthError(
|
|
528
|
+
f"Error while getting the token response "
|
|
529
|
+
f"status code: {response.status_code}, "
|
|
530
|
+
f"body: {response.text}")
|
|
531
|
+
|
|
532
|
+
raise exceptions.HyperlakeAuthError("Exceeded max attempts while getting the token")
|
|
533
|
+
|
|
534
|
+
def _get_token_from_cache(self, key: Optional[str]) -> Optional[str]:
|
|
535
|
+
with self._token_lock:
|
|
536
|
+
return self._token_cache.get_token_from_cache(key)
|
|
537
|
+
|
|
538
|
+
def _store_token_to_cache(self, key: Optional[str], token: str) -> None:
|
|
539
|
+
with self._token_lock:
|
|
540
|
+
self._token_cache.store_token_to_cache(key, token)
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def _determine_host(url: Optional[str]) -> Any:
|
|
544
|
+
return urlparse(url).hostname
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _determine_user(headers: Mapping[Any, Any]) -> Optional[Any]:
|
|
548
|
+
return headers.get(HEADER_USER)
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _construct_cache_key(host: Optional[str], user: Optional[str]) -> Optional[str]:
|
|
552
|
+
if user is None:
|
|
553
|
+
return host
|
|
554
|
+
else:
|
|
555
|
+
return f"{host}@{user}"
|
|
556
|
+
|
|
557
|
+
@staticmethod
|
|
558
|
+
def _parse_authenticate_header(header: str) -> Dict[str, str]:
|
|
559
|
+
logger.debug(f"Authentication header: {header}")
|
|
560
|
+
components = header.split(",")
|
|
561
|
+
auth_info_headers = {}
|
|
562
|
+
|
|
563
|
+
for component in components:
|
|
564
|
+
component = component.strip()
|
|
565
|
+
if "=" in component:
|
|
566
|
+
key, value = component.split("=", 1)
|
|
567
|
+
if value[0] == '"' and value[-1] == '"':
|
|
568
|
+
value = value[1:-1]
|
|
569
|
+
auth_info_headers[key.lower()] = value
|
|
570
|
+
return auth_info_headers
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class OAuth2Authentication(Authentication):
|
|
574
|
+
def __init__(self, redirect_auth_url_handler: CompositeRedirectHandler = CompositeRedirectHandler([
|
|
575
|
+
WebBrowserRedirectHandler(),
|
|
576
|
+
ConsoleRedirectHandler()
|
|
577
|
+
])):
|
|
578
|
+
self._redirect_auth_url = redirect_auth_url_handler
|
|
579
|
+
self._bearer = _OAuth2TokenBearer(self._redirect_auth_url)
|
|
580
|
+
|
|
581
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
582
|
+
http_session.auth = self._bearer
|
|
583
|
+
return http_session
|
|
584
|
+
|
|
585
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
586
|
+
return ()
|
|
587
|
+
|
|
588
|
+
def __eq__(self, other: object) -> bool:
|
|
589
|
+
if not isinstance(other, OAuth2Authentication):
|
|
590
|
+
return False
|
|
591
|
+
return self._redirect_auth_url == other._redirect_auth_url
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class CertificateAuthentication(Authentication):
|
|
595
|
+
def __init__(self, cert: str, key: str):
|
|
596
|
+
self._cert = cert
|
|
597
|
+
self._key = key
|
|
598
|
+
|
|
599
|
+
def set_http_session(self, http_session: Session) -> Session:
|
|
600
|
+
http_session.cert = (self._cert, self._key)
|
|
601
|
+
return http_session
|
|
602
|
+
|
|
603
|
+
def get_exceptions(self) -> Tuple[Any, ...]:
|
|
604
|
+
return ()
|
|
605
|
+
|
|
606
|
+
def __eq__(self, other: object) -> bool:
|
|
607
|
+
if not isinstance(other, CertificateAuthentication):
|
|
608
|
+
return False
|
|
609
|
+
return self._cert == other._cert and self._key == other._key
|