Skip to content

Commit 0eb96eb

Browse files
Sandbox importing (#1187)
* initial explorations into warnings and errors for dynamic imports and non-passthroughs * create modules to lazily import rather than relying on a random dependency * Do some renaming, apply linter. * fix some naming and typos * Rename error type and shorten pre-defined import policy name * Cleanup new UnintentionalPassthroughError * Apply some naming suggestions * run formatter * remove static policy definitions * Add default option for SandboxRestrictions.import_notification_policy rather than requiring it.
1 parent c3448cc commit 0eb96eb

File tree

8 files changed

+305
-4
lines changed

8 files changed

+305
-4
lines changed

temporalio/worker/workflow_sandbox/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@
5858
RestrictedWorkflowAccessError,
5959
SandboxMatcher,
6060
SandboxRestrictions,
61+
UnintentionalPassthroughError,
6162
)
6263
from ._runner import SandboxedWorkflowRunner
6364

6465
__all__ = [
6566
"RestrictedWorkflowAccessError",
67+
"UnintentionalPassthroughError",
6668
"SandboxedWorkflowRunner",
6769
"SandboxMatcher",
6870
"SandboxRestrictions",

temporalio/worker/workflow_sandbox/_importer.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
RestrictedWorkflowAccessError,
4343
RestrictionContext,
4444
SandboxRestrictions,
45+
UnintentionalPassthroughError,
4546
)
4647

4748
logger = logging.getLogger(__name__)
@@ -200,6 +201,17 @@ def _import(
200201

201202
# Check module restrictions and passthrough modules
202203
if full_name not in sys.modules:
204+
# Issue a warning if appropriate
205+
if (
206+
self.restriction_context.in_activation
207+
and self._is_import_notification_policy_applied(
208+
temporalio.workflow.SandboxImportNotificationPolicy.WARN_ON_DYNAMIC_IMPORT
209+
)
210+
):
211+
warnings.warn(
212+
f"Module {full_name} was imported after initial workflow load."
213+
)
214+
203215
# Make sure not an entirely invalid module
204216
self._assert_valid_module(full_name)
205217

@@ -282,13 +294,36 @@ def module_configured_passthrough(self, name: str) -> bool:
282294
break
283295
return True
284296

297+
def _is_import_notification_policy_applied(
298+
self, policy: temporalio.workflow.SandboxImportNotificationPolicy
299+
) -> bool:
300+
override_policy = (
301+
temporalio.workflow.unsafe.current_import_notification_policy_override()
302+
)
303+
if override_policy:
304+
return policy in override_policy
305+
306+
return policy in self.restrictions.import_notification_policy
307+
285308
def _maybe_passthrough_module(self, name: str) -> Optional[types.ModuleType]:
286309
# If imports not passed through and all modules are not passed through
287310
# and name not in passthrough modules, check parents
288311
if (
289312
not temporalio.workflow.unsafe.is_imports_passed_through()
290313
and not self.module_configured_passthrough(name)
291314
):
315+
if self._is_import_notification_policy_applied(
316+
temporalio.workflow.SandboxImportNotificationPolicy.RAISE_ON_UNINTENTIONAL_PASSTHROUGH
317+
):
318+
raise UnintentionalPassthroughError(name)
319+
320+
if self._is_import_notification_policy_applied(
321+
temporalio.workflow.SandboxImportNotificationPolicy.WARN_ON_UNINTENTIONAL_PASSTHROUGH
322+
):
323+
warnings.warn(
324+
f"Module {name} was not intentionally passed through to the sandbox."
325+
)
326+
292327
return None
293328
# Do the pass through
294329
with self._unapplied():

temporalio/worker/workflow_sandbox/_restrictions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
except ImportError:
4343
HAVE_PYDANTIC = False
4444

45+
import temporalio.exceptions
4546
import temporalio.workflow
4647

4748
logger = logging.getLogger(__name__)
@@ -82,6 +83,21 @@ def default_message(qualified_name: str) -> str:
8283
)
8384

8485

