clue-api 1.0.0.dev7__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.
- clue/.gitignore +21 -0
- clue/__init__.py +0 -0
- clue/api/__init__.py +211 -0
- clue/api/base.py +99 -0
- clue/api/v1/__init__.py +82 -0
- clue/api/v1/actions.py +92 -0
- clue/api/v1/auth.py +243 -0
- clue/api/v1/configs.py +83 -0
- clue/api/v1/fetchers.py +94 -0
- clue/api/v1/lookup.py +221 -0
- clue/api/v1/registration.py +109 -0
- clue/api/v1/static.py +94 -0
- clue/app.py +166 -0
- clue/cache/__init__.py +129 -0
- clue/common/__init__.py +0 -0
- clue/common/classification.py +1006 -0
- clue/common/classification.yml +130 -0
- clue/common/dict_utils.py +130 -0
- clue/common/exceptions.py +199 -0
- clue/common/forge.py +152 -0
- clue/common/json_utils.py +10 -0
- clue/common/list_utils.py +11 -0
- clue/common/logging/__init__.py +291 -0
- clue/common/logging/audit.py +157 -0
- clue/common/logging/format.py +42 -0
- clue/common/regex.py +31 -0
- clue/common/str_utils.py +213 -0
- clue/common/swagger.py +139 -0
- clue/common/uid.py +47 -0
- clue/config.py +60 -0
- clue/constants/__init__.py +0 -0
- clue/constants/supported_types.py +38 -0
- clue/cronjobs/__init__.py +30 -0
- clue/cronjobs/plugins.py +32 -0
- clue/error.py +129 -0
- clue/gunicorn_config.py +29 -0
- clue/healthz.py +74 -0
- clue/helper/discover.py +53 -0
- clue/helper/headers.py +30 -0
- clue/helper/oauth.py +128 -0
- clue/models/__init__.py +0 -0
- clue/models/actions.py +243 -0
- clue/models/config.py +456 -0
- clue/models/fetchers.py +136 -0
- clue/models/graph.py +162 -0
- clue/models/model_list.py +52 -0
- clue/models/network.py +430 -0
- clue/models/results/__init__.py +34 -0
- clue/models/results/base.py +10 -0
- clue/models/results/graph.py +26 -0
- clue/models/results/image.py +22 -0
- clue/models/results/status.py +55 -0
- clue/models/results/validation.py +57 -0
- clue/models/selector.py +67 -0
- clue/models/utils.py +52 -0
- clue/models/validators.py +19 -0
- clue/patched.py +8 -0
- clue/plugin/__init__.py +1008 -0
- clue/plugin/helpers/__init__.py +0 -0
- clue/plugin/helpers/central_server.py +27 -0
- clue/plugin/helpers/email_render.py +228 -0
- clue/plugin/helpers/token.py +34 -0
- clue/plugin/helpers/trino.py +103 -0
- clue/plugin/interactive.py +270 -0
- clue/plugin/models.py +19 -0
- clue/plugin/utils.py +78 -0
- clue/remote/__init__.py +0 -0
- clue/remote/datatypes/__init__.py +130 -0
- clue/remote/datatypes/cache.py +62 -0
- clue/remote/datatypes/events.py +118 -0
- clue/remote/datatypes/hash.py +193 -0
- clue/remote/datatypes/queues/__init__.py +0 -0
- clue/remote/datatypes/queues/comms.py +62 -0
- clue/remote/datatypes/set.py +96 -0
- clue/remote/datatypes/user_quota_tracker.py +54 -0
- clue/security/__init__.py +211 -0
- clue/security/obo.py +95 -0
- clue/security/utils.py +34 -0
- clue/services/action_service.py +186 -0
- clue/services/auth_service.py +348 -0
- clue/services/config_service.py +38 -0
- clue/services/fetcher_service.py +203 -0
- clue/services/jwt_service.py +233 -0
- clue/services/lookup_service.py +786 -0
- clue/services/type_service.py +165 -0
- clue/services/user_service.py +152 -0
- clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
- clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
- clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
- clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
- clue_api-1.0.0.dev7.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# implementation based on this stackoverflow post:
|
|
2
|
+
# https://stackoverflow.com/a/67943659
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, cast
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
import requests
|
|
9
|
+
from jwt.api_jwk import PyJWK
|
|
10
|
+
|
|
11
|
+
from clue.common.exceptions import ClueKeyError, ClueValueError
|
|
12
|
+
from clue.common.logging import get_logger
|
|
13
|
+
from clue.config import cache, config
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__file__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_jwk(access_token: str) -> PyJWK:
|
|
19
|
+
"""Get the JSON Web Key associated with the given JWT"""
|
|
20
|
+
# "kid" is the JSON Web Key's identifier. It tells us which key was used to validate the token.
|
|
21
|
+
kid = jwt.get_unverified_header(access_token).get("kid")
|
|
22
|
+
jwks, _ = get_jwks()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
# Check to see if we have it cached
|
|
26
|
+
key = PyJWK(jwks[kid])
|
|
27
|
+
except KeyError:
|
|
28
|
+
# We don't, so we need to refresh the key set
|
|
29
|
+
cache.delete(key="get_jwks")
|
|
30
|
+
try:
|
|
31
|
+
jwks, _ = get_jwks()
|
|
32
|
+
key = jwks[kid]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise ClueKeyError("There is no valid JWK for this token.")
|
|
35
|
+
|
|
36
|
+
return key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_provider(access_token: str) -> str:
|
|
40
|
+
"""Get the provider of a given access token
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
access_token (str): The access token to determine the provider of
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ClueValueError: The provider of this access token does not match any supported providers
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
str: The provider of the token
|
|
50
|
+
"""
|
|
51
|
+
# "kid" is the JSON Web Key's identifier. It tells us which key was used to validate the token.
|
|
52
|
+
kid = jwt.get_unverified_header(access_token).get("kid")
|
|
53
|
+
_, providers = get_jwks()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# Check to see if we have it cached
|
|
57
|
+
oauth_provider = providers[kid]
|
|
58
|
+
except KeyError:
|
|
59
|
+
# We don't, so we need to refresh the key set
|
|
60
|
+
cache.delete(key="get_jwks")
|
|
61
|
+
try:
|
|
62
|
+
_, providers = get_jwks()
|
|
63
|
+
oauth_provider = providers[kid]
|
|
64
|
+
except KeyError:
|
|
65
|
+
raise ClueValueError("The provider of this access token does not match any supported providers")
|
|
66
|
+
|
|
67
|
+
return oauth_provider
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cache.cached(timeout=60 * 60 * 12, key_prefix="get_jwks") # Cached for 12hrs
|
|
71
|
+
def get_jwks() -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
|
|
72
|
+
"""Get the JSON Web Key Set for all supported providers
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
tuple[dict[str, str], dict[str, str]]: The JWKS and the providers that are included in it
|
|
76
|
+
"""
|
|
77
|
+
# JWKS = JSON Web Key Set. We merge the key set from all oauth providers
|
|
78
|
+
jwks: dict[str, dict[str, Any]] = {}
|
|
79
|
+
# Mapping of keys to their provider (i.e. azure, keycloak)
|
|
80
|
+
providers: dict[str, str] = {}
|
|
81
|
+
|
|
82
|
+
for (
|
|
83
|
+
provider_name,
|
|
84
|
+
provider_data,
|
|
85
|
+
) in config.auth.oauth.providers.items():
|
|
86
|
+
# Fetch the JSON Web Key Set for each provider that supports them
|
|
87
|
+
if provider_data.jwks_uri:
|
|
88
|
+
provider_jwks = requests.get(provider_data.jwks_uri, timeout=10).json()["keys"]
|
|
89
|
+
for jwk in provider_jwks:
|
|
90
|
+
jwks[jwk["kid"]] = jwk
|
|
91
|
+
providers[jwk["kid"]] = provider_name
|
|
92
|
+
|
|
93
|
+
return (jwks, providers)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_audience(oauth_provider: str) -> str:
|
|
97
|
+
"""Get the audience for the specified OAuth provider
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
oauth_provider (str): The OAuth provider to retrieve the audience of
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ClueValueError: The provider is azure, and is improperly formatted
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
str: The audience of the provider
|
|
107
|
+
"""
|
|
108
|
+
audience: str = "clue"
|
|
109
|
+
provider_data = config.auth.oauth.providers[oauth_provider]
|
|
110
|
+
if provider_data.audience:
|
|
111
|
+
audience = provider_data.audience
|
|
112
|
+
elif provider_data.client_id:
|
|
113
|
+
audience = provider_data.client_id
|
|
114
|
+
|
|
115
|
+
if oauth_provider == "azure" and f"{audience}/.default" not in provider_data.scope:
|
|
116
|
+
raise ClueValueError("Azure scope must contain the <client_id>/.default claim!")
|
|
117
|
+
|
|
118
|
+
return audience
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def decode(
|
|
122
|
+
access_token: str,
|
|
123
|
+
key: Optional[str] = None,
|
|
124
|
+
algorithms: Optional[list[str]] = None,
|
|
125
|
+
audience: Optional[str] = None,
|
|
126
|
+
validate_audience: bool = False,
|
|
127
|
+
**kwargs,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Decode an access token into a JSON Web Token dict
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
access_token (str): The access token to decode
|
|
133
|
+
key (Optional[str], optional): The key used to sign the token. Defaults to None.
|
|
134
|
+
algorithms (Optional[list[str]], optional): The algorithm to use when decoding. Defaults to None.
|
|
135
|
+
audience (Optional[str], optional): The audience to check against, if validating the audience. Defaults to None.
|
|
136
|
+
validate_audience (bool, optional): Should we validate the audience? Defaults to False.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
dict[str, Any]: The decoded JWT, in dict format
|
|
140
|
+
"""
|
|
141
|
+
if not key:
|
|
142
|
+
key = get_jwk(access_token).key
|
|
143
|
+
|
|
144
|
+
if not algorithms:
|
|
145
|
+
algorithms = [jwt.get_unverified_header(access_token).get("alg", "HS256")]
|
|
146
|
+
|
|
147
|
+
if validate_audience and not audience:
|
|
148
|
+
audience = get_audience(get_provider(access_token))
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
return jwt.decode(
|
|
152
|
+
jwt=access_token,
|
|
153
|
+
key=cast(str, key),
|
|
154
|
+
algorithms=algorithms,
|
|
155
|
+
audience=audience,
|
|
156
|
+
options={"verify_aud": validate_audience},
|
|
157
|
+
**kwargs,
|
|
158
|
+
) # type: ignore
|
|
159
|
+
except jwt.InvalidAudienceError:
|
|
160
|
+
logger.debug("Default audience did not match - checking additional audiences")
|
|
161
|
+
if config.auth.oauth.other_audiences is not None:
|
|
162
|
+
# The main audience isn't valid, let's try the others
|
|
163
|
+
for audience in config.auth.oauth.other_audiences:
|
|
164
|
+
logger.debug("Checking audience %s", audience)
|
|
165
|
+
try:
|
|
166
|
+
return jwt.decode(
|
|
167
|
+
jwt=access_token,
|
|
168
|
+
key=cast(str, key),
|
|
169
|
+
algorithms=algorithms,
|
|
170
|
+
audience=audience,
|
|
171
|
+
options={"verify_aud": validate_audience},
|
|
172
|
+
**kwargs,
|
|
173
|
+
) # type: ignore
|
|
174
|
+
except jwt.InvalidAudienceError:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
logger.debug("Default and additional audiences failed to validate")
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def fetch_sa_token() -> Optional[str]:
|
|
182
|
+
"""Use a service account to fetch a valid token, if service accounts are enabled"""
|
|
183
|
+
if not config.auth.service_account.enabled:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# TODO: Eventually support multiple accounts
|
|
187
|
+
service_account = config.auth.service_account.accounts[0]
|
|
188
|
+
cache_key = f"sa_refresh_token_{service_account.username}"
|
|
189
|
+
|
|
190
|
+
provider = config.auth.oauth.providers[service_account.provider]
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Eventually switch this to a redis cache (the rest of this file too)
|
|
194
|
+
refresh_token = cache.get(key=cache_key)
|
|
195
|
+
use_cache = True
|
|
196
|
+
except AttributeError:
|
|
197
|
+
refresh_token = None
|
|
198
|
+
use_cache = False
|
|
199
|
+
|
|
200
|
+
if refresh_token:
|
|
201
|
+
sa_jwt = requests.post(
|
|
202
|
+
provider.access_token_url,
|
|
203
|
+
data={
|
|
204
|
+
"client_id": provider.client_id,
|
|
205
|
+
"client_secret": provider.client_secret,
|
|
206
|
+
"grant_type": "refresh_token",
|
|
207
|
+
"refresh_token": refresh_token,
|
|
208
|
+
"scope": provider.scope,
|
|
209
|
+
},
|
|
210
|
+
timeout=30,
|
|
211
|
+
).json()
|
|
212
|
+
else:
|
|
213
|
+
sa_jwt = requests.post(
|
|
214
|
+
provider.access_token_url,
|
|
215
|
+
data={
|
|
216
|
+
"client_id": provider.client_id,
|
|
217
|
+
"client_secret": provider.client_secret,
|
|
218
|
+
"grant_type": "password",
|
|
219
|
+
"username": service_account.username,
|
|
220
|
+
"password": service_account.password,
|
|
221
|
+
"scope": provider.scope,
|
|
222
|
+
},
|
|
223
|
+
timeout=30,
|
|
224
|
+
).json()
|
|
225
|
+
|
|
226
|
+
if "error" in sa_jwt:
|
|
227
|
+
logger.critical("[%s]: %s", sa_jwt["error"], sa_jwt["error_description"])
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
if "refresh_token" in sa_jwt and use_cache:
|
|
231
|
+
cache.set(cache_key, sa_jwt["refresh_token"], timeout=60 * 60 * 12)
|
|
232
|
+
|
|
233
|
+
return sa_jwt["access_token"]
|