clue-api 1.7.0.dev415__tar.gz → 1.7.0.dev420__tar.gz

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 (102) hide show
  1. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/PKG-INFO +1 -1
  2. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/auth.py +52 -51
  3. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/registration.py +43 -24
  4. clue_api-1.7.0.dev420/clue/cronjobs/plugins.py +58 -0
  5. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/helper/oauth.py +12 -12
  6. clue_api-1.7.0.dev420/clue/helper/plugin_requests.py +234 -0
  7. clue_api-1.7.0.dev420/clue/models/auth_user.py +61 -0
  8. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/config.py +121 -7
  9. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/set.py +28 -0
  10. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/security/__init__.py +31 -55
  11. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/action_service.py +12 -8
  12. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/auth_service.py +65 -83
  13. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/fetcher_service.py +12 -8
  14. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/jwt_service.py +5 -5
  15. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/lookup_service.py +9 -4
  16. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/type_service.py +6 -6
  17. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/user_service.py +16 -25
  18. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/pyproject.toml +1 -1
  19. clue_api-1.7.0.dev415/clue/cronjobs/plugins.py +0 -32
  20. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/LICENSE +0 -0
  21. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/README.md +0 -0
  22. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/.gitignore +0 -0
  23. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/__init__.py +0 -0
  24. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/__init__.py +0 -0
  25. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/base.py +0 -0
  26. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/__init__.py +0 -0
  27. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/actions.py +0 -0
  28. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/configs.py +0 -0
  29. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/fetchers.py +0 -0
  30. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/lookup.py +0 -0
  31. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/static.py +0 -0
  32. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/api/v1/sync.py +0 -0
  33. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/app.py +0 -0
  34. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/cache/__init__.py +0 -0
  35. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/__init__.py +0 -0
  36. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/bytes_utils.py +0 -0
  37. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/classification.py +0 -0
  38. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/classification.yml +0 -0
  39. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/dict_utils.py +0 -0
  40. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/exceptions.py +0 -0
  41. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/forge.py +0 -0
  42. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/json_utils.py +0 -0
  43. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/list_utils.py +0 -0
  44. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/logging/__init__.py +0 -0
  45. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/logging/audit.py +0 -0
  46. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/logging/format.py +0 -0
  47. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/regex.py +0 -0
  48. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/str_utils.py +0 -0
  49. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/swagger.py +0 -0
  50. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/common/uid.py +0 -0
  51. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/config.py +0 -0
  52. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/constants/__init__.py +0 -0
  53. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/constants/env.py +0 -0
  54. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/constants/supported_types.py +0 -0
  55. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/cronjobs/__init__.py +0 -0
  56. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/error.py +0 -0
  57. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/extensions/__init__.py +0 -0
  58. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/extensions/config.py +0 -0
  59. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/gunicorn_config.py +0 -0
  60. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/healthz.py +0 -0
  61. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/helper/discover.py +0 -0
  62. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/helper/headers.py +0 -0
  63. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/__init__.py +0 -0
  64. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/actions.py +0 -0
  65. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/fetchers.py +0 -0
  66. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/graph.py +0 -0
  67. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/model_list.py +0 -0
  68. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/network.py +0 -0
  69. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/__init__.py +0 -0
  70. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/base.py +0 -0
  71. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/file.py +0 -0
  72. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/graph.py +0 -0
  73. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/image.py +0 -0
  74. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/status.py +0 -0
  75. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/results/validation.py +0 -0
  76. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/schema.py +0 -0
  77. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/selector.py +0 -0
  78. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/sync.py +0 -0
  79. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/models/validators.py +0 -0
  80. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/patched.py +0 -0
  81. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/__init__.py +0 -0
  82. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/celery_app.py +0 -0
  83. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/helpers/__init__.py +0 -0
  84. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/helpers/central_server.py +0 -0
  85. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/helpers/email_render.py +0 -0
  86. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/helpers/token.py +0 -0
  87. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/helpers/trino.py +0 -0
  88. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/models.py +0 -0
  89. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/plugin/utils.py +0 -0
  90. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/py.typed +0 -0
  91. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/__init__.py +0 -0
  92. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/__init__.py +0 -0
  93. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/cache.py +0 -0
  94. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/events.py +0 -0
  95. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/hash.py +0 -0
  96. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/queues/__init__.py +0 -0
  97. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/queues/comms.py +0 -0
  98. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/remote/datatypes/user_quota_tracker.py +0 -0
  99. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/security/obo.py +0 -0
  100. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/security/utils.py +0 -0
  101. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/config_service.py +0 -0
  102. {clue_api-1.7.0.dev415 → clue_api-1.7.0.dev420}/clue/services/mongo_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clue-api
