clue-api 1.3.0.dev92__tar.gz → 1.3.0.dev102__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/PKG-INFO +2 -1
  2. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/actions.py +33 -0
  3. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/app.py +6 -1
  4. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/regex.py +0 -2
  5. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/constants/supported_types.py +10 -9
  6. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/extensions/config.py +4 -0
  7. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/actions.py +14 -3
  8. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/__init__.py +138 -1
  9. clue_api-1.3.0.dev102/clue/plugin/celery_app.py +29 -0
  10. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/action_service.py +58 -0
  11. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/pyproject.toml +2 -1
  12. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/LICENSE +0 -0
  13. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/README.md +0 -0
  14. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/.gitignore +0 -0
  15. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/__init__.py +0 -0
  16. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/__init__.py +0 -0
  17. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/base.py +0 -0
  18. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/__init__.py +0 -0
  19. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/auth.py +0 -0
  20. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/configs.py +0 -0
  21. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/fetchers.py +0 -0
  22. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/lookup.py +0 -0
  23. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/registration.py +0 -0
  24. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/static.py +0 -0
  25. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/cache/__init__.py +0 -0
  26. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/__init__.py +0 -0
  27. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/classification.py +0 -0
  28. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/classification.yml +0 -0
  29. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/dict_utils.py +0 -0
  30. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/exceptions.py +0 -0
  31. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/forge.py +0 -0
  32. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/json_utils.py +0 -0
  33. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/list_utils.py +0 -0
  34. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/logging/__init__.py +0 -0
  35. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/logging/audit.py +0 -0
  36. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/logging/format.py +0 -0
  37. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/str_utils.py +0 -0
  38. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/swagger.py +0 -0
  39. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/uid.py +0 -0
  40. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/config.py +0 -0
  41. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/constants/__init__.py +0 -0
  42. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/constants/env.py +0 -0
  43. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/cronjobs/__init__.py +0 -0
  44. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/cronjobs/plugins.py +0 -0
  45. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/error.py +0 -0
  46. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/extensions/__init__.py +0 -0
  47. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/gunicorn_config.py +0 -0
  48. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/healthz.py +0 -0
  49. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/helper/discover.py +0 -0
  50. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/helper/headers.py +0 -0
  51. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/helper/oauth.py +0 -0
  52. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/__init__.py +0 -0
  53. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/config.py +0 -0
  54. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/fetchers.py +0 -0
  55. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/graph.py +0 -0
  56. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/model_list.py +0 -0
  57. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/network.py +0 -0
  58. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/__init__.py +0 -0
  59. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/base.py +0 -0
  60. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/graph.py +0 -0
  61. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/image.py +0 -0
  62. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/status.py +0 -0
  63. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/validation.py +0 -0
  64. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/selector.py +0 -0
  65. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/validators.py +0 -0
  66. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/patched.py +0 -0
  67. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/__init__.py +0 -0
  68. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/central_server.py +0 -0
  69. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/email_render.py +0 -0
  70. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/token.py +0 -0
  71. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/trino.py +0 -0
  72. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/models.py +0 -0
  73. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/utils.py +0 -0
  74. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/py.typed +0 -0
  75. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/__init__.py +0 -0
  76. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/__init__.py +0 -0
  77. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/cache.py +0 -0
  78. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/events.py +0 -0
  79. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/hash.py +0 -0
  80. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/queues/__init__.py +0 -0
  81. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/queues/comms.py +0 -0
  82. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/set.py +0 -0
  83. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/user_quota_tracker.py +0 -0
  84. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/security/__init__.py +0 -0
  85. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/security/obo.py +0 -0
  86. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/security/utils.py +0 -0
  87. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/auth_service.py +0 -0
  88. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/config_service.py +0 -0
  89. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/fetcher_service.py +0 -0
  90. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/jwt_service.py +0 -0
  91. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/lookup_service.py +0 -0
  92. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/type_service.py +0 -0
  93. {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/user_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clue-api
3
- Version: 1.3.0.dev92
3
+ Version: 1.3.0.dev102
4
4
  Summary: Clue distributed enrichment service
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -24,6 +24,7 @@ Requires-Dist: authlib (<2.0.0) ; extra == "server"
24
24
  Requires-Dist: bcrypt (>=4.1.2,<5.0.0) ; extra == "server"
25
25
  Requires-Dist: beautifulsoup4 (>=4.13.3,<5.0.0)
26
26
  Requires-Dist: cart (>=1.2.3,<2.0.0)
27
+ Requires-Dist: celery (>=5.6.2,<6.0.0)
27
28
  Requires-Dist: elastic-apm (>=6.22.0,<7.0.0)
28
29
  Requires-Dist: flasgger (>=0.9.7.1,<0.10.0.0) ; extra == "server"
29
30
  Requires-Dist: flask (<3.0.0)
@@ -90,3 +90,36 @@ def execute_action(plugin_id: str, action_id: str, **kwargs) -> ActionResult:
90
90
  return not_found(err=err.message)
91
91
  except ClueException as err:
92
92
  return internal_error(err=err.message)
93
+
94
+
95
+ @generate_swagger_docs(responses={200: "Successfully fetched status of action"})
96
+ @actions_api.route("/<plugin_id>/<action_id>/status/<task_id>", methods=["GET"])
97
+ @api_login()
98
+ def get_action_status(plugin_id: str, action_id: str, task_id: str, **kwargs) -> ActionResult:
99
+ """Get the status or result of a running action.
100
+
101
+ Variables:
102
+ plugin_id (str): the ID of the plugin who owns the action to execute
103
+ action_id (str): the ID of the action to execute
104
+ task_id (str): the ID of the specific task to get the status of
105
+
106
+ Arguments:
107
+ task_id (str): the celery task id to get the status of
108
+
109
+
110
+ Result Example:
111
+ {
112
+ "outcome": "success | failure | pending", # was this execution a success or failure or is it still pending?
113
+ "format": "link", # What format is the output in?
114
+ "output": "http://example.com" # The output of the action. Can be any data structure.
115
+ "task_id": if the action is still running, what is the task id so that we can fetch the status again
116
+ }
117
+ """
118
+ try:
119
+ if not task_id:
120
+ return internal_error(err="no task_id found in url. task_id is required for this request.")
121
+ return ok(action_service.get_action_status(plugin_id, action_id, task_id, kwargs["user"]))
122
+ except NotFoundException as err:
123
+ return not_found(err=err.message)
124
+ except ClueException as err:
125
+ return internal_error(err=err.message)
@@ -2,6 +2,8 @@ import warnings
2
2
 
3
3
  from gevent import monkey
4
4
 
5
+ from clue.constants.supported_types import SUPPORTED_TYPES
6
+
5
7
  monkey.patch_all()
6
8
 
7
9
  import os
@@ -132,7 +134,8 @@ app.register_blueprint(registration_api)
132
134
  app.register_blueprint(static_api)
133
135
 
134
136
 
135
- logger.info("Checking extensions for additional routes")
137
+ logger.info("Checking extensions for initialization and additional routes")
138
+ num_buildin_types = len(SUPPORTED_TYPES)
136
139
  for extension in get_extensions():
137
140
  if extension.modules.init:
138
141
  extension.modules.init(flask_app=app)
@@ -144,6 +147,8 @@ for extension in get_extensions():
144
147
  logger.info("Enabling additional endpoint: %s", route.url_prefix)
145
148
  app.register_blueprint(route)
146
149
 
150
+ logger.info("%s types configured (%s custom types)", len(SUPPORTED_TYPES), len(SUPPORTED_TYPES) - num_buildin_types)
151
+
147
152
  # Setup OAuth providers
148
153
  if config.auth.oauth.enabled:
149
154
  providers = []
@@ -40,5 +40,3 @@ URI_ONLY = f"^{URI_REGEX}$"
40
40
  UUID4_REGEX = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
41
41
 
42
42
  EMAIL_PATH_REGEX = r"^[A-Z]+_EMAIL://.*"
43
- HBS_AGENT_ID_REGEX = r"[0-9a-fA-F]{1,4}\.[0-9a-fA-F]{1,4}\.[0-9a-fA-F]{1,4}\.[0-9a-fA-F]{1,4}"
44
- HBS_AGENT_ID_ONLY_REGEX = f"^{HBS_AGENT_ID_REGEX}$"
@@ -3,7 +3,6 @@ from clue.common.regex import (
3
3
  DOMAIN_ONLY_REGEX,
4
4
  EMAIL_PATH_REGEX,
5
5
  EMAIL_REGEX,
6
- HBS_AGENT_ID_REGEX,
7
6
  IPV4_ONLY_REGEX,
8
7
  IPV6_ONLY_REGEX,
9
8
  MD5_REGEX,
@@ -32,16 +31,17 @@ SUPPORTED_TYPES = {
32
31
  "md5": MD5_REGEX,
33
32
  "sha1": SHA1_REGEX,
34
33
  "sha256": SHA256_REGEX,
35
- "hbs_oid": None,
36
- "hbs_agent_id": HBS_AGENT_ID_REGEX,
37
34
  "telemetry": None,
38
- "howler_id": None,
39
35
  "hostname": None,
40
36
  "tenant-id": UUID4_REGEX,
41
37
  }
42
38
 
39
+ CASE_INSENSITIVE_TYPES = ["ip", "domain", "port", "tenant-id", "hbs_oid", "hbs_agent_id"]
40
+
43
41
 
44
- def add_supported_type(type: str, regex: str | None = None, namespace: str | None = None):
42
+ def add_supported_type(
43
+ type: str, regex: str | None = None, namespace: str | None = None, case_insensitive: bool = False
44
+ ):
45
45
  r"""Add a supported type to the SUPPORTED_TYPES registry.
46
46
 
47
47
  This function registers a new type with an optional regex pattern for validation.
@@ -62,10 +62,11 @@ def add_supported_type(type: str, regex: str | None = None, namespace: str | Non
62
62
  """
63
63
  if not namespace:
64
64
  logger.info("Adding new type %s to the default namespace with regex %s", type, regex)
65
- SUPPORTED_TYPES[type] = regex
65
+ new_entry = type
66
66
  else:
67
67
  logger.info("Adding type %s to namespace %s with regex %s", type, namespace, regex)
68
- SUPPORTED_TYPES[f"{namespace}/{type}"] = regex
68
+ new_entry = f"{namespace}/{type}"
69
69
 
70
-
71
- CASE_INSENSITIVE_TYPES = ["ip", "domain", "port", "tenant-id", "hbs_oid", "hbs_agent_id"]
70
+ SUPPORTED_TYPES[new_entry] = regex
71
+ if case_insensitive:
72
+ CASE_INSENSITIVE_TYPES.append(new_entry)
@@ -59,6 +59,10 @@ class BaseExtensionConfig(BaseSettings):
59
59
 
60
60
  data["modules"]["routes"] = new_routes
61
61
 
62
+ if "init" in data["modules"]:
63
+ if isinstance(data["modules"]["init"], bool):
64
+ data["modules"]["init"] = f"{plugin_name}.init:initialize"
65
+
62
66
  if "obo_module" in data["modules"]:
63
67
  if isinstance(data["modules"]["obo_module"], bool):
64
68
  data["modules"]["obo_module"] = f"{plugin_name}.obo:get_obo_token"
@@ -69,6 +69,10 @@ class ActionContextInformation(BaseModel):
69
69
  ActionContextInformationType = TypeVar("ActionContextInformationType", bound=ActionContextInformation)
70
70
 
71
71
 
72
+ class ActionStatusRequest(BaseModel):
73
+ task_id: str = Field(description="The task id to get the status for.")
74
+
75
+
72
76
  class ExecuteRequest(BaseModel):
73
77
  context: ActionContextInformation | None = Field(
74
78
  description="Contextual information on where the action is being executed (if provided)", default=None
@@ -123,6 +127,7 @@ class ActionBase(BaseModel):
123
127
  )
124
128
  accept_empty: bool = Field(description="Does this action support execution with no selectors?", default=False)
125
129
  accept_multiple: bool = Field(description="Does this action support multiple values?", default=False)
130
+ async_result: bool = Field(description="Does this action run asynchronously?", default=False)
126
131
  format: str | None = Field(
127
132
  description="What is the format of the output, if known?",
128
133
  default=None,
@@ -242,7 +247,9 @@ class Action(ActionBase, Generic[ER]):
242
247
 
243
248
 
244
249
  class ActionResult(BaseModel, Generic[DATA]):
245
- outcome: Union[Literal["success"], Literal["failure"]] = Field(description="Did the action succeed or fail?")
250
+ outcome: Union[Literal["success"], Literal["failure"], Literal["pending"]] = Field(
251
+ description="Did the action succeed/fail, or is it pending?"
252
+ )
246
253
  summary: str | None = Field(description="Message explaining the outcome of the action.", default=None)
247
254
  output: DATA | Url | None = Field(description="The output of the action.", default=None)
248
255
  format: str | None = Field(
@@ -251,6 +258,7 @@ class ActionResult(BaseModel, Generic[DATA]):
251
258
  default=None,
252
259
  )
253
260
  link: Url | None = Field(description="Link to more information on the outcome of the action", default=None)
261
+ task_id: str | None = Field(description="The celery task id if the action is pending.", default=None)
254
262
 
255
263
  @model_validator(mode="after")
256
264
  def validate_model(self: Self, info: ValidationInfo) -> Self: # noqa: C901
@@ -262,8 +270,11 @@ class ActionResult(BaseModel, Generic[DATA]):
262
270
  Returns:
263
271
  Self: The validated model.
264
272
  """
265
- if not self.format and self.outcome != "failure":
266
- raise ClueValueError("You must set a format if outcome is not failure.")
273
+ if not self.format and self.outcome == "success":
274
+ raise ClueValueError("You must set a format if outcome is success.")
275
+
276
+ if not self.task_id and self.outcome == "pending":
277
+ raise ClueValueError("task_id must be set if outcome is pending.")
267
278
 
268
279
  if self.format == "pivot" and (not self.output or not isinstance(self.output, Url)):
269
280
  if isinstance(self.output, str):
@@ -32,11 +32,14 @@ from clue.models.actions import (
32
32
  ActionBase,
33
33
  ActionResult,
34
34
  ActionSpec,
35
+ ActionStatusRequest,
35
36
  ExecuteRequest,
36
37
  )
38
+ from clue.models.config import Config
37
39
  from clue.models.fetchers import FetcherDefinition, FetcherResult
38
40
  from clue.models.network import QueryEntry
39
41
  from clue.models.selector import Selector
42
+ from clue.plugin.celery_app import celery_init_app
40
43
  from clue.plugin.helpers.token import get_username
41
44
  from clue.plugin.models import BulkEntry
42
45
  from clue.plugin.utils import Params
@@ -52,6 +55,7 @@ OVERRIDABLE_FUNCTIONS = [
52
55
  "liveness", # Kubernetes liveness probe endpoint
53
56
  "readiness", # Kubernetes readiness probe endpoint
54
57
  "run_action", # Function to execute plugin actions
58
+ "get_status", # Function to check the status or result of a pending action
55
59
  "run_fetcher", # Function to execute plugin fetchers
56
60
  "setup_actions", # Runtime action definition generation
57
61
  "validate_token", # Custom authentication token validation
@@ -130,6 +134,37 @@ def build_default_logger() -> logging.Logger:
130
134
  return logger
131
135
 
132
136
 
137
+ config: Config = Config()
138
+
139
+
140
+ def create_app(app_name: str, enable_celery: bool = False, tasks: list[str] | None = None):
141
+ """helper function to create the flask app and set up the celery config if enabled
142
+
143
+ Args:
144
+ enable_celery (bool): whether or not to enable celery
145
+
146
+ Returns:
147
+ _type_: flask app
148
+ """
149
+ app = Flask(__name__.split(".")[0])
150
+ if enable_celery:
151
+ redis_url = (
152
+ f"redis://:{config.core.redis.password}@{config.core.redis.host}:{config.core.redis.port}"
153
+ if config.core.redis.password
154
+ else f"redis://{config.core.redis.host}:{config.core.redis.port}"
155
+ )
156
+ app.config.from_mapping(
157
+ CELERY=dict(
158
+ broker_url=redis_url,
159
+ result_backend=redis_url,
160
+ result_backend_transport_options={"global_keyprefix": app_name + "_results"},
161
+ result_expires=3600, # expire results after one hour
162
+ ),
163
+ )
164
+ celery_init_app(app, tasks)
165
+ return app
166
+
167
+
133
168
  class CluePlugin:
134
169
  """Helper class for creating clue plugins with proper server responses and behaviour.
135
170
 
@@ -259,6 +294,13 @@ class CluePlugin:
259
294
  user parameters) that instance will be passed instead, and casting the argument will be necessary.
260
295
  """
261
296
 
297
+ get_status: Callable[[Action, ActionStatusRequest, str | None], ActionResult] | None
298
+ """The function to get the status and result of running actions.
299
+
300
+ Accepts the selected action definition as well as an ActionStatusRequest instance which contains the
301
+ specific task id to check the status of.
302
+ """
303
+
262
304
  fetchers: list[FetcherDefinition] | None
263
305
  "A list of fetcher definitions this plugin supports."
264
306
 
@@ -275,6 +317,9 @@ class CluePlugin:
275
317
  readiness: Callable[[], Response]
276
318
  "A readiness probe for kubernetes implementations of clue."
277
319
 
320
+ enable_celery: bool
321
+ "Flag to enable celery in plugin"
322
+
278
323
  def __init__(
279
324
  self: Self,
280
325
  app_name: str,
@@ -284,6 +329,7 @@ class CluePlugin:
284
329
  classification: str | None = os.environ.get("CLASSIFICATION", None),
285
330
  enable_apm: bool = False,
286
331
  enable_cache: Union[bool, Literal["redis"], Literal["local"]] = True,
332
+ enable_celery: bool = False,
287
333
  enrich: Callable[[str, str, Params, str | None], Union[list[QueryEntry], QueryEntry]] | None = None,
288
334
  fetchers: list[FetcherDefinition] | None = None,
289
335
  liveness: Callable[[], Response] = default_liveness,
@@ -291,6 +337,7 @@ class CluePlugin:
291
337
  logger: logging.Logger | None = None,
292
338
  readiness: Callable[[], Response] = default_readiness,
293
339
  run_action: Callable[[Action, ExecuteRequest, str | None], ActionResult] | None = None,
340
+ get_status: Callable[[Action, ActionStatusRequest, str | None], ActionResult] | None = None,
294
341
  run_fetcher: Callable[[FetcherDefinition, Selector, str | None], FetcherResult] | None = None,
295
342
  setup_actions: Callable[[list[Action], str | None], list[Action]] | None = None,
296
343
  supported_types: set[str] | str | None = None,
@@ -360,7 +407,7 @@ class CluePlugin:
360
407
  """
361
408
  self.alternate_bulk_lookup = alternate_bulk_lookup
362
409
  # Create Flask app using the module name (before first dot) as app name
363
- self.app = Flask(__name__.split(".")[0])
410
+ self.app = create_app(app_name, enable_celery)
364
411
  self.app_name = app_name
365
412
 
366
413
  # Classification is required for security - must be specified via env var or parameter
@@ -389,7 +436,9 @@ class CluePlugin:
389
436
  self.logger = logger if logger else build_default_logger()
390
437
 
391
438
  self.enrich = enrich
439
+ self.enable_celery = enable_celery
392
440
  self.run_action = run_action
441
+ self.get_status = get_status
393
442
  self.validate_token = validate_token
394
443
 
395
444
  self.fetchers = fetchers
@@ -662,6 +711,12 @@ class CluePlugin:
662
711
  self.app.add_url_rule(
663
712
  "/actions/<action_id>/", self.execute_action.__name__, self.execute_action, methods=["POST"]
664
713
  )
714
+ self.app.add_url_rule(
715
+ "/actions/<action_id>/status/<task_id>",
716
+ self.get_action_status.__name__,
717
+ self.get_action_status,
718
+ methods=["GET"],
719
+ )
665
720
  self.app.add_url_rule("/fetchers/", self.get_fetchers.__name__, self.get_fetchers, methods=["GET"])
666
721
  self.app.add_url_rule(
667
722
  "/fetchers/<fetcher_id>", self.execute_fetcher.__name__, self.execute_fetcher, methods=["POST"]
@@ -1128,6 +1183,88 @@ class CluePlugin:
1128
1183
 
1129
1184
  return self.make_api_response(result)
1130
1185
 
1186
+ def get_action_status(self: Self, action_id: str, task_id: str): # noqa: C901
1187
+ """Retrieves the status of the specified action.
1188
+
1189
+ Args:
1190
+ action_id (str): The ID of the action to get the status for
1191
+ task_id (str): The celery task id to get the result from
1192
+
1193
+ Returns:
1194
+ Response: A Response object with an ActionResult as the body.
1195
+ """
1196
+ if not task_id:
1197
+ return self.make_api_response(
1198
+ {}, err="task id not provided. task id is required for this request.", status_code=400
1199
+ )
1200
+
1201
+ if not self.get_status:
1202
+ return self.make_api_response(
1203
+ {}, err=f"{self.app_name} does not support the get action status functions.", status_code=400
1204
+ )
1205
+
1206
+ try:
1207
+ actions = self.__check_actions()
1208
+ except Exception:
1209
+ self.logger.exception("Exception on setup actions:")
1210
+
1211
+ return self.make_api_response({}, err="Error on action setup.", status_code=500)
1212
+
1213
+ if actions is None:
1214
+ actions = self.actions or []
1215
+
1216
+ action_to_check = next((action for action in actions if action.id == action_id), None)
1217
+ if not action_to_check:
1218
+ return self.make_api_response({}, err="Action does not exist", status_code=404)
1219
+
1220
+ token: str | None = None
1221
+ if self.validate_token:
1222
+ self.logger.debug("Executing plugin-provided token validator")
1223
+
1224
+ token, error = self.validate_token()
1225
+
1226
+ if error:
1227
+ return self.make_api_response(None, f"Error on token validation: {error}", status_code=401)
1228
+
1229
+ self.logger.debug("Token is valid")
1230
+ else:
1231
+ self.logger.warning("No token validation provided. The access token will not be provided to the action.")
1232
+
1233
+ try:
1234
+ # Validate request body against the action's parameter schema
1235
+ status_request = ActionStatusRequest(task_id=task_id)
1236
+
1237
+ self.logger.info(
1238
+ "Getting status for Action '%s' with task_id: %s",
1239
+ action_id,
1240
+ task_id,
1241
+ )
1242
+
1243
+ result = self.get_status(action_to_check, status_request, token)
1244
+ except json.JSONDecodeError as e:
1245
+ self.logger.warning("JSON decoding error while getting status: %s", str(e))
1246
+
1247
+ result = ActionResult(
1248
+ outcome="failure",
1249
+ summary=f"Invalid request format. Request body must be valid JSON. Error: {str(e)}",
1250
+ )
1251
+ except ValidationError as err:
1252
+ self.logger.warning("Validation error during execution: %s", str(err))
1253
+
1254
+ result = ActionResult(outcome="failure", summary=f"Validation error: {str(err)}")
1255
+ except ClueException as e:
1256
+ self.logger.exception("ClueException during execution:")
1257
+
1258
+ result = ActionResult(outcome="failure", summary=f"Error encountered during execution: {e.message}")
1259
+ except Exception as e:
1260
+ self.logger.exception("%s during execution:", e.__class__.__name__)
1261
+
1262
+ result = ActionResult(outcome="failure", summary=f"An unknown error occurred during execution: {str(e)}")
1263
+
1264
+ self.logger.info("Action status: %s", result.outcome)
1265
+
1266
+ return self.make_api_response(result)
1267
+
1131
1268
  def get_fetchers(self: Self) -> Response:
1132
1269
  """Get all available fetchers for this plugin.
1133
1270
 
@@ -0,0 +1,29 @@
1
+ from typing import List
2
+
3
+ from celery import Celery, Task
4
+ from flask import Flask
5
+
6
+ celery = Celery("app")
7
+
8
+
9
+ def celery_init_app(app: Flask, tasks: List[str] | None = None) -> None:
10
+ """initialize the celery worker for the flask app
11
+
12
+ Args:
13
+ app (Flask): flask app instance
14
+ """
15
+
16
+ class FlaskTask(Task):
17
+ def __call__(self, *args: object, **kwargs: object) -> object:
18
+ with app.app_context():
19
+ return self.run(*args, **kwargs)
20
+
21
+ celery_app = Celery(app.name, task_cls=FlaskTask)
22
+ celery_app.config_from_object(app.config["CELERY"])
23
+ celery_app.set_default()
24
+ if tasks:
25
+ celery_app.autodiscover_tasks(
26
+ tasks,
27
+ force=True,
28
+ )
29
+ app.extensions["celery"] = celery_app
@@ -184,3 +184,61 @@ def execute_action(plugin_id: str, action_id: str, user: dict[str, Any]) -> Acti
184
184
  raise ClueException(
185
185
  f"Something went wrong when retrieving the result from plugin '{plugin_id}': {err.__class__.__name__}."
186
186
  )
187
+
188
+
189
+ def get_action_status(plugin_id: str, action_id: str, task_id: str, user: dict[str, Any]) -> ActionResult:
190
+ """Gets the status of a specified action with task_id.
191
+
192
+ Args:
193
+ plugin_id (str): The ID of the plugin.
194
+ action_id (str): The ID of the action to run.
195
+ task_id (str): The celery task id to fetch the status for
196
+ user (dict[str, Any]): The user dict of the user running the action.
197
+
198
+ Raises:
199
+ NotFoundException: Raised whenever the plugin or the action doesn't exist.
200
+ ClueException: Raised whenever an error is returned by the plugin endpoint.
201
+
202
+ Returns:
203
+ ActionResult: The result of the action.
204
+ """
205
+ plugin = next((source for source in config.api.external_sources if source.name == plugin_id), None)
206
+
207
+ if not plugin:
208
+ raise NotFoundException(f"Plugin {plugin_id} does not exist.")
209
+
210
+ access_token = request.headers.get("Authorization", type=str)
211
+ if access_token:
212
+ access_token = access_token.split(" ")[1]
213
+
214
+ obo_access_token = None
215
+ if access_token:
216
+ obo_access_token, error = auth_service.check_obo(plugin, access_token, user["uname"])
217
+
218
+ if error:
219
+ logger.error("%s: %s", plugin.name, error)
220
+ return ActionResult(outcome="failure", summary="Invalid token provided.")
221
+
222
+ headers = generate_headers(obo_access_token or access_token, access_token if obo_access_token else None)
223
+
224
+ try:
225
+ req_url = urljoin(plugin.url, f"actions/{action_id}/status/{task_id}")
226
+ logger.debug("Getting status for action %s with task_id %s for user %s", req_url, task_id, user["uname"])
227
+
228
+ response = requests.get(
229
+ req_url,
230
+ headers=headers,
231
+ timeout=request.args.get("max_timeout", plugin.default_timeout, type=float),
232
+ )
233
+
234
+ result = response.json()
235
+
236
+ if not response.ok:
237
+ raise ClueException(result["api_error_message"])
238
+
239
+ return ActionResult.model_validate(result["api_response"])
240
+ except (JSONDecodeError, exceptions.ConnectionError) as err:
241
+ logger.exception(f"Something went wrong when retrieving the status from plugin '{plugin_id}'")
242
+ raise ClueException(
243
+ f"Something went wrong when retrieving the status from plugin '{plugin_id}': {err.__class__.__name__}."
244
+ )
@@ -142,7 +142,7 @@ log_cli_level = "WARN"
142
142
  [tool.poetry]
143
143
  package-mode = true
144
144
  name = "clue-api"
145
- version = "1.3.0.dev92"
145
+ version = "1.3.0.dev102"
146
146
  description = "Clue distributed enrichment service"
147
147
  authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
148
148
  license = "MIT"
@@ -204,6 +204,7 @@ authlib = { version = "<2.0.0", optional = true }
204
204
  flask-cors = { version = ">=4.0.1,<7.0.0", optional = true }
205
205
  flasgger = { version = "^0.9.7.1", optional = true }
206
206
  trino = "^0.336.0"
207
+ celery = "^5.6.2"
207
208
 
208
209
  [tool.poetry.extras]
209
210
  server = [
File without changes