Skip to content

Commit 2d4b8ca

Browse files
Validate auth composition contract in SDK
1 parent a7271b2 commit 2d4b8ca

8 files changed

Lines changed: 382 additions & 0 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ manifests from `GET /api/cluster/info`:
394394

395395
- `control_plane.version: "2"`
396396
- `control_plane.request_contract.schema: durable-workflow.v2.control-plane-request.contract` version `1`
397+
- `auth_composition_contract.schema: durable-workflow.v2.auth-composition.contract` version `1`
397398
- `worker_protocol.version: "1.0"`
398399
- `worker_protocol.external_task_input_contract.schema: durable-workflow.v2.external-task-input.contract` version `1`
399400
- `worker_protocol.external_task_result_contract.schema: durable-workflow.v2.external-task-result.contract` version `1`
@@ -402,6 +403,10 @@ The top-level server `version` is build identity only. The worker checks these
402403
protocol manifests at startup and fails closed when compatibility is missing,
403404
unknown, or undiscoverable.
404405

406+
Carriers and support tooling can validate `auth_composition_contract` with
407+
`parse_auth_composition_contract()` before resolving connection, namespace,
408+
token, TLS, profile, and redacted effective-configuration diagnostics.
409+
405410
External task carriers can validate fixture artifacts from
406411
`worker_protocol.external_task_input_contract.fixtures` with
407412
`parse_external_task_input_artifact()` and parse leased task envelopes with

docs/reference/auth_composition.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Auth Composition
2+
3+
::: durable_workflow.auth_composition

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ nav:
6464
- Home: index.md
6565
- Reference:
6666
- Client: reference/client.md
67+
- Auth composition: reference/auth_composition.md
6768
- Worker: reference/worker.md
6869
- Workflow: reference/workflow.md
6970
- Activity: reference/activity.md

src/durable_workflow/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88

