clue-api 1.7.0.dev433__tar.gz → 1.7.0.dev435__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.
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/PKG-INFO +1 -1
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/actions.py +4 -2
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/fetchers.py +3 -1
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/helper/headers.py +6 -7
- clue_api-1.7.0.dev435/clue/helper/obo.py +31 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/action_service.py +76 -73
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/auth_service.py +6 -6
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/fetcher_service.py +68 -77
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/lookup_service.py +28 -36
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/type_service.py +15 -18
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/pyproject.toml +1 -1
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/LICENSE +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/README.md +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/.gitignore +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/base.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/auth.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/configs.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/lookup.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/registration.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/static.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/api/v1/sync.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/app.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/cache/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/bytes_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/classification.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/classification.yml +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/dict_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/exceptions.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/forge.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/json_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/list_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/logging/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/logging/audit.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/logging/format.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/regex.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/str_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/swagger.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/common/uid.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/constants/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/constants/env.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/constants/supported_types.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/cronjobs/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/cronjobs/plugins.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/error.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/extensions/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/extensions/config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/gunicorn_config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/healthz.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/helper/discover.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/helper/oauth.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/helper/plugin_requests.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/actions.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/auth_user.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/fetchers.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/graph.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/model_list.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/network.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/base.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/file.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/graph.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/image.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/status.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/results/validation.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/schema.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/selector.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/sync.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/models/validators.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/patched.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/celery_app.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/helpers/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/helpers/central_server.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/helpers/email_render.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/helpers/token.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/helpers/trino.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/models.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/plugin/utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/py.typed +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/cache.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/events.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/hash.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/queues/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/queues/comms.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/set.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/remote/datatypes/user_quota_tracker.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/security/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/security/obo.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/security/utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/config_service.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/jwt_service.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/mongo_service.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev435}/clue/services/user_service.py +0 -0
|
@@ -8,7 +8,7 @@ List and execute actions
|
|
|
8
8
|
|
|
9
9
|
from flask_cors import CORS
|
|
10
10
|
|
|
11
|
-
from clue.api import internal_error, make_subapi_blueprint, not_found, ok
|
|
11
|
+
from clue.api import internal_error, make_subapi_blueprint, not_found, ok, service_unavailable
|
|
12
12
|
from clue.common.exceptions import ClueException, NotFoundException
|
|
13
13
|
from clue.common.logging import get_logger
|
|
14
14
|
from clue.common.swagger import generate_swagger_docs
|
|
@@ -22,7 +22,7 @@ logger = get_logger(__file__)
|
|
|
22
22
|
|
|
23
23
|
SUB_API = "actions"
|
|
24
24
|
actions_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
25
|
-
actions_api._doc = "Run actions on data through configured external data sources/systems."
|
|
25
|
+
actions_api._doc = "Run actions on data through configured external data sources/systems." # type: ignore
|
|
26
26
|
|
|
27
27
|
CORS(actions_api, origins=config.ui.cors_origins, supports_credentials=True)
|
|
28
28
|
|
|
@@ -122,4 +122,6 @@ def get_action_status(plugin_id: str, action_id: str, task_id: str, **kwargs) ->
|
|
|
122
122
|
except NotFoundException as err:
|
|
123
123
|
return not_found(err=err.message)
|
|
124
124
|
except ClueException as err:
|
|
125
|
+
if err.status_code == 503:
|
|
126
|
+
return service_unavailable(err=err.message)
|
|
125
127
|
return internal_error(err=err.message)
|
|
@@ -8,7 +8,7 @@ List and execute fetchers that provide data to be rendered client-side
|
|
|
8
8
|
|
|
9
9
|
from flask_cors import CORS
|
|
10
10
|
|
|
11
|
-
from clue.api import bad_gateway, bad_request, make_subapi_blueprint, not_found, ok
|
|
11
|
+
from clue.api import bad_gateway, bad_request, make_subapi_blueprint, not_found, ok, service_unavailable
|
|
12
12
|
from clue.common.exceptions import ClueException, NotFoundException
|
|
13
13
|
from clue.common.logging import get_logger
|
|
14
14
|
from clue.common.swagger import generate_swagger_docs
|
|
@@ -120,6 +120,8 @@ def get_fetcher_status(plugin_id: str, fetcher_id: str, task_id: str, **kwargs):
|
|
|
120
120
|
except NotFoundException as err:
|
|
121
121
|
return not_found(err=err.message)
|
|
122
122
|
except ClueException as err:
|
|
123
|
+
if err.status_code == 503:
|
|
124
|
+
return service_unavailable(err=err.message)
|
|
123
125
|
if err.status_code == 400:
|
|
124
126
|
logger.warning(
|
|
125
127
|
"Bad request from fetcher %s.%s with task_id: %s: %s", plugin_id, fetcher_id, task_id, err.message
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
from clue.common.logging import get_logger
|
|
2
|
-
from clue.config import
|
|
2
|
+
from clue.config import config
|
|
3
3
|
|
|
4
4
|
logger = get_logger(__file__)
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
def generate_headers(access_token: str | None, clue_access_token: str | None) -> dict[str, str]:
|
|
7
|
+
def generate_headers(obo_access_token: str | None, access_token: str | None) -> dict[str, str]:
|
|
9
8
|
"""Generates the request headers.
|
|
10
9
|
|
|
11
10
|
Args:
|
|
@@ -19,12 +18,12 @@ def generate_headers(access_token: str | None, clue_access_token: str | None) ->
|
|
|
19
18
|
"content-type": "application/json",
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
if access_token:
|
|
21
|
+
if obo_access_token or access_token:
|
|
23
22
|
logger.debug("Appending authorization header")
|
|
24
|
-
_headers["Authorization"] = f"Bearer {access_token}"
|
|
23
|
+
_headers["Authorization"] = f"Bearer {obo_access_token or access_token}"
|
|
25
24
|
|
|
26
|
-
if config.auth.propagate_clue_key and
|
|
25
|
+
if config.auth.propagate_clue_key and obo_access_token and access_token:
|
|
27
26
|
logger.debug("Appending custom authorization header")
|
|
28
|
-
_headers["X-Clue-Authorization"] =
|
|
27
|
+
_headers["X-Clue-Authorization"] = access_token
|
|
29
28
|
|
|
30
29
|
return _headers
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from flask import has_request_context, request
|
|
4
|
+
|
|
5
|
+
from clue.common.exceptions import AuthenticationException
|
|
6
|
+
from clue.common.logging import get_logger
|
|
7
|
+
from clue.models.config import ExternalSource
|
|
8
|
+
from clue.services import auth_service
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__file__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_obo_access_token(
|
|
14
|
+
source: ExternalSource, user: dict[str, Any], access_token: Optional[str] = None
|
|
15
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
16
|
+
"""Get the caller access token and an OBO token for an external source when needed."""
|
|
17
|
+
if access_token is None and has_request_context():
|
|
18
|
+
auth_header = request.headers.get("Authorization", type=str)
|
|
19
|
+
if auth_header:
|
|
20
|
+
parts = auth_header.split(" ", 1)
|
|
21
|
+
access_token = parts[1] if len(parts) == 2 else auth_header
|
|
22
|
+
|
|
23
|
+
if not access_token:
|
|
24
|
+
return None, None
|
|
25
|
+
|
|
26
|
+
obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
|
|
27
|
+
if error:
|
|
28
|
+
logger.error("%s: %s", source.name, error)
|
|
29
|
+
raise AuthenticationException("Invalid token provided for this enrichment.")
|
|
30
|
+
|
|
31
|
+
return access_token, obo_access_token
|
|
@@ -1,80 +1,76 @@
|
|
|
1
|
-
from
|
|
1
|
+
from time import monotonic
|
|
2
|
+
from typing import Any
|
|
2
3
|
from urllib.parse import urljoin
|
|
3
4
|
|
|
4
5
|
import requests
|
|
5
6
|
from elasticapm.traces import capture_span
|
|
6
7
|
from flask import request
|
|
7
|
-
from pydantic import TypeAdapter
|
|
8
|
+
from pydantic import TypeAdapter
|
|
8
9
|
from requests import JSONDecodeError, exceptions
|
|
9
10
|
|
|
10
|
-
from clue.common.exceptions import ClueException, NotFoundException
|
|
11
|
+
from clue.common.exceptions import AuthenticationException, ClueException, NotFoundException
|
|
11
12
|
from clue.common.logging import get_logger
|
|
12
13
|
from clue.config import CLASSIFICATION, config
|
|
13
14
|
from clue.helper.headers import generate_headers
|
|
15
|
+
from clue.helper.obo import get_obo_access_token
|
|
14
16
|
from clue.helper.plugin_requests import request_with_safe_redirects
|
|
15
17
|
from clue.models.actions import ActionResult, ActionSpec
|
|
16
18
|
from clue.models.config import ExternalSource
|
|
17
|
-
from clue.services import auth_service
|
|
18
19
|
|
|
19
20
|
logger = get_logger(__file__)
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
def _raise_action_metadata_unavailable(error: Exception | None = None) -> None:
|
|
24
|
+
raise ClueException(
|
|
25
|
+
"Unable to verify action availability with the upstream source.", error, status_code=503
|
|
26
|
+
) from error
|
|
27
|
+
|
|
28
|
+
|
|
22
29
|
def get_supported_actions(
|
|
23
|
-
source: ExternalSource,
|
|
30
|
+
source: ExternalSource,
|
|
31
|
+
headers: dict[str, str],
|
|
32
|
+
*,
|
|
33
|
+
timeout: float = 10.0,
|
|
34
|
+
raise_on_error: bool = False,
|
|
24
35
|
) -> dict[str, ActionSpec]:
|
|
25
36
|
"""Gets all supported actions for a source
|
|
26
37
|
|
|
27
38
|
Args:
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
source (ExternalSource): The source whose actions to retrieve.
|
|
40
|
+
headers (dict[str, str]): Headers to include in the upstream request.
|
|
41
|
+
timeout (float): The upstream request timeout in seconds.
|
|
42
|
+
raise_on_error (bool): Raise a 503 when metadata cannot be verified.
|
|
30
43
|
|
|
31
44
|
Returns:
|
|
32
45
|
dict[str, ActionSpec]: A dict of each action and their schema
|
|
33
46
|
"""
|
|
34
47
|
logger.info("Fetching actions for source %s", source.name)
|
|
35
|
-
|
|
36
48
|
url = urljoin(source.url, "actions/")
|
|
37
49
|
|
|
38
|
-
obo_access_token = None
|
|
39
|
-
if access_token:
|
|
40
|
-
obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
|
|
41
|
-
|
|
42
|
-
if error:
|
|
43
|
-
logger.error("%s: %s", source.name, error)
|
|
44
|
-
return {}
|
|
45
|
-
|
|
46
|
-
headers = generate_headers(obo_access_token or access_token, access_token if obo_access_token else None)
|
|
47
|
-
|
|
48
50
|
with capture_span(f"GET {url}", span_type="http"):
|
|
49
51
|
rsp = None
|
|
50
52
|
try:
|
|
51
|
-
rsp = request_with_safe_redirects(requests.get, url, headers=headers, timeout=
|
|
53
|
+
rsp = request_with_safe_redirects(requests.get, url, headers=headers, timeout=timeout)
|
|
52
54
|
result = rsp.json()
|
|
53
55
|
|
|
54
56
|
if not rsp.ok:
|
|
55
57
|
err = result["api_error_message"]
|
|
56
58
|
logger.error(f"Error from upstream server: {rsp.status_code=}, {err=}")
|
|
59
|
+
if raise_on_error:
|
|
60
|
+
_raise_action_metadata_unavailable()
|
|
61
|
+
return {}
|
|
57
62
|
|
|
58
63
|
return TypeAdapter(dict[str, ActionSpec]).validate_python(result["api_response"])
|
|
59
|
-
except
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"External API did not return expected format. Full data:\n\n%s\n\nStack Trace:",
|
|
66
|
-
rsp.text if rsp else "None",
|
|
67
|
-
)
|
|
68
|
-
return {}
|
|
69
|
-
except ValidationError:
|
|
70
|
-
logger.exception("ValidationError in response from %s:\n%s", source.url)
|
|
71
|
-
return {}
|
|
72
|
-
except Exception:
|
|
73
|
-
logger.exception("Unknown exception occurred on action fetching:")
|
|
64
|
+
except ClueException:
|
|
65
|
+
raise
|
|
66
|
+
except Exception as err:
|
|
67
|
+
logger.exception("Unable to retrieve action metadata from %s", source.url)
|
|
68
|
+
if raise_on_error:
|
|
69
|
+
_raise_action_metadata_unavailable(err)
|
|
74
70
|
return {}
|
|
75
71
|
|
|
76
72
|
|
|
77
|
-
def all_supported_actions(user: dict[str, Any]
|
|
73
|
+
def all_supported_actions(user: dict[str, Any]) -> dict[str, ActionSpec]:
|
|
78
74
|
"""Gets all supported actions for all sources
|
|
79
75
|
|
|
80
76
|
Args:
|
|
@@ -86,7 +82,17 @@ def all_supported_actions(user: dict[str, Any], access_token: Optional[str] = No
|
|
|
86
82
|
all_actions: dict[str, ActionSpec] = {}
|
|
87
83
|
|
|
88
84
|
for source in config.api.external_sources:
|
|
89
|
-
|
|
85
|
+
if not CLASSIFICATION.is_accessible(user["classification"], source.classification):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
access_token, obo_access_token = get_obo_access_token(source, user)
|
|
90
|
+
except AuthenticationException:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
supported_actions = get_supported_actions(
|
|
94
|
+
source, generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
95
|
+
)
|
|
90
96
|
total_actions = 0
|
|
91
97
|
for key, action in supported_actions.items():
|
|
92
98
|
total_actions += 1
|
|
@@ -100,14 +106,7 @@ def get_plugins_supported_actions(user: dict[str, Any]) -> dict[str, ActionSpec]
|
|
|
100
106
|
"""Return the supported actions of each external service, filtered to what the user has access to."""
|
|
101
107
|
available_actions: dict[str, ActionSpec] = {}
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
if access_token:
|
|
105
|
-
access_token = access_token.split(" ")[1]
|
|
106
|
-
|
|
107
|
-
all_actions = all_supported_actions(
|
|
108
|
-
user,
|
|
109
|
-
access_token=access_token,
|
|
110
|
-
)
|
|
109
|
+
all_actions = all_supported_actions(user)
|
|
111
110
|
|
|
112
111
|
logger.info("Fetching actions for classification %s", user["classification"])
|
|
113
112
|
|
|
@@ -144,22 +143,19 @@ def execute_action(plugin_id: str, action_id: str, user: dict[str, Any]) -> Acti
|
|
|
144
143
|
"""
|
|
145
144
|
plugin = next((source for source in config.api.external_sources if source.name == plugin_id), None)
|
|
146
145
|
|
|
147
|
-
if not plugin:
|
|
148
|
-
raise NotFoundException(
|
|
149
|
-
|
|
150
|
-
access_token = request.headers.get("Authorization", type=str)
|
|
151
|
-
if access_token:
|
|
152
|
-
access_token = access_token.split(" ")[1]
|
|
146
|
+
if not plugin or not CLASSIFICATION.is_accessible(user["classification"], plugin.classification):
|
|
147
|
+
raise NotFoundException("Action not found.", status_code=404)
|
|
153
148
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
149
|
+
try:
|
|
150
|
+
access_token, obo_access_token = get_obo_access_token(plugin, user)
|
|
151
|
+
except AuthenticationException:
|
|
152
|
+
return ActionResult(outcome="failure", summary="Invalid token provided for this enrichment.")
|
|
157
153
|
|
|
158
|
-
|
|
159
|
-
logger.error("%s: %s", plugin.name, error)
|
|
160
|
-
return ActionResult(outcome="failure", summary="Invalid token provided for this enrichment.")
|
|
154
|
+
headers = generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
161
155
|
|
|
162
|
-
|
|
156
|
+
action = get_supported_actions(plugin, headers).get(action_id)
|
|
157
|
+
if action is None or not CLASSIFICATION.is_accessible(user["classification"], action.classification):
|
|
158
|
+
raise NotFoundException("Action not found.", status_code=404)
|
|
163
159
|
|
|
164
160
|
if request.content_type == "application/json":
|
|
165
161
|
parameters = request.json
|
|
@@ -211,22 +207,29 @@ def get_action_status(plugin_id: str, action_id: str, task_id: str, user: dict[s
|
|
|
211
207
|
"""
|
|
212
208
|
plugin = next((source for source in config.api.external_sources if source.name == plugin_id), None)
|
|
213
209
|
|
|
214
|
-
if not plugin:
|
|
215
|
-
raise NotFoundException(
|
|
216
|
-
|
|
217
|
-
access_token = request.headers.get("Authorization", type=str)
|
|
218
|
-
if access_token:
|
|
219
|
-
access_token = access_token.split(" ")[1]
|
|
220
|
-
|
|
221
|
-
obo_access_token = None
|
|
222
|
-
if access_token:
|
|
223
|
-
obo_access_token, error = auth_service.check_obo(plugin, access_token, user["uname"])
|
|
210
|
+
if not plugin or not CLASSIFICATION.is_accessible(user["classification"], plugin.classification):
|
|
211
|
+
raise NotFoundException("Action not found.", status_code=404)
|
|
224
212
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
213
|
+
try:
|
|
214
|
+
access_token, obo_access_token = get_obo_access_token(plugin, user)
|
|
215
|
+
except AuthenticationException:
|
|
216
|
+
return ActionResult(outcome="failure", summary="Invalid token provided.")
|
|
217
|
+
|
|
218
|
+
headers = generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
219
|
+
|
|
220
|
+
timeout = request.args.get("max_timeout", plugin.default_timeout, type=float)
|
|
221
|
+
metadata_started = monotonic()
|
|
222
|
+
# Authorization metadata must stay fresh; include this lookup in the caller's timeout budget.
|
|
223
|
+
action = get_supported_actions(
|
|
224
|
+
plugin,
|
|
225
|
+
headers,
|
|
226
|
+
timeout=max(min(timeout, 10.0), 0.001),
|
|
227
|
+
raise_on_error=True,
|
|
228
|
+
).get(action_id)
|
|
229
|
+
if action is None or not CLASSIFICATION.is_accessible(user["classification"], action.classification):
|
|
230
|
+
raise NotFoundException("Action not found.", status_code=404)
|
|
231
|
+
|
|
232
|
+
remaining_timeout = max(timeout - (monotonic() - metadata_started), 0.001)
|
|
230
233
|
|
|
231
234
|
try:
|
|
232
235
|
req_url = urljoin(plugin.url, f"actions/{action_id}/status/{task_id}")
|
|
@@ -236,7 +239,7 @@ def get_action_status(plugin_id: str, action_id: str, task_id: str, user: dict[s
|
|
|
236
239
|
requests.get,
|
|
237
240
|
req_url,
|
|
238
241
|
headers=headers,
|
|
239
|
-
timeout=
|
|
242
|
+
timeout=remaining_timeout,
|
|
240
243
|
)
|
|
241
244
|
|
|
242
245
|
result = response.json()
|
|
@@ -283,7 +283,7 @@ def basic_auth(data: str, is_base64: bool = True) -> AuthResult:
|
|
|
283
283
|
|
|
284
284
|
|
|
285
285
|
# TODO: sa-clue support
|
|
286
|
-
def check_obo(source: ExternalSource, access_token: str, username: str) -> tuple[Optional[str], Optional[str]]:
|
|
286
|
+
def check_obo(source: ExternalSource, access_token: str | None, username: str) -> tuple[Optional[str], Optional[str]]:
|
|
287
287
|
"""Checks whether a token's audience matches the source, and if it doesn't, tries to get an OBO token for the source
|
|
288
288
|
|
|
289
289
|
Args:
|
|
@@ -295,16 +295,16 @@ def check_obo(source: ExternalSource, access_token: str, username: str) -> tuple
|
|
|
295
295
|
tuple[Optional[str], Optional[str]]: A tuple with either the valid access token in the first arg, or the error
|
|
296
296
|
message in the second arg.
|
|
297
297
|
"""
|
|
298
|
-
obo_access_token:
|
|
298
|
+
obo_access_token: str | None = None
|
|
299
299
|
if source.obo_target:
|
|
300
300
|
logger.debug("Source %s requires OBO to %s", source.name, source.obo_target)
|
|
301
301
|
|
|
302
|
-
if "." not in access_token:
|
|
302
|
+
if access_token and "." not in access_token:
|
|
303
303
|
logger.warning("JWT not provided, using service account.")
|
|
304
|
-
|
|
305
|
-
return None, None
|
|
304
|
+
access_token = jwt_service.fetch_sa_token()
|
|
306
305
|
|
|
307
|
-
|
|
306
|
+
if not access_token:
|
|
307
|
+
return None, "Valid access token not provided."
|
|
308
308
|
|
|
309
309
|
audience = jwt_service.extract_audience(access_token)
|
|
310
310
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
from
|
|
1
|
+
from time import monotonic
|
|
2
|
+
from typing import Any
|
|
2
3
|
from urllib.parse import urljoin
|
|
3
4
|
|
|
4
5
|
import requests
|
|
5
6
|
from elasticapm.traces import capture_span
|
|
6
|
-
from flask import
|
|
7
|
+
from flask import request
|
|
7
8
|
from pydantic import TypeAdapter, ValidationError
|
|
8
9
|
from requests import JSONDecodeError, exceptions
|
|
9
10
|
|
|
@@ -15,48 +16,37 @@ from clue.common.exceptions import (
|
|
|
15
16
|
NotFoundException,
|
|
16
17
|
)
|
|
17
18
|
from clue.common.logging import get_logger
|
|
18
|
-
from clue.config import CLASSIFICATION,
|
|
19
|
+
from clue.config import CLASSIFICATION, config
|
|
20
|
+
from clue.helper.headers import generate_headers
|
|
21
|
+
from clue.helper.obo import get_obo_access_token
|
|
19
22
|
from clue.helper.plugin_requests import request_with_safe_redirects
|
|
20
23
|
from clue.models.config import ExternalSource
|
|
21
24
|
from clue.models.fetchers import FetcherDefinition, FetcherResult
|
|
22
25
|
from clue.models.selector import Selector
|
|
23
|
-
from clue.services import auth_service
|
|
24
26
|
|
|
25
27
|
logger = get_logger(__file__)
|
|
26
28
|
|
|
27
|
-
# Either cache for one second in debug mode, or five minutes in production
|
|
28
|
-
CACHE_TIMEOUT: int = 1 if DEBUG else 5 * 60
|
|
29
29
|
|
|
30
|
+
def _raise_fetcher_metadata_unavailable(error: Exception | None = None) -> None:
|
|
31
|
+
raise ClueException(
|
|
32
|
+
"Unable to verify fetcher availability with the upstream source.", error, status_code=503
|
|
33
|
+
) from error
|
|
30
34
|
|
|
31
|
-
def get_obo_access_token(
|
|
32
|
-
source: ExternalSource, user: dict[str, Any], access_token: Optional[str] = None
|
|
33
|
-
) -> tuple[Optional[str], Optional[str]]:
|
|
34
|
-
"""Get the caller access token and an OBO token for an external source when needed."""
|
|
35
|
-
if access_token is None and has_request_context():
|
|
36
|
-
auth_header = request.headers.get("Authorization", type=str)
|
|
37
|
-
if auth_header:
|
|
38
|
-
parts = auth_header.split(" ", 1)
|
|
39
|
-
access_token = parts[1] if len(parts) == 2 and parts[0].lower() == "bearer" else auth_header
|
|
40
|
-
if not access_token:
|
|
41
|
-
return None, None
|
|
42
35
|
|
|
43
|
-
obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
|
|
44
|
-
if error:
|
|
45
|
-
logger.error("%s: %s", source.name, error)
|
|
46
|
-
raise AuthenticationException("Invalid token provided for this enrichment.")
|
|
47
|
-
|
|
48
|
-
return access_token, obo_access_token
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@cache.memoize(timeout=1 if DEBUG else 5 * 60, args_to_ignore=["access_token"]) # Cached for 5 minutes
|
|
52
36
|
def get_supported_fetchers(
|
|
53
|
-
source: ExternalSource,
|
|
37
|
+
source: ExternalSource,
|
|
38
|
+
headers: dict[str, str],
|
|
39
|
+
*,
|
|
40
|
+
timeout: float = 5.0,
|
|
41
|
+
raise_on_error: bool = False,
|
|
54
42
|
) -> dict[str, FetcherDefinition]:
|
|
55
|
-
"""
|
|
43
|
+
"""Fetch current metadata without caching authorization classifications.
|
|
56
44
|
|
|
57
45
|
Args:
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
source (ExternalSource): The source whose fetchers to retrieve.
|
|
47
|
+
headers (dict[str, str]): Headers to include in the upstream request.
|
|
48
|
+
timeout (float): The upstream request timeout in seconds.
|
|
49
|
+
raise_on_error (bool): Raise a 503 when metadata cannot be verified.
|
|
60
50
|
|
|
61
51
|
Returns:
|
|
62
52
|
dict[str, FetcherDefinition]: A dict of each ids mapped to fetcher metadata
|
|
@@ -65,42 +55,33 @@ def get_supported_fetchers(
|
|
|
65
55
|
|
|
66
56
|
url = urljoin(source.url, "fetchers/")
|
|
67
57
|
|
|
68
|
-
try:
|
|
69
|
-
access_token, obo_access_token = get_obo_access_token(source, user, access_token)
|
|
70
|
-
except AuthenticationException:
|
|
71
|
-
return {}
|
|
72
|
-
|
|
73
|
-
headers = {"Accept": "application/json"}
|
|
74
|
-
if obo_access_token or access_token:
|
|
75
|
-
headers["Authorization"] = f"Bearer {obo_access_token or access_token}"
|
|
76
|
-
|
|
77
58
|
with capture_span(f"GET {url}", span_type="http"):
|
|
78
59
|
try:
|
|
79
|
-
rsp = request_with_safe_redirects(requests.get, url, headers=headers, timeout=
|
|
60
|
+
rsp = request_with_safe_redirects(requests.get, url, headers=headers, timeout=timeout)
|
|
80
61
|
result = rsp.json()
|
|
81
62
|
|
|
82
63
|
if not rsp.ok:
|
|
83
64
|
err = result["api_error_message"]
|
|
84
65
|
logger.error(f"Error from upstream server: {rsp.status_code=}, {err=}")
|
|
66
|
+
if raise_on_error:
|
|
67
|
+
_raise_fetcher_metadata_unavailable()
|
|
68
|
+
return {}
|
|
85
69
|
|
|
86
70
|
return TypeAdapter(dict[str, FetcherDefinition]).validate_python(result["api_response"])
|
|
87
|
-
except
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return {}
|
|
94
|
-
except ValidationError:
|
|
95
|
-
logger.exception("ValidationError in response from %s:", source.url)
|
|
71
|
+
except ClueException:
|
|
72
|
+
raise
|
|
73
|
+
except Exception as err:
|
|
74
|
+
logger.exception("Unable to retrieve fetcher metadata from %s", source.url)
|
|
75
|
+
if raise_on_error:
|
|
76
|
+
_raise_fetcher_metadata_unavailable(err)
|
|
96
77
|
return {}
|
|
97
78
|
|
|
98
79
|
|
|
99
|
-
def all_supported_fetchers(user: dict[str, Any]
|
|
80
|
+
def all_supported_fetchers(user: dict[str, Any]) -> dict[str, FetcherDefinition]:
|
|
100
81
|
"""Gets all supported fetchers for all sources
|
|
101
82
|
|
|
102
83
|
Args:
|
|
103
|
-
|
|
84
|
+
user (dict[str, Any]): The user requesting the fetchers.
|
|
104
85
|
|
|
105
86
|
Returns:
|
|
106
87
|
dict[str, FetcherDefinition]: A dict of all fetchers and their matching schema
|
|
@@ -108,7 +89,17 @@ def all_supported_fetchers(user: dict[str, Any], access_token: Optional[str] = N
|
|
|
108
89
|
all_fetchers: dict[str, FetcherDefinition] = {}
|
|
109
90
|
|
|
110
91
|
for source in config.api.external_sources:
|
|
111
|
-
|
|
92
|
+
if not CLASSIFICATION.is_accessible(user["classification"], source.classification):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
access_token, obo_access_token = get_obo_access_token(source, user)
|
|
97
|
+
except AuthenticationException:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
supported_fetchers = get_supported_fetchers(
|
|
101
|
+
source, generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
102
|
+
)
|
|
112
103
|
total_fetchers = 0
|
|
113
104
|
for key, action in supported_fetchers.items():
|
|
114
105
|
total_fetchers += 1
|
|
@@ -122,14 +113,7 @@ def get_plugins_supported_fetchers(user: dict[str, Any]) -> dict[str, FetcherDef
|
|
|
122
113
|
"""Return the supported fetchers of each external service, filtered to what the user has access to."""
|
|
123
114
|
available_fetchers: dict[str, FetcherDefinition] = {}
|
|
124
115
|
|
|
125
|
-
|
|
126
|
-
if access_token:
|
|
127
|
-
access_token = access_token.split(" ")[1]
|
|
128
|
-
|
|
129
|
-
all_fetchers = all_supported_fetchers(
|
|
130
|
-
user,
|
|
131
|
-
access_token=access_token,
|
|
132
|
-
)
|
|
116
|
+
all_fetchers = all_supported_fetchers(user)
|
|
133
117
|
|
|
134
118
|
logger.info("Retrieving fetchers for classification %s", user["classification"])
|
|
135
119
|
|
|
@@ -175,14 +159,12 @@ def run_fetcher(plugin_id: str, fetcher_id: str, user: dict[str, Any]) -> Fetche
|
|
|
175
159
|
"""
|
|
176
160
|
plugin = next((source for source in config.api.external_sources if source.name == plugin_id), None)
|
|
177
161
|
|
|
178
|
-
if not plugin:
|
|
179
|
-
raise NotFoundException(
|
|
162
|
+
if not plugin or not CLASSIFICATION.is_accessible(user["classification"], plugin.classification):
|
|
163
|
+
raise NotFoundException("Fetcher not found.", status_code=404)
|
|
180
164
|
|
|
181
165
|
access_token, obo_access_token = get_obo_access_token(plugin, user)
|
|
182
166
|
|
|
183
|
-
headers =
|
|
184
|
-
if obo_access_token or access_token:
|
|
185
|
-
headers["Authorization"] = f"Bearer {obo_access_token or access_token}"
|
|
167
|
+
headers = generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
186
168
|
|
|
187
169
|
if request.is_json:
|
|
188
170
|
parameters = request.json
|
|
@@ -197,14 +179,11 @@ def run_fetcher(plugin_id: str, fetcher_id: str, user: dict[str, Any]) -> Fetche
|
|
|
197
179
|
|
|
198
180
|
try:
|
|
199
181
|
selector = Selector.model_validate(parameters)
|
|
200
|
-
supported_fetchers = get_supported_fetchers(plugin,
|
|
201
|
-
|
|
202
|
-
if len(supported_fetchers) < 1:
|
|
203
|
-
raise NotFoundException(f"{plugin_id} does not support any fetchers.")
|
|
182
|
+
supported_fetchers = get_supported_fetchers(plugin, headers)
|
|
204
183
|
|
|
205
184
|
fetcher = supported_fetchers.get(fetcher_id)
|
|
206
|
-
if fetcher is None:
|
|
207
|
-
raise NotFoundException(
|
|
185
|
+
if fetcher is None or not CLASSIFICATION.is_accessible(user["classification"], fetcher.classification):
|
|
186
|
+
raise NotFoundException("Fetcher not found.", status_code=404)
|
|
208
187
|
_validate_fetcher_classification(fetcher, selector, fetcher_id)
|
|
209
188
|
|
|
210
189
|
response = request_with_safe_redirects(
|
|
@@ -255,14 +234,26 @@ def get_fetcher_status(plugin_id: str, fetcher_id: str, task_id: str, user: dict
|
|
|
255
234
|
"""
|
|
256
235
|
plugin = next((source for source in config.api.external_sources if source.name == plugin_id), None)
|
|
257
236
|
|
|
258
|
-
if not plugin:
|
|
259
|
-
raise NotFoundException(
|
|
237
|
+
if not plugin or not CLASSIFICATION.is_accessible(user["classification"], plugin.classification):
|
|
238
|
+
raise NotFoundException("Fetcher not found.", status_code=404)
|
|
260
239
|
|
|
261
240
|
access_token, obo_access_token = get_obo_access_token(plugin, user)
|
|
262
241
|
|
|
263
|
-
headers =
|
|
264
|
-
|
|
265
|
-
|
|
242
|
+
headers = generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
243
|
+
|
|
244
|
+
timeout = request.args.get("max_timeout", 60.0, type=float)
|
|
245
|
+
metadata_started = monotonic()
|
|
246
|
+
# Authorization metadata must stay fresh; include this lookup in the caller's timeout budget.
|
|
247
|
+
fetcher = get_supported_fetchers(
|
|
248
|
+
plugin,
|
|
249
|
+
headers,
|
|
250
|
+
timeout=max(min(timeout, 5.0), 0.001),
|
|
251
|
+
raise_on_error=True,
|
|
252
|
+
).get(fetcher_id)
|
|
253
|
+
if fetcher is None or not CLASSIFICATION.is_accessible(user["classification"], fetcher.classification):
|
|
254
|
+
raise NotFoundException("Fetcher not found.", status_code=404)
|
|
255
|
+
|
|
256
|
+
remaining_timeout = max(timeout - (monotonic() - metadata_started), 0.001)
|
|
266
257
|
|
|
267
258
|
try:
|
|
268
259
|
req_url = urljoin(plugin.url, f"fetchers/{fetcher_id}/status/{task_id}")
|
|
@@ -272,7 +263,7 @@ def get_fetcher_status(plugin_id: str, fetcher_id: str, task_id: str, user: dict
|
|
|
272
263
|
requests.get,
|
|
273
264
|
req_url,
|
|
274
265
|
headers=headers,
|
|
275
|
-
timeout=
|
|
266
|
+
timeout=remaining_timeout,
|
|
276
267
|
)
|
|
277
268
|
|
|
278
269
|
result = response.json()
|
|
@@ -26,11 +26,12 @@ from clue.common.logging.audit import audit
|
|
|
26
26
|
from clue.config import CLASSIFICATION as CLASSIFICATION
|
|
27
27
|
from clue.config import DEBUG, config
|
|
28
28
|
from clue.helper.headers import generate_headers
|
|
29
|
+
from clue.helper.obo import get_obo_access_token
|
|
29
30
|
from clue.helper.plugin_requests import request_with_safe_redirects
|
|
30
31
|
from clue.models.config import ExternalSource
|
|
31
32
|
from clue.models.network import QueryEntry, QueryResult
|
|
32
33
|
from clue.models.selector import Selector
|
|
33
|
-
from clue.services import
|
|
34
|
+
from clue.services import mongo_service, type_service, user_service
|
|
34
35
|
|
|
35
36
|
logger = get_logger(__file__)
|
|
36
37
|
CLIENTS: dict[str, Session] = {}
|
|
@@ -361,8 +362,8 @@ def query_external(
|
|
|
361
362
|
value: str,
|
|
362
363
|
limit: int,
|
|
363
364
|
timeout: float,
|
|
364
|
-
access_token: str,
|
|
365
|
-
|
|
365
|
+
access_token: str | None,
|
|
366
|
+
obo_access_token: str | None,
|
|
366
367
|
no_annotation: bool = False,
|
|
367
368
|
no_cache: bool = False,
|
|
368
369
|
include_raw: bool = True,
|
|
@@ -375,7 +376,10 @@ def query_external(
|
|
|
375
376
|
finish_result = functools.partial(build_result, type_name, value, source)
|
|
376
377
|
|
|
377
378
|
with capture_span(query_external.__name__, span_type="greenlet"):
|
|
378
|
-
|
|
379
|
+
supported_types = type_service.get_supported_types(
|
|
380
|
+
source.url, access_token=access_token, obo_access_token=obo_access_token
|
|
381
|
+
)
|
|
382
|
+
if not supported_types or type_name not in supported_types:
|
|
379
383
|
return finish_result(error="invalid_type")
|
|
380
384
|
|
|
381
385
|
if config.api.audit:
|
|
@@ -407,7 +411,7 @@ def query_external(
|
|
|
407
411
|
get_client(source.url, timeout).get,
|
|
408
412
|
url,
|
|
409
413
|
params=generate_params(limit, timeout, no_annotation, include_raw, no_cache),
|
|
410
|
-
headers=generate_headers(
|
|
414
|
+
headers=generate_headers(obo_access_token=obo_access_token, access_token=access_token),
|
|
411
415
|
timeout=(timeout, timeout * 3),
|
|
412
416
|
)
|
|
413
417
|
rsp.raise_for_status()
|
|
@@ -493,15 +497,10 @@ def enrich(type_name: str, value: str, user: dict[str, Any]): # noqa: C901
|
|
|
493
497
|
|
|
494
498
|
finish_result = functools.partial(build_result, type_name, value, source)
|
|
495
499
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
results[source.name] = finish_result(error="You must have a valid JWT to access this plugin.")
|
|
501
|
-
continue
|
|
502
|
-
|
|
503
|
-
if error:
|
|
504
|
-
results[source.name] = finish_result(error=error)
|
|
500
|
+
try:
|
|
501
|
+
access_token, obo_access_token = get_obo_access_token(source, user)
|
|
502
|
+
except AuthenticationException as err:
|
|
503
|
+
results[source.name] = finish_result(error=err.message)
|
|
505
504
|
continue
|
|
506
505
|
|
|
507
506
|
# check query against the max supported classification of the external system
|
|
@@ -527,8 +526,8 @@ def enrich(type_name: str, value: str, user: dict[str, Any]): # noqa: C901
|
|
|
527
526
|
value=value,
|
|
528
527
|
limit=query_params.limit,
|
|
529
528
|
timeout=query_params.max_timeout,
|
|
530
|
-
access_token=
|
|
531
|
-
|
|
529
|
+
access_token=access_token,
|
|
530
|
+
obo_access_token=obo_access_token,
|
|
532
531
|
no_annotation=query_params.no_annotation,
|
|
533
532
|
include_raw=query_params.include_raw,
|
|
534
533
|
no_cache=query_params.no_cache,
|
|
@@ -562,8 +561,8 @@ def bulk_query_external( # noqa: C901
|
|
|
562
561
|
source: ExternalSource,
|
|
563
562
|
limit: int,
|
|
564
563
|
timeout: float,
|
|
565
|
-
access_token: str,
|
|
566
|
-
|
|
564
|
+
access_token: str | None,
|
|
565
|
+
obo_access_token: str | None,
|
|
567
566
|
no_annotation: bool = False,
|
|
568
567
|
no_cache: bool = False,
|
|
569
568
|
include_raw: bool = True,
|
|
@@ -574,14 +573,16 @@ def bulk_query_external( # noqa: C901
|
|
|
574
573
|
execution_context.set_transaction(apm_transaction)
|
|
575
574
|
|
|
576
575
|
with capture_span(bulk_query_external.__name__, span_type="greenlet"):
|
|
577
|
-
supported_types = type_service.
|
|
576
|
+
supported_types = type_service.get_supported_types(
|
|
577
|
+
source.url, access_token=access_token, obo_access_token=obo_access_token
|
|
578
|
+
)
|
|
578
579
|
bulk_result: dict[str, dict[str, QueryResult]] = {}
|
|
579
580
|
|
|
580
581
|
filtered_data: list[Selector] = []
|
|
581
582
|
for entry in data:
|
|
582
583
|
bulk_result.setdefault(entry.type, {})
|
|
583
584
|
|
|
584
|
-
if entry.type not in supported_types:
|
|
585
|
+
if not supported_types or entry.type not in supported_types:
|
|
585
586
|
bulk_result[entry.type][entry.value] = build_result(entry.type, entry.value, source, "invalid_type")
|
|
586
587
|
continue
|
|
587
588
|
|
|
@@ -629,7 +630,7 @@ def bulk_query_external( # noqa: C901
|
|
|
629
630
|
get_method=client.get,
|
|
630
631
|
params=generate_params(limit, timeout, no_annotation, include_raw, no_cache),
|
|
631
632
|
json=[entry.model_dump(exclude_none=True, exclude_unset=True) for entry in data],
|
|
632
|
-
headers=generate_headers(
|
|
633
|
+
headers=generate_headers(obo_access_token=obo_access_token, access_token=access_token),
|
|
633
634
|
timeout=(timeout * 3, timeout * 3),
|
|
634
635
|
)
|
|
635
636
|
rsp.raise_for_status()
|
|
@@ -697,11 +698,6 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
|
|
|
697
698
|
f"excluding sources [{','.join(excluded_sources)}]"
|
|
698
699
|
)
|
|
699
700
|
|
|
700
|
-
access_token = request.headers.get("Authorization", type=str)
|
|
701
|
-
if not access_token:
|
|
702
|
-
raise AuthenticationException("Access token is required to enrich.")
|
|
703
|
-
access_token = access_token.split(" ")[1]
|
|
704
|
-
|
|
705
701
|
if len(data) < 1:
|
|
706
702
|
raise InvalidDataException("You must provide at least one value to lookup.")
|
|
707
703
|
|
|
@@ -744,16 +740,12 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
|
|
|
744
740
|
|
|
745
741
|
greenlets: list[tuple[list[Selector], ExternalSource, Greenlet[Any, dict[str, dict[str, QueryResult]]]]] = []
|
|
746
742
|
for source in selected_sources:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
logger.error("%s: %s", source.name, error)
|
|
751
|
-
|
|
752
|
-
# TODO: sa-clue support
|
|
753
|
-
if not obo_access_token and source.obo_target:
|
|
743
|
+
try:
|
|
744
|
+
access_token, obo_access_token = get_obo_access_token(source, user)
|
|
745
|
+
except AuthenticationException as err:
|
|
754
746
|
for entry in data:
|
|
755
747
|
bulk_result[entry.type][entry.value][source.name] = build_result(
|
|
756
|
-
entry.type, entry.value, source,
|
|
748
|
+
entry.type, entry.value, source, err.message
|
|
757
749
|
)
|
|
758
750
|
continue
|
|
759
751
|
|
|
@@ -811,8 +803,8 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
|
|
|
811
803
|
source=source,
|
|
812
804
|
limit=query_params.limit,
|
|
813
805
|
timeout=query_params.max_timeout,
|
|
814
|
-
access_token=
|
|
815
|
-
|
|
806
|
+
access_token=access_token,
|
|
807
|
+
obo_access_token=obo_access_token,
|
|
816
808
|
no_annotation=query_params.no_annotation,
|
|
817
809
|
no_cache=query_params.no_cache,
|
|
818
810
|
include_raw=query_params.include_raw,
|
|
@@ -5,15 +5,16 @@ from elasticapm.traces import capture_span
|
|
|
5
5
|
from flask import request
|
|
6
6
|
from requests import exceptions
|
|
7
7
|
|
|
8
|
+
from clue.common.exceptions import AuthenticationException
|
|
8
9
|
from clue.common.logging import get_logger
|
|
9
10
|
from clue.config import CLASSIFICATION, DEBUG, cache, config
|
|
10
11
|
from clue.constants.env import DISABLE_CACHE
|
|
11
12
|
from clue.constants.supported_types import SUPPORTED_TYPES
|
|
12
13
|
from clue.helper.headers import generate_headers
|
|
14
|
+
from clue.helper.obo import get_obo_access_token
|
|
13
15
|
from clue.helper.plugin_requests import request_with_safe_redirects
|
|
14
16
|
from clue.models.config import ExternalSource
|
|
15
17
|
from clue.remote.datatypes.cache import RedisCache
|
|
16
|
-
from clue.services import auth_service
|
|
17
18
|
|
|
18
19
|
logger = get_logger(__file__)
|
|
19
20
|
|
|
@@ -28,10 +29,7 @@ def get_types_regular_expressions(user: dict[str, Any]):
|
|
|
28
29
|
if access_token:
|
|
29
30
|
access_token = access_token.split(" ")[1]
|
|
30
31
|
|
|
31
|
-
all_types = all_supported_types(
|
|
32
|
-
user,
|
|
33
|
-
access_token=access_token,
|
|
34
|
-
)
|
|
32
|
+
all_types = all_supported_types(user)
|
|
35
33
|
|
|
36
34
|
type_detection = {}
|
|
37
35
|
|
|
@@ -47,7 +45,9 @@ def get_types_regular_expressions(user: dict[str, Any]):
|
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
@cache.memoize(timeout=CACHE_TIMEOUT)
|
|
50
|
-
def get_supported_types(
|
|
48
|
+
def get_supported_types(
|
|
49
|
+
source_url: str, access_token: str | None = None, obo_access_token: str | None = None
|
|
50
|
+
) -> dict[str, str] | None:
|
|
51
51
|
"""Gets all supported types for the specified source.
|
|
52
52
|
|
|
53
53
|
Args:
|
|
@@ -65,7 +65,7 @@ def get_supported_types(source_url: str, access_token: str | None = None, obo_ac
|
|
|
65
65
|
|
|
66
66
|
logger.debug("Cache miss, polling plugin")
|
|
67
67
|
with capture_span(f"GET {url}", span_type="http"):
|
|
68
|
-
headers = generate_headers(obo_access_token
|
|
68
|
+
headers = generate_headers(obo_access_token=obo_access_token, access_token=access_token)
|
|
69
69
|
|
|
70
70
|
try:
|
|
71
71
|
rsp = request_with_safe_redirects(requests.get, url, headers=headers, timeout=3.0)
|
|
@@ -111,7 +111,7 @@ def get_supported_types(source_url: str, access_token: str | None = None, obo_ac
|
|
|
111
111
|
return None
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
def all_supported_types(user: dict[str, Any]
|
|
114
|
+
def all_supported_types(user: dict[str, Any]) -> dict[str, dict[str, str]]:
|
|
115
115
|
"""Gets supported types by all sources.
|
|
116
116
|
|
|
117
117
|
Args:
|
|
@@ -123,12 +123,13 @@ def all_supported_types(user: dict[str, Any], access_token: str | None = None) -
|
|
|
123
123
|
all_types = {}
|
|
124
124
|
|
|
125
125
|
for source in config.api.external_sources:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
|
|
126
|
+
if not CLASSIFICATION.is_accessible(user["classification"], source.classification):
|
|
127
|
+
continue
|
|
129
128
|
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
try:
|
|
130
|
+
access_token, obo_access_token = get_obo_access_token(source, user)
|
|
131
|
+
except AuthenticationException:
|
|
132
|
+
continue
|
|
132
133
|
|
|
133
134
|
supported_types = get_supported_types(source.url, access_token=access_token, obo_access_token=obo_access_token)
|
|
134
135
|
if supported_types is not None:
|
|
@@ -142,11 +143,7 @@ def get_plugins_supported_types(user: dict[str, Any]) -> dict[str, list[str]]:
|
|
|
142
143
|
configured_sources: list[ExternalSource] = getattr(config.api, "external_sources", [])
|
|
143
144
|
available_types: dict[str, list[str]] = {}
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
if access_token:
|
|
147
|
-
access_token = access_token.split(" ")[1]
|
|
148
|
-
|
|
149
|
-
all_types = all_supported_types(user, access_token=access_token)
|
|
146
|
+
all_types = all_supported_types(user)
|
|
150
147
|
|
|
151
148
|
logger.info("Fetching sources for classification %s", user["classification"])
|
|
152
149
|
|
|
@@ -141,7 +141,7 @@ log_cli_level = "WARN"
|
|
|
141
141
|
[tool.poetry]
|
|
142
142
|
package-mode = true
|
|
143
143
|
name = "clue-api"
|
|
144
|
-
version = "1.7.0.
|
|
144
|
+
version = "1.7.0.dev435"
|
|
145
145
|
description = "Clue distributed enrichment service"
|
|
146
146
|
authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
|
|
147
147
|
license = "MIT"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|