3
- Version: 1.7.0.dev415
3
+ Version: 1.7.0.dev420
4
4
  Summary: Clue distributed enrichment service
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,11 +1,10 @@
1
- import typing
2
1
  from typing import Any, Optional
3
2
  from urllib.parse import urlparse
4
3
 
5
4
  from authlib.integrations.base_client import OAuthError
6
5
  from flask import current_app, request
6
+ from pydantic import ValidationError
7
7
 
8
- import clue.services.auth_service as auth_service
9
8
  import clue.services.user_service as user_service
10
9
  from clue.api import (
11
10
  bad_request,
@@ -26,6 +25,7 @@ from clue.common.logging import get_logger
26
25
  from clue.common.str_utils import default_string_value
27
26
  from clue.common.swagger import generate_swagger_docs
28
27
  from clue.config import config
28
+ from clue.models.auth_user import Privilege
29
29
  from clue.security.utils import generate_random_secret
30
30
 
31
31
  logger = get_logger(__file__)
@@ -33,7 +33,7 @@ logger = get_logger(__file__)
33
33
 
34
34
  SUB_API = "auth"
35
35
  auth_api = make_subapi_blueprint(SUB_API, api_version=1)
36
- auth_api._doc = "Allow user to authenticate to the web server"
36
+ auth_api._doc = "Allow user to authenticate to the web server" # type: ignore
37
37
 
38
38
  logger = get_logger(__file__)
39
39
 
@@ -42,43 +42,43 @@ logger = get_logger(__file__)
42
42
  @generate_swagger_docs()
43
43
  @auth_api.route("/login", methods=["GET", "POST"])
44
44
  def login(**_) -> dict[str, Any]: # noqa: C901
45
- """Log the user into the system, in one of three ways.
46
-
47
- 1. Username/Password Authentication
48
- 2. Username/API Key Authentication
49
- 3. OAuth Login flow
50
- (See here: https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow)
51
-
52
- Variables:
53
- None
54
-
55
- Arguments:
56
- NOTE: The arguments are used only when completing the OAuth authorization flow.
57
- provider => The provider of the OAuth code.
58
- state => Random state used in the OAuth authentication flow.
59
- code => The code provided by the OAuth provider used to exchange for an access token.
60
-
61
- Data Block:
62
- {
63
- "user": "user", # The username to authenticate as (optional)
64
- "password": "password", # The password used to authenticate (optional)
65
- "apikey": "devkey:user", # The apikey used ot authenticate (optional)
66
- "oauth_provider": "keycloak" # The oauth provider initiate an OAuth Authorization Flow with (optional)
67
- }
68
-
69
- Result Example:
70
- {
71
- # Profile picture for the user
72
- "avatar": "data:image/png;base64, ...",
73
- # Username of the authenticated user
74
- "username": "user",
75
- # Different privileges that the user will get for this session
76
- "privileges": ["R", "W"],
77
- # A token generated by us the user can use to authenticate with clue
78
- "app_token": "asdfsd876opqwm465a89sdf4",
79
- # A JSON Web Access Token generated by an OAuth provider to authenticate with them
80
- "access_token": "<JWT>",
81
- }
45
+ """Authenticate a user through an OAuth provider.
46
+
47
+ The initial request redirects the user to the selected OAuth provider.
48
+ The callback exchanges the authorization code, validates the returned user,
49
+ and returns the provider-issued access token as the application token.
50
+
51
+ Args:
52
+ provider: The OAuth provider to use for authentication.
53
+ oauth_provider: Alternative name for ``provider``.
54
+ code: The authorization code returned by the OAuth provider.
55
+ refresh_token: An OAuth refresh token used to obtain a new access token.
56
+ nonce: Optional nonce used during the OAuth authorization flow.
57
+
58
+ Returns:
59
+ A Clue API response containing the access token, refresh token, user
60
+ information, and granted privileges. An initial authentication request
61
+ returns the provider's authorization redirect response.
62
+
63
+ Example:
64
+ A successful authentication returns a response containing the provider
65
+ token, privileges, and normalized user details::
66
+
67
+ {
68
+ "app_token": "<OAuth access token>",
69
+ "provider": "keycloak",
70
+ "refresh_token": "<OAuth refresh token>",
71
+ "privileges": ["R", "W"],
72
+ "user": {
73
+ "uname": "user",
74
+ "name": "User Name",
75
+ "email": "user@example.com",
76
+ "classification": "UNCLASSIFIED",
77
+ "groups": ["clue_user"],
78
+ "roles": ["user"],
79
+ "avatar": "data:image/png;base64, ..."
80
+ }
81
+ }
82
82
  """
83
83
  data: dict[str, Any]
84
84
  if request.is_json and len(request.data) > 0:
@@ -100,7 +100,7 @@ def login(**_) -> dict[str, Any]: # noqa: C901
100
100
  logged_in_uname = None
101
101
  access_token = None
102
102
  refresh_token = data.get("refresh_token", None)
103
- priv: Optional[list[str]] = []
103
+ priv: Optional[list[Privilege]] = []
104
104
 
105
105
  try:
106
106
  # First, we'll try oauth
@@ -173,12 +173,18 @@ def login(**_) -> dict[str, Any]: # noqa: C901
173
173
  access_token = token_data.get("access_token", None)
174
174
  refresh_token = token_data.get("refresh_token", None)
175
175
 
176
+ if not access_token:
177
+ raise AuthenticationException("The OAuth provider did not return an access token.")
178
+
176
179
  # Get a useful dict of user data from the web token
177
- cur_user = user_service.parse_user_data(token_data, oauth_provider)
180
+ try:
181
+ cur_user = user_service.parse_user_data(token_data, oauth_provider)
182
+ except ValidationError as e:
183
+ raise AuthenticationException("The OAuth provider returned invalid user information.") from e
178
184
 
179
- logged_in_uname = cur_user["uname"]
185
+ logged_in_uname = cur_user.uname
180
186
 
181
- priv = ["R", "W"]
187
+ priv = [Privilege.READ, Privilege.WRITE]
182
188
 
183
189
  # No oauth provider was specified, so we fall back to user/pass or user/apikey
184
190
  # elif user and (password or apikey):
@@ -226,18 +232,13 @@ def login(**_) -> dict[str, Any]: # noqa: C901
226
232
 
227
233
  # Generate the token this user can use to authenticate from now on
228
234
 
229
- if access_token:
230
- app_token = access_token
231
- else:
232
- app_token = f"{logged_in_uname}:{auth_service.create_token(logged_in_uname, typing.cast(list[str], priv))}"
233
-
234
235
  return ok(
235
236
  {
236
- "app_token": app_token,
237
+ "app_token": access_token,
237
238
  "provider": oauth_provider,
238
239
  "refresh_token": refresh_token,
239
240
  "privileges": priv,
240
- "user": cur_user,
241
+ "user": cur_user.model_dump(mode="json"),
241
242
  },
242
243
  cookies={"XSRF-TOKEN": xsrf_token},
243
244
  )
@@ -5,6 +5,7 @@ from clue.api import bad_request, make_subapi_blueprint, no_content, ok
5
5
  from clue.common.logging import get_logger
6
6
  from clue.common.swagger import generate_swagger_docs
7
7
  from clue.config import config, get_redis
8
+ from clue.models.auth_user import Privilege, UserRole
8
9
  from clue.models.config import ExternalSource
9
10
  from clue.remote.datatypes.set import Set
10
11
  from clue.security import api_login
@@ -15,12 +16,12 @@ EXTERNAL_PLUGIN_SET = Set("plugin_set", host=get_redis())
15
16
 
16
17
  SUB_API = "registration"
17
18
  registration_api = make_subapi_blueprint(SUB_API, api_version=1)
18
- registration_api._doc = "Register external plugins"
19
+ registration_api._doc = "Register external plugins" # type: ignore
19
20
 
20
21
 
21
22
  @generate_swagger_docs()
22
23
  @registration_api.route("/register/", methods=["POST"])
23
- @api_login()
24
+ @api_login(required_priv=[Privilege.WRITE], required_roles=[UserRole.ADMIN])
24
25
  def register_application(**kwargs):
25
26
  """Register the plugin given the provided data via REST API.
26
27
 
@@ -56,23 +57,43 @@ def register_application(**kwargs):
56
57
  }
