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
23 changes: 23 additions & 0 deletions MIGRATION_v7_to_v8.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 14 additions & 2 deletions src/adcp/decisioning/pg/task_webhook_outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import asyncio
import inspect
import json
import logging
import os
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions tests/test_task_webhook_outbox_pg.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import json
from functools import wraps
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
Loading