99
from . import activity, sync, testing, workflow
1010
from .activity import ActivityContext, ActivityInfo
11+
from .auth_composition import (
12+
AUTH_COMPOSITION_CONTRACT_SCHEMA,
13+
AUTH_COMPOSITION_CONTRACT_VERSION,
14+
AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS,
15+
AuthCompositionContract,
16+
AuthCompositionContractError,
17+
parse_auth_composition_contract,
18+
)
1119
from .client import (
1220
Client,
1321
ScheduleAction,
@@ -125,6 +133,11 @@
125133
"ChildWorkflowFailed",
126134
"Client",
127135
"ContinueAsNew",
136+
"AUTH_COMPOSITION_CONTRACT_SCHEMA",
137+
"AUTH_COMPOSITION_CONTRACT_VERSION",
138+
"AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS",
139+
"AuthCompositionContract",
140+
"AuthCompositionContractError",
128141
"NonRetryableError",
129142
"ReplayOutcome",
130143
"Replayer",
@@ -152,6 +165,7 @@
152165
"WorkflowList",
153166
"WorkflowPayloadDecodeError",
154167
"activity",
168+
"parse_auth_composition_contract",
155169
"sync",
156170
"testing",
157171
"workflow",
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Mapping, Sequence
4+
from dataclasses import dataclass
5+
from typing import Any
6+
7+
AUTH_COMPOSITION_CONTRACT_SCHEMA = "durable-workflow.v2.auth-composition.contract"
8+
AUTH_COMPOSITION_CONTRACT_VERSION = 1
9+
10+
AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS = (
11+
"server_url",
12+
"namespace",
13+
"profile",
14+
"auth",
15+
"tls",
16+
"identity",
17+
)
18+
19+
20+
@dataclass(frozen=True)
21+
class AuthCompositionContractError(ValueError):
22+
"""Raised when the server auth-composition manifest is not compatible."""
23+
24+
message: str
25+
26+
def __str__(self) -> str:
27+
return self.message
28+
29+
30+
@dataclass(frozen=True)
31+
class AuthCompositionContract:
32+
schema: str
33+
version: int
34+
connection_precedence: tuple[str, ...]
35+
profile_precedence: tuple[str, ...]
36+
canonical_environment: Mapping[str, str]
37+
auth_material: Mapping[str, Mapping[str, Any]]
38+
effective_config_required_fields: tuple[str, ...]
39+
redaction_never_echo: tuple[str, ...]
40+
41+
@property
42+
def supports_token_auth(self) -> bool:
43+
token = self.auth_material.get("token")
44+
return token is not None and token.get("status") == "supported"
45+
46+
@property
47+
def reserves_mtls(self) -> bool:
48+
mtls = self.auth_material.get("mtls")
49+
return mtls is not None and mtls.get("status") == "reserved"
50+
51+
@property
52+
def reserves_signed_headers(self) -> bool:
53+
signed_headers = self.auth_material.get("signed_headers")
54+
return signed_headers is not None and signed_headers.get("status") == "reserved"
55+
56+
57+
def parse_auth_composition_contract(manifest: Mapping[str, Any]) -> AuthCompositionContract:
58+
"""Parse and validate the v1 carrier auth-composition contract manifest."""
59+
60+
_require_value(manifest, "schema", AUTH_COMPOSITION_CONTRACT_SCHEMA)
61+
_require_value(manifest, "version", AUTH_COMPOSITION_CONTRACT_VERSION)
62+
63+
precedence = _require_mapping(manifest, "precedence")
64+
canonical_environment = _parse_str_mapping(_require_mapping(manifest, "canonical_environment"))
65+
auth_material = _parse_auth_material(_require_mapping(manifest, "auth_material"))
66+
effective_config = _require_mapping(manifest, "effective_config")
67+
redaction = _require_mapping(manifest, "redaction")
68+
69+
required_fields = _require_str_sequence(effective_config, "required_fields")
70+
missing_effective_fields = set(AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS).difference(required_fields)
71+
if missing_effective_fields:
72+
fields = ", ".join(sorted(missing_effective_fields))
73+
raise AuthCompositionContractError(
74+
f"Auth composition contract effective_config.required_fields missing [{fields}]."
75+
)
76+
77+
_require_env(canonical_environment, "server_url", "DURABLE_WORKFLOW_SERVER_URL")
78+
_require_env(canonical_environment, "namespace", "DURABLE_WORKFLOW_NAMESPACE")
79+
_require_env(canonical_environment, "auth_token", "DURABLE_WORKFLOW_AUTH_TOKEN")
80+
_require_env(canonical_environment, "tls_verify", "DURABLE_WORKFLOW_TLS_VERIFY")
81+
_require_env(canonical_environment, "profile", "DW_ENV")
82+
83+
token = auth_material.get("token")
84+
if token is None or token.get("status") != "supported" or token.get("effective_config_value") != "redacted":
85+
raise AuthCompositionContractError("Auth composition contract must support redacted token auth.")
86+
87+
for reserved in ("mtls", "signed_headers"):
88+
material = auth_material.get(reserved)
89+
if material is None or material.get("status") != "reserved":
90+
raise AuthCompositionContractError(
91+
f"Auth composition contract must reserve [{reserved}] auth material."
92+
)
93+
94+
never_echo = _require_str_sequence(redaction, "never_echo")
95+
for secret in ("bearer_tokens", "private_keys", "raw_authorization_headers"):
96+
if secret not in never_echo:
97+
raise AuthCompositionContractError(
98+
f"Auth composition contract redaction.never_echo missing [{secret}]."
99+
)
100+
101+
return AuthCompositionContract(
102+
schema=AUTH_COMPOSITION_CONTRACT_SCHEMA,
103+
version=AUTH_COMPOSITION_CONTRACT_VERSION,
104+
connection_precedence=tuple(_require_str_sequence(precedence, "connection_values")),
105+
profile_precedence=tuple(_require_str_sequence(precedence, "profile_selection")),
106+
canonical_environment=canonical_environment,
107+
auth_material=auth_material,
108+
effective_config_required_fields=tuple(required_fields),
109+
redaction_never_echo=tuple(never_echo),
110+
)
111+
112+
113+
def _parse_auth_material(value: Mapping[str, Any]) -> Mapping[str, Mapping[str, Any]]:
114+
parsed: dict[str, Mapping[str, Any]] = {}
115+
for key, material in value.items():
116+
if not isinstance(key, str):
117+
raise AuthCompositionContractError("Auth composition auth_material keys must be strings.")
118+
if not isinstance(material, Mapping):
119+
raise AuthCompositionContractError(
120+
f"Auth composition auth_material field [{key}] must be an object."
121+
)
122+
parsed[key] = material
123+
return parsed
124+
125+
126+
def _parse_str_mapping(value: Mapping[str, Any]) -> Mapping[str, str]:
127+
parsed: dict[str, str] = {}
128+
for key, item in value.items():
129+
if not isinstance(key, str):
130+
raise AuthCompositionContractError("Auth composition mapping keys must be strings.")
131+
if not isinstance(item, str):
132+
raise AuthCompositionContractError(f"Auth composition field [{key}] must be a string.")
133+
parsed[key] = item
134+
return parsed
135+
136+
137+
def _require_mapping(value: Mapping[str, Any], key: str) -> Mapping[str, Any]:
138+
if key not in value:
139+
raise AuthCompositionContractError(f"Auth composition contract is missing required field [{key}].")
140+
item = value[key]
141+
if not isinstance(item, Mapping):
142+
raise AuthCompositionContractError(f"Auth composition contract field [{key}] must be an object.")
143+
return item
144+
145+
146+
def _require_str_sequence(value: Mapping[str, Any], key: str) -> Sequence[str]:
147+
if key not in value:
148+
raise AuthCompositionContractError(f"Auth composition contract is missing required field [{key}].")
149+
item = value[key]
150+
if not isinstance(item, Sequence) or isinstance(item, str | bytes):
151+
raise AuthCompositionContractError(f"Auth composition contract field [{key}] must be a list.")
152+
if not all(isinstance(element, str) for element in item):
153+
raise AuthCompositionContractError(f"Auth composition contract field [{key}] must contain only strings.")
154+
return item
155+
156+
157+
def _require_value(value: Mapping[str, Any], key: str, expected: object) -> None:
158+
if key not in value:
159+
raise AuthCompositionContractError(f"Auth composition contract is missing required field [{key}].")
160+
if value[key] != expected:
161+
raise AuthCompositionContractError(
162+
f"Auth composition contract field [{key}] must be [{expected}], got [{value[key]}]."
163+
)
164+
165+
166+
def _require_env(value: Mapping[str, str], key: str, expected: str) -> None:
167+
actual = value.get(key)
168+
if actual != expected:
169+
raise AuthCompositionContractError(
170+
f"Auth composition canonical_environment.{key} must be [{expected}], got [{actual}]."
171+
)

src/durable_workflow/worker.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929

3030
from . import serializer
3131
from .activity import ActivityContext, ActivityInfo, _set_context
32+
from .auth_composition import (
33+
AUTH_COMPOSITION_CONTRACT_SCHEMA,
34+
AUTH_COMPOSITION_CONTRACT_VERSION,
35+
AuthCompositionContractError,
36+
parse_auth_composition_contract,
37+
)
3238
from .client import (
3339
CONTROL_PLANE_REQUEST_CONTRACT_SCHEMA,
3440
CONTROL_PLANE_REQUEST_CONTRACT_VERSION,
@@ -196,6 +202,18 @@ def _validate_server_compatibility(info: dict[str, Any]) -> None:
196202
f"{worker_protocol_version!r}; sdk-python 0.2.x requires {PROTOCOL_VERSION!r}."
197203
)
198204

205+
auth_composition = info.get("auth_composition_contract")
206+
if not isinstance(auth_composition, dict):
207+
raise RuntimeError(
208+
"Server compatibility error: missing auth_composition_contract; "
209+
f"expected {AUTH_COMPOSITION_CONTRACT_SCHEMA} v{AUTH_COMPOSITION_CONTRACT_VERSION}."
210+
)
211+
212+
try:
213+
parse_auth_composition_contract(auth_composition)
214+
except AuthCompositionContractError as exc:
215+
raise RuntimeError(f"Server compatibility error: unsupported auth_composition_contract: {exc}") from exc
216+
199217

200218
def _server_supports_query_tasks(info: dict[str, Any]) -> bool:
201219
worker_protocol = info.get("worker_protocol")

tests/test_auth_composition.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import copy
2+
from typing import Any
3+
4+
import pytest
5+
6+
from durable_workflow.auth_composition import (
7+
AUTH_COMPOSITION_CONTRACT_SCHEMA,
8+
AUTH_COMPOSITION_CONTRACT_VERSION,
9+
AuthCompositionContractError,
10+
parse_auth_composition_contract,
11+
)
12+
13+
14+
def auth_composition_manifest() -> dict[str, Any]:
15+
return {
16+
"schema": AUTH_COMPOSITION_CONTRACT_SCHEMA,
17+
"version": AUTH_COMPOSITION_CONTRACT_VERSION,
18+
"scope": "external_execution_carriers",
19+
"unknown_field_policy": "ignore_additive_reject_unknown_required",
20+
"precedence": {
21+
"connection_values": ["flag", "environment", "selected_profile", "default"],
22+
"profile_selection": ["flag_env", "DW_ENV", "current_profile", "default_profile"],
23+
},
24+
"canonical_environment": {
25+
"server_url": "DURABLE_WORKFLOW_SERVER_URL",
26+
"namespace": "DURABLE_WORKFLOW_NAMESPACE",
27+
"auth_token": "DURABLE_WORKFLOW_AUTH_TOKEN",
28+
"tls_verify": "DURABLE_WORKFLOW_TLS_VERIFY",
29+
"profile": "DW_ENV",
30+
},
31+
"auth_material": {
32+
"token": {
33+
"status": "supported",
34+
"transport": "bearer_authorization_header",
35+
"persisted_as": "secret_reference_or_profile_env_reference",
36+
"effective_config_value": "redacted",
37+
},
38+
"mtls": {
39+
"status": "reserved",
40+
"persisted_as": "certificate_and_key_references",
41+
"effective_config_value": "references_only",
42+
},
43+
"signed_headers": {
44+
"status": "reserved",
45+
"persisted_as": "key_reference_and_header_allowlist",
46+
"effective_config_value": "references_only",
47+
},
48+
},
49+
"effective_config": {
50+
"required_fields": ["server_url", "namespace", "profile", "auth", "tls", "identity"],
51+
"source_values": ["flag", "environment", "selected_profile", "profile_env", "default", "server"],
52+
},
53+
"redaction": {
54+
"never_echo": [
55+
"bearer_tokens",
56+
"private_keys",
57+
"shared_signature_keys",
58+
"client_certificate_private_key_material",
59+
"raw_authorization_headers",
60+
],
61+
"allowed_diagnostics": [
62+
"redacted",
63+
"secret_reference_name",
64+
"environment_variable_name",
65+
"profile_name",
66+
"certificate_reference_name",
67+
"key_reference_name",
68+
],
69+
},
70+
}
71+
72+
73+
def test_auth_composition_contract_parses_server_manifest() -> None:
74+
contract = parse_auth_composition_contract(auth_composition_manifest())
75+
76+
assert contract.schema == "durable-workflow.v2.auth-composition.contract"
77+
assert contract.version == 1
78+
assert contract.connection_precedence == ("flag", "environment", "selected_profile", "default")
79+
assert contract.profile_precedence == ("flag_env", "DW_ENV", "current_profile", "default_profile")
80+
assert contract.canonical_environment["auth_token"] == "DURABLE_WORKFLOW_AUTH_TOKEN"
81+
assert contract.supports_token_auth is True
82+
assert contract.reserves_mtls is True
83+
assert contract.reserves_signed_headers is True
84+
assert "identity" in contract.effective_config_required_fields
85+
assert "bearer_tokens" in contract.redaction_never_echo
86+
87+
88+
@pytest.mark.parametrize(
89+
("path", "value", "message"),
90+
[
91+
(("schema",), "wrong.schema", "schema"),
92+
(("version",), 2, "version"),
93+
(("canonical_environment", "auth_token"), "TOKEN", "auth_token"),
94+
(("auth_material", "token", "effective_config_value"), "plain", "token auth"),
95+
(("auth_material", "mtls", "status"), "supported", "mtls"),
96+
(("redaction", "never_echo"), ["private_keys", "raw_authorization_headers"], "bearer_tokens"),
97+
(("effective_config", "required_fields"), ["server_url"], "namespace"),
98+
],
99+
)
100+
def test_auth_composition_contract_rejects_incompatible_manifest(
101+
path: tuple[str, ...], value: object, message: str
102+
) -> None:
103+
manifest = auth_composition_manifest()
104+
cursor: dict[str, Any] = manifest
105+
for key in path[:-1]:
106+
cursor = cursor[key]
107+
cursor[path[-1]] = value
108+
109+
with pytest.raises(AuthCompositionContractError, match=message):
110+
parse_auth_composition_contract(manifest)
111+
112+
113+
def test_auth_composition_contract_ignores_additive_fields() -> None:
114+
manifest = copy.deepcopy(auth_composition_manifest())
115+
manifest["future"] = {"ignored": True}
116+
manifest["auth_material"]["extensions"] = {"status": "reserved"}
117+
118+
contract = parse_auth_composition_contract(manifest)
119+
120+
assert contract.auth_material["extensions"]["status"] == "reserved"

0 commit comments

Comments
 (0)