57
58
 
58
59
  """
59
- if not request.json:
60
+ if not request.is_json:
61
+ return bad_request(err="Invalid data type.")
62
+
63
+ body = request.json
64
+ if not (body and isinstance(body, dict)):
60
65
  return bad_request(err="No data provided")
61
66
 
67
+ existing_source_names = {source.name for source in config.api.external_sources}
68
+ existing_source_names.update(
69
+ source["name"]
70
+ for source in EXTERNAL_PLUGIN_SET.members()
71
+ if isinstance(source, dict) and isinstance(source.get("name"), str)
72
+ )
73
+
62
74
  try:
63
- registration_request = ExternalSource(**request.json, built_in=False)
64
- except ValidationError:
65
- return bad_request(err="Request data could not be converted to an ExternalSource object")
75
+ registration_request = ExternalSource.model_validate(
76
+ {**request.json, "built_in": False},
77
+ context={
78
+ "registration_allowed_origins": config.api.registration_allowed_origins,
79
+ "existing_source_names": existing_source_names,
80
+ },
81
+ )
82
+ except ValidationError as error:
83
+ return bad_request(err="; ".join(item["msg"] for item in error.errors()))
84
+
85
+ persisted_source = registration_request.model_dump(mode="json", exclude_none=True)
86
+ if not EXTERNAL_PLUGIN_SET.add_if_field_absent(persisted_source, "name", registration_request.name):
87
+ return bad_request(err="An external source with that name already exists")
66
88
 
67
89
  config.api.external_sources.append(registration_request)
68
- EXTERNAL_PLUGIN_SET.add(registration_request.model_dump(mode="json", exclude_none=True))
69
90
 
70
91
  return ok(data=registration_request.name)
71
92
 
72
93
 
73
94
  @generate_swagger_docs()
74
- @registration_api.route("<plugin_id>", methods=["DELETE"])
75
- @api_login()
95
+ @registration_api.route("/<plugin_id>", methods=["DELETE"])
96
+ @api_login(required_priv=[Privilege.WRITE], required_roles=[UserRole.ADMIN])
76
97
  def remove_application(plugin_id: str, **kwargs):
77
98
  """Remove the given plugin from the external_sources list via REST API.
