Skip to content

Commit eee0f41

Browse files
Bernd VerstCopilot
andcommitted
Add configurable Azure Managed token resource IDs
Support explicit resource_id audiences and REGION_NAME defaults for government regions across clients, workers, and sandbox registration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent a6c18f2 commit eee0f41

12 files changed

Lines changed: 437 additions & 17 deletions

File tree

‎docs/getting-started.md‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,69 @@
11
# Getting Started
22

3+
## Configure Azure Managed Authentication
4+
5+
Pass an Azure `TokenCredential` to the Azure Managed client or worker. Use
6+
`resource_id` to override the resource audience for token requests:
7+
8+
```python
9+
from azure.identity import AzureAuthorityHosts, DefaultAzureCredential
10+
from durabletask.azuremanaged import DurableTaskSchedulerClient
11+
12+
credential = DefaultAzureCredential(
13+
authority=AzureAuthorityHosts.AZURE_GOVERNMENT,
14+
)
15+
16+
client = DurableTaskSchedulerClient(
17+
host_address="https://myaccount.usgovvirginia.durabletask.azure.us",
18+
taskhub="my-task-hub",
19+
token_credential=credential,
20+
resource_id="https://durabletask.azure.us",
21+
)
22+
```
23+
24+
The same `resource_id` parameter is available on `DurableTaskSchedulerWorker`,
25+
`AsyncDurableTaskSchedulerClient`, and the preview `SandboxActivitiesClient` and
26+
`SandboxWorker`. The async client requires an async credential, such as
27+
`azure.identity.aio.DefaultAzureCredential`. Sandbox workers continue to use
28+
their runtime-injected endpoint and managed identity.
29+
30+
An explicit resource ID takes precedence over `REGION_NAME`. If `resource_id`
31+
is `None` or `""`, the SDK resolves the default when the client or worker is
32+
constructed:
33+
34+
| `REGION_NAME` | Default resource ID |
35+
| --- | --- |
36+
| Starts with `usgov` or `usdod` (case-insensitive) | `https://durabletask.azure.us` |
37+
| Any other value, empty, or unset | `https://durabletask.io` |
38+
39+
For example, to select the government default without passing `resource_id`:
40+
41+
Bash:
42+
43+
```bash
44+
export REGION_NAME=usgovvirginia
45+
```
46+
47+
PowerShell:
48+
49+
```powershell
50+
$env:REGION_NAME = "usgovvirginia"
51+
```
52+
53+
The SDK trims surrounding whitespace and trailing slashes, removes an existing
54+
`/.default` suffix (case-insensitive), and appends `/.default` for the token
55+
request. For example, both `https://durabletask.azure.us/` and
56+
`https://durabletask.azure.us/.default` request
57+
`https://durabletask.azure.us/.default`. Nonempty inputs that become empty after
58+
normalization, such as whitespace, `///`, or `/.default`, raise `ValueError`.
59+
60+
> [!NOTE]
61+
> The resource audience, service endpoint, and credential authority/cloud are
62+
> separate settings. Neither `resource_id` nor `REGION_NAME` changes the endpoint
63+
> or the credential's authority. Configure the credential and any underlying
64+
> developer tools for the target cloud separately. Other clouds or custom
65+
> audiences require an explicit resource ID; they are not inferred from the endpoint.
66+
367
## Run the Order Processing Example
468

