-
Notifications
You must be signed in to change notification settings - Fork 5
fix(signing): harden trust and request atomicity #999
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,12 +4,11 @@ | |
|
|
||
| 1. Retrieve a cached response by ``(principal_id, idempotency_key)``, honoring | ||
| the seller's replay TTL. | ||
| 2. Atomically commit ``(payload_hash, response)`` on a fresh key. Atomicity | ||
| with the handler's business writes is the backend's choice — | ||
| :class:`MemoryBackend` makes no such guarantee; :class:`PgBackend` shares | ||
| a connection pool so adopters with the same Postgres can compose their | ||
| handler's transaction with the cache write (v1 commits in a separate | ||
| pool connection — co-tx wiring is a v1.1 affordance). | ||
| 2. Hold an execution lock across lookup, handler execution, and cache commit. | ||
| 3. Atomically insert webhook-dedup markers with first-writer-wins semantics. | ||
|
|
||
| The lock prevents concurrent duplicate execution. Atomicity with unrelated | ||
| business writes remains the adopter's responsibility. | ||
|
|
||
| Backends expose async methods. The in-process :class:`MemoryBackend` is | ||
| synchronous under the hood but wrapped in ``async`` signatures so the store | ||
|
|
@@ -22,8 +21,11 @@ | |
| import json | ||
| import re | ||
| import time | ||
| import weakref | ||
| from abc import ABC, abstractmethod | ||
| from collections.abc import Callable | ||
| from collections.abc import AsyncIterator, Callable | ||
| from contextlib import asynccontextmanager | ||
| from contextvars import ContextVar | ||
| from dataclasses import dataclass | ||
| from datetime import datetime, timezone | ||
| from typing import Any | ||
|
|
@@ -83,8 +85,8 @@ class IdempotencyBackend(ABC): | |
| """Abstract storage backend contract. | ||
|
|
||
| All methods are async. Implementations MUST be safe to call concurrently | ||
| from multiple asyncio tasks — :class:`IdempotencyStore` does not serialize | ||
| access on the caller's behalf. | ||
| from multiple asyncio tasks. ``hold`` must coordinate all processes that | ||
| share the backend namespace. | ||
| """ | ||
|
|
||
| @abstractmethod | ||
|
|
@@ -109,6 +111,30 @@ async def put( | |
| or expired, so an overwrite in that window is a legitimate retry of | ||
| the write itself.""" | ||
|
|
||
| def hold(self, scope_key: str, key: str) -> Any: | ||
| """Return an async context manager holding the key's execution lock. | ||
|
|
||
| Custom backends must implement this operation to be usable by | ||
| :class:`IdempotencyStore`. It is concrete only to keep existing | ||
| backend subclasses importable while adopters migrate. | ||
| """ | ||
| raise NotImplementedError( | ||
| f"{type(self).__name__} must implement atomic hold(scope_key, key)" | ||
| ) | ||
|
|
||
| async def put_if_absent(self, scope_key: str, key: str, entry: CachedResponse) -> bool: | ||
| """Atomically insert a fresh/expired slot; return whether it won. | ||
|
|
||
| The default composes ``hold`` with the legacy ``get``/``put`` API, | ||
| allowing custom backends to implement one locking primitive. Backends | ||
| with a native conditional insert should override this method. | ||
| """ | ||
| async with self.hold(scope_key, key): | ||
| if await self.get(scope_key, key) is not None: | ||
| return False | ||
| await self.put(scope_key, key, entry) | ||
| return True | ||
|
|
||
| @abstractmethod | ||
| async def delete_expired(self, now_epoch: float | None = None) -> int: | ||
| """Best-effort sweep of expired entries. Returns the count removed. | ||
|
|
@@ -139,6 +165,9 @@ class MemoryBackend(IdempotencyBackend): | |
| def __init__(self, *, clock: Callable[[], float] = time.time) -> None: | ||
| self._store: dict[tuple[str, str], CachedResponse] = {} | ||
| self._lock = asyncio.Lock() | ||
| self._key_locks: weakref.WeakValueDictionary[tuple[str, str], asyncio.Lock] = ( | ||
| weakref.WeakValueDictionary() | ||
| ) | ||
| self._clock = clock | ||
|
|
||
| async def get(self, scope_key: str, key: str) -> CachedResponse | None: | ||
|
|
@@ -162,6 +191,28 @@ async def put( | |
| async with self._lock: | ||
| self._store[(scope_key, key)] = entry | ||
|
|
||
| @asynccontextmanager | ||
| async def hold(self, scope_key: str, key: str) -> AsyncIterator[None]: | ||
| """Serialize one idempotent handler execution in this process.""" | ||
| slot = (scope_key, key) | ||
| async with self._lock: | ||
| key_lock = self._key_locks.get(slot) | ||
| if key_lock is None: | ||
| key_lock = asyncio.Lock() | ||
| self._key_locks[slot] = key_lock | ||
| async with key_lock: | ||
| yield | ||
|
|
||
| async def put_if_absent(self, scope_key: str, key: str, entry: CachedResponse) -> bool: | ||
| """Atomically claim a missing or expired slot.""" | ||
| slot = (scope_key, key) | ||
| async with self._lock: | ||
| existing = self._store.get(slot) | ||
| if existing is not None and existing.expires_at_epoch > self._clock(): | ||
| return False | ||
| self._store[slot] = entry | ||
| return True | ||
|
|
||
| async def delete_expired(self, now_epoch: float | None = None) -> int: | ||
| cutoff = now_epoch if now_epoch is not None else self._clock() | ||
| async with self._lock: | ||
|
|
@@ -198,15 +249,17 @@ class PgBackend(IdempotencyBackend): | |
| from adcp.server.idempotency import IdempotencyStore, PgBackend | ||
|
|
||
| pool = AsyncConnectionPool("postgresql://...", min_size=2, max_size=10) | ||
| backend = PgBackend(pool=pool) | ||
| lock_pool = AsyncConnectionPool("postgresql://...", min_size=2, max_size=10) | ||
| backend = PgBackend(pool=pool, lock_pool=lock_pool) | ||
| await backend.create_schema() # idempotent; safe to call on every boot | ||
|
|
||
| store = IdempotencyStore(backend=backend, ttl_seconds=86400) | ||
|
|
||
| **Atomicity caveat (v1).** ``put`` commits on a fresh pool connection — | ||
| the cache write is NOT in the same transaction as the handler's | ||
| business writes. A crash between handler success and cache commit | ||
| leaves the slot empty; the next retry re-executes the handler. | ||
| **Atomicity caveat.** The backend holds a Postgres advisory lock and writes | ||
| the cache in its transaction, but the cache write is NOT automatically in | ||
| the same transaction as the handler's unrelated business writes. A crash | ||
| after an external side effect but before cache commit can still leave the | ||
| slot empty; the next retry re-executes the handler. | ||
| Idempotent handlers absorb this without harm. **Handlers with | ||
| non-idempotent side effects** (e.g., ``INSERT INTO media_buys`` | ||
| without a unique constraint on the buyer's idempotency_key) need | ||
|
|
@@ -261,6 +314,10 @@ class PgBackend(IdempotencyBackend): | |
| :param pool: ``psycopg_pool.AsyncConnectionPool`` owned by the caller. | ||
| Each operation acquires a short-lived connection. We don't open, | ||
| own, or close the pool. | ||
| :param lock_pool: A distinct caller-owned pool reserved for advisory-lock | ||
| transactions. It MUST NOT be the business/cache ``pool``: ``hold`` | ||
| keeps one connection checked out while adopter code runs, and sharing | ||
| that pool with handler SQL can deadlock under saturation. | ||
| :param table_name: Override the default table name. Useful for | ||
| multi-tenant schema scoping. Default ``adcp_idempotency``. | ||
|
|
||
|
|
@@ -274,12 +331,19 @@ def __init__( | |
| self, | ||
| *, | ||
| pool: Any, # psycopg_pool.AsyncConnectionPool — Any avoids runtime psycopg import | ||
| lock_pool: Any, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| table_name: str = DEFAULT_IDEMPOTENCY_TABLE, | ||
| ) -> None: | ||
| if not _PG_AVAILABLE: | ||
| raise ImportError(_PG_INSTALL_HINT) | ||
| if lock_pool is pool: | ||
| raise ValueError("lock_pool must be distinct from pool to prevent handler deadlocks") | ||
| self._pool = pool | ||
| self._lock_pool = lock_pool | ||
| self._table = _safe_identifier(table_name) | ||
| self._active_connection: ContextVar[tuple[Any, asyncio.Task[Any] | None] | None] = ( | ||
| ContextVar(f"adcp_idempotency_connection_{id(self)}", default=None) | ||
| ) | ||
|
|
||
| # Pre-format SQL once. Validated identifier so f-string interpolation | ||
| # is byte-safe; values always go through %s parameterization. Same | ||
|
|
@@ -309,6 +373,16 @@ def __init__( | |
| f"WHERE {t}.expires_at <= now()" | ||
| ) | ||
| self._sql_delete_expired = f"DELETE FROM {t} WHERE expires_at <= %s" # noqa: S608 | ||
| self._sql_lock = "SELECT pg_advisory_xact_lock(hashtextextended(%s, 6217))" | ||
| self._sql_put_if_absent = ( | ||
| f"INSERT INTO {t} " # noqa: S608 | ||
| f"(scope_key, key, payload_hash, response, expires_at) " | ||
| f"VALUES (%s, %s, %s, %s::jsonb, %s) " | ||
| f"ON CONFLICT (scope_key, key) DO UPDATE SET " | ||
| f" payload_hash = EXCLUDED.payload_hash, response = EXCLUDED.response, " | ||
| f" expires_at = EXCLUDED.expires_at " | ||
| f"WHERE {t}.expires_at <= now() RETURNING 1" | ||
| ) | ||
|
|
||
| async def create_schema(self) -> None: | ||
| """Bootstrap the table + index. Idempotent. | ||
|
|
@@ -341,17 +415,25 @@ async def get(self, scope_key: str, key: str) -> CachedResponse | None: | |
| sweeps them. ``get`` self-filters via ``expires_at > now()`` so a | ||
| stale row never replays. | ||
| """ | ||
| active = self._active_connection.get() | ||
| if active is not None and active[1] is asyncio.current_task(): | ||
| return await self._get_on_connection(active[0], scope_key, key) | ||
| async with self._pool.connection() as conn: | ||
| cur = await conn.execute(self._sql_get, (scope_key, key)) | ||
| row = await cur.fetchone() | ||
| if row is None: | ||
| return None | ||
| payload_hash, response, expires_at = row | ||
| return CachedResponse( | ||
| payload_hash=payload_hash, | ||
| response=response if isinstance(response, dict) else json.loads(response), | ||
| expires_at_epoch=_to_epoch(expires_at), | ||
| ) | ||
| return await self._get_on_connection(conn, scope_key, key) | ||
|
|
||
| async def _get_on_connection( | ||
| self, conn: Any, scope_key: str, key: str | ||
| ) -> CachedResponse | None: | ||
| cur = await conn.execute(self._sql_get, (scope_key, key)) | ||
| row = await cur.fetchone() | ||
| if row is None: | ||
| return None | ||
| payload_hash, response, expires_at = row | ||
| return CachedResponse( | ||
| payload_hash=payload_hash, | ||
| response=response if isinstance(response, dict) else json.loads(response), | ||
| expires_at_epoch=_to_epoch(expires_at), | ||
| ) | ||
|
|
||
| async def put( | ||
| self, | ||
|
|
@@ -366,9 +448,42 @@ async def put( | |
| that window is a legitimate retry of the write itself. | ||
| """ | ||
| expires_at_dt = datetime.fromtimestamp(entry.expires_at_epoch, tz=timezone.utc) | ||
| params = ( | ||
| scope_key, | ||
| key, | ||
| entry.payload_hash, | ||
| json.dumps(entry.response), | ||
| expires_at_dt, | ||
| ) | ||
| active = self._active_connection.get() | ||
| if active is not None and active[1] is asyncio.current_task(): | ||
| await active[0].execute(self._sql_put, params) | ||
| return | ||
| async with self._pool.connection() as conn: | ||
| await conn.execute(self._sql_put, params) | ||
|
|
||
| @asynccontextmanager | ||
| async def hold(self, scope_key: str, key: str) -> AsyncIterator[None]: | ||
| """Hold a cross-process transaction advisory lock for this key. | ||
|
|
||
| The same pooled connection remains checked out while the handler runs; | ||
| nested ``get``/``put`` calls reuse it via a context-local binding. | ||
| """ | ||
| lock_identity = json.dumps([scope_key, key], separators=(",", ":")) | ||
| async with self._lock_pool.connection() as conn, conn.transaction(): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This holds a Three further mismatches. Root cause: |
||
| await conn.execute(self._sql_lock, (lock_identity,)) | ||
| token = self._active_connection.set((conn, asyncio.current_task())) | ||
| try: | ||
| yield | ||
| finally: | ||
| self._active_connection.reset(token) | ||
|
|
||
| async def put_if_absent(self, scope_key: str, key: str, entry: CachedResponse) -> bool: | ||
| """Atomically insert a webhook dedup marker, including stale replace.""" | ||
| expires_at_dt = datetime.fromtimestamp(entry.expires_at_epoch, tz=timezone.utc) | ||
| async with self._pool.connection() as conn: | ||
| await conn.execute( | ||
| self._sql_put, | ||
| cur = await conn.execute( | ||
| self._sql_put_if_absent, | ||
| ( | ||
| scope_key, | ||
| key, | ||
|
|
@@ -377,6 +492,7 @@ async def put( | |
| expires_at_dt, | ||
| ), | ||
| ) | ||
| return await cur.fetchone() is not None | ||
|
|
||
| async def delete_expired(self, now_epoch: float | None = None) -> int: | ||
| """Best-effort sweep of expired entries. Returns rows removed.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The normative
format_schemafetch contract (docs/creative/canonical-formats.mdx:217-231@ 3.1.8) enumerates its bounds —$refdepth ≤ 8, total$refcount ≤ 256, compiled keyword count ≤ 10 000, 1 MiB streaming cap, ≤ 5 s timeout — and has no$idbound. A publisher schema with 200$refs and 40$ids satisfies every listed bound and is nowINVALID_SCHEMA, which per:230means "same as digest mismatch (unresolvable, surface viaerrors[], skip)" — buyer-visible loss of the product declaration, and two conformant SDKs disagreeing on the same digest-pinned document.The DoS motivation is real:
_validate_schema_id_valuecalls_resolve_public_httpsper$id. But$idmust already be same-origin or the AAO catalog, so a document reaches at most two distinct hosts — memoize the host resolution per document rather than lowering the acceptance bar, or raise the default to the spec's own 256. Citecanonical-formats.mdx §format_schema fetch contract @ 3.1.8next to the constant.Also note this addition breaks the ASCII ordering of the
__all__list it joins at:724, which was sorted before this diff.