78
99
 
@@ -90,20 +111,18 @@ def remove_application(plugin_id: str, **kwargs):
90
111
  "response_status": "204 NO CONTENT" # HTTP status code
91
112
  }
92
113
  """
93
- source_to_remove = None
94
-
95
- for source in config.api.external_sources:
96
- if source.name == plugin_id and source.built_in is False:
97
- source_to_remove = source
98
- break
99
-
100
- if (
101
- source_to_remove is not None
102
- and source_to_remove.model_dump(mode="json", exclude_none=True) in EXTERNAL_PLUGIN_SET.members()
103
- ):
104
- config.api.external_sources.remove(source_to_remove)
105
- EXTERNAL_PLUGIN_SET.remove(source_to_remove.model_dump(mode="json", exclude_none=True))
106
- logger.info(no_content(data=source_to_remove.name))
107
- return no_content(data=source_to_remove.name)
114
+ persisted_sources = [
115
+ source
116
+ for source in EXTERNAL_PLUGIN_SET.members()
117
+ if isinstance(source, dict) and source.get("name") == plugin_id
118
+ ]
119
+
120
+ if persisted_sources:
121
+ config.api.external_sources = [
122
+ source for source in config.api.external_sources if source.name != plugin_id or source.built_in is True
123
+ ]
124
+ EXTERNAL_PLUGIN_SET.remove(*persisted_sources)
125
+ logger.info("Removed plugin with id %s", plugin_id)
126
+ return no_content(data=plugin_id)
108
127
 
109
128
  return no_content(data=f"No plugin found with id: {plugin_id}")
@@ -0,0 +1,58 @@
1
+ from apscheduler.schedulers.base import BaseScheduler
2
+ from gevent.queue import Queue
3
+ from pydantic import ValidationError
4
+
5
+ from clue.api.v1.registration import EXTERNAL_PLUGIN_SET
6
+ from clue.common.logging import get_logger
7
+ from clue.config import config
8
+ from clue.models.config import ExternalSource
9
+
10
+ logger = get_logger(__file__)
11
+
12
+ config_updates = Queue()
13
+
14
+ __scheduler_instance: BaseScheduler | None = None
15
+
16
+
17
+ def update_external_source_list():
18
+ """Updates the external_sources list with the plugins that have been registered through the API."""
19
+ built_in_sources = [item for item in config.api.external_sources if item.built_in is True]
20
+ source_names = {source.name for source in built_in_sources}
21
+ plugin_list: list[ExternalSource] = []
22
+
23
+ for item in EXTERNAL_PLUGIN_SET.members():
24
+ if not isinstance(item, dict):
25
+ logger.warning("Ignoring invalid runtime external source configuration: expected an object")
26
+ continue
27
+
28
+ try:
29
+ source = ExternalSource.model_validate(
30
+ {**item, "built_in": False},
31
+ context={
32
+ "registration_allowed_origins": config.api.registration_allowed_origins,
33
+ "existing_source_names": source_names,
34
+ },
35
+ )
36
+ except ValidationError as error:
37
+ logger.warning(
38
+ "Ignoring invalid runtime external source configuration: %s",
39
+ "; ".join(item["msg"] for item in error.errors()),
40
+ )
41
+ continue
42
+
43
+ source_names.add(source.name)
44
+ plugin_list.append(source)
45
+
46
+ config.api.external_sources = built_in_sources + plugin_list
47
+
48
+
49
+ def setup_job(sched: BaseScheduler):
50
+ """Sets the scheduler instance to the one provided, and refreshes the external sources.
51
+
52
+ Arguments:
53
+ sched: The scheduler instance to set.
54
+ """
55
+ global __scheduler_instance
56
+ __scheduler_instance = sched
57
+ sched.add_job(update_external_source_list, "interval", minutes=1)
58
+ logger.debug("Plugin job setup complete.")
@@ -6,7 +6,8 @@ import elasticapm
6
6
 
7
7
  from clue.common.logging import get_logger
8
8
  from clue.config import CLASSIFICATION as CL_ENGINE
9
- from clue.config import USER_TYPES, config
9
+ from clue.config import config
10
+ from clue.models.auth_user import UserRole
10
11
  from clue.models.config import (
11
12
  DEFAULT_EMAIL_FIELDS,
12
13
  DEFAULT_USER_FIELDS,
@@ -34,7 +35,7 @@ def reorder_name(name: Optional[str]) -> Optional[str]:
34
35
  return " ".join(name.split(", ", 1)[::-1])
35
36
 
36
37
 
37
- @elasticapm.capture_span(span_type="authentication")
38
+ @elasticapm.capture_span(span_type="authentication") # type: ignore
38
39
  def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> dict[str, Any]: # noqa: C901
39
40
  """Find email address and normalize it for further processing"""
40
41
  email_adr: str | None = None
@@ -61,10 +62,10 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
61
62
  break
62
63
 
63
64
  # Try to find a username or use email address
64
- uname = None
65
+ uname: str | None = None
65
66
  for field in DEFAULT_USER_FIELDS:
66
67
  if field in profile:
67
- uname: str = profile[field]
68
+ uname = profile[field]
68
69
  break
69
70
  uname = uname or email_adr
70
71
 
@@ -101,28 +102,27 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
101
102
 
102
103
  # Compute access, roles and classification using auto_properties
103
104
  access = True
104
- roles = ["user"]
105
+ roles = [UserRole.USER]
105
106
  # TODO: correctly figure out the classification
106
107
  classification = CL_ENGINE.UNRESTRICTED
107
108
 
108
109
  # Infer roles from groups
109
110
  if profile.get("groups") and provider_config.role_map:
110
- for user_type in USER_TYPES:
111
+ for role in UserRole:
111
112
  if (
112
- user_type in provider_config.role_map
113
- and provider_config.role_map[user_type] in profile.get("groups", [])
114
- and user_type not in roles
113
+ role in provider_config.role_map
114
+ and provider_config.role_map[role] in profile.get("groups", [])
115
+ and role not in roles
115
116
  ):
116
- roles.append(user_type)
117
+ roles.append(role)
117
118
 
118
119
  return dict(
119
120
  access=access,
120
- type=roles,
121
+ roles=roles,
121
122
  classification=classification,
122
123
  uname=uname,
123
124
  name=name,
124
125
  email=email_adr,
125
- password="__NO_PASSWORD__", # noqa: S106
126
126
  avatar=profile.get("picture", alternate),
127
127
  groups=profile.get("groups", []),
128
128
  )
@@ -0,0 +1,234 @@
1
+ from collections.abc import Callable
2
+ from time import monotonic
3
+ from typing import Any, TypeAlias
4
+ from urllib.parse import urljoin, urlsplit
5
+
6
+ from requests import Response
7
+ from requests.exceptions import ConnectionError, Timeout
8
+
9
+ _Timeout: TypeAlias = int | float | tuple[int | float | None, int | float | None] | None
10
+
11
+
12
+ def _timeout_budget(timeout: _Timeout) -> float | None:
13
+ """Calculate the total duration represented by a requests timeout.
14
+
15
+ Requests accepts either a scalar timeout or a (connect, read) pair. A
16
+ timeout with an unbounded component has no finite total budget.
17
+
18
+ Args:
19
+ timeout: A numeric timeout, a connect/read timeout pair, or ``None``.
20
+
21
+ Returns:
22
+ The total timeout in seconds, or ``None`` when no finite budget exists.
23
+ """
24
+ if isinstance(timeout, tuple):
25
+ # If either phase is unbounded, do not pretend the pair has a deadline.
26
+ connect_timeout, read_timeout = timeout
27
+ if connect_timeout is None or read_timeout is None:
28
+ return None
29
+ return float(connect_timeout) + float(read_timeout) or None
30
+ return float(timeout) if timeout else None
31
+
32
+
33
+ def _remaining_timeout(timeout: _Timeout, remaining: float) -> _Timeout:
34
+ """Fit an original timeout into the remaining overall request budget.
35
+
36
+ Args:
37
+ timeout: The original scalar or connect/read timeout setting.
38
+ remaining: Time left before the overall deadline, in seconds.
39
+
40
+ Returns:
41
+ A timeout value that does not exceed the remaining budget. An
42
+ unbounded setting is preserved as-is.
43
+ """
44
+ if isinstance(timeout, tuple):
45
+ budget = _timeout_budget(timeout)
46
+ if budget is None:
47
+ return timeout
48
+ # Preserve the connect/read ratio while shrinking both phases together.
49
+ scale = min(1.0, remaining / budget)
50
+ connect_timeout, read_timeout = timeout
51
+ if connect_timeout is None or read_timeout is None:
52
+ return timeout
53
+ return float(connect_timeout) * scale, float(read_timeout) * scale
54
+ if timeout is None:
55
+ return None
56
+ return min(float(timeout), remaining)
57
+
58
+
59
+ def _switch_to_get(status_code: int, get_method: Callable[..., Response] | None) -> bool:
60
+ """Return whether a redirect should use the supplied GET method.
61
+
62
+ Args:
63
+ status_code: HTTP status code returned by the redirect response.
64
+ get_method: Optional GET callable to use for method-changing redirects.
65
+
66
+ Returns:
67
+ Whether a GET method is available and the status commonly changes the
68
+ redirected request to GET.
69
+ """
70
+ return get_method is not None and status_code in {301, 302, 303}
71
+
72
+
73
+ def _clear_request_body(kwargs: dict[str, Any]) -> None:
74
+ """Remove payload and query arguments from a redirected GET request.
75
+
76
+ Args:
77
+ kwargs: Request keyword arguments, updated in place.
78
+ """
79
+ for key in ("json", "data", "files", "params"):
80
+ kwargs.pop(key, None)
81
+
82
+
83
+ def _next_redirect(
84
+ response: Response,
85
+ url: str,
86
+ request_method: Callable[..., Response],
87
+ get_method: Callable[..., Response] | None,
88
+ kwargs: dict[str, Any],
89
+ redirect_count: int,
90
+ ) -> tuple[Callable[..., Response], str]:
91
+ """Validate and prepare the next hop in a redirect chain.
92
+
93
+ Args:
94
+ response: The response that requested a redirect.
95
+ url: The URL used for the current request.
96
+ request_method: The method callable currently used for requests.
97
+ get_method: Optional callable used when a redirect changes to GET.
98
+ kwargs: Request keyword arguments, updated for the next hop.
99
+ redirect_count: Number of redirects already followed.
100
+
101
+ Returns:
102
+ The request callable and absolute URL to use for the next hop. When a
103
+ response has no Location header, the current method and URL are returned.
104
+
105
+ Raises:
106
+ ConnectionError: If the redirect target is invalid, unsafe, or exceeds
107
+ the redirect limit.
108
+ """
109
+ location = response.headers.get("Location")
110
+ if not location:
111
+ return request_method, url
112
+
113
+ try:
114
+ # Resolve relative locations against the response URL, as HTTP requires.
115
+ target_url = urljoin(response.url or url, location)
116
+ except ValueError as err:
117
+ response.close()
118
+ raise ConnectionError("Plugin redirected to an untrusted URL") from err
119
+ if not _safe_redirect(url, target_url):
120
+ response.close()
121
+ raise ConnectionError("Plugin redirected to an untrusted URL")
122
+ if redirect_count == 5:
123
+ response.close()
124
+ raise ConnectionError("Plugin exceeded the redirect limit")
125
+
126
+ # Some redirect codes turn a request into GET; others retain its method.
127
+ switch_to_get = _switch_to_get(response.status_code, get_method)
128
+ if switch_to_get:
129
+ request_method = get_method # type: ignore[assignment]
130
+ if response.status_code == 303 or switch_to_get:
131
+ _clear_request_body(kwargs)
132
+ else:
133
+ kwargs.pop("params", None)
134
+
135
+ response.close()
136
+ return request_method, target_url
137
+
138
+
139
+ def _safe_redirect(current_url: str, target_url: str) -> bool:
140
+ """Check whether a redirect stays on the same trusted host and port.
141
+
142
+ Redirects are permitted within the same origin, and from the default HTTP
143
+ origin to the default HTTPS origin on the same host. Other host, port,
144
+ scheme, and embedded-credential changes are rejected.
145
+
146
+ Args:
147
+ current_url: URL from which the redirect originated.
148
+ target_url: Resolved URL requested by the redirect response.
149
+
150
+ Returns:
151
+ Whether the redirect target satisfies the allowed origin rules.
152
+ """
153
+ try:
154
+ current = urlsplit(current_url)
155
+ target = urlsplit(target_url)
156
+ # Reject non-web schemes, malformed hosts, and credential-bearing URLs.
157
+ if (
158
+ target.scheme not in {"http", "https"}
159
+ or not target.hostname
160
+ or target.username is not None
161
+ or target.password is not None
162
+ ):
163
+ return False
164
+ current_port = current.port or (443 if current.scheme == "https" else 80)
165
+ target_port = target.port or (443 if target.scheme == "https" else 80)
166
+ except ValueError:
167
+ return False
168
+
169
+ # Never let a plugin redirect send request headers to a different host.
170
+ if current.hostname != target.hostname:
171
+ return False
172
+ # Permit same-origin redirects or only the conventional HTTP-to-HTTPS upgrade.
173
+ if current.scheme == target.scheme and current_port == target_port:
174
+ return True
175
+ return current.scheme == "http" and current_port == 80 and target.scheme == "https" and target_port == 443
176
+
177
+
178
+ def request_with_safe_redirects(
179
+ request_method: Callable[..., Response],
180
+ url: str,
181
+ *,
182
+ get_method: Callable[..., Response] | None = None,
183
+ **kwargs: Any,
184
+ ) -> Response:
185
+ """Send a request while safely following a bounded chain of redirects.
186
+
187
+ Redirects are handled manually so each destination can be checked before
188
+ sending the request again. A single total timeout budget is maintained
189
+ across all hops when a finite timeout is supplied.
190
+
191
+ Args:
192
+ request_method: Callable used to send the initial request and redirects
193
+ that preserve the method.
194
+ url: Initial request URL.
195
+ get_method: Optional callable to use when a redirect changes the method
196
+ to GET.
197
+ **kwargs: Additional keyword arguments passed to the request callable.
198
+ ``allow_redirects`` is always disabled so this function can validate
199
+ each redirect itself.
200
+
201
+ Returns:
202
+ The first response that does not request a redirect.
203
+
204
+ Raises:
205
+ ConnectionError: If a redirect is unsafe or the redirect limit is
206
+ exceeded.
207
+ Timeout: If the total timeout budget expires between redirect hops.
208
+ """
209
+ # Disable requests' automatic redirects to validate every target ourselves.
210
+ kwargs["allow_redirects"] = False
211
+ timeout = kwargs.get("timeout")
212
+ budget = _timeout_budget(timeout)
213
+ # Reuse one deadline so a chain cannot receive a fresh timeout per hop.
214
+ deadline = monotonic() + budget if budget is not None else None
215
+
216
+ for redirect_count in range(6):
217
+ if deadline is not None and redirect_count > 0:
218
+ remaining = deadline - monotonic()
219
+ if remaining <= 0:
220
+ raise Timeout("Plugin request exceeded its total timeout")
221
+ # Pass only the time left to the next network operation.
222
+ kwargs["timeout"] = _remaining_timeout(timeout, remaining)
223
+
224
+ response = request_method(url, **kwargs)
225
+ if response.status_code not in {301, 302, 303, 307, 308}:
226
+ return response
227
+
228
+ next_method, next_url = _next_redirect(response, url, request_method, get_method, kwargs, redirect_count)
229
+ # A redirect status without Location is not actionable; return it intact.
230
+ if next_url == url and not response.headers.get("Location"):
231
+ return response
232
+ request_method, url = next_method, next_url
233
+
234
+ raise ConnectionError("Plugin exceeded the redirect limit")