569
- Check out the [Durable Task Scheduler

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
ADDED
11+
12+
- Added optional `resource_id` configuration for the token audience on
13+
`DurableTaskSchedulerClient`, `AsyncDurableTaskSchedulerClient`,
14+
`DurableTaskSchedulerWorker`, `SandboxActivitiesClient`, and `SandboxWorker`.
15+
Explicit values override region defaults and support custom resource URIs.
16+
Surrounding whitespace, trailing slashes, and an existing `/.default` suffix
17+
are normalized before token requests; values that become empty are rejected.
18+
19+
CHANGED
20+
21+
- When `resource_id` is omitted or empty, Azure Managed clients and workers now
22+
use `https://durabletask.azure.us` if `REGION_NAME` starts with `usgov` or `usdod`
23+
(case-insensitive). Other regions, including an unset `REGION_NAME`, retain
24+
`https://durabletask.io`. The endpoint and credential authority remain separately
25+
configured.
26+
1027
## v1.10.1
1128

1229
CHANGED

‎durabletask-azuremanaged/durabletask/azuremanaged/client.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,22 @@
2525

2626
# Client class used for Durable Task Scheduler (DTS)
2727
class DurableTaskSchedulerClient(TaskHubGrpcClient):
28+
"""A client for Azure Durable Task Scheduler.
29+
30+
``resource_id`` optionally overrides the token audience. If None or empty,
31+
it defaults to ``https://durabletask.azure.us`` when ``REGION_NAME`` starts
32+
with ``usgov`` or ``usdod`` (case-insensitive), or ``https://durabletask.io``
33+
otherwise. Surrounding whitespace, trailing slashes, and an existing
34+
``/.default`` suffix are removed before requesting the ``/.default`` scope.
35+
Values that become empty raise ``ValueError``. This does not configure the
36+
service endpoint or the credential's authority.
37+
"""
38+
2839
def __init__(self, *,
2940
host_address: str,
3041
taskhub: str,
3142
token_credential: TokenCredential | None,
43+
resource_id: str | None = None,
3244
channel: grpc.Channel | None = None,
3345
secure_channel: bool = True,
3446
interceptors: Sequence[shared.ClientInterceptor] | None = None,
@@ -47,7 +59,8 @@ def __init__(self, *,
4759
resolved_interceptors: list[shared.ClientInterceptor] = (
4860
list(interceptors) if interceptors is not None else []
4961
)
50-
resolved_interceptors.append(DTSDefaultClientInterceptorImpl(token_credential, taskhub))
62+
resolved_interceptors.append(DTSDefaultClientInterceptorImpl(
63+
token_credential, taskhub, resource_id=resource_id))
5164

5265
# We pass in None for the metadata so we don't construct an additional interceptor in the parent class
5366
# Since the parent class doesn't use anything metadata for anything else, we can set it as None
@@ -81,6 +94,12 @@ class AsyncDurableTaskSchedulerClient(AsyncTaskHubGrpcClient):
8194
taskhub (str): The name of the task hub. Cannot be empty.
8295
token_credential (TokenCredential | None): Azure credential for authentication.
8396
If None, anonymous authentication will be used.
97+
resource_id (str | None, optional): Token audience override. If None or empty,
98+
defaults to ``https://durabletask.azure.us`` when ``REGION_NAME`` starts
99+
with ``usgov`` or ``usdod`` (case-insensitive), or ``https://durabletask.io``
100+
otherwise. Surrounding whitespace, trailing slashes, and an existing
101+
``/.default`` suffix are removed before requesting the ``/.default`` scope.
102+
Does not configure the service endpoint or the credential's authority.
84103
secure_channel (bool, optional): Whether to use a secure gRPC channel (TLS).
85104
Defaults to True.
86105
resiliency_options (GrpcClientResiliencyOptions | None, optional): Client-side
@@ -98,6 +117,7 @@ class AsyncDurableTaskSchedulerClient(AsyncTaskHubGrpcClient):
98117
99118
Raises:
100119
ValueError: If taskhub is empty or None.
120+
ValueError: If resource_id becomes empty after normalization.
101121
102122
Example:
103123
>>> from azure.identity.aio import DefaultAzureCredential
@@ -116,6 +136,7 @@ def __init__(self, *,
116136
host_address: str,
117137
taskhub: str,
118138
token_credential: AsyncTokenCredential | None,
139+
resource_id: str | None = None,
119140
channel: grpc.aio.Channel | None = None,
120141
secure_channel: bool = True,
121142
interceptors: Sequence[shared.AsyncClientInterceptor] | None = None,
@@ -134,7 +155,8 @@ def __init__(self, *,
134155
resolved_interceptors: list[shared.AsyncClientInterceptor] = (
135156
list(interceptors) if interceptors is not None else []
136157
)
137-
resolved_interceptors.append(DTSAsyncDefaultClientInterceptorImpl(token_credential, taskhub))
158+
resolved_interceptors.append(DTSAsyncDefaultClientInterceptorImpl(
159+
token_credential, taskhub, resource_id=resource_id))
138160

139161
# We pass in None for the metadata so we don't construct an additional interceptor in the parent class
140162
# Since the parent class doesn't use anything metadata for anything else, we can set it as None

‎durabletask-azuremanaged/durabletask/azuremanaged/internal/access_token_manager.py‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33
import asyncio
4+
import os
45
from datetime import datetime, timedelta, timezone
56
from threading import Lock
67

@@ -10,14 +11,31 @@
1011
import durabletask.internal.shared as shared
1112

1213

14+
def resolve_resource_id(resource_id: str | None) -> str:
15+
"""Normalize an explicit token audience or resolve the current region's default."""
16+
if resource_id is None or resource_id == "":
17+
region = os.getenv("REGION_NAME", "")
18+
if region.lower().startswith(("usgov", "usdod")):
19+
return "https://durabletask.azure.us"
20+
return "https://durabletask.io"
21+
22+
resource_id = resource_id.strip().rstrip("/")
23+
if resource_id.lower().endswith("/.default"):
24+
resource_id = resource_id[:-len("/.default")].rstrip("/")
25+
if not resource_id:
26+
raise ValueError("resource_id cannot be empty after normalization.")
27+
return resource_id
28+
29+
1330
# By default, when there's 10minutes left before the token expires, refresh the token
1431
class AccessTokenManager:
1532

1633
_token: AccessToken | None
1734
expiry_time: datetime | None
1835

19-
def __init__(self, token_credential: TokenCredential | None, refresh_interval_seconds: int = 600):
20-
self._scope = "https://durabletask.io/.default"
36+
def __init__(self, token_credential: TokenCredential | None, refresh_interval_seconds: int = 600,
37+
*, resource_id: str | None = None):
38+
self._scope = f"{resolve_resource_id(resource_id)}/.default"
2139
self._refresh_interval_seconds = refresh_interval_seconds
2240
self._logger = shared.get_logger("token_manager")
2341

@@ -63,8 +81,8 @@ class AsyncAccessTokenManager:
6381
_token: AccessToken | None
6482

6583
def __init__(self, token_credential: AsyncTokenCredential | None,
66-
refresh_interval_seconds: int = 600):
67-
self._scope = "https://durabletask.io/.default"
84+
refresh_interval_seconds: int = 600, *, resource_id: str | None = None):
85+
self._scope = f"{resolve_resource_id(resource_id)}/.default"
6886
self._refresh_interval_seconds = refresh_interval_seconds
6987
self._logger = shared.get_logger("async_token_manager")
7088

‎durabletask-azuremanaged/durabletask/azuremanaged/internal/durabletask_grpc_interceptor.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from durabletask.azuremanaged.internal.access_token_manager import (
1212
AccessTokenManager,
1313
AsyncAccessTokenManager,
14+
resolve_resource_id,
1415
)
1516
from durabletask.internal.grpc_interceptor import (
1617
DefaultAsyncClientInterceptorImpl,
@@ -43,7 +44,10 @@ def __init__(
4344
self,
4445
token_credential: TokenCredential | None,
4546
taskhub_name: str,
46-
worker_id: str | None = None):
47+
worker_id: str | None = None,
48+
*, resource_id: str | None = None):
49+
if token_credential is None:
50+
resolve_resource_id(resource_id)
4751
user_agent = f"durabletask-python/{_get_sdk_version()}"
4852
self._metadata = [
4953
("taskhub", taskhub_name),
@@ -58,7 +62,8 @@ def __init__(
5862
self._token_manager = None
5963
if token_credential is not None:
6064
self._token_credential = token_credential
61-
self._token_manager = AccessTokenManager(token_credential=self._token_credential)
65+
self._token_manager = AccessTokenManager(
66+
token_credential=self._token_credential, resource_id=resource_id)
6267

6368
def _upsert_authorization_header(self, token: str) -> None:
6469
found = False
@@ -91,7 +96,10 @@ class DTSAsyncDefaultClientInterceptorImpl(DefaultAsyncClientInterceptorImpl):
9196
This class implements async gRPC interceptors to add DTS-specific headers
9297
(task hub name, user agent, and authentication token) to all async calls."""
9398

94-
def __init__(self, token_credential: AsyncTokenCredential | None, taskhub_name: str):
99+
def __init__(self, token_credential: AsyncTokenCredential | None, taskhub_name: str,
100+
*, resource_id: str | None = None):
101+
if token_credential is None:
102+
resolve_resource_id(resource_id)
95103
user_agent = f"durabletask-python/{_get_sdk_version()}"
96104
self._metadata = [
97105
("taskhub", taskhub_name),
@@ -104,7 +112,8 @@ def __init__(self, token_credential: AsyncTokenCredential | None, taskhub_name:
104112
self._token_manager = None
105113
if token_credential is not None:
106114
self._token_credential = token_credential
107-
self._token_manager = AsyncAccessTokenManager(token_credential=self._token_credential)
115+
self._token_manager = AsyncAccessTokenManager(
116+
token_credential=self._token_credential, resource_id=resource_id)
108117

109118
def _upsert_authorization_header(self, token: str) -> None:
110119
found = False

‎durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/client.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@
1818

1919

2020
class SandboxActivitiesClient:
21-
"""Client for Durable Task Scheduler sandbox activity management operations."""
21+
"""Client for Durable Task Scheduler sandbox activity management operations.
22+
23+
``resource_id`` selects the token audience using the same normalization and
24+
``REGION_NAME`` defaults as ``DurableTaskSchedulerClient``. It does not change
25+
the endpoint or credential authority. A supplied channel owns its authentication.
26+
"""
2227

2328
def __init__(
2429
self, *,
2530
host_address: str,
2631
taskhub: str,
2732
token_credential: Optional[TokenCredential],
33+
resource_id: Optional[str] = None,
2834
channel: Optional[grpc.Channel] = None,
2935
secure_channel: bool = True,
3036
interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
@@ -33,6 +39,7 @@ def __init__(
3339
host_address=host_address,
3440
taskhub=taskhub,
3541
token_credential=token_credential,
42+
resource_id=resource_id,
3643
channel=channel,
3744
secure_channel=secure_channel,
3845
interceptors=interceptors,

‎durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/transport.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
host_address: str,
4141
taskhub: str,
4242
token_credential: Optional[TokenCredential],
43+
resource_id: Optional[str] = None,
4344
channel: Optional[grpc.Channel] = None,
4445
secure_channel: bool = True,
4546
interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
@@ -52,7 +53,8 @@ def __init__(
5253
resolved_interceptors: list[shared.ClientInterceptor] = (
5354
list(interceptors) if interceptors is not None else []
5455
)
55-
resolved_interceptors.append(DTSDefaultClientInterceptorImpl(token_credential, taskhub))
56+
resolved_interceptors.append(DTSDefaultClientInterceptorImpl(
57+
token_credential, taskhub, resource_id=resource_id))
5658
channel = shared.get_grpc_channel(
5759
host_address=host_address,
5860
secure_channel=secure_channel,

‎durabletask-azuremanaged/durabletask/azuremanaged/preview/sandboxes/worker.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from azure.identity import ManagedIdentityCredential
1313

1414
from durabletask.azuremanaged.internal import sandbox_service_pb2 as pb
15+
from durabletask.azuremanaged.internal.access_token_manager import resolve_resource_id
1516
from durabletask.azuremanaged.preview.sandboxes.helpers import SandboxActivity
1617
from durabletask.azuremanaged.preview.sandboxes.helpers import resolve_activities
1718
from durabletask.azuremanaged.preview.sandboxes.worker_profiles import (
@@ -38,9 +39,15 @@ class SandboxWorker(DurableTaskSchedulerWorker):
3839
3940
This worker registers a live worker session with Durable Task Scheduler and
4041
restricts dispatch to the activities registered on this worker.
42+
43+
``resource_id`` selects the token audience using the same normalization and
44+
``REGION_NAME`` defaults as ``DurableTaskSchedulerWorker``. It applies to both
45+
activity execution and worker registration without changing the runtime endpoint
46+
or managed identity configuration.
4147
"""
4248

43-
def __init__(self) -> None:
49+
def __init__(self, *, resource_id: str | None = None) -> None:
50+
resolved_resource_id = resource_id or resolve_resource_id(None)
4451
resolved_host_address = _resolve_host_address()
4552
resolved_taskhub = _resolve_taskhub()
4653
resolved_secure_channel = _resolve_secure_channel(resolved_host_address)
@@ -54,12 +61,14 @@ def __init__(self) -> None:
5461
self._sandbox_host_address = resolved_host_address
5562
self._sandbox_secure_channel = resolved_secure_channel
5663
self._sandbox_token_credential = resolved_token_credential
64+
self._sandbox_resource_id = resolved_resource_id
5765
self._sandbox_logger = shared.get_logger("worker")
5866

5967
super().__init__(
6068
host_address=resolved_host_address,
6169
taskhub=resolved_taskhub,
6270
token_credential=resolved_token_credential,
71+
resource_id=resolved_resource_id,
6372
secure_channel=resolved_secure_channel,
6473
concurrency_options=concurrency_options)
6574

@@ -139,6 +148,7 @@ def _run_sandbox_registration_loop(self) -> None:
139148
host_address=self._sandbox_host_address,
140149
taskhub=self._sandbox_taskhub,
141150
token_credential=self._sandbox_token_credential,
151+
resource_id=self._sandbox_resource_id,
142152
secure_channel=self._sandbox_secure_channel)
143153
client.connect_sandbox_activity_worker(self._registration_messages())
144154
retry_delay = 1.0

0 commit comments

Comments
 (0)