Skip to content

Commit 04c99e2

Browse files
authored
feat(webhooks): support tenant-scoped signing senders (#1085)
1 parent 4175ed7 commit 04c99e2

18 files changed

Lines changed: 1131 additions & 55 deletions

docs/handler-authoring.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,73 @@ copy of the callback token is cleared in that transaction. Workers use expiring
14461446
leases and exact retries; the 1–7 day horizon begins on the first attempt and
14471447
must exactly match the advertised value.
14481448

1449+
Multi-tenant sellers can resolve a different signing identity for each trusted
1450+
server-side tenant scope. Use `sender_resolver=` on the outbox and pair it with
1451+
`webhook_signing_scope_resolver=` on the registry:
1452+
1453+
```python
1454+
from adcp.decisioning import (
1455+
PgTaskRegistry,
1456+
PgTaskWebhookOutbox,
1457+
ScopePermanentlyUnknown,
1458+
ScopeTransientlyUnavailable,
1459+
WebhookSenderResolution,
1460+
)
1461+
1462+
class TenantWebhookSenders:
1463+
async def resolve(self, signing_scope_id: str):
1464+
credential = await internal_key_store.active_for_scope(signing_scope_id)
1465+
if credential is None:
1466+
raise ScopePermanentlyUnknown
1467+
if credential.rotation_in_progress:
1468+
raise ScopeTransientlyUnavailable
1469+
return WebhookSenderResolution(
1470+
sender=credential.webhook_sender, # cached, lifecycle-managed sender
1471+
# Read from the same trusted record used by request-scoped capabilities.
1472+
advertised_algorithms=frozenset(credential.webhook_signing_algorithms),
1473+
)
1474+
1475+
def trusted_signing_scope(context):
1476+
# Use only internal tenant/platform metadata populated by the server.
1477+
return context.account.metadata.webhook_signing_scope_id
1478+
1479+
outbox = PgTaskWebhookOutbox(
1480+
pool=pool,
1481+
sender_resolver=TenantWebhookSenders(),
1482+
encryption_key=task_webhook_encryption_key,
1483+
delivery_retry_horizon_seconds=86_400,
1484+
)
1485+
registry = PgTaskRegistry(
1486+
pool=pool,
1487+
task_webhook_outbox=outbox,
1488+
webhook_signing_scope_resolver=trusted_signing_scope,
1489+
)
1490+
```
1491+
1492+
The scope is encrypted at task issuance, persisted on the durable outbox row,
1493+
and authenticated as envelope AAD. The worker resolves a fresh sender on every
1494+
attempt, so key rotation changes the signature without changing the stored body
1495+
or idempotency key. `ScopeTransientlyUnavailable` releases the row for retry;
1496+
`ScopePermanentlyUnknown` quarantines it for operator reconciliation. Every
1497+
resolved sender is revalidated as an RFC 9421 sender using an SDK-owned,
1498+
IP-pinned transport with private destinations disabled. Its actual key
1499+
algorithm must also appear in the trusted scope's advertised algorithm set;
1500+
a mismatch is quarantined before any request is sent.
1501+
1502+
The resolver owns sender lifecycle. Return cached senders whose clients are
1503+
closed during application shutdown; do not allocate a new `WebhookSender` (and
1504+
therefore a new connection pool) on every delivery attempt.
1505+
1506+
Never derive the signing scope from `push_notification_config`, the request's
1507+
buyer-supplied `context`, or an unqualified buyer account id. It must be an
1508+
opaque identifier obtained from trusted internal tenant/platform metadata.
1509+
Pass exactly one of `sender=` or `sender_resolver=`; the fixed-sender path and
1510+
existing `NULL signing_scope_id` rows remain backward compatible while that
1511+
fixed-sender mode is retained. Before switching an existing deployment to
1512+
resolver mode, drain or reconcile its pre-migration `NULL` rows: the worker
1513+
cannot safely infer a tenant key for them and will quarantine them rather than
1514+
guess a signing identity.
1515+
14491516
Production adopters may set `auto_emit_task_webhooks=False` only when an external
14501517
durable outbox owns publication, retries, immutable body/key retention, and
14511518
reconciliation. Set `webhook_signing_managed_externally=True` in the corresponding

src/adcp/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,13 +724,17 @@ def _resolve_version() -> str:
724724
"LegacyHmacFallback",
725725
"MemoryBackend",
726726
"PreparedWebhook",
727+
"ScopePermanentlyUnknown",
728+
"ScopeTransientlyUnavailable",
727729
"WebhookChallengeError",
728730
"WebhookChallengeResult",
729731
"WebhookDedupStore",
730732
"WebhookDestinationPolicy",
731733
"WebhookReceiver",
732734
"WebhookReceiverConfig",
733735
"WebhookSender",
736+
"WebhookSenderResolution",
737+
"WebhookSenderResolver",
734738
"WebhookVerifyOptions",
735739
"challenge_webhook_destination",
736740
"create_a2a_webhook_payload",
@@ -968,6 +972,10 @@ def get_adcp_version() -> str:
968972
"WebhookReceiver",
969973
"WebhookReceiverConfig",
970974
"WebhookSender",
975+
"WebhookSenderResolution",
976+
"WebhookSenderResolver",
977+
"ScopePermanentlyUnknown",
978+
"ScopeTransientlyUnavailable",
971979
"WebhookVerifyOptions",
972980
"WebhookDedupStore",
973981
"MemoryBackend",
@@ -2140,13 +2148,17 @@ def get_adcp_version() -> str:
21402148
LegacyHmacFallback,
21412149
MemoryBackend,
21422150
PreparedWebhook,
2151+
ScopePermanentlyUnknown,
2152+
ScopeTransientlyUnavailable,
21432153
WebhookChallengeError,
21442154
WebhookChallengeResult,
21452155
WebhookDedupStore,
21462156
WebhookDestinationPolicy,
21472157
WebhookReceiver,
21482158
WebhookReceiverConfig,
21492159
WebhookSender,
2160+
WebhookSenderResolution,
2161+
WebhookSenderResolver,
21502162
WebhookVerifyOptions,
21512163
challenge_webhook_destination,
21522164
create_a2a_webhook_payload,

src/adcp/decisioning/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ def create_media_buy(
273273
validate_capabilities_response_shape,
274274
validate_capabilities_response_shape_async,
275275
)
276+
from adcp.webhook_sender import (
277+
ScopePermanentlyUnknown,
278+
ScopeTransientlyUnavailable,
279+
WebhookSenderResolution,
280+
WebhookSenderResolver,
281+
)
276282

277283
# Conditional import: PgTaskRegistry needs the [pg] extra. Always expose
278284
# the name — when psycopg isn't installed we fall through to a stub class whose
@@ -289,6 +295,7 @@ def create_media_buy(
289295
PgTaskRegistry,
290296
PgTaskWebhookOutbox,
291297
PostgresTaskRegistry,
298+
WebhookSigningScopeResolver,
292299
)
293300
except ImportError: # pragma: no cover — exercised by the [pg] extra tests
294301
from typing import ClassVar as _ClassVar
@@ -341,6 +348,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
341348
"(Poetry: `poetry add 'adcp[pg]'`)."
342349
)
343350

