Skip to content

Commit a2fc703

Browse files
Add plane-scoped auth tokens
1 parent 9ac2f98 commit a2fc703

5 files changed

Lines changed: 67 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88

99
### Added
10+
- Plane-scoped SDK bearer tokens: `Client(..., control_token=..., worker_token=...)`
11+
and the sync wrapper now support least-privilege server deployments where
12+
operator/admin credentials are separate from worker credentials. The existing
13+
`token=` argument remains the shared fallback.
1014
- `Worker.run_until(workflow_id=..., timeout=...)` for examples, smoke tests,
1115
and single-workflow scripts that need to run a worker until one workflow
1216
reaches a terminal state.

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ multi-activity order workflow against a local server with Docker Compose.
6363
- **Codec envelopes**: Avro payloads by default, with JSON decode compatibility for existing history
6464
- **Metrics hooks**: Pluggable counters and histograms, with an optional Prometheus adapter
6565

66+
## Authentication
67+
68+
For local servers that use one shared bearer token, pass `token=`:
69+
70+
```python
71+
client = Client("http://server:8080", token="shared-token", namespace="default")
72+
```
73+
74+
For production servers with role-scoped tokens, pass separate credentials for
75+
control-plane calls and worker-plane polling:
76+
77+
```python
78+
client = Client(
79+
"https://workflow.example.internal",
80+
control_token="operator-token",
81+
worker_token="worker-token",
82+
namespace="orders",
83+
)
84+
```
85+
86+
Create one client per namespace when your deployment issues namespace-scoped
87+
tokens. The SDK sends the configured token as `Authorization: Bearer ...` and
88+
the namespace as `X-Namespace` on every request.
89+
6690
## Metrics
6791

6892
Pass a recorder to `Client(metrics=...)` or `Worker(metrics=...)` to collect request, poll, and task metrics. The SDK ships a no-op default, an `InMemoryMetrics` recorder for tests or custom exporter loops, and `PrometheusMetrics` for deployments that install the optional extra:

src/durable_workflow/client.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,17 @@ def __init__(
271271
base_url: str,
272272
*,
273273
token: str | None = None,
274+
control_token: str | None = None,
275+
worker_token: str | None = None,
274276
namespace: str = "default",
275277
timeout: float = 60.0,
276278
retry_policy: RetryPolicy | None = None,
277279
metrics: MetricsRecorder | None = None,
278280
) -> None:
279281
self.base_url = base_url.rstrip("/")
280282
self.token = token
283+
self.control_token = control_token
284+
self.worker_token = worker_token
281285
self.namespace = namespace
282286
self.retry_policy = retry_policy or RetryPolicy()
283287
self.metrics = metrics or NOOP_METRICS
@@ -294,15 +298,21 @@ async def __aexit__(self, *exc: Any) -> None:
294298

295299
def _headers(self, *, worker: bool = False) -> dict[str, str]:
296300
h: dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
297-
if self.token:
298-
h["Authorization"] = f"Bearer {self.token}"
301+
token = self._auth_token(worker=worker)
302+
if token:
303+
h["Authorization"] = f"Bearer {token}"
299304
h["X-Namespace"] = self.namespace
300305
if worker:
301306
h["X-Durable-Workflow-Protocol-Version"] = PROTOCOL_VERSION
302307
else:
303308
h["X-Durable-Workflow-Control-Plane-Version"] = CONTROL_PLANE_VERSION
304309
return h
305310

311+
def _auth_token(self, *, worker: bool = False) -> str | None:
312+
if worker:
313+
return self.worker_token or self.token or self.control_token
314+
return self.control_token or self.token or self.worker_token
315+
306316
async def _request(
307317
self,
308318
method: str,

src/durable_workflow/sync.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ def __init__(
139139
base_url: str,
140140
*,
141141
token: str | None = None,
142+
control_token: str | None = None,
143+
worker_token: str | None = None,
142144
namespace: str = "default",
143145
timeout: float = 60.0,
144146
retry_policy: RetryPolicy | None = None,
@@ -147,6 +149,8 @@ def __init__(
147149
self._async = AsyncClient(
148150
base_url,
149151
token=token,
152+
control_token=control_token,
153+
worker_token=worker_token,
150154
namespace=namespace,
151155
timeout=timeout,
152156
retry_policy=retry_policy,

tests/test_client.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,29 @@ def test_no_token(self) -> None:
5151
h = c._headers()
5252
assert "Authorization" not in h
5353

54+
def test_plane_scoped_tokens(self) -> None:
55+
c = Client(
56+
"http://localhost:8080",
57+
token="legacy-token",
58+
control_token="operator-token",
59+
worker_token="worker-token",
60+
)
61+
62+
control_headers = c._headers(worker=False)
63+
worker_headers = c._headers(worker=True)
64+
65+
assert control_headers["Authorization"] == "Bearer operator-token"
66+
assert worker_headers["Authorization"] == "Bearer worker-token"
67+
68+
def test_single_plane_token_can_fetch_cluster_info(self) -> None:
69+
c = Client("http://localhost:8080", worker_token="worker-token")
70+
71+
control_headers = c._headers(worker=False)
72+
worker_headers = c._headers(worker=True)
73+
74+
assert control_headers["Authorization"] == "Bearer worker-token"
75+
assert worker_headers["Authorization"] == "Bearer worker-token"
76+
5477

5578
class TestStartWorkflow:
5679
@pytest.mark.asyncio

0 commit comments

Comments
 (0)