clue-api 1.5.0.dev276__tar.gz → 1.5.0.dev305__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.5.0.dev276 → clue_api-1.5.0.dev305}/PKG-INFO +1 -1
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/actions.py +32 -3
- clue_api-1.5.0.dev305/clue/plugin/helpers/central_server.py +417 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/type_service.py +1 -1
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/pyproject.toml +1 -1
- clue_api-1.5.0.dev276/clue/plugin/helpers/central_server.py +0 -27
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/LICENSE +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/README.md +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/.gitignore +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/base.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/actions.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/auth.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/configs.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/fetchers.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/lookup.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/registration.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/static.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/api/v1/sync.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/app.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/cache/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/bytes_utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/classification.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/classification.yml +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/dict_utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/exceptions.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/forge.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/json_utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/list_utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/logging/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/logging/audit.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/logging/format.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/regex.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/str_utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/swagger.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/common/uid.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/config.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/constants/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/constants/env.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/constants/supported_types.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/cronjobs/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/cronjobs/plugins.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/error.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/extensions/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/extensions/config.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/gunicorn_config.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/healthz.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/helper/discover.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/helper/headers.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/helper/oauth.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/config.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/fetchers.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/graph.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/model_list.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/network.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/base.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/file.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/graph.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/image.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/status.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/results/validation.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/schema.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/selector.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/sync.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/models/validators.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/patched.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/celery_app.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/helpers/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/helpers/email_render.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/helpers/token.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/helpers/trino.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/models.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/plugin/utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/py.typed +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/cache.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/events.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/hash.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/queues/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/queues/comms.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/set.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/remote/datatypes/user_quota_tracker.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/security/__init__.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/security/obo.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/security/utils.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/action_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/auth_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/config_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/fetcher_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/jwt_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/lookup_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/mongo_service.py +0 -0
- {clue_api-1.5.0.dev276 → clue_api-1.5.0.dev305}/clue/services/user_service.py +0 -0
|
@@ -258,18 +258,32 @@ class Action(ActionBase, Generic[ER]):
|
|
|
258
258
|
return data
|
|
259
259
|
|
|
260
260
|
|
|
261
|
+
class PendingActionOutput(BaseModel, Generic[DATA]):
|
|
262
|
+
model_config = ConfigDict(extra="forbid")
|
|
263
|
+
|
|
264
|
+
message: str | None = Field(
|
|
265
|
+
description="Optional message to display feedback or the current state of the pending action.",
|
|
266
|
+
default=None,
|
|
267
|
+
)
|
|
268
|
+
progress: float | None = Field(
|
|
269
|
+
description="Optional progress of the pending async action.", default=None, ge=0.0, le=1.0
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
partial_data: DATA | None = Field(description="Optional partial data of the pending async action.", default=None)
|
|
273
|
+
|
|
274
|
+
|
|
261
275
|
class ActionResult(BaseModel, Generic[DATA]):
|
|
262
276
|
outcome: Literal["success", "failure", "pending"] = Field(
|
|
263
277
|
description="Did the action succeed/fail, or is it pending?"
|
|
264
278
|
)
|
|
265
279
|
summary: str | None = Field(description="Message explaining the outcome of the action.", default=None)
|
|
266
|
-
output: DATA | Url | None = Field(description="The output of the action.", default=None)
|
|
280
|
+
output: DATA | Url | PendingActionOutput[DATA] | None = Field(description="The output of the action.", default=None)
|
|
267
281
|
format: str | None = Field(
|
|
268
282
|
description="What is the format of the output? Used to indicate what component to use when rendering "
|
|
269
283
|
"the output.",
|
|
270
284
|
default=None,
|
|
271
285
|
)
|
|
272
|
-
link: Url | None = Field(description="Link to more information on the outcome of the action", default=None)
|
|
286
|
+
link: Url | None = Field(description="Link to more information on the outcome of the action.", default=None)
|
|
273
287
|
task_id: str | None = Field(description="The celery task id if the action is pending.", default=None)
|
|
274
288
|
|
|
275
289
|
@model_validator(mode="after")
|
|
@@ -288,6 +302,18 @@ class ActionResult(BaseModel, Generic[DATA]):
|
|
|
288
302
|
if not self.task_id and self.outcome == "pending":
|
|
289
303
|
raise ClueValueError("task_id must be set if outcome is pending.")
|
|
290
304
|
|
|
305
|
+
if self.outcome == "pending" and self.output is not None:
|
|
306
|
+
try:
|
|
307
|
+
self.output = PendingActionOutput[DATA].model_validate(self.output)
|
|
308
|
+
if self.output.partial_data is not None:
|
|
309
|
+
if self.format is None:
|
|
310
|
+
raise ClueValueError("You must set a format if partial_data is provided in output.")
|
|
311
|
+
else:
|
|
312
|
+
self.output.partial_data = validate_result(self.format, self.output.partial_data, info)
|
|
313
|
+
|
|
314
|
+
except ValidationError as exc:
|
|
315
|
+
raise ClueValueError("output must be a valid PendingActionOutput when outcome is pending.") from exc
|
|
316
|
+
|
|
291
317
|
if self.format == "pivot" and (not self.output or not isinstance(self.output, Url)):
|
|
292
318
|
if isinstance(self.output, str):
|
|
293
319
|
try:
|
|
@@ -301,7 +327,10 @@ class ActionResult(BaseModel, Generic[DATA]):
|
|
|
301
327
|
if self.format != "pivot" and isinstance(self.output, Url):
|
|
302
328
|
raise ClueValueError("You can only return a Url if format is set to pivot.")
|
|
303
329
|
|
|
304
|
-
if self.
|
|
330
|
+
if self.outcome != "pending" and isinstance(self.output, PendingActionOutput):
|
|
331
|
+
raise ClueValueError("output must not be PendingActionOutput unless outcome is pending.")
|
|
332
|
+
|
|
333
|
+
if self.format and not isinstance(self.output, Url) and not isinstance(self.output, PendingActionOutput):
|
|
305
334
|
self.output = validate_result(self.format, self.output, info)
|
|
306
335
|
|
|
307
336
|
return self
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from flask import request
|
|
8
|
+
from pydantic import TypeAdapter, ValidationError
|
|
9
|
+
from requests import JSONDecodeError
|
|
10
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
11
|
+
|
|
12
|
+
from clue.common.logging import get_logger
|
|
13
|
+
from clue.models.actions import ActionResult, ActionSpec
|
|
14
|
+
from clue.models.fetchers import FetcherDefinition, FetcherResult
|
|
15
|
+
from clue.models.network import QueryEntry
|
|
16
|
+
from clue.models.selector import Selector
|
|
17
|
+
from clue.plugin.models import BulkEntry
|
|
18
|
+
|
|
19
|
+
CENTRAL_SERVER_URL = os.getenv("CENTRAL_API_URL", "http://enrichment-rest.enrichment.svc.cluster.local:5000")
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__file__)
|
|
22
|
+
|
|
23
|
+
# Module-level session cache keyed by sha256(url:retries).
|
|
24
|
+
_SESSIONS: dict[str, requests.Session] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_session(base_url: str, retries: int) -> requests.Session:
|
|
28
|
+
"""Return a cached, retry-configured :class:`requests.Session` for *base_url*.
|
|
29
|
+
|
|
30
|
+
Sessions are keyed by ``(base_url, retries)``. Request timeout is provided
|
|
31
|
+
per call and does not affect Session or adapter configuration.
|
|
32
|
+
"""
|
|
33
|
+
cache_key = sha256(f"{base_url}:{retries}".encode()).hexdigest()
|
|
34
|
+
if cache_key not in _SESSIONS:
|
|
35
|
+
session = requests.Session()
|
|
36
|
+
pool_size = max(1, math.floor(int(os.environ.get("EXECUTOR_THREADS", 32)) / 2))
|
|
37
|
+
retry_strategy = Retry(
|
|
38
|
+
total=retries,
|
|
39
|
+
backoff_factor=0.5,
|
|
40
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
41
|
+
allowed_methods=["GET", "POST"],
|
|
42
|
+
)
|
|
43
|
+
adapter = HTTPAdapter(
|
|
44
|
+
pool_connections=pool_size,
|
|
45
|
+
pool_maxsize=pool_size,
|
|
46
|
+
max_retries=retry_strategy,
|
|
47
|
+
)
|
|
48
|
+
session.mount("http://", adapter)
|
|
49
|
+
session.mount("https://", adapter)
|
|
50
|
+
_SESSIONS[cache_key] = session
|
|
51
|
+
return _SESSIONS[cache_key]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _connect_to_central_server(retries: int = 3) -> tuple[requests.Session, dict[str, str]]:
|
|
55
|
+
"""Return a ``(session, headers)`` pair for calling the central server.
|
|
56
|
+
|
|
57
|
+
Token extraction priority:
|
|
58
|
+
|
|
59
|
+
1. ``X-Clue-Authorization`` — a pre-OBO'd token forwarded by the central API.
|
|
60
|
+
Used as the primary ``Authorization`` bearer. The original ``Authorization``
|
|
61
|
+
token is included as ``X-Clue-Authorization`` so the central API can
|
|
62
|
+
propagate it further downstream if needed.
|
|
63
|
+
2. ``Authorization`` — the raw user bearer token, used when no pre-OBO token
|
|
64
|
+
is present.
|
|
65
|
+
|
|
66
|
+
The returned *session* is shared across calls (pooled); the returned *headers*
|
|
67
|
+
dict is scoped to the current request and must not be mutated after use.
|
|
68
|
+
"""
|
|
69
|
+
clue_token = request.headers.get("X-Clue-Authorization", None)
|
|
70
|
+
raw_auth = request.headers.get("Authorization", None)
|
|
71
|
+
auth_token = raw_auth.split(" ", 1)[1] if raw_auth and " " in raw_auth else None
|
|
72
|
+
|
|
73
|
+
if clue_token:
|
|
74
|
+
logger.info("X-Clue-Authorization header specified, using pre-OBO token")
|
|
75
|
+
primary_token = clue_token
|
|
76
|
+
secondary_token = auth_token # forward original as X-Clue-Authorization
|
|
77
|
+
elif auth_token:
|
|
78
|
+
logger.warning("X-Clue-Authorization header not specified, falling back to Authorization header")
|
|
79
|
+
primary_token = auth_token
|
|
80
|
+
secondary_token = None
|
|
81
|
+
else:
|
|
82
|
+
logger.warning("No token specified, continuing with no authentication")
|
|
83
|
+
primary_token = None
|
|
84
|
+
secondary_token = None
|
|
85
|
+
|
|
86
|
+
headers: dict[str, str] = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
87
|
+
if primary_token:
|
|
88
|
+
headers["Authorization"] = f"Bearer {primary_token}"
|
|
89
|
+
if secondary_token:
|
|
90
|
+
headers["X-Clue-Authorization"] = secondary_token
|
|
91
|
+
|
|
92
|
+
session = _get_session(CENTRAL_SERVER_URL, retries)
|
|
93
|
+
return session, headers
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _safe_central_get(
|
|
97
|
+
url: str,
|
|
98
|
+
session: requests.Session,
|
|
99
|
+
headers: dict[str, str],
|
|
100
|
+
timeout: float = 5.0,
|
|
101
|
+
) -> dict | None:
|
|
102
|
+
"""Make a GET request to the central server with standardized error handling.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
url: The full URL to request.
|
|
106
|
+
session: A cached ``requests.Session`` from ``_get_session()``.
|
|
107
|
+
headers: Request headers dict from ``_connect_to_central_server()``.
|
|
108
|
+
timeout: HTTP timeout in seconds. Defaults to 5.0.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The ``api_response`` dict from the response body on success, or ``None``
|
|
112
|
+
on any connection, parse, or HTTP error.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
rsp = session.get(url, headers=headers, timeout=timeout)
|
|
116
|
+
rsp.raise_for_status()
|
|
117
|
+
return rsp.json().get("api_response", {})
|
|
118
|
+
except requests.exceptions.ConnectionError:
|
|
119
|
+
logger.exception("Unable to connect to central server at %s", url)
|
|
120
|
+
except requests.exceptions.Timeout:
|
|
121
|
+
logger.exception("Timeout calling central server %s", url)
|
|
122
|
+
except (JSONDecodeError, KeyError, AttributeError):
|
|
123
|
+
logger.exception("Central server returned unexpected format for %s", url)
|
|
124
|
+
except requests.exceptions.HTTPError:
|
|
125
|
+
logger.exception("HTTP error from central server %s", url)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _safe_central_post(
|
|
130
|
+
url: str,
|
|
131
|
+
session: requests.Session,
|
|
132
|
+
headers: dict[str, str],
|
|
133
|
+
json_body: dict | list | None = None,
|
|
134
|
+
params: dict | None = None,
|
|
135
|
+
timeout: float | tuple[float, float] = 5.0,
|
|
136
|
+
) -> dict | None:
|
|
137
|
+
"""Make a POST request to the central server with standardized error handling.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
url: The full URL to request.
|
|
141
|
+
session: A cached ``requests.Session`` from ``_get_session()``.
|
|
142
|
+
headers: Request headers dict from ``_connect_to_central_server()``.
|
|
143
|
+
json_body: JSON payload to POST, or ``None``.
|
|
144
|
+
params: Query parameters dict, or ``None``.
|
|
145
|
+
timeout: HTTP timeout in seconds or (connect, read) tuple. Defaults to 5.0.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The ``api_response`` dict from the response body on success, or ``None``
|
|
149
|
+
on any connection, parse, or HTTP error.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
rsp = session.post(url, json=json_body, params=params, headers=headers, timeout=timeout)
|
|
153
|
+
rsp.raise_for_status()
|
|
154
|
+
return rsp.json().get("api_response", {})
|
|
155
|
+
except requests.exceptions.ConnectionError:
|
|
156
|
+
logger.exception("Unable to connect to central server at %s", url)
|
|
157
|
+
except requests.exceptions.Timeout:
|
|
158
|
+
logger.exception("Timeout calling central server %s", url)
|
|
159
|
+
except (JSONDecodeError, KeyError, AttributeError):
|
|
160
|
+
logger.exception("Central server returned unexpected format for %s", url)
|
|
161
|
+
except requests.exceptions.HTTPError:
|
|
162
|
+
logger.exception("HTTP error from central server %s", url)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_sources() -> dict[str, list[str]]:
|
|
167
|
+
"""Return the types supported by each plugin visible to the current user.
|
|
168
|
+
|
|
169
|
+
Calls ``GET /api/v1/lookup/types/`` on the central server. Results are
|
|
170
|
+
filtered server-side by the user's classification — no local filtering is
|
|
171
|
+
performed here.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A dict mapping each source name to its list of supported type names,
|
|
175
|
+
e.g. ``{"geoip": ["ipv4", "ipv6"], "vt": ["ipv4", "domain", ...]}``.
|
|
176
|
+
Returns an empty dict on any connection or parse error.
|
|
177
|
+
"""
|
|
178
|
+
session, headers = _connect_to_central_server()
|
|
179
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/lookup/types/")
|
|
180
|
+
result = _safe_central_get(url, session, headers)
|
|
181
|
+
return result if result is not None else {}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def enrich(
|
|
185
|
+
sources: str | list[str],
|
|
186
|
+
selectors: Selector | list[Selector],
|
|
187
|
+
*,
|
|
188
|
+
limit: int = 10,
|
|
189
|
+
timeout: float = 5.0,
|
|
190
|
+
no_annotation: bool = False,
|
|
191
|
+
include_raw: bool = False,
|
|
192
|
+
no_cache: bool = False,
|
|
193
|
+
) -> dict[str, dict[str, dict[str, BulkEntry]]]:
|
|
194
|
+
"""Run bulk enrichment on one or more selectors via the central API.
|
|
195
|
+
|
|
196
|
+
Plugins can call this helper to query other plugins before or during their
|
|
197
|
+
own enrichment — for example, invoking a geolocation plugin on an IP address
|
|
198
|
+
and using the result to produce a richer annotation.
|
|
199
|
+
|
|
200
|
+
A single :class:`~clue.models.selector.Selector` or source string is
|
|
201
|
+
automatically promoted to a list.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
sources: Plugin name(s) to target. Pass an empty list to query all sources
|
|
205
|
+
available to the current user.
|
|
206
|
+
selectors: One or more :class:`~clue.models.selector.Selector` objects.
|
|
207
|
+
limit: Maximum result items returned per source per selector. Defaults to 10.
|
|
208
|
+
timeout: HTTP timeout in seconds. Defaults to 5.0.
|
|
209
|
+
no_annotation: Omit annotation data from results. Defaults to ``False``.
|
|
210
|
+
include_raw: Include raw plugin data in results. Defaults to ``False``.
|
|
211
|
+
no_cache: Bypass the cache and re-query plugins directly. Defaults to ``False``.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
A three-level dict ``{type: {value: {source: BulkEntry}}}`` mirroring the
|
|
215
|
+
structure returned by the central ``/api/v1/lookup/enrich`` endpoint.
|
|
216
|
+
Returns an empty dict on connection or parse errors.
|
|
217
|
+
"""
|
|
218
|
+
if isinstance(sources, str):
|
|
219
|
+
sources = [sources]
|
|
220
|
+
if isinstance(selectors, Selector):
|
|
221
|
+
selectors = [selectors]
|
|
222
|
+
|
|
223
|
+
session, headers = _connect_to_central_server()
|
|
224
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/lookup/enrich")
|
|
225
|
+
|
|
226
|
+
params: dict[str, str | int | float | bool] = {
|
|
227
|
+
"limit": limit,
|
|
228
|
+
"max_timeout": max(timeout * 0.95, 0.5),
|
|
229
|
+
}
|
|
230
|
+
if sources:
|
|
231
|
+
params["sources"] = "|".join(sources)
|
|
232
|
+
if no_annotation:
|
|
233
|
+
params["no_annotation"] = True
|
|
234
|
+
if include_raw:
|
|
235
|
+
params["include_raw"] = True
|
|
236
|
+
if no_cache:
|
|
237
|
+
params["no_cache"] = True
|
|
238
|
+
|
|
239
|
+
payload = [s.model_dump(exclude_none=True, exclude_unset=True) for s in selectors]
|
|
240
|
+
result: dict[str, dict[str, dict[str, BulkEntry]]] = {}
|
|
241
|
+
|
|
242
|
+
api_response = _safe_central_post(
|
|
243
|
+
url,
|
|
244
|
+
session,
|
|
245
|
+
headers,
|
|
246
|
+
json_body=payload,
|
|
247
|
+
params=params,
|
|
248
|
+
timeout=(timeout, timeout * 3),
|
|
249
|
+
)
|
|
250
|
+
if api_response is None:
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
_parse_enrich_response(api_response, result)
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_enrich_response(
|
|
258
|
+
api_response: dict,
|
|
259
|
+
result: dict[str, dict[str, dict[str, BulkEntry]]],
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Parse the central-API bulk-enrich response into the *result* dict in-place.
|
|
262
|
+
|
|
263
|
+
Central response shape: ``{type: {value: {source: QueryResult}}}``.
|
|
264
|
+
"""
|
|
265
|
+
for type_name, values in api_response.items():
|
|
266
|
+
result.setdefault(type_name, {})
|
|
267
|
+
for value, sources_data in values.items():
|
|
268
|
+
result[type_name].setdefault(value, {})
|
|
269
|
+
for source_name, query_result in sources_data.items():
|
|
270
|
+
try:
|
|
271
|
+
items = [QueryEntry.model_validate(item) for item in (query_result.get("items") or [])]
|
|
272
|
+
result[type_name][value][source_name] = BulkEntry(
|
|
273
|
+
error=query_result.get("error"),
|
|
274
|
+
items=items,
|
|
275
|
+
raw_data=query_result.get("raw_data"),
|
|
276
|
+
)
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.exception("Failed to parse result from source %s for %s/%s", source_name, type_name, value)
|
|
279
|
+
result[type_name][value][source_name] = BulkEntry(
|
|
280
|
+
error=f"Failed to parse response from {source_name}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def list_actions() -> dict[str, ActionSpec]:
|
|
285
|
+
"""List all actions available on the central server for the current user.
|
|
286
|
+
|
|
287
|
+
Calls ``GET /api/v1/actions/`` and returns a dict keyed by
|
|
288
|
+
``"<plugin_id>.<action_id>"``, filtered server-side by user classification.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
A dict of ``{plugin_id.action_id: ActionSpec}``.
|
|
292
|
+
Returns an empty dict on connection or parse errors.
|
|
293
|
+
"""
|
|
294
|
+
session, headers = _connect_to_central_server()
|
|
295
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/actions/")
|
|
296
|
+
api_response = _safe_central_get(url, session, headers)
|
|
297
|
+
if api_response is None:
|
|
298
|
+
return {}
|
|
299
|
+
try:
|
|
300
|
+
return TypeAdapter(dict[str, ActionSpec]).validate_python(api_response)
|
|
301
|
+
except ValidationError:
|
|
302
|
+
logger.exception("Failed to validate actions response")
|
|
303
|
+
return {}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def execute_action(
|
|
307
|
+
plugin_id: str,
|
|
308
|
+
action_id: str,
|
|
309
|
+
payload: dict | None = None,
|
|
310
|
+
*,
|
|
311
|
+
timeout: float = 30.0,
|
|
312
|
+
) -> ActionResult:
|
|
313
|
+
"""Execute an action on another plugin via the central server.
|
|
314
|
+
|
|
315
|
+
Calls ``POST /api/v1/actions/execute/{plugin_id}/{action_id}``.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
plugin_id: ID of the plugin that owns the action (e.g. ``"geoip"``).
|
|
319
|
+
action_id: ID of the action to execute (e.g. ``"locate"``).
|
|
320
|
+
payload: Optional JSON-serialisable dict passed as the request body.
|
|
321
|
+
Typically contains a ``selector`` dict and any action-specific parameters.
|
|
322
|
+
timeout: HTTP timeout in seconds. Defaults to 30.0.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
:class:`~clue.models.actions.ActionResult` from the central server.
|
|
326
|
+
Returns a ``failure`` result on connection or parse errors.
|
|
327
|
+
"""
|
|
328
|
+
# Retrying a POST action execution can unintentionally execute the action multiple times.
|
|
329
|
+
session, headers = _connect_to_central_server(retries=0)
|
|
330
|
+
url = urljoin(CENTRAL_SERVER_URL, f"/api/v1/actions/execute/{plugin_id}/{action_id}")
|
|
331
|
+
api_response = _safe_central_post(
|
|
332
|
+
url,
|
|
333
|
+
session,
|
|
334
|
+
headers,
|
|
335
|
+
json_body=payload or {},
|
|
336
|
+
timeout=timeout,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if api_response is None:
|
|
340
|
+
return ActionResult(
|
|
341
|
+
outcome="failure",
|
|
342
|
+
summary=f"Unable to connect to central server to execute {plugin_id}.{action_id}.",
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
return ActionResult.model_validate(api_response, context={"is_response": True})
|
|
347
|
+
except ValidationError:
|
|
348
|
+
logger.exception("Failed to validate action result for %s.%s", plugin_id, action_id)
|
|
349
|
+
return ActionResult(
|
|
350
|
+
outcome="failure",
|
|
351
|
+
summary=f"Unexpected response format from central server for {plugin_id}.{action_id}.",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def list_fetchers() -> dict[str, FetcherDefinition]:
|
|
356
|
+
"""List all fetchers available on the central server for the current user.
|
|
357
|
+
|
|
358
|
+
Calls ``GET /api/v1/fetchers/`` and returns a dict keyed by
|
|
359
|
+
``"<plugin_id>.<fetcher_id>"``, filtered server-side by user classification.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A dict of ``{plugin_id.fetcher_id: FetcherDefinition}``.
|
|
363
|
+
Returns an empty dict on connection or parse errors.
|
|
364
|
+
"""
|
|
365
|
+
session, headers = _connect_to_central_server()
|
|
366
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/fetchers/")
|
|
367
|
+
api_response = _safe_central_get(url, session, headers)
|
|
368
|
+
if api_response is None:
|
|
369
|
+
return {}
|
|
370
|
+
try:
|
|
371
|
+
return TypeAdapter(dict[str, FetcherDefinition]).validate_python(api_response)
|
|
372
|
+
except ValidationError:
|
|
373
|
+
logger.exception("Failed to validate fetchers response")
|
|
374
|
+
return {}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def run_fetcher(
|
|
378
|
+
plugin_id: str,
|
|
379
|
+
fetcher_id: str,
|
|
380
|
+
selector: Selector,
|
|
381
|
+
*,
|
|
382
|
+
timeout: float = 60.0,
|
|
383
|
+
) -> FetcherResult:
|
|
384
|
+
"""Execute a fetcher on another plugin via the central server.
|
|
385
|
+
|
|
386
|
+
Calls ``POST /api/v1/fetchers/{plugin_id}/{fetcher_id}`` with the provided
|
|
387
|
+
:class:`~clue.models.selector.Selector` as the JSON body.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
plugin_id: ID of the plugin that owns the fetcher (e.g. ``"geoip"``).
|
|
391
|
+
fetcher_id: ID of the fetcher to run (e.g. ``"location_report"``).
|
|
392
|
+
selector: The :class:`~clue.models.selector.Selector` to enrich.
|
|
393
|
+
timeout: HTTP timeout in seconds. Defaults to 60.0.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
:class:`~clue.models.fetchers.FetcherResult` from the central server.
|
|
397
|
+
Returns a ``failure`` result on connection or parse errors.
|
|
398
|
+
"""
|
|
399
|
+
session, headers = _connect_to_central_server()
|
|
400
|
+
url = urljoin(CENTRAL_SERVER_URL, f"/api/v1/fetchers/{plugin_id}/{fetcher_id}")
|
|
401
|
+
payload = selector.model_dump(exclude_none=True, exclude_unset=True)
|
|
402
|
+
api_response = _safe_central_post(
|
|
403
|
+
url,
|
|
404
|
+
session,
|
|
405
|
+
headers,
|
|
406
|
+
json_body=payload,
|
|
407
|
+
timeout=timeout,
|
|
408
|
+
)
|
|
409
|
+
if api_response is None:
|
|
410
|
+
return FetcherResult.error_result(f"Unable to connect to central server to run {plugin_id}.{fetcher_id}.")
|
|
411
|
+
try:
|
|
412
|
+
return FetcherResult.model_validate(api_response, context={"is_response": True})
|
|
413
|
+
except ValidationError:
|
|
414
|
+
logger.exception("Failed to validate fetcher result for %s.%s", plugin_id, fetcher_id)
|
|
415
|
+
return FetcherResult.error_result(
|
|
416
|
+
f"Unexpected response format from central server for {plugin_id}.{fetcher_id}."
|
|
417
|
+
)
|
|
@@ -137,7 +137,7 @@ def all_supported_types(user: dict[str, Any], access_token: str | None = None) -
|
|
|
137
137
|
return all_types
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def get_plugins_supported_types(user: dict[str, Any]):
|
|
140
|
+
def get_plugins_supported_types(user: dict[str, Any]) -> dict[str, list[str]]:
|
|
141
141
|
"""Return the supported type names of each external service, filtered to what the user has access to."""
|
|
142
142
|
configured_sources: list[ExternalSource] = getattr(config.api, "external_sources", [])
|
|
143
143
|
available_types: dict[str, list[str]] = {}
|
|
@@ -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.5.0.
|
|
144
|
+
version = "1.5.0.dev305"
|
|
145
145
|
description = "Clue distributed enrichment service"
|
|
146
146
|
authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
|
|
147
147
|
license = "MIT"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# import os
|
|
2
|
-
|
|
3
|
-
# from clue_client import get_client
|
|
4
|
-
# from clue_client.api.v1 import V1
|
|
5
|
-
# from flask import request
|
|
6
|
-
|
|
7
|
-
# from clue.common.logging import get_logger
|
|
8
|
-
|
|
9
|
-
# CENTRAL_SERVER_URL = os.getenv("CENTRAL_API_URL", "http://enrichment-rest.enrichment.svc.cluster.local:5000")
|
|
10
|
-
|
|
11
|
-
# logger = get_logger(__file__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# def connect_to_central_server(timeout: int | None = 3, retries: int = 3) -> V1:
|
|
15
|
-
# "Connect to the central server using the clue client"
|
|
16
|
-
# access_token = request.headers.get("X-Clue-Authorization", None)
|
|
17
|
-
|
|
18
|
-
# if access_token:
|
|
19
|
-
# logger.info("X-Clue-Authorization header specified, using pre-OBO token")
|
|
20
|
-
# else:
|
|
21
|
-
# logger.warning("X-Clue-Authorization header not specified, falling back to core Authorization Header")
|
|
22
|
-
# access_token = request.headers.get("Authorization", " ", type=str).split(" ")[1]
|
|
23
|
-
|
|
24
|
-
# if not access_token:
|
|
25
|
-
# logger.warning("No token specified, continuing with no authentication")
|
|
26
|
-
|
|
27
|
-
# return get_client(CENTRAL_SERVER_URL, auth=access_token, version=1, timeout=timeout, retries=retries, logger=logger)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|