From 8a8d98dc5d59a4aa9b2279cdd19b0bccd47485f4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 25 Aug 2026 13:57:00 +0100 Subject: [PATCH] fix(webhooks): validate outbox worker configuration --- MIGRATION_v7_to_v8.md | 23 +++++++++++ docs/handler-authoring.md | 2 + .../decisioning/pg/task_webhook_outbox.py | 16 +++++++- tests/test_task_webhook_outbox_pg.py | 40 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/MIGRATION_v7_to_v8.md b/MIGRATION_v7_to_v8.md index ab59ee405..0c4309317 100644 --- a/MIGRATION_v7_to_v8.md +++ b/MIGRATION_v7_to_v8.md @@ -83,6 +83,29 @@ empty. The external publisher must provide the same atomic state/outbox, retention, exact-retry, and reconciliation guarantees. Sellers without either publisher must stop advertising task webhooks and operate polling-only. +## 8.0.0-beta.8 to beta.9: tenant-scoped webhook signing + +Beta.9 adds the nullable `signing_scope_id` column to the PostgreSQL task +webhook outbox. Run `await outbox.create_schema()` during deployment before +starting beta.9 application or worker processes. + +The fixed `sender=` configuration remains the single-tenant default and must +use an RFC 9421 `WebhookSender` with the SDK-owned IP-pinned transport. For +multi-tenant signing, configure both a `sender_resolver=` on +`PgTaskWebhookOutbox` and a `webhook_signing_scope_resolver=` on +`PgTaskRegistry`; see the +[multi-tenant signing example](docs/handler-authoring.md#multi-tenant-webhook-signing). +The sender resolver must implement `async resolve(signing_scope_id)`. + +Rows created by fixed-sender deployments have a NULL signing scope. Do not run +fixed- and resolver-mode issuers or workers against the same outbox table at +the same time. Before switching modes, drain or explicitly reconcile every +undelivered `pending` or `in_flight` NULL-scope row, stop all fixed-mode issuers +and workers, and only then start resolver-mode issuers and workers. A mixed +rolling deployment can quarantine rows: fixed workers reject scoped rows, and +resolver workers reject NULL-scope rows rather than guessing which tenant key +should sign them. + ## Brand identity imports The generated `adcp.types.generated_poc.brand.Brand` path was private and is diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index d975f79d8..7f3088dce 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -1446,6 +1446,8 @@ copy of the callback token is cleared in that transaction. Workers use expiring leases and exact retries; the 1–7 day horizon begins on the first attempt and must exactly match the advertised value. +### Multi-tenant webhook signing + Multi-tenant sellers can resolve a different signing identity for each trusted server-side tenant scope. Use `sender_resolver=` on the outbox and pair it with `webhook_signing_scope_resolver=` on the registry: diff --git a/src/adcp/decisioning/pg/task_webhook_outbox.py b/src/adcp/decisioning/pg/task_webhook_outbox.py index b4d765309..f7f8be51f 100644 --- a/src/adcp/decisioning/pg/task_webhook_outbox.py +++ b/src/adcp/decisioning/pg/task_webhook_outbox.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import inspect import json import logging import os @@ -87,6 +88,17 @@ def __init__( raise ImportError(_INSTALL_HINT) if (sender is None) == (sender_resolver is None): raise ValueError("pass exactly one of sender or sender_resolver") + if sender_resolver is not None: + resolver_method = getattr(sender_resolver, "resolve", None) + if not callable(resolver_method): + is_async_resolver = False + else: + try: + is_async_resolver = inspect.iscoroutinefunction(inspect.unwrap(resolver_method)) + except ValueError: + is_async_resolver = False + if not is_async_resolver: + raise ValueError("sender_resolver must define async resolve(signing_scope_id)") if len(encryption_key) != 32: raise ValueError("encryption_key must be exactly 32 bytes for AES-256-GCM") if sender is not None: @@ -98,8 +110,8 @@ def __init__( "delivery_retry_horizon_seconds must be an integer from " f"{MIN_RETRY_HORIZON_SECONDS} through {MAX_RETRY_HORIZON_SECONDS}" ) - if type(lease_seconds) is not int or lease_seconds <= 0: - raise ValueError("lease_seconds must be a positive integer") + if type(lease_seconds) is not int or lease_seconds <= 1: + raise ValueError("lease_seconds must be an integer greater than 1") sender_timeout = float(getattr(sender, "_timeout", 0.0)) if sender is not None else 0.0 if sender is not None and lease_seconds < sender_timeout + 5: raise ValueError( diff --git a/tests/test_task_webhook_outbox_pg.py b/tests/test_task_webhook_outbox_pg.py index 4ce7437c3..2503eca95 100644 --- a/tests/test_task_webhook_outbox_pg.py +++ b/tests/test_task_webhook_outbox_pg.py @@ -4,6 +4,7 @@ import asyncio import json +from functools import wraps from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -194,6 +195,14 @@ def test_outbox_rejects_sender_without_sdk_pinned_transport() -> None: ) +def test_outbox_rejects_sender_without_rfc9421_signing() -> None: + sender = _sender() + sender.signs_with_rfc9421 = False + + with pytest.raises(ValueError, match="RFC 9421"): + _outbox(MagicMock(), sender) + + def test_outbox_requires_exactly_one_sender_mode() -> None: from adcp.decisioning.pg.task_webhook_outbox import PgTaskWebhookOutbox @@ -214,6 +223,37 @@ def test_outbox_requires_exactly_one_sender_mode() -> None: ) +@pytest.mark.parametrize( + "resolver", + [object(), MagicMock(resolve=MagicMock())], +) +def test_outbox_requires_an_async_sender_resolver(resolver: Any) -> None: + with pytest.raises(ValueError, match="must define async resolve"): + _resolver_outbox(MagicMock(), resolver) + + +def test_outbox_accepts_a_wrapped_async_sender_resolver() -> None: + async def resolve(_scope: str) -> WebhookSenderResolution: + return _resolution(_sender()) + + @wraps(resolve) + def instrumented_resolve(scope: str) -> Any: + return resolve(scope) + + resolver = MagicMock(resolve=instrumented_resolve) + + assert _resolver_outbox(MagicMock(), resolver)._sender_resolver is resolver + + +def test_outbox_requires_a_nonzero_delivery_lease_budget() -> None: + resolver = MagicMock(resolve=AsyncMock()) + + with pytest.raises(ValueError, match="greater than 1"): + _resolver_outbox(MagicMock(), resolver, lease_seconds=1) + + assert _resolver_outbox(MagicMock(), resolver, lease_seconds=2)._lease_seconds == 2 + + def test_scoped_registration_is_encrypted_and_mode_bound() -> None: resolver = MagicMock(resolve=AsyncMock()) outbox = _resolver_outbox(MagicMock(), resolver)