351+
from adcp.decisioning.pg.task_registry import WebhookSigningScopeResolver
352+
344353

345354
__all__ = [
346355
"Account",
@@ -446,6 +455,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
446455
"SalesResult",
447456
"SalesSpecialism",
448457
"ServiceUnavailableError",
458+
"ScopePermanentlyUnknown",
459+
"ScopeTransientlyUnavailable",
449460
"SignalsPlatform",
450461
"SingletonAccounts",
451462
"SELF_SERVE_UPDATE_ACTION_MODES",
@@ -458,6 +469,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
458469
"TaskHandoffContext",
459470
"TaskRegistry",
460471
"TaskState",
472+
"WebhookSenderResolver",
473+
"WebhookSenderResolution",
474+
"WebhookSigningScopeResolver",
461475
"TranslationMap",
462476
"UNKNOWN_UPDATE_ACTION",
463477
"UnsupportedFeatureError",

src/adcp/decisioning/dispatch.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2092,10 +2092,15 @@ async def _project_handoff(
20922092
and getattr(registry, "task_webhook_outbox", None) is not None
20932093
):
20942094
push_url, push_token = push_target
2095+
signing_scope_id: str | None = None
2096+
signing_scope_resolver = getattr(registry, "resolve_webhook_signing_scope", None)
2097+
if signing_scope_resolver is not None:
2098+
signing_scope_id = await signing_scope_resolver(ctx)
20952099
issue_kwargs.update(
20962100
webhook_url=push_url,
20972101
webhook_operation_id=_extract_push_operation_id(request_params),
20982102
webhook_token=push_token,
2103+
webhook_signing_scope_id=signing_scope_id,
20992104
)
21002105
task_id = await registry.issue(**issue_kwargs)
21012106

@@ -2364,10 +2369,15 @@ async def _project_workflow_handoff(
23642369
and getattr(registry, "task_webhook_outbox", None) is not None
23652370
):
23662371
push_url, push_token = push_target
2372+
signing_scope_id: str | None = None
2373+
signing_scope_resolver = getattr(registry, "resolve_webhook_signing_scope", None)
2374+
if signing_scope_resolver is not None:
2375+
signing_scope_id = await signing_scope_resolver(ctx)
23672376
issue_kwargs.update(
23682377
webhook_url=push_url,
23692378
webhook_operation_id=_extract_push_operation_id(request_params),
23702379
webhook_token=push_token,
2380+
webhook_signing_scope_id=signing_scope_id,
23712381
)
23722382
task_id = await registry.issue(**issue_kwargs)
23732383
handoff_ctx = TaskHandoffContext(id=task_id, _registry=registry)

src/adcp/decisioning/pg/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
PgBuyerAgentRegistry,
3737
)
3838
from adcp.decisioning.pg.proposal_store import PgProposalStore
39-
from adcp.decisioning.pg.task_registry import PgTaskRegistry, PostgresTaskRegistry
39+
from adcp.decisioning.pg.task_registry import (
40+
PgTaskRegistry,
41+
PostgresTaskRegistry,
42+
WebhookSigningScopeResolver,
43+
)
4044
from adcp.decisioning.pg.task_webhook_outbox import PgTaskWebhookOutbox
4145