86+
class UnintentionalPassthroughError(temporalio.exceptions.TemporalError):
87+
"""Error that occurs when a workflow unintentionally passes an import to the sandbox when
88+
the import notification policy includes :py:attr:`temporalio.workflow.SandboxImportNotificationPolicy.RAISE_ON_NON_PASSTHROUGH`.
89+
90+
Attributes:
91+
qualified_name: Fully qualified name of what was passed through to the sandbox.
92+
"""
93+
94+
def __init__(self, qualified_name: str) -> None:
95+
"""Create an unintentional passthrough error."""
96+
super().__init__(
97+
f"Module {qualified_name} was not intentionally passed through to the sandbox."
98+
)
99+
100+
85101
@dataclass(frozen=True)
86102
class SandboxRestrictions:
87103
"""Set of restrictions that can be applied to a sandbox."""
@@ -110,6 +126,13 @@ class methods (including __init__, etc). The check compares the against the
110126
fully qualified path to the item.
111127
"""
112128

129+
import_notification_policy: temporalio.workflow.SandboxImportNotificationPolicy = (
130+
temporalio.workflow.SandboxImportNotificationPolicy.WARN_ON_DYNAMIC_IMPORT
131+
)
132+
"""
133+
The import notification policy to use when an import is triggered during workflow loading or execution. See :py:class:`temporalio.workflow.SandboxImportNotificationPolicy` for options.
134+
"""
135+
113136
passthrough_all_modules: bool = False
114137
"""
115138
Pass through all modules, do not sandbox any modules. This is the equivalent
@@ -170,6 +193,12 @@ def with_passthrough_all_modules(self) -> SandboxRestrictions:
170193
"""
171194
return dataclasses.replace(self, passthrough_all_modules=True)
172195

196+
def with_import_notification_policy(
197+
self, policy: temporalio.workflow.SandboxImportNotificationPolicy
198+
) -> SandboxRestrictions:
199+
"""Create a new restriction set with the given import notification policy as the :py:attr:`import_policy`."""
200+
return dataclasses.replace(self, import_notification_policy=policy)
201+
173202

174203
# We intentionally use specific fields instead of generic "matcher" callbacks
175204
# for optimization reasons.
@@ -305,10 +334,12 @@ def access_matcher(
305334
if not child_matcher:
306335
return None
307336
matcher = child_matcher
337+
308338
if not context.is_runtime and matcher.only_runtime:
309339
return None
310340
if not matcher.match_self:
311341
return None
342+
312343
return matcher
313344

314345
def match_access(
@@ -819,6 +850,7 @@ def unwrap_if_proxied(v: Any) -> Any:
819850
def __init__(self) -> None:
820851
"""Create a restriction context."""
821852
self.is_runtime = False
853+
self.in_activation = False
822854

823855

824856
@dataclass

temporalio/worker/workflow_sandbox/_runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def activate(
159159
self, act: temporalio.bridge.proto.workflow_activation.WorkflowActivation
160160
) -> temporalio.bridge.proto.workflow_completion.WorkflowActivationCompletion:
161161
self.importer.restriction_context.is_runtime = True
162+
self.importer.restriction_context.in_activation = True
162163
try:
163164
self._run_code(
164165
"with __temporal_importer.applied():\n"
@@ -169,6 +170,7 @@ def activate(
169170
return self.globals_and_locals.pop("__temporal_completion") # type: ignore
170171
finally:
171172
self.importer.restriction_context.is_runtime = False
173+
self.importer.restriction_context.in_activation = False
172174

173175
def _run_code(self, code: str, **extra_globals: Any) -> None:
174176
for k, v in extra_globals.items():

temporalio/workflow.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from contextlib import contextmanager
1414
from dataclasses import dataclass
1515
from datetime import datetime, timedelta, timezone
16-
from enum import Enum, IntEnum
16+
from enum import Enum, Flag, IntEnum, auto
1717
from functools import partial
1818
from random import Random
1919
from typing import (
@@ -1410,6 +1410,20 @@ async def wait_condition(
14101410
_sandbox_unrestricted = threading.local()
14111411
_in_sandbox = threading.local()
14121412
_imports_passed_through = threading.local()
1413+
_sandbox_import_notification_policy_override = threading.local()
1414+
1415+
1416+
class SandboxImportNotificationPolicy(Flag):
1417+
"""Defines the behavior taken when modules are imported into the sandbox after the workflow is initially loaded or unintentionally missing from the passthrough list."""
1418+
1419+
SILENT = auto()
1420+
"""Allow imports that do not violate sandbox restrictions and no warnings are generated."""
1421+
WARN_ON_DYNAMIC_IMPORT = auto()
1422+
"""Allows dynamic imports that do not violate sandbox restrictions but issues a warning when an import is triggered in the sandbox after initial workflow load."""
1423+
WARN_ON_UNINTENTIONAL_PASSTHROUGH = auto()
1424+
"""Allows imports that do not violate sandbox restrictions but issues a warning when an import is triggered in the sandbox that was unintentionally passed through."""
1425+
RAISE_ON_UNINTENTIONAL_PASSTHROUGH = auto()
1426+
"""Raise an error when an import is triggered in the sandbox that was unintentionally passed through."""
14131427

14141428

14151429
class unsafe:
@@ -1498,6 +1512,35 @@ def imports_passed_through() -> Iterator[None]:
14981512
finally:
14991513
_imports_passed_through.value = False
15001514

1515+
@staticmethod
1516+
def current_import_notification_policy_override() -> (
1517+
Optional[SandboxImportNotificationPolicy]
1518+
):
1519+
"""Gets the current import notification policy override if one is set."""
1520+
applied_policy = getattr(
1521+
_sandbox_import_notification_policy_override,
1522+
"value",
1523+
None,
1524+
)
1525+
return applied_policy
1526+
1527+
@staticmethod
1528+
@contextmanager
1529+
def sandbox_import_notification_policy(
1530+
policy: SandboxImportNotificationPolicy,
1531+
) -> Iterator[None]:
1532+
"""Context manager to apply the given import notification policy."""
1533+
original_policy = _sandbox_import_notification_policy_override.value = getattr(
1534+
_sandbox_import_notification_policy_override,
1535+
"value",
1536+
None,
1537+
)
1538+
_sandbox_import_notification_policy_override.value = policy
1539+
try:
1540+
yield None
1541+
finally:
1542+
_sandbox_import_notification_policy_override.value = original_policy
1543+
15011544

15021545
class LoggerAdapter(logging.LoggerAdapter):
15031546
"""Adapter that adds details to the log about the running workflow.

0 commit comments

Comments
 (0)