Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/decisioning-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/adcp/decisioning/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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.
Expand Down
30 changes: 28 additions & 2 deletions src/adcp/decisioning/webhook_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions tests/test_decisioning_capabilities_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 56 additions & 1 deletion tests/test_webhook_signing_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading