orchestrator-core 4.0.4__py3-none-any.whl → 4.1.0__py3-none-any.whl

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.
orchestrator/__init__.py CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  """This is the orchestrator workflow engine."""
15
15
 
16
- __version__ = "4.0.4"
16
+ __version__ = "4.1.0"
17
17
 
18
18
  from orchestrator.app import OrchestratorCore
19
19
  from orchestrator.settings import app_settings
@@ -25,7 +25,7 @@ from fastapi.param_functions import Body, Depends, Header
25
25
  from fastapi.routing import APIRouter
26
26
  from fastapi.websockets import WebSocket
27
27
  from fastapi_etag.dependency import CacheHit
28
- from more_itertools import chunked
28
+ from more_itertools import chunked, last
29
29
  from sentry_sdk.tracing import trace
30
30
  from sqlalchemy import CompoundSelect, Select, select
31
31
  from sqlalchemy.orm import defer, joinedload
@@ -56,6 +56,7 @@ from orchestrator.services.processes import (
56
56
  )
57
57
  from orchestrator.services.settings import get_engine_settings
58
58
  from orchestrator.settings import app_settings
59
+ from orchestrator.utils.auth import Authorizer
59
60
  from orchestrator.utils.enrich_process import enrich_process
60
61
  from orchestrator.websocket import (
61
62
  WS_CHANNELS,
@@ -63,7 +64,7 @@ from orchestrator.websocket import (
63
64
  broadcast_process_update_to_websocket,
64
65
  websocket_manager,
65
66
  )
66
- from orchestrator.workflow import ProcessStatus
67
+ from orchestrator.workflow import ProcessStat, ProcessStatus, StepList, Workflow
67
68
  from pydantic_forms.types import JSON, State
68
69
 
69
70
  router = APIRouter()
@@ -86,6 +87,48 @@ def check_global_lock() -> None:
86
87
  )
87
88
 
88
89
 
