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.
Files changed (91) hide show
  1. clue/.gitignore +21 -0
  2. clue/__init__.py +0 -0
  3. clue/api/__init__.py +211 -0
  4. clue/api/base.py +99 -0
  5. clue/api/v1/__init__.py +82 -0
  6. clue/api/v1/actions.py +92 -0
  7. clue/api/v1/auth.py +243 -0
  8. clue/api/v1/configs.py +83 -0
  9. clue/api/v1/fetchers.py +94 -0
  10. clue/api/v1/lookup.py +221 -0
  11. clue/api/v1/registration.py +109 -0
  12. clue/api/v1/static.py +94 -0
  13. clue/app.py +166 -0
  14. clue/cache/__init__.py +129 -0
  15. clue/common/__init__.py +0 -0
  16. clue/common/classification.py +1006 -0
  17. clue/common/classification.yml +130 -0
  18. clue/common/dict_utils.py +130 -0
  19. clue/common/exceptions.py +199 -0
  20. clue/common/forge.py +152 -0
  21. clue/common/json_utils.py +10 -0
  22. clue/common/list_utils.py +11 -0
  23. clue/common/logging/__init__.py +291 -0
  24. clue/common/logging/audit.py +157 -0
  25. clue/common/logging/format.py +42 -0
  26. clue/common/regex.py +31 -0
  27. clue/common/str_utils.py +213 -0
  28. clue/common/swagger.py +139 -0
  29. clue/common/uid.py +47 -0
  30. clue/config.py +60 -0
  31. clue/constants/__init__.py +0 -0
  32. clue/constants/supported_types.py +38 -0
  33. clue/cronjobs/__init__.py +30 -0
  34. clue/cronjobs/plugins.py +32 -0
  35. clue/error.py +129 -0
  36. clue/gunicorn_config.py +29 -0
  37. clue/healthz.py +74 -0
  38. clue/helper/discover.py +53 -0
  39. clue/helper/headers.py +30 -0
  40. clue/helper/oauth.py +128 -0
  41. clue/models/__init__.py +0 -0
  42. clue/models/actions.py +243 -0
  43. clue/models/config.py +456 -0
  44. clue/models/fetchers.py +136 -0
  45. clue/models/graph.py +162 -0
  46. clue/models/model_list.py +52 -0
  47. clue/models/network.py +430 -0
  48. clue/models/results/__init__.py +34 -0
  49. clue/models/results/base.py +10 -0
  50. clue/models/results/graph.py +26 -0
  51. clue/models/results/image.py +22 -0
  52. clue/models/results/status.py +55 -0
  53. clue/models/results/validation.py +57 -0
  54. clue/models/selector.py +67 -0
  55. clue/models/utils.py +52 -0
  56. clue/models/validators.py +19 -0
  57. clue/patched.py +8 -0
  58. clue/plugin/__init__.py +1008 -0
  59. clue/plugin/helpers/__init__.py +0 -0
  60. clue/plugin/helpers/central_server.py +27 -0
  61. clue/plugin/helpers/email_render.py +228 -0
  62. clue/plugin/helpers/token.py +34 -0
  63. clue/plugin/helpers/trino.py +103 -0
  64. clue/plugin/interactive.py +270 -0
  65. clue/plugin/models.py +19 -0
  66. clue/plugin/utils.py +78 -0
  67. clue/remote/__init__.py +0 -0
  68. clue/remote/datatypes/__init__.py +130 -0
  69. clue/remote/datatypes/cache.py +62 -0
  70. clue/remote/datatypes/events.py +118 -0
  71. clue/remote/datatypes/hash.py +193 -0
  72. clue/remote/datatypes/queues/__init__.py +0 -0
  73. clue/remote/datatypes/queues/comms.py +62 -0
  74. clue/remote/datatypes/set.py +96 -0
  75. clue/remote/datatypes/user_quota_tracker.py +54 -0
  76. clue/security/__init__.py +211 -0
  77. clue/security/obo.py +95 -0
  78. clue/security/utils.py +34 -0
  79. clue/services/action_service.py +186 -0
  80. clue/services/auth_service.py +348 -0
  81. clue/services/config_service.py +38 -0
  82. clue/services/fetcher_service.py +203 -0
  83. clue/services/jwt_service.py +233 -0
  84. clue/services/lookup_service.py +786 -0
  85. clue/services/type_service.py +165 -0
  86. clue/services/user_service.py +152 -0
  87. clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
  88. clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
  89. clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
  90. clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
  91. 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"]