clue-api 1.7.0.dev433__tar.gz → 1.7.0.dev446__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.dev446}/PKG-INFO +1 -1
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/actions.py +4 -2
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/fetchers.py +3 -1
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/constants/supported_types.py +1 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/helper/headers.py +6 -7
- clue_api-1.7.0.dev446/clue/helper/obo.py +31 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/action_service.py +76 -73
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/auth_service.py +6 -6
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/fetcher_service.py +68 -77
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/lookup_service.py +28 -36
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/type_service.py +15 -18
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/pyproject.toml +1 -1
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/LICENSE +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/README.md +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/.gitignore +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/base.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/auth.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/configs.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/lookup.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/registration.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/static.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/api/v1/sync.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/app.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/cache/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/bytes_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/classification.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/classification.yml +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/dict_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/exceptions.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/forge.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/json_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/list_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/logging/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/logging/audit.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/logging/format.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/regex.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/str_utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/swagger.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/common/uid.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/constants/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/constants/env.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/cronjobs/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/cronjobs/plugins.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/error.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/extensions/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/extensions/config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/gunicorn_config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/healthz.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/helper/discover.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/helper/oauth.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/helper/plugin_requests.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/actions.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/auth_user.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/config.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/fetchers.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/graph.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/model_list.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/network.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/base.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/file.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/graph.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/image.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/status.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/results/validation.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/schema.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/selector.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/sync.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/models/validators.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/patched.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/celery_app.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/helpers/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/helpers/central_server.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/helpers/email_render.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/helpers/token.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/helpers/trino.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/models.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/plugin/utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/py.typed +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/cache.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/events.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/hash.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/queues/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/queues/comms.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/set.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/remote/datatypes/user_quota_tracker.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/security/__init__.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/security/obo.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/security/utils.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/config_service.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/jwt_service.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/clue/services/mongo_service.py +0 -0
- {clue_api-1.7.0.dev433 → clue_api-1.7.0.dev446}/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
|
|
@@ -25,6 +25,7 @@ SUPPORTED_TYPES = {
|
|
|
25
25
|
"ipv6": IPV6_ONLY_REGEX,
|
|
26
26
|
# We don't auto-detect ip types, as it's redundant with ipv4/v6. This is just a convenience/backwards compat thing
|
|
27
27
|
"ip": None,
|
|
28
|
+
"asn": None,
|
|
28
29
|
"domain": DOMAIN_ONLY_REGEX,
|
|
29
30
|
"port": PORT_REGEX,
|
|
30
31
|
"url": URI_ONLY,
|
|
@@ -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()
|