90
+ def get_current_steps(pstat: ProcessStat) -> StepList:
91
+ """Extract past and current steps from the ProcessStat."""
92
+ remaining_steps = pstat.log
93
+ past_steps = pstat.workflow.steps[: -len(remaining_steps)]
94
+ return StepList(past_steps + [pstat.log[0]])
95
+
96
+
97
+ def get_auth_callbacks(steps: StepList, workflow: Workflow) -> tuple[Authorizer | None, Authorizer | None]:
98
+ """Iterate over workflow and prior steps to determine correct authorization callbacks for the current step.
99
+
100
+ It's safest to always iterate through the steps. We could track these callbacks statefully
101
+ as we progress through the workflow, but if we fail a step and the system restarts, the previous
102
+ callbacks will be lost if they're only available in the process state.
103
+
104
+ Priority:
105
+ - RESUME callback is explicit RESUME callback, else previous START/RESUME callback
106
+ - RETRY callback is explicit RETRY, else explicit RESUME, else previous RETRY
107
+ """
108
+ # Default to workflow start callbacks
109
+ auth_resume = workflow.authorize_callback
110
+ # auth_retry defaults to the workflow start callback if not otherwise specified.
111
+ # A workflow SHOULD have both callbacks set to not-None. This enforces the correct default regardless.
112
+ auth_retry = workflow.retry_auth_callback or auth_resume # type: ignore[unreachable, truthy-function]
113
+
114
+ # Choose the most recently established value for resume.
115
+ auth_resume = last(filter(None, (step.resume_auth_callback for step in steps)), auth_resume)
116
+ # Choose the most recently established value for retry, unless there is a more recent value for resume.
117
+ auth_retry = last(
118
+ filter(None, (step.retry_auth_callback or step.resume_auth_callback for step in steps)), auth_retry
119
+ )
120
+ return auth_resume, auth_retry
121
+
122
+
123
+ def can_be_resumed(status: ProcessStatus) -> bool:
124
+ return status in (
125
+ ProcessStatus.SUSPENDED, # Can be resumed
126
+ ProcessStatus.FAILED, # Can be retried
127
+ ProcessStatus.API_UNAVAILABLE, # subtype of FAILED
128
+ ProcessStatus.INCONSISTENT_DATA, # subtype of FAILED
129
+ )
130
+
131
+
89
132
  def resolve_user_name(
90
133
  *,
91
134
  reporter: Reporter | None,
@@ -150,18 +193,25 @@ def new_process(
150
193
  dependencies=[Depends(check_global_lock, use_cache=False)],
151
194
  )
152
195
  def resume_process_endpoint(
153
- process_id: UUID, request: Request, json_data: JSON = Body(...), user: str = Depends(user_name)
196
+ process_id: UUID,
197
+ request: Request,
198
+ json_data: JSON = Body(...),
199
+ user: str = Depends(user_name),
200
+ user_model: OIDCUserModel | None = Depends(authenticate),
154
201
  ) -> None:
155
202
  process = _get_process(process_id)
156
203
 
157
- if process.last_status == ProcessStatus.COMPLETED:
158
- raise_status(HTTPStatus.CONFLICT, "Resuming a completed workflow is not possible")
159
-
160
- if process.last_status == ProcessStatus.RUNNING:
161
- raise_status(HTTPStatus.CONFLICT, "Resuming a running workflow is not possible")
162
-
163
- if process.last_status == ProcessStatus.RESUMED:
164
- raise_status(HTTPStatus.CONFLICT, "Resuming a resumed workflow is not possible")
204
+ if not can_be_resumed(process.last_status):
205
+ raise_status(HTTPStatus.CONFLICT, f"Resuming a {process.last_status.lower()} workflow is not possible")
206
+
207
+ pstat = load_process(process)
208
+ auth_resume, auth_retry = get_auth_callbacks(get_current_steps(pstat), pstat.workflow)
209
+ if process.last_status == ProcessStatus.SUSPENDED:
210
+ if auth_resume is not None and not auth_resume(user_model):
211
+ raise_status(HTTPStatus.FORBIDDEN, "User is not authorized to resume step")
212
+ elif process.last_status == ProcessStatus.FAILED:
213
+ if auth_retry is not None and not auth_retry(user_model):
214
+ raise_status(HTTPStatus.FORBIDDEN, "User is not authorized to retry step")
165
215
 
166
216
  broadcast_invalidate_status_counts()
167
217
  broadcast_func = api_broadcast_process_data(request)
@@ -22,11 +22,17 @@ from sqlalchemy.exc import SQLAlchemyError
22
22
  from oauth2_lib.fastapi import OIDCUserModel
23
23
  from orchestrator.api.error_handling import raise_status
24
24
  from orchestrator.db import EngineSettingsTable
25
- from orchestrator.schemas import EngineSettingsBaseSchema, EngineSettingsSchema, WorkerStatus
25
+ from orchestrator.schemas import (
26
+ EngineSettingsBaseSchema,
27
+ EngineSettingsSchema,
28
+ WorkerStatus,
29
+ )
26
30
  from orchestrator.security import authenticate
27
31
  from orchestrator.services import processes, settings
28
32
  from orchestrator.services.settings import generate_engine_global_status
33
+ from orchestrator.services.settings_env_variables import get_all_exposed_settings
29
34
  from orchestrator.settings import ExecutorType, app_settings
35
+ from orchestrator.utils.expose_settings import SettingsExposedSchema
30
36
  from orchestrator.utils.json import json_dumps
31
37
  from orchestrator.utils.redis import delete_keys_matching_pattern
32
38
  from orchestrator.utils.redis_client import create_redis_asyncio_client
@@ -169,3 +175,8 @@ def generate_engine_status_response(
169
175
  result = EngineSettingsSchema.model_validate(engine_settings)
170
176
  result.global_status = generate_engine_global_status(engine_settings)
171
177
  return result
178
+
179
+
180
+ @router.get("/overview", response_model=list[SettingsExposedSchema])
181
+ def get_exposed_settings() -> list[SettingsExposedSchema]:
182
+ return get_all_exposed_settings()
@@ -19,6 +19,7 @@ import structlog
19
19
  from celery.result import AsyncResult
20
20
  from kombu.exceptions import ConnectionError, OperationalError
21
21
 
22
+ from oauth2_lib.fastapi import OIDCUserModel
22
23
  from orchestrator import app_settings
23
24
  from orchestrator.api.error_handling import raise_status
24
25
  from orchestrator.db import ProcessTable, db
@@ -42,7 +43,11 @@ def _block_when_testing(task_result: AsyncResult) -> None:
42
43
 
43
44
 
44
45
  def _celery_start_process(
45
- workflow_key: str, user_inputs: list[State] | None, user: str = SYSTEM_USER, **kwargs: Any
46
+ workflow_key: str,
47
+ user_inputs: list[State] | None,
48
+ user: str = SYSTEM_USER,
49
+ user_model: OIDCUserModel | None = None,
50
+ **kwargs: Any,
46
51
  ) -> UUID:
47
52
  """Client side call of Celery."""
48
53
  from orchestrator.services.tasks import NEW_TASK, NEW_WORKFLOW, get_celery_task
@@ -57,7 +62,7 @@ def _celery_start_process(
57
62
 
58
63
  task_name = NEW_TASK if wf_table.is_task else NEW_WORKFLOW
59
64
  trigger_task = get_celery_task(task_name)
60
- pstat = create_process(workflow_key, user_inputs, user)
65
+ pstat = create_process(workflow_key, user_inputs=user_inputs, user=user, user_model=user_model)
61
66
  try:
62
67
  result = trigger_task.delay(pstat.process_id, workflow_key, user)
63
68
  _block_when_testing(result)
@@ -467,9 +467,7 @@ def thread_start_process(
467
467
  user_model: OIDCUserModel | None = None,
468
468
  broadcast_func: BroadcastFunc | None = None,
469
469
  ) -> UUID:
470
- pstat = create_process(workflow_key, user_inputs=user_inputs, user=user)
471
- if not pstat.workflow.authorize_callback(user_model):
472
- raise_status(HTTPStatus.FORBIDDEN, error_message_unauthorized(workflow_key))
470
+ pstat = create_process(workflow_key, user_inputs=user_inputs, user=user, user_model=user_model)
473
471
 
474
472
  _safe_logstep_with_func = partial(safe_logstep, broadcast_func=broadcast_func)
475
473
  return _run_process_async(pstat.process_id, lambda: runwf(pstat, _safe_logstep_with_func))
@@ -506,7 +504,6 @@ def thread_resume_process(
506
504
  *,
507
505
  user_inputs: list[State] | None = None,
508
506
  user: str | None = None,
509
- user_model: OIDCUserModel | None = None,
510
507
  broadcast_func: BroadcastFunc | None = None,
511
508
  ) -> UUID:
512
509
  # ATTENTION!! When modifying this function make sure you make similar changes to `resume_workflow` in the test code
@@ -515,8 +512,6 @@ def thread_resume_process(
515
512
  user_inputs = [{}]
516
513
 
517
514
  pstat = load_process(process)
518
- if not pstat.workflow.authorize_callback(user_model):
519
- raise_status(HTTPStatus.FORBIDDEN, error_message_unauthorized(str(process.workflow_name)))
520
515
 
521
516
  if pstat.workflow == removed_workflow:
522
517
  raise ValueError("This workflow cannot be resumed")
@@ -556,7 +551,6 @@ def resume_process(
556
551
  *,
557
552
  user_inputs: list[State] | None = None,
558
553
  user: str | None = None,
559
- user_model: OIDCUserModel | None = None,
560
554
  broadcast_func: BroadcastFunc | None = None,
561
555
  ) -> UUID:
562
556
  """Resume a failed or suspended process.
@@ -565,7 +559,6 @@ def resume_process(
565
559
  process: Process from database
566
560
  user_inputs: Optional user input from forms
567
561
  user: user who resumed this process
568
- user_model: OIDCUserModel of user who resumed this process
569
562
  broadcast_func: Optional function to broadcast process data
570
563
 
571
564
  Returns:
@@ -573,8 +566,6 @@ def resume_process(
573
566
 
574
567
  """
575
568
  pstat = load_process(process)
576
- if not pstat.workflow.authorize_callback(user_model):
577
- raise_status(HTTPStatus.FORBIDDEN, error_message_unauthorized(str(process.workflow_name)))
578
569
 
579
570
  try:
580
571
  post_form(pstat.log[0].form, pstat.state.unwrap(), user_inputs=user_inputs or [])
@@ -0,0 +1,68 @@
1
+ # Copyright 2019-2025 SURF.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from typing import Any, Dict, Type
15
+
16
+ from pydantic import SecretStr as PydanticSecretStr
17
+ from pydantic_core import MultiHostUrl
18
+ from pydantic_settings import BaseSettings
19
+
20
+ from orchestrator.utils.expose_settings import SecretStr as OrchSecretStr
21
+ from orchestrator.utils.expose_settings import SettingsEnvVariablesSchema, SettingsExposedSchema
22
+
23
+ EXPOSED_ENV_SETTINGS_REGISTRY: Dict[str, Type[BaseSettings]] = {}
24
+ MASK = "**********"
25
+
26
+
27
+ def expose_settings(settings_name: str, base_settings: Type[BaseSettings]) -> Type[BaseSettings]:
28
+ """Decorator to register settings classes."""
29
+ EXPOSED_ENV_SETTINGS_REGISTRY[settings_name] = base_settings
30
+ return base_settings
31
+
32
+
33
+ def mask_value(key: str, value: Any) -> Any:
34
+ key_lower = key.lower()
35
+
36
+ if "secret" in key_lower or "password" in key_lower:
37
+ # Mask sensitive information
38
+ return MASK
39
+
40
+ if isinstance(value, PydanticSecretStr):
41
+ # Need to convert SecretStr to str for serialization
42
+ return str(value)
43
+
44
+ if isinstance(value, OrchSecretStr):
45
+ return MASK
46
+
47
+ # PostgresDsn is just MultiHostUrl with extra metadata (annotations)
48
+ if isinstance(value, MultiHostUrl):
49
+ # Convert PostgresDsn to str for serialization
50
+ return MASK
51
+
52
+ return value
53
+
54
+
55
+ def get_all_exposed_settings() -> list[SettingsExposedSchema]:
56
+ """Return all registered settings as dicts."""
57
+
58
+ def _get_settings_env_variables(base_settings: Type[BaseSettings]) -> list[SettingsEnvVariablesSchema]:
59
+ """Get environment variables from settings."""
60
+ return [
61
+ SettingsEnvVariablesSchema(env_name=key, env_value=mask_value(key, value))
62
+ for key, value in base_settings.model_dump().items() # type: ignore
63
+ ]
64
+
65
+ return [
66
+ SettingsExposedSchema(name=name, variables=_get_settings_env_variables(base_settings))
67
+ for name, base_settings in EXPOSED_ENV_SETTINGS_REGISTRY.items()
68
+ ]
orchestrator/settings.py CHANGED
@@ -20,6 +20,8 @@ from pydantic import Field, NonNegativeInt, PostgresDsn, RedisDsn
20
20
  from pydantic_settings import BaseSettings
21
21
 
22
22
  from oauth2_lib.settings import oauth2lib_settings
23
+ from orchestrator.services.settings_env_variables import expose_settings
24
+ from orchestrator.utils.expose_settings import SecretStr as OrchSecretStr
23
25
  from pydantic_forms.types import strEnum
24
26
 
25
27
 
@@ -30,7 +32,7 @@ class ExecutorType(strEnum):
30
32
 
31
33
  class AppSettings(BaseSettings):
32
34
  TESTING: bool = True
33
- SESSION_SECRET: str = "".join(secrets.choice(string.ascii_letters) for i in range(16)) # noqa: S311
35
+ SESSION_SECRET: OrchSecretStr = "".join(secrets.choice(string.ascii_letters) for i in range(16)) # type: ignore
34
36
  CORS_ORIGINS: str = "*"
35
37
  CORS_ALLOW_METHODS: list[str] = ["GET", "PUT", "PATCH", "POST", "DELETE", "OPTIONS", "HEAD"]
36
38
  CORS_ALLOW_HEADERS: list[str] = ["If-None-Match", "Authorization", "If-Match", "Content-Type"]
@@ -55,7 +57,7 @@ class AppSettings(BaseSettings):
55
57
  MAIL_PORT: int = 25
56
58
  MAIL_STARTTLS: bool = False
57
59
  CACHE_URI: RedisDsn = "redis://localhost:6379/0" # type: ignore
58
- CACHE_HMAC_SECRET: str | None = None # HMAC signing key, used when pickling results in the cache
60
+ CACHE_HMAC_SECRET: OrchSecretStr | None = None # HMAC signing key, used when pickling results in the cache
59
61
  REDIS_RETRY_COUNT: NonNegativeInt = Field(
60
62
  2, description="Number of retries for redis connection errors/timeouts, 0 to disable"
61
63
  ) # More info: https://redis-py.readthedocs.io/en/stable/retry.html
@@ -87,6 +89,8 @@ class AppSettings(BaseSettings):
87
89
  ENABLE_PROMETHEUS_METRICS_ENDPOINT: bool = False
88
90
  VALIDATE_OUT_OF_SYNC_SUBSCRIPTIONS: bool = False
89
91
  FILTER_BY_MODE: Literal["partial", "exact"] = "exact"
92
+ EXPOSE_SETTINGS: bool = False
93
+ EXPOSE_OAUTH_SETTINGS: bool = False
90
94
 
91
95
 
92
96
  app_settings = AppSettings()
@@ -94,3 +98,8 @@ app_settings = AppSettings()
94
98
  # Set oauth2lib_settings variables to the same (default) value of settings
95
99
  oauth2lib_settings.SERVICE_NAME = app_settings.SERVICE_NAME
96
100
  oauth2lib_settings.ENVIRONMENT = app_settings.ENVIRONMENT
101
+
102
+ if app_settings.EXPOSE_SETTINGS:
103
+ expose_settings("app_settings", app_settings) # type: ignore
104
+ if app_settings.EXPOSE_OAUTH_SETTINGS:
105
+ expose_settings("oauth2lib_settings", oauth2lib_settings) # type: ignore
@@ -0,0 +1,9 @@
1
+ from collections.abc import Callable
2
+ from typing import TypeAlias
3
+
4
+ from oauth2_lib.fastapi import OIDCUserModel
5
+
6
+ # This file is broken out separately to avoid circular imports.
7
+
8
+ # Can instead use "type Authorizer = ..." in later Python versions.
9
+ Authorizer: TypeAlias = Callable[[OIDCUserModel | None], bool]
@@ -0,0 +1,45 @@
1
+ # Copyright 2019-2025 SURF.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ """Utility module for exposing settings in a structured format.
14
+
15
+ Unfortunately, this module needs to be imported from the utils and cannot be added to the schemas folder.
16
+ This is due to circular import issues with the combination of schemas/settings.
17
+ """
18
+
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel
22
+ from pydantic_core import core_schema
23
+
24
+
25
+ class SecretStr(str):
26
+ """A string that is treated as a secret, for example, passwords or API keys.
27
+
28
+ This class is used to indicate that the string should not be logged or displayed in plaintext.
29
+ """
30
+
31
+ @classmethod
32
+ def __get_pydantic_core_schema__(cls, source_type, handler): # type: ignore
33
+ # This method is used to define how the SecretStr type should be handled by Pydantic.
34
+ # With this implementation, it will fail validation.
35
+ return core_schema.no_info_plain_validator_function(cls)
36
+
37
+
38
+ class SettingsEnvVariablesSchema(BaseModel):
39
+ env_name: str
40
+ env_value: Any
41
+
42
+
43
+ class SettingsExposedSchema(BaseModel):
44
+ name: str
45
+ variables: list[SettingsEnvVariablesSchema]
orchestrator/workflow.py CHANGED
@@ -45,6 +45,7 @@ from orchestrator.db import db, transactional
45
45
  from orchestrator.services.settings import get_engine_settings
46
46
  from orchestrator.targets import Target
47
47
  from orchestrator.types import ErrorDict, StepFunc
48
+ from orchestrator.utils.auth import Authorizer
48
49
  from orchestrator.utils.docs import make_workflow_doc
49
50
  from orchestrator.utils.errors import error_state_to_dict
50
51
  from orchestrator.utils.state import form_inject_args, inject_args
@@ -80,6 +81,8 @@ class Step(Protocol):
80
81
  name: str
81
82
  form: InputFormGenerator | None
82
83
  assignee: Assignee | None
84
+ resume_auth_callback: Authorizer | None = None
85
+ retry_auth_callback: Authorizer | None = None
83
86
 
84
87
  def __call__(self, state: State) -> Process: ...
85
88
 
@@ -90,7 +93,8 @@ class Workflow(Protocol):
90
93
  __qualname__: str
91
94
  name: str
92
95
  description: str
93
- authorize_callback: Callable[[OIDCUserModel | None], bool]
96
+ authorize_callback: Authorizer
97
+ retry_auth_callback: Authorizer
94
98
  initial_input_form: InputFormGenerator | None = None
95
99
  target: Target
96
100
  steps: StepList
@@ -99,13 +103,20 @@ class Workflow(Protocol):
99
103
 
100
104
 
101
105
  def make_step_function(
102
- f: Callable, name: str, form: InputFormGenerator | None = None, assignee: Assignee | None = Assignee.SYSTEM
106
+ f: Callable,
107
+ name: str,
108
+ form: InputFormGenerator | None = None,
109
+ assignee: Assignee | None = Assignee.SYSTEM,
110
+ resume_auth_callback: Authorizer | None = None,
111
+ retry_auth_callback: Authorizer | None = None,
103
112
  ) -> Step:
104
113
  step_func = cast(Step, f)
105
114
 
106
115
  step_func.name = name
107
116
  step_func.form = form
108
117
  step_func.assignee = assignee
118
+ step_func.resume_auth_callback = resume_auth_callback
119
+ step_func.retry_auth_callback = retry_auth_callback
109
120
  return step_func
110
121
 
111
122
 
@@ -167,6 +178,7 @@ class StepList(list[Step]):
167
178
 
168
179
 
169
180
  def _handle_simple_input_form_generator(f: StateInputStepFunc) -> StateInputFormGenerator:
181
+ """Processes f into a form generator and injects a pre-hook for user authorization."""
170
182
  if inspect.isgeneratorfunction(f):
171
183
  return cast(StateInputFormGenerator, f)
172
184
  if inspect.isgenerator(f):
@@ -191,7 +203,8 @@ def make_workflow(
191
203
  initial_input_form: InputStepFunc | None,
192
204
  target: Target,
193
205
  steps: StepList,
194
- authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
206
+ authorize_callback: Authorizer | None = None,
207
+ retry_auth_callback: Authorizer | None = None,
195
208
  ) -> Workflow:
196
209
  @functools.wraps(f)
197
210
  def wrapping_function() -> NoReturn:
@@ -202,6 +215,10 @@ def make_workflow(
202
215
  wrapping_function.name = f.__name__ # default, will be changed by LazyWorkflowInstance
203
216
  wrapping_function.description = description
204
217
  wrapping_function.authorize_callback = allow if authorize_callback is None else authorize_callback
218
+ # If no retry auth policy is given, defer to policy for process creation.
219
+ wrapping_function.retry_auth_callback = (
220
+ wrapping_function.authorize_callback if retry_auth_callback is None else retry_auth_callback
221
+ )
205
222
 
206
223
  if initial_input_form is None:
207
224
  # We always need a form to prevent starting a workflow when no input is needed.
@@ -270,9 +287,16 @@ def retrystep(name: str) -> Callable[[StepFunc], Step]:
270
287
  return decorator
271
288
 
272
289
 
273
- def inputstep(name: str, assignee: Assignee) -> Callable[[InputStepFunc], Step]:
290
+ def inputstep(
291
+ name: str,
292
+ assignee: Assignee,
293
+ resume_auth_callback: Authorizer | None = None,
294
+ retry_auth_callback: Authorizer | None = None,
295
+ ) -> Callable[[InputStepFunc], Step]:
274
296
  """Add user input step to workflow.
275
297
 
298
+ Any authorization callbacks will be attached to the resulting Step.
299
+
276
300
  IMPORTANT: In contrast to other workflow steps, the `@inputstep` wrapped function will not run in the
277
301
  workflow engine! This means that it must be free of side effects!
278
302
 
@@ -299,7 +323,14 @@ def inputstep(name: str, assignee: Assignee) -> Callable[[InputStepFunc], Step]:
299
323
  def suspend(state: State) -> Process:
300
324
  return Suspend(state)
301
325
 
302
- return make_step_function(suspend, name, wrapper, assignee)
326
+ return make_step_function(
327
+ suspend,
328
+ name,
329
+ wrapper,
330
+ assignee,
331
+ resume_auth_callback=resume_auth_callback,
332
+ retry_auth_callback=retry_auth_callback,
333
+ )
303
334
 
304
335
  return decorator
305
336
 
@@ -479,7 +510,8 @@ def workflow(
479
510
  description: str,
480
511
  initial_input_form: InputStepFunc | None = None,
481
512
  target: Target = Target.SYSTEM,
482
- authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
513
+ authorize_callback: Authorizer | None = None,
514
+ retry_auth_callback: Authorizer | None = None,
483
515
  ) -> Callable[[Callable[[], StepList]], Workflow]:
484
516
  """Transform an initial_input_form and a step list into a workflow.
485
517
 
@@ -500,7 +532,13 @@ def workflow(
500
532
 
501
533
  def _workflow(f: Callable[[], StepList]) -> Workflow:
502
534
  return make_workflow(
503
- f, description, initial_input_form_in_form_inject_args, target, f(), authorize_callback=authorize_callback
535
+ f,
536
+ description,
537
+ initial_input_form_in_form_inject_args,
538
+ target,
539
+ f(),
540
+ authorize_callback=authorize_callback,
541
+ retry_auth_callback=retry_auth_callback,
504
542
  )
505
543
 
506
544
  return _workflow
@@ -23,6 +23,7 @@ from orchestrator.services.subscriptions import get_subscription
23
23
  from orchestrator.targets import Target
24
24
  from orchestrator.types import SubscriptionLifecycle
25
25
  from orchestrator.utils.json import to_serializable
26
+ from orchestrator.websocket import sync_invalidate_subscription_cache
26
27
  from orchestrator.workflow import Step, step
27
28
  from pydantic_forms.types import State, UUIDstr
28
29
 
@@ -33,6 +34,7 @@ logger = structlog.get_logger(__name__)
33
34
  def resync(subscription: SubscriptionModel) -> State:
34
35
  """Transition a subscription to in sync."""
35
36
  subscription.insync = True
37
+ sync_invalidate_subscription_cache(subscription.subscription_id)
36
38
  return {"subscription": subscription}
37
39
 
38
40
 
@@ -93,6 +95,7 @@ def unsync(subscription_id: UUIDstr, __old_subscriptions__: dict | None = None)
93
95
  if not subscription.insync:
94
96
  raise ValueError("Subscription is already out of sync, cannot continue!")
95
97
  subscription.insync = False
98
+ sync_invalidate_subscription_cache(subscription.subscription_id)
96
99
 
97
100
  return {"subscription": subscription, "__old_subscriptions__": subscription_backup}
98
101
 
@@ -20,12 +20,12 @@ from more_itertools import first_true
20
20
  from pydantic import field_validator, model_validator
21
21
  from sqlalchemy import select
22
22
 
23
- from oauth2_lib.fastapi import OIDCUserModel
24
23
  from orchestrator.db import ProductTable, SubscriptionTable, db
25
24
  from orchestrator.forms.validators import ProductId
26
25
  from orchestrator.services import subscriptions
27
26
  from orchestrator.targets import Target
28
27
  from orchestrator.types import SubscriptionLifecycle
28
+ from orchestrator.utils.auth import Authorizer
29
29
  from orchestrator.utils.errors import StaleDataError
30
30
  from orchestrator.utils.state import form_inject_args
31
31
  from orchestrator.utils.validate_data_version import validate_data_version
@@ -201,7 +201,8 @@ def create_workflow(
201
201
  initial_input_form: InputStepFunc | None = None,
202
202
  status: SubscriptionLifecycle = SubscriptionLifecycle.ACTIVE,
203
203
  additional_steps: StepList | None = None,
204
- authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
204
+ authorize_callback: Authorizer | None = None,
205
+ retry_auth_callback: Authorizer | None = None,
205
206
  ) -> Callable[[Callable[[], StepList]], Workflow]:
206
207
  """Transform an initial_input_form and a step list into a workflow with a target=Target.CREATE.
207
208
 
@@ -234,6 +235,7 @@ def create_workflow(
234
235
  Target.CREATE,
235
236
  steplist,
236
237
  authorize_callback=authorize_callback,
238
+ retry_auth_callback=retry_auth_callback,
237
239
  )
238
240
 
239
241
  return _create_workflow
@@ -243,7 +245,8 @@ def modify_workflow(
243
245
  description: str,
244
246
  initial_input_form: InputStepFunc | None = None,
245
247
  additional_steps: StepList | None = None,
246
- authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
248
+ authorize_callback: Authorizer | None = None,
249
+ retry_auth_callback: Authorizer | None = None,
247
250
  ) -> Callable[[Callable[[], StepList]], Workflow]:
248
251
  """Transform an initial_input_form and a step list into a workflow.
249
252
 
@@ -278,6 +281,7 @@ def modify_workflow(
278
281
  Target.MODIFY,
279
282
  steplist,
280
283
  authorize_callback=authorize_callback,
284
+ retry_auth_callback=retry_auth_callback,
281
285
  )
282
286
 
283
287
  return _modify_workflow
@@ -287,7 +291,8 @@ def terminate_workflow(
287
291
  description: str,
288
292
  initial_input_form: InputStepFunc | None = None,
289
293
  additional_steps: StepList | None = None,
290
- authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
294
+ authorize_callback: Authorizer | None = None,
295
+ retry_auth_callback: Authorizer | None = None,
291
296
  ) -> Callable[[Callable[[], StepList]], Workflow]:
292
297
  """Transform an initial_input_form and a step list into a workflow.
293
298
 
@@ -323,6 +328,7 @@ def terminate_workflow(
323
328
  Target.TERMINATE,
324
329
  steplist,
325
330
  authorize_callback=authorize_callback,
331
+ retry_auth_callback=retry_auth_callback,
326
332
  )
327
333
 
328
334
  return _terminate_workflow
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestrator-core
3
- Version: 4.0.4
3
+ Version: 4.1.0
4
4
  Summary: This is the orchestrator workflow engine.
5
5
  Requires-Python: >=3.11,<3.14
6
6
  Classifier: Intended Audience :: Information Technology
@@ -39,7 +39,7 @@ Requires-Dist: more-itertools~=10.7.0
39
39
  Requires-Dist: itsdangerous
40
40
  Requires-Dist: Jinja2==3.1.6
41
41
  Requires-Dist: orjson==3.10.18
42
- Requires-Dist: prometheus-client==0.22.0
42
+ Requires-Dist: prometheus-client==0.22.1
43
43
  Requires-Dist: psycopg2-binary==2.9.10
44
44
  Requires-Dist: pydantic[email]~=2.8.2
45
45
  Requires-Dist: pydantic-settings~=2.9.1
@@ -84,7 +84,7 @@ Requires-Dist: dirty-equals ; extra == "test"
84
84
  Requires-Dist: jsonref ; extra == "test"
85
85
  Requires-Dist: mypy==1.9 ; extra == "test"
86
86
  Requires-Dist: pyinstrument ; extra == "test"
87
- Requires-Dist: pytest==8.3.5 ; extra == "test"
87
+ Requires-Dist: pytest==8.4.1 ; extra == "test"
88
88
  Requires-Dist: pytest-asyncio==0.21.2 ; extra == "test"
89
89
  Requires-Dist: pytest-codspeed ; extra == "test"
90
90
  Requires-Dist: pytest-cov ; extra == "test"
@@ -1,14 +1,14 @@
1
- orchestrator/__init__.py,sha256=YVe09WczpF4MK2XPRNEAfLMjIE3YCIBlDRgfibiP9U8,1063
1
+ orchestrator/__init__.py,sha256=DbrwQbPx3_QWT2Pl7cyt5TeORw36VLXtYZ5lA7oO9qA,1063
2
2
  orchestrator/app.py,sha256=7UrXKjBKNSEaSSXAd5ww_RdMFhFqE4yvfj8faS2MzAA,12089
3
3
  orchestrator/exception_handlers.py,sha256=UsW3dw8q0QQlNLcV359bIotah8DYjMsj2Ts1LfX4ClY,1268
4
4
  orchestrator/log_config.py,sha256=1tPRX5q65e57a6a_zEii_PFK8SzWT0mnA5w2sKg4hh8,1853
5
5
  orchestrator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  orchestrator/security.py,sha256=iXFxGxab54aav7oHEKLAVkTgrQMJGHy6IYLojEnD7gI,2422
7
- orchestrator/settings.py,sha256=ep4RXOIZfYb5ws4qT_E_iiANMQR91pvOhQQCv1t8osw,3879
7
+ orchestrator/settings.py,sha256=TFIv09JIKY-lXqd04lH_XEcijEEyheaz3zTcgeG8DEI,4339
8
8
  orchestrator/targets.py,sha256=WizBgnp8hWX9YLFUIju7ewSubiwQqinCvyiYNcXHbHI,802
9
9
  orchestrator/types.py,sha256=qzs7xx5AYRmKbpYRyJJP3wuDb0W0bcAzefCN0RWLAco,15459
10
10
  orchestrator/version.py,sha256=b58e08lxs47wUNXv0jXFO_ykpksmytuzEXD4La4W-NQ,1366
11
- orchestrator/workflow.py,sha256=T_3Z1PrlF70C16ehAthKRF1hd3jpwV-MREDVxSLfxOw,44063
11
+ orchestrator/workflow.py,sha256=N4KGmH_Nf0U6nTxs62-qby7WVIoppHF6uKrZKCdxwfQ,45285
12
12
  orchestrator/api/__init__.py,sha256=GyHNfEFCGKQwRiN6rQmvSRH2iYX7npjMZn97n8XzmLU,571
13
13
  orchestrator/api/error_handling.py,sha256=YrPCxSa-DSa9KwqIMlXI-KGBGnbGIW5ukOPiikUH9E4,1502
14
14
  orchestrator/api/helpers.py,sha256=s0QRHYw8AvEmlkmRhuEzz9xixaZKUF3YuPzUVHkcoXk,6933
@@ -17,11 +17,11 @@ orchestrator/api/api_v1/__init__.py,sha256=GyHNfEFCGKQwRiN6rQmvSRH2iYX7npjMZn97n
17
17
  orchestrator/api/api_v1/api.py,sha256=m4iDktsSpzxUDaudkdgXeZ83a6B4wfc3pczQsa-Pb-8,2866
18
18
  orchestrator/api/api_v1/endpoints/__init__.py,sha256=GyHNfEFCGKQwRiN6rQmvSRH2iYX7npjMZn97n8XzmLU,571
19
19
  orchestrator/api/api_v1/endpoints/health.py,sha256=iaxs1XX1_250_gKNsspuULCV2GEMBjbtjsmfQTOvMAI,1284
20
- orchestrator/api/api_v1/endpoints/processes.py,sha256=i5GRoszvdmpZqOxSqON83DNuc0UIV-ohOqbAa0OhESE,13564
20
+ orchestrator/api/api_v1/endpoints/processes.py,sha256=Ah_Dc9ONXDpaFzLTqSNdhkGPHC7vUBkWmx1fWsEwbzo,16076
21
21
  orchestrator/api/api_v1/endpoints/product_blocks.py,sha256=kZ6ywIOsS_S2qGq7RvZ4KzjvaS1LmwbGWR37AKRvWOw,2146
22
22
  orchestrator/api/api_v1/endpoints/products.py,sha256=BfFtwu9dZXEQbtKxYj9icc73GKGvAGMR5ytyf41nQlQ,3081
23
23
  orchestrator/api/api_v1/endpoints/resource_types.py,sha256=gGyuaDyOD0TAVoeFGaGmjDGnQ8eQQArOxKrrk4MaDzA,2145
24
- orchestrator/api/api_v1/endpoints/settings.py,sha256=orcwFqGiQ3Ala3mLm_27ChXPkUFoGUeGNaDZnEIk2Ak,5848
24
+ orchestrator/api/api_v1/endpoints/settings.py,sha256=5s-k169podZjgGHUbVDmSQwpY_3Cs_Bbf2PPtZIkBcw,6184
25
25
  orchestrator/api/api_v1/endpoints/subscription_customer_descriptions.py,sha256=1_6LtgQleoq3M6z_W-Qz__Bj3OFUweoPrUqHMwSH6AM,3288
26
26
  orchestrator/api/api_v1/endpoints/subscriptions.py,sha256=V-ebvjtFEKlALx8SKX42SiNPM-GAgSMWbuaimyktUQQ,8758
27
27
  orchestrator/api/api_v1/endpoints/translations.py,sha256=dIWh_fCnZZUxJoGiNeJ49DK_xpf75IpR_0EIMSvzIvY,963
@@ -260,26 +260,29 @@ orchestrator/schemas/subscription.py,sha256=-jXyHZIed9Xlia18ksSDyenblNN6Q2yM2FlG
260
260
  orchestrator/schemas/subscription_descriptions.py,sha256=Ft_jw1U0bf9Z0U8O4OWfLlcl0mXCVT_qYVagBP3GbIQ,1262
261
261
  orchestrator/schemas/workflow.py,sha256=VqQ9XfV4fVd6MjY0LRRQzWBJHmlPsAamWfTwDx1cZkg,2102
262
262
  orchestrator/services/__init__.py,sha256=GyHNfEFCGKQwRiN6rQmvSRH2iYX7npjMZn97n8XzmLU,571
263
- orchestrator/services/celery.py,sha256=SmAUsN755yE7cZ3og92qTvPPeRIpdEKlbaLih7o38h8,5089
263
+ orchestrator/services/celery.py,sha256=PsIgRBJsmA3vKwAUaqPq9ynLwDsXHY2ggDWc-nQAwgM,5232
264
264
  orchestrator/services/fixed_inputs.py,sha256=kyz7s2HLzyDulvcq-ZqefTw1om86COvyvTjz0_5CmgI,876
265
265
  orchestrator/services/input_state.py,sha256=HF7wl9fWdaAW8pdCCqbuYoKyNj8dY0g8Ff8vXis8z5A,2211
266
266
  orchestrator/services/process_broadcast_thread.py,sha256=D44YbjF8mRqGuznkRUV4SoRn1J0lfy_x1H508GnSVlU,4649
267
- orchestrator/services/processes.py,sha256=rTH6zLNsun3qDCPguz2LYS87MQR_LJREIPrgkGS6kwk,30494
267
+ orchestrator/services/processes.py,sha256=gXWdsJK8m_KwM4g-8LBQMb1tEcjUJWWIKmqobTE7OmU,29903
268
268
  orchestrator/services/products.py,sha256=BP4KyE8zO-8z7Trrs5T6zKBOw53S9BfBJnHWI3p6u5Y,1943
269
269
  orchestrator/services/resource_types.py,sha256=_QBy_JOW_X3aSTqH0CuLrq4zBJL0p7Q-UDJUcuK2_qc,884
270
270
  orchestrator/services/settings.py,sha256=HEWfFulgoEDwgfxGEO__QTr5fDiwNBEj1UhAeTAdbLQ,3159
271
+ orchestrator/services/settings_env_variables.py,sha256=U2xz0h3bOAE8wWQF2iCp5jf-2v0NUOOCTecNTouB1jY,2496
271
272
  orchestrator/services/subscription_relations.py,sha256=9C126TUfFvyBe7y4x007kH_dvxJ9pZ1zSnaWeH6HC5k,12261
272
273
  orchestrator/services/subscriptions.py,sha256=nr2HI89nC0lYjzTh2j-lEQ5cPQK43LNZv3gvP6jbepw,27189
273
274
  orchestrator/services/tasks.py,sha256=NjPkuauQoh9UJDcjA7OcKFgPk0i6NoKdDO7HlpGbBJ8,6575
274
275
  orchestrator/services/translations.py,sha256=GyP8soUFGej8AS8uulBsk10CCK6Kwfjv9AHMFm3ElQY,1713
275
276
  orchestrator/services/workflows.py,sha256=iEkt2OBuTwkDru4V6ZSKatnw0b96ZdPV-VQqeZ9EOgU,4015
276
277
  orchestrator/utils/__init__.py,sha256=GyHNfEFCGKQwRiN6rQmvSRH2iYX7npjMZn97n8XzmLU,571
278
+ orchestrator/utils/auth.py,sha256=IWn0amdquLobt1mRNwhgKT0ErCBjLGDtLdsDuaY8rlE,309
277
279
  orchestrator/utils/crypt.py,sha256=18eNamYWMllPkxyRtWIde3FDr3rSF74R5SAL6WsCj9Y,5584
278
280
  orchestrator/utils/datetime.py,sha256=a1WQ_yvu7MA0TiaRpC5avwbOSFdrj4eMrV4a7I2sD5Q,1477
279
281
  orchestrator/utils/deprecation_logger.py,sha256=oqju7ecJcB_r7cMnldaOAA79QUZYS_h69IkDrFV9nAg,875
280
282
  orchestrator/utils/docs.py,sha256=GbyD61oKn1yVYaphUKHCBvrWEWJDTQfRc_VEbVb-zgU,6172
281
283
  orchestrator/utils/enrich_process.py,sha256=o_QSy5Q4wn1SMHhzVOw6bp7uhDXr7GhAIWRDDMWUVO4,4699
282
284
  orchestrator/utils/errors.py,sha256=6FxvXrITmRjP5bYnJJ3CxjAwA5meNjRAVYouz4TWKkU,4653
285
+ orchestrator/utils/expose_settings.py,sha256=0NOjLBifQy4k2zUYJ31QjGQCaXEQ1zB4UtCle7XglAM,1640
283
286
  orchestrator/utils/fixed_inputs.py,sha256=pnL6I_19VMp_Bny8SYjSzVFNvTFDyeCxFFOWGhTnDiQ,2665
284
287
  orchestrator/utils/functional.py,sha256=X1MDNwHmkU3-8mFb21m31HGlcfc5TygliXR0sXN3-rU,8304
285
288
  orchestrator/utils/get_subscription_dict.py,sha256=hctkFvD3U6LpygNwz2uesRMdnXSY-PaohBqPATsi9r4,694
@@ -299,15 +302,15 @@ orchestrator/websocket/managers/memory_websocket_manager.py,sha256=lF5EEx1iFMCGE
299
302
  orchestrator/workflows/__init__.py,sha256=NzIGGI-8SNAwCk2YqH6sHhEWbgAY457ntDwjO15N8v4,4131
300
303
  orchestrator/workflows/modify_note.py,sha256=l6QtijRPv8gnHxzwTz_4nWIPcZ0FcKQh_yFbtjYEDMg,2163
301
304
  orchestrator/workflows/removed_workflow.py,sha256=V0Da5TEdfLdZZKD38ig-MTp3_IuE7VGqzHHzvPYQmLI,909
302
- orchestrator/workflows/steps.py,sha256=ulpheoHaCOE1qh71Bja4KW4pItQh1Z6q4QU4tn5GtNk,6067
303
- orchestrator/workflows/utils.py,sha256=yBwfITlseimyLEzbwI0ehOAlr-xI1N3WGVudFz4boXk,13778
305
+ orchestrator/workflows/steps.py,sha256=l2YokXOzNazvMXB8DEi5WCif97yZl0Ui0-o9OoMSvUw,6275
306
+ orchestrator/workflows/utils.py,sha256=9j0hQOvGnjXUuVUMMzSIpdiyT5NzijhLh1sBXbd5QJA,14008
304
307
  orchestrator/workflows/tasks/__init__.py,sha256=GyHNfEFCGKQwRiN6rQmvSRH2iYX7npjMZn97n8XzmLU,571
305
308
  orchestrator/workflows/tasks/cleanup_tasks_log.py,sha256=BfWYbPXhnLAHUJ0mlODDnjZnQQAvKCZJDVTwbwOWI04,1624
306
309
  orchestrator/workflows/tasks/resume_workflows.py,sha256=MzJqlSXUvKStkT7NGzxZyRlfAer_ezYm-kjUqaZi0yc,2359
307
310
  orchestrator/workflows/tasks/validate_product_type.py,sha256=paG-NAY1bdde3Adt8zItkcBKf5Pxw6f5ngGW6an6dYU,3192
308
311
  orchestrator/workflows/tasks/validate_products.py,sha256=GZJBoFF-WMphS7ghMs2-gqvV2iL1F0POhk0uSNt93n0,8510
309
312
  orchestrator/workflows/translations/en-GB.json,sha256=ST53HxkphFLTMjFHonykDBOZ7-P_KxksktZU3GbxLt0,846
310
- orchestrator_core-4.0.4.dist-info/licenses/LICENSE,sha256=b-aA5OZQuuBATmLKo_mln8CQrDPPhg3ghLzjPjLn4Tg,11409
311
- orchestrator_core-4.0.4.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
312
- orchestrator_core-4.0.4.dist-info/METADATA,sha256=EDGeOW4AxLgPbD6ZQDojlTPd8H3j4GOpD1vfWg-MgKA,5070
313
- orchestrator_core-4.0.4.dist-info/RECORD,,
313
+ orchestrator_core-4.1.0.dist-info/licenses/LICENSE,sha256=b-aA5OZQuuBATmLKo_mln8CQrDPPhg3ghLzjPjLn4Tg,11409
314
+ orchestrator_core-4.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
315
+ orchestrator_core-4.1.0.dist-info/METADATA,sha256=jU0NSrnz4d5GFIaNE58_68_qIVp5pO-7XL9quPvEf8c,5070
316
+ orchestrator_core-4.1.0.dist-info/RECORD,,