4246
__all__ = [
@@ -47,4 +51,5 @@
4751
"PgTaskRegistry",
4852
"PgTaskWebhookOutbox",
4953
"PostgresTaskRegistry",
54+
"WebhookSigningScopeResolver",
5055
]

src/adcp/decisioning/pg/task_registry.py

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,20 @@ async def main():
6262

6363
from __future__ import annotations
6464

65+
import inspect
6566
import json
6667
import re
6768
import time
6869
import uuid
69-
from typing import TYPE_CHECKING, Any, ClassVar
70+
from collections.abc import Awaitable, Callable
71+
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias
7072

7173
from adcp.decisioning.account_projection import strip_credentials_from_wire_result
7274

7375
if TYPE_CHECKING:
7476
from psycopg_pool import AsyncConnectionPool
7577

78+
from adcp.decisioning.context import RequestContext
7679
from adcp.decisioning.pg.task_webhook_outbox import PgTaskWebhookOutbox
7780

7881
try:
@@ -95,6 +98,8 @@ async def main():
9598
# the only protection against SQL injection or Unicode homoglyph substitution.
9699
_SAFE_IDENTIFIER_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
97100

101+
WebhookSigningScopeResolver: TypeAlias = Callable[["RequestContext[Any]"], str | Awaitable[str]]
102+
98103

