diff --git a/docs/decisioning-capabilities.md b/docs/decisioning-capabilities.md index 33482f3e..528498f4 100644 --- a/docs/decisioning-capabilities.md +++ b/docs/decisioning-capabilities.md @@ -168,9 +168,17 @@ class MultiTenantSeller(DecisioningPlatform): base, media_buy=media_buy, webhook_signing=webhook_signing, + webhook_signing_managed_externally=tenant.has_active_signing_credential, ) ``` +Set `webhook_signing_managed_externally=True` only when your platform +signs outbound webhooks outside the SDK `WebhookSender` stack. The SDK +then trusts your `webhook_signing` capability declaration when no SDK +sender or supervisor is wired. If you do wire an SDK `WebhookSender`, the +framework still validates that the sender produces RFC 9421 signatures +matching the advertised algorithms. + The hook may be synchronous or asynchronous. It receives the typed `get_adcp_capabilities` request (or dict, for custom dispatch paths) and the current `ToolContext`, so tenant identity can come from diff --git a/src/adcp/decisioning/platform.py b/src/adcp/decisioning/platform.py index df86d959..c9bd3d55 100644 --- a/src/adcp/decisioning/platform.py +++ b/src/adcp/decisioning/platform.py @@ -145,6 +145,12 @@ class DecisioningCapabilities: ship. The flag itself is the contract that lands now; the enforcement lands in Stage 3. See ``docs/proposals/decisioning-platform-dispatch-design.md#d15``. + :param webhook_signing_managed_externally: Set ``True`` only when + the platform advertises ``webhook_signing.supported=True`` but + signs outbound webhooks through adopter-owned infrastructure + rather than the SDK's :class:`adcp.webhook_sender.WebhookSender`. + The framework then trusts the adopter's capability declaration + when no SDK sender/supervisor is wired. Deprecated flat-declaration shortcuts (will be removed in v5): @@ -172,6 +178,7 @@ class DecisioningCapabilities: creative_agents: list[Any] = field(default_factory=list) config: dict[str, Any] = field(default_factory=dict) governance_aware: bool = False + webhook_signing_managed_externally: bool = False # When True, the framework calls get_products and slices the full result # set to the requested page. Only suitable for in-memory / small-catalog # adopters whose get_products returns the complete unfiltered product set. diff --git a/src/adcp/decisioning/webhook_emit.py b/src/adcp/decisioning/webhook_emit.py index cebee740..aa0b2f3f 100644 --- a/src/adcp/decisioning/webhook_emit.py +++ b/src/adcp/decisioning/webhook_emit.py @@ -440,6 +440,34 @@ def validate_webhook_signing_for_capabilities( if webhook_signing is None or not getattr(webhook_signing, "supported", False): return + adopter_managed = getattr(capabilities, "webhook_signing_managed_externally", False) + + from adcp.decisioning.types import AdcpError + + if not isinstance(adopter_managed, bool): + raise AdcpError( + "INVALID_REQUEST", + message=( + "DecisioningCapabilities.webhook_signing_managed_externally " + "must be a bool. Non-bool values are rejected so a mistyped " + "configuration cannot bypass SDK webhook-signing validation." + ), + recovery="terminal", + details={ + "field": "webhook_signing_managed_externally", + "value_type": type(adopter_managed).__name__, + }, + ) + + if adopter_managed is True and sender is None and supervisor is None: + logger.info( + "[adcp.decisioning] capabilities.webhook_signing.supported=True " + "and DecisioningCapabilities.webhook_signing_managed_externally=True; " + "skipping SDK WebhookSender validation. Operator owns the RFC 9421 " + "delivery contract for outbound webhooks." + ) + return + resolved_sender: Any = sender if resolved_sender is None and supervisor is not None: # Both reference supervisors store the underlying WebhookSender @@ -460,8 +488,6 @@ def validate_webhook_signing_for_capabilities( ) return - from adcp.decisioning.types import AdcpError - if resolved_sender is None: raise AdcpError( "INVALID_REQUEST", diff --git a/tests/test_decisioning_capabilities_projection.py b/tests/test_decisioning_capabilities_projection.py index 65bb7824..85c258de 100644 --- a/tests/test_decisioning_capabilities_projection.py +++ b/tests/test_decisioning_capabilities_projection.py @@ -336,6 +336,35 @@ def get_adcp_capabilities_for_request(self, params=None, context=None): assert exc_info.value.details["missing"] == "webhook_sender_with_rfc9421_key" +def test_request_scoped_webhook_signing_can_be_adopter_managed( + executor: ThreadPoolExecutor, +) -> None: + class _TenantCapabilitiesPlatform(_SalesPlatform): + def get_adcp_capabilities_for_request(self, params=None, context=None): + del params, context + return replace( + self.capabilities, + webhook_signing=WebhookSigning( + supported=True, + profile="adcp/webhook-signing/v1", + algorithms=["ed25519"], + legacy_hmac_fallback=True, + ), + webhook_signing_managed_externally=True, + ) + + handler = _build_handler(_TenantCapabilitiesPlatform(), executor) + + response = asyncio.run(handler.get_adcp_capabilities(context=ToolContext(tenant_id="signed"))) + + assert response["webhook_signing"] == { + "supported": True, + "profile": "adcp/webhook-signing/v1", + "algorithms": ["ed25519"], + "legacy_hmac_fallback": True, + } + + def test_request_scoped_capabilities_are_schema_validated( executor: ThreadPoolExecutor, ) -> None: diff --git a/tests/test_webhook_signing_capabilities.py b/tests/test_webhook_signing_capabilities.py index 205a5cb7..3c42cb91 100644 --- a/tests/test_webhook_signing_capabilities.py +++ b/tests/test_webhook_signing_capabilities.py @@ -150,8 +150,14 @@ class _Caps: ``webhook_signing`` attribute. """ - def __init__(self, webhook_signing: WebhookSigning | None) -> None: + def __init__( + self, + webhook_signing: WebhookSigning | None, + *, + webhook_signing_managed_externally: bool = False, + ) -> None: self.webhook_signing = webhook_signing + self.webhook_signing_managed_externally = webhook_signing_managed_externally def test_boot_passes_when_capabilities_omit_webhook_signing() -> None: @@ -187,6 +193,39 @@ def test_boot_fails_when_signing_advertised_but_no_sender() -> None: assert exc_info.value.details["capabilities_webhook_signing_supported"] is True +def test_boot_passes_when_signing_is_adopter_managed_without_sdk_sender() -> None: + """Adopters with external webhook delivery/signing can advertise the + capability without wiring the SDK sender stack.""" + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning( + supported=True, + profile="adcp/webhook-signing/v1", + algorithms=["ed25519"], + ), + webhook_signing_managed_externally=True, + ), + sender=None, + supervisor=None, + ) + + +def test_adopter_managed_flag_rejects_non_bool_values() -> None: + """Fail closed on mistyped config instead of truthiness-bypassing validation.""" + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning(supported=True), + webhook_signing_managed_externally="false", # type: ignore[arg-type] + ), + sender=None, + supervisor=None, + ) + assert exc_info.value.code == "INVALID_REQUEST" + assert exc_info.value.details["field"] == "webhook_signing_managed_externally" + assert exc_info.value.details["value_type"] == "str" + + def test_boot_fails_when_signing_advertised_with_bearer_sender() -> None: """A non-JWK sender (bearer / HMAC) advertised as RFC 9421 trips the same gate — buyers see the capability but receive unsignable bytes. @@ -202,6 +241,22 @@ def test_boot_fails_when_signing_advertised_with_bearer_sender() -> None: assert exc_info.value.details["sender_auth_mode"] == "BearerTokenStrategy" +def test_adopter_managed_flag_does_not_bypass_wired_sender_validation() -> None: + """If the SDK sender is wired, validate the actual bytes it will emit.""" + sender = WebhookSender.from_bearer_token("test-token") + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning(supported=True), + webhook_signing_managed_externally=True, + ), + sender=sender, + supervisor=None, + ) + assert exc_info.value.code == "INVALID_REQUEST" + assert exc_info.value.details["sender_auth_mode"] == "BearerTokenStrategy" + + def test_boot_fails_when_signing_advertised_with_legacy_hmac_sender() -> None: """3.x's ``legacy_hmac_fallback`` is delivery-axis only — a seller advertising ``webhook_signing.supported=True`` still owes RFC 9421