Add Tenuo authorization contrib module#1447
Add Tenuo authorization contrib module#1447aimable100 wants to merge 2 commits intotemporalio:mainfrom
Conversation
Adds `temporalio.contrib.tenuo`, a SimplePlugin integration for
Tenuo warrant-based authorization in Temporal workflows.
The plugin (`TenuoPlugin`) wires client interceptors, worker
interceptors, and workflow sandbox passthrough in a single line:
from temporalio.contrib.tenuo import TenuoPlugin
plugin = TenuoPlugin(config)
client = await Client.connect("localhost:7233", plugins=[plugin])
Key design decisions:
- Thin adapter: only TenuoPlugin, TENUO_PLUGIN_NAME, and
ensure_tenuo_workflow_runner are exported from the contrib module.
All other types (TenuoPluginConfig, EnvKeyResolver, etc.) are
imported directly from tenuo.temporal.
- No private imports: all tenuo.temporal internals used by the plugin
are exposed through public lazy-loaded names.
- No re-exports of external package types, matching the pattern
established by openai_agents and other contrib modules.
Files:
- temporalio/contrib/tenuo/__init__.py — public API (3 exports)
- temporalio/contrib/tenuo/_plugin.py — TenuoPlugin SimplePlugin subclass
- temporalio/contrib/tenuo/README.md — multi-agent delegation example
- tests/contrib/tenuo/test_tenuo.py — unit + live integration tests
- tests/contrib/tenuo/test_tenuo_replay.py — record-and-replay tests
- pyproject.toml — tenuo optional dependency
Add Tenuo authorization contrib module
|
I don't think it is likely that we are willing to accept this. We welcome folks using Temporal as a part of their solution, but including it in the SDK's contrib comes with an implication of our maintenance and ownership of the solution. From a technical perspective, you are welcome to create a plugin external to the SDK repo, and we can have a discussion about partnership. If you reach out in our community slack, I can put you in touch with the folks running AI partnership. |
|
Thanks for the note. This was submitted through Temporal's AI Partner Program — I was invited and completed the submission form. Happy to move to an external plugin if that's the preferred path for partners too. Will follow up with the team to confirm. |
|
@aimable100 - thank you for preparing this plugin. I will leave this PR open so that our team can provide feedback. You should plan to move it to one of your repositories, though. |
This comment was marked as low quality.
This comment was marked as low quality.
DABH
left a comment
There was a problem hiding this comment.
This plugin appears to be following the recommended strategy of using SimplePlugin's interface. The plugin is also performing authorization by intercepting activities - interceptors are indeed designed to perform actions like this as seen in the OpenTelemetryPlugin and BraintrustPlugin, so no concerns there. Replay testing is being taken seriously here, which is nice to see.
One overall concern is the naming/shipping strategy here. Tenuo appears to already ship a Temporal plugin (https://tenuo.ai/temporal). The present plugin imports a bunch of stuff (including TenuoPlugin) from tenuo.temporal, so it seems like the present PR is really a thin wrapper/adapter around what's already been published. Can you explain how the present PR is different than the existing plugin, and whether both need to exist? On naming, Tenuo's README on tenuo-ai/tenuo uses the name TenuoTemporalPlugin, while the integration docs page uses TenuoPlugin, and the PR adds a third binding temporalio.contrib.tenuo.TenuoPlugin - three names in three places could be a little confusing.
I left a few inline comments and suggestions in the code below. Happy to continue the conversation and keep iterating. Thank you for your efforts!
| __all__ = [ | ||
| "TENUO_PLUGIN_NAME", | ||
| "TenuoPlugin", | ||
| "ensure_tenuo_workflow_runner", |
There was a problem hiding this comment.
Does ensure_tenuo_workflow_runner need to be publicly exposed? This looks more like an internal implementation detail
| *passthrough | ||
| ) | ||
| ) | ||
| if isinstance(existing, SandboxedWorkflowRunner): |
There was a problem hiding this comment.
If the user has a custom non-sandboxed workflow runner (e.g., UnsandboxedWorkflowRunner for debugging), this branch silently returns existing unchanged (tenuo and tenuo_core don't get added as passthrough modules). That feels like an edge case that should either be handled, or, the plugin should detect that case and throw an error
| raise RuntimeError( | ||
| "Duplicate Tenuo plugin registration: the same TenuoPlugin " | ||
| "instance was used to configure_worker more than once. " | ||
| "Create separate instances for each worker." |
There was a problem hiding this comment.
If you create a client object and pass the plugin into that, then worker objects you create with the client will automatically get the plugin. So instead of creating different plugin instances, I think the recommendation here (and anywhere else in the docs?) should be to create one plugin object and just pass it into the client.
| config.activity_fns = list(existing) | ||
| config._activity_registry = build_activity_registry(config.activity_fns) |
There was a problem hiding this comment.
Here the code appears to be modifying config, which is a user-owned object. Should the code be referencing self._tenuo_config instead? If not - can we avoid mutating objects that users are passing in (which presumably they don't expect to be modified?)?
As a possible failure mode: if the user constructs one TenuoPluginConfig and hands it to two TenuoPlugin instances for different workers with different activity sets, the second worker's activities silently overwrite the first's registry (well, they don't, because of the if not config.activity_fns guard; but then the second worker silently inherits the first worker's activities, which is arguably worse: the user gets authorization checks against the wrong activity function references).
| @@ -0,0 +1,27 @@ | |||
| """Tenuo warrant-based authorization for Temporal workflows. | |||
There was a problem hiding this comment.
Tenuo appears to have specific exception types (PopVerificationError, WarrantExpired, ChainValidationError, etc.) that should arguably be registered as workflow_failure_exception_types so they fail workflows cleanly rather than getting wrapped in generic ActivityError. (Current code doesn't register any)
| KEY_ID = "replay-key" | ||
|
|
||
|
|
||
| class DictKeyResolver(KeyResolver): |
There was a problem hiding this comment.
Should this have been used in test_tenuo.py too to avoid manipulating things like _key_cache?
| def resolve_sync(self, key_id: str) -> tenuo.SigningKey: | ||
| """Resolve a key by ID synchronously.""" | ||
| if key_id not in self.keys: | ||
| raise ValueError(f"Key {key_id!r} not found") |
There was a problem hiding this comment.
Nit, but KeyResolutionError instead of ValueError?
| plugins=[replay_plugin], | ||
| ).replay_workflow(history, raise_on_replay_failure=False) | ||
|
|
||
| assert replay_result.replay_failure is None, ( |
There was a problem hiding this comment.
Would be good to add a test of a replay with tampered history - a negative test where you corrupt the warrant header in the captured history and assert replay_failure is not None would prove the verification path is actually engaged during replay. Without it, we don't know the plugin is verifying anything during replay; we only know it's not crashing.
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Tests |
There was a problem hiding this comment.
A couple tests to consider adding that I think might be useful:
- Replay of a denied workflow. The recording captures a successful authorization. Replay of a denied workflow (the unauthorized activity case from
test_tenuo.py) should also terminate identically on replay. Not tested currently since the replay file doesn't have any unauthorized scenarios. - Replay after issuer-key rotation. Tenuo's docs make a specific claim: "On refresh failure, the worker retains the previous Authorizer and logs a warning." The replay tests use a static trusted root. A test where the plugin config for replay uses a rotated root set (with overlap) would validate the rotation path.
- Replay across clock-boundary. Tenuo's PoP uses
window_ts = (unix_now // 30) * 30- 30-second buckets. A test that records a workflow whose activities span a window boundary would exercise the bucket-crossing logic, which could be helpful.
| if _preload_all is not None: | ||
| try: | ||
| _preload_all() | ||
| except Exception as exc: | ||
| _logger.warning("key preload failed: %s", exc) |
There was a problem hiding this comment.
My understanding is that preloading is not optional for EnvKeyResolver - if it silently fails here, every subsequent workflow execution fails with KeyResolutionError. Assuming that is the case, we should do something stronger than warning - this should either (a) raise and abort worker construction, (b) record which resolver failed so the later error message can point back here, or (c) at minimum, log at error level.
## Summary Addresses worker-side feedback from the Temporal team (DABH) on [temporalio/sdk-python#1447](temporalio/sdk-python#1447). **Plugin (`tenuo-python/tenuo/temporal_plugin.py`)** - No longer mutates the user's `TenuoPluginConfig`; works on a shallow copy so two workers sharing a config stay isolated. - Registers Tenuo's domain exceptions (`TenuoContextError`, `PopVerificationError`, `TemporalConstraintViolation`, `WarrantExpired`, `ChainValidationError`, `KeyResolutionError`, `LocalActivityError`) as `workflow_failure_exception_types` on SDKs that support it. - Preload failures log at `ERROR` with the resolver class name; `EnvKeyResolver` preload failure raises `ConfigurationError` (no safe `os.environ` fallback in the sandbox). - `ensure_tenuo_workflow_runner` emits a `UserWarning` plus a logger warning when given `UnsandboxedWorkflowRunner` (Tenuo still works — the user is just opting out of Temporal's own determinism guardrails, which is a legitimate choice for debugging), and warns for unknown custom runners. - Duplicate-registration error now points at `Client.connect(plugins=[plugin])` inheritance instead of advising one-plugin-per-worker. **Plugin-confusion rename (`tenuo.temporal.TenuoPlugin` → `TenuoWorkerInterceptor`)** - The old name was a Temporal SDK `WorkerInterceptor`, not a Temporal SDK `Plugin`, and its resemblance to `tenuo.temporal_plugin.TenuoTemporalPlugin` caused real misconfigurations (e.g. `Worker(plugins=[TenuoPlugin(...)])` silently accepting an unusable argument). - New canonical name: `tenuo.temporal.TenuoWorkerInterceptor`. - Backward compat: `tenuo.temporal.TenuoPlugin` is still importable as a deprecated alias and emits a `DeprecationWarning` on first resolution; scheduled for removal in a future beta. Most users register `TenuoTemporalPlugin` via `Client.connect(plugins=[plugin])` and are unaffected. - Updated all internal usages, tests, examples (5 files), and docs. Added an "About the names" callout table in `docs/temporal.md` and a "renamed from" breadcrumb in `docs/temporal-reference.md`. - New unit test asserts the alias warns and resolves to the new class. **Tests** - `DictKeyResolver` raises `KeyResolutionError` instead of `ValueError`. - 7 new unit tests in `tests/adapters/test_temporal_plugin.py` cover every plugin-side change above, plus the deprecation-alias test. **Deferred to follow-ups** - Making `ensure_tenuo_workflow_runner` private — useful public escape hatch for advanced users; keep public. - Replay-time negative tests (tampered history, rotated trusted roots, clock-boundary). Initial attempts revealed that the current plugin architecture does not re-verify activity PoP during replay — activities don't re-execute, and the workflow inbound interceptor only stashes headers without re-checking. Designing meaningful replay-safety tests requires plumbing changes and should be scoped as its own task. ## Test plan - [x] `uv run pytest tests/adapters/test_temporal_plugin.py` — 33 passed (incl. new deprecation test). - [x] `uv run pytest tests/adapters/test_temporal.py tests/adapters/test_transparent_interceptor.py tests/adapters/test_temporal_integration.py tests/e2e/test_temporal_replay.py` — 166 passed. - [x] `uv run pytest tests/e2e/test_temporal_e2e.py tests/e2e/test_temporal_replay.py` — 61 passed. - [x] `uv run pytest tests/security/test_security_contracts.py tests/security/test_integration_invariants.py` — 117 passed, 22 skipped. - [x] `uvx ruff check` clean on modified files. - [x] `mypy tenuo/temporal/__init__.py tenuo/temporal/_interceptors.py tenuo/temporal_plugin.py` — no errors. - [x] All 5 Temporal examples byte-compile.
Summary
Adds
temporalio.contrib.tenuo, aSimplePluginthat wires Tenuo warrant-based authorization into Temporal workflows. Agents (workflows) carry signed warrants specifying which tools (activities) they can call and with what argument constraints. Sub-agents (child workflows) receive attenuated warrants — capabilities can only shrink, never expand.TenuoPlugin— registers client interceptor (warrant header injection), worker interceptors (PoP signing + authorization verification), and sandbox passthrough for thetenuonative extension.TenuoPlugin,TENUO_PLUGIN_NAME,ensure_tenuo_workflow_runner). All other types are imported fromtenuo.temporal, matching the pattern established byopenai_agentsand other contrib modules.tenuo.temporalinternals used by the plugin are exposed through public lazy-loaded names.Files
temporalio/contrib/tenuo/__init__.pytemporalio/contrib/tenuo/_plugin.pytemporalio/contrib/tenuo/README.mdtests/contrib/tenuo/test_tenuo.pytests/contrib/tenuo/test_tenuo_replay.pypyproject.tomlReplay safety
Replay determinism is verified at two levels:
workflow.now()(nottime.time()), nodatetime.now(), noos.urandom/random/uuid4, notime.sleep, nothreading.Thread.fetch_history(), and a freshTenuoPlugininstance replays viaReplayer. Tests cover single-tool and multi-tool (sequential PoP ordering) scenarios.Integration tests
test_authorized_activity_succeeds— full warrant → PoP → authorization flowtest_start_workflow_authorized—start_workflow_authorizedreturns a handletest_unauthorized_activity_is_non_retryable— unauthorized tool call producesWorkflowFailureErrorwithApplicationError(non_retryable=True)test_duplicate_registration_raises— same plugin instance on two workers raisesRuntimeErrorTest plan
pytest tests/contrib/tenuo/test_tenuo.py -v— unit + integration testspytest tests/contrib/tenuo/test_tenuo_replay.py -v— replay determinism testsruff check temporalio/contrib/tenuo/ tests/contrib/tenuo/— no lint errors