99104
class PgTaskRegistry:
100105
"""PostgreSQL-backed :class:`~adcp.decisioning.TaskRegistry` — v6.1.
@@ -131,6 +136,7 @@ def __init__(
131136
*,
132137
pool: AsyncConnectionPool,
133138
task_webhook_outbox: PgTaskWebhookOutbox | None = None,
139+
webhook_signing_scope_resolver: WebhookSigningScopeResolver | None = None,
134140
_table: str = _DEFAULT_TABLE,
135141
) -> None:
136142
if not PG_AVAILABLE:
@@ -141,9 +147,18 @@ def __init__(
141147
raise ValueError(
142148
"PgTaskRegistry and PgTaskWebhookOutbox must use the same connection pool"
143149
)
150+
uses_sender_resolver = (
151+
task_webhook_outbox is not None and task_webhook_outbox._sender_resolver is not None
152+
)
153+
if uses_sender_resolver != (webhook_signing_scope_resolver is not None):
154+
raise ValueError(
155+
"webhook_signing_scope_resolver is required exactly when the "
156+
"PgTaskWebhookOutbox uses sender_resolver"
157+
)
144158
self._pool = pool
145159
self._table = _table
146160
self.task_webhook_outbox = task_webhook_outbox
161+
self._webhook_signing_scope_resolver = webhook_signing_scope_resolver
147162
self.atomic_task_webhook_outbox = task_webhook_outbox is not None
148163

149164
# Pre-format queries at construction so the hot path avoids f-strings per call.
@@ -247,6 +262,7 @@ async def issue(
247262
webhook_url: str | None = None,
248263
webhook_operation_id: str | None = None,
249264
webhook_token: str | None = None,
265+
webhook_signing_scope_id: str | None = None,
250266
**_extra: Any,
251267
) -> str:
252268
"""Allocate a task_id, persist a ``submitted`` row, return the id.
@@ -269,6 +285,8 @@ async def issue(
269285
raise ValueError("webhook_url must be non-empty when supplied")
270286
if webhook_operation_id is not None and not webhook_operation_id:
271287
raise ValueError("webhook_operation_id must be non-empty when supplied")
288+
if webhook_url is None and webhook_signing_scope_id is not None:
289+
raise ValueError("webhook_signing_scope_id requires webhook_url")
272290
outbox = self.task_webhook_outbox
273291
if webhook_url is not None:
274292
if outbox is None:
@@ -287,6 +305,7 @@ async def issue(
287305
url=webhook_url,
288306
operation_id=webhook_operation_id,
289307
token=webhook_token,
308+
signing_scope_id=webhook_signing_scope_id,
290309
)
291310
now = time.time()
292311
async with self._pool.connection() as conn:
@@ -305,6 +324,46 @@ async def issue(
305324
)
306325
return task_id
307326

327+
async def resolve_webhook_signing_scope(
328+
self,
329+
context: RequestContext[Any],
330+
) -> str | None:
331+
"""Derive an opaque signing scope from trusted framework context.
332+
333+
The callback is operator wiring and receives the hydrated
334+
:class:`RequestContext`. It must use internal tenant/platform metadata,
335+
never buyer request ``context``, ``push_notification_config``, or an
336+
unqualified buyer account id.
337+
"""
338+
resolver = self._webhook_signing_scope_resolver
339+
if resolver is None:
340+
return None
341+
from adcp.decisioning.types import AdcpError
342+
343+
try:
344+
value: object = resolver(context)
345+
if inspect.isawaitable(value):
346+
value = await value
347+
except Exception:
348+
raise AdcpError(
349+
"INTERNAL_ERROR",
350+
message="Webhook signing scope resolution failed",
351+
recovery="terminal",
352+
) from None
353+
if not isinstance(value, str):
354+
raise AdcpError(
355+
"INTERNAL_ERROR",
356+
message="Webhook signing scope resolver returned an invalid value",
357+
recovery="terminal",
358+
)
359+
# Reuse the outbox's bounded opaque-ID validation before any task row
360+
# is issued. This is trusted server state, but it still crosses a DB
361+
# and authenticated-envelope boundary.
362+
if self.task_webhook_outbox is None:
363+
raise RuntimeError("signing scope resolver requires a task webhook outbox")
364+
self.task_webhook_outbox._validate_signing_scope_id(value)
365+
return value
366+
308367
async def update_progress(
309368
self,
310369
task_id: str,
@@ -475,12 +534,14 @@ async def _enqueue_terminal_if_registered(
475534
)
476535
if registration_nonce is None:
477536
raise RuntimeError(f"Task {task_id!r} has incomplete webhook registration")
478-
url, operation_id, token = self.task_webhook_outbox.open_registration(
479-
account_id=account_id,
480-
task_id=task_id,
481-
task_type=task_type,
482-
encrypted_registration=bytes(encrypted_registration),
483-
nonce=bytes(registration_nonce),
537+
url, operation_id, token, signing_scope_id = (
538+
self.task_webhook_outbox._open_registration_with_scope(
539+
account_id=account_id,
540+
task_id=task_id,
541+
task_type=task_type,
542+
encrypted_registration=bytes(encrypted_registration),
543+
nonce=bytes(registration_nonce),
544+
)
484545
)
485546
await self.task_webhook_outbox.enqueue_terminal(
486547
conn,
@@ -492,6 +553,7 @@ async def _enqueue_terminal_if_registered(
492553
url=url,
493554
operation_id=operation_id,
494555
token=token,
556+
signing_scope_id=signing_scope_id,
495557
)
496558
# The encrypted outbox envelope now owns the callback registration.
497559
# Clear the task-row copy in this same transaction.
@@ -515,4 +577,9 @@ async def discard(self, task_id: str) -> None:
515577
PostgresTaskRegistry = PgTaskRegistry
516578

517579

518-
__all__ = ["PG_AVAILABLE", "PgTaskRegistry", "PostgresTaskRegistry"]
580+
__all__ = [
581+
"PG_AVAILABLE",
582+
"PgTaskRegistry",
583+
"PostgresTaskRegistry",
584+
"WebhookSigningScopeResolver",
585+
]

0 commit comments

Comments
 (0)