Module adcp.audit_sink
Audit-event observability seam paralleling :class:~adcp.webhook_supervisor.DeliveryLogSink.
:class:~adcp.webhook_supervisor.DeliveryLogSink records delivery
attempts (one per webhook send). This module records skill dispatches
(one per tool/A2A call) — the seller-facing observability trail that
activity feeds, internal dashboards, and SecOps alert channels read.
Two seams:
-
:class:
AuditSink— Protocol. Adopters implement this against their durable store of choice (audit_logtable, S3, Splunk HEC, etc.). -
:class:
LoggingAuditSinkand :class:SlackAlertSink— reference implementations sufficient for development, single-instance servers, or as templates for a custom adopter sink.
Wire one or more sinks into the :data:~adcp.server.SkillMiddleware
chain via :func:make_audit_middleware(). Put audit middleware
outermost in the chain (see SkillMiddleware composition docs)
so rate-limited or feature-gated calls still appear in the trail.
Integrity contract — observability, NOT compliance-of-record
Each sink call is bounded by sink_timeout_seconds and wrapped in a
log-and-swallow try: a misbehaving sink (DB stall, Slack outage,
DNS failure) cannot wedge the dispatch hot path. This matches the
:class:~adcp.webhook_supervisor.DeliveryLogSink trade-off the design
explicitly parallels.
This module is NOT sufficient for any of the following regimes — all of which require non-repudiable, write-ahead, durable records that a best-effort, in-process, drop-on-failure sink fundamentally cannot provide:
- SOX (financial controls on media buys) — requires tamper-evident records committed in the same transaction as the financial mutation.
- GDPR Article 30 (records of processing activities) — requires durable records produce-able to a regulator on demand.
- IAB TCF (consent decisions for ad targeting) — requires consent decisions cryptographically tied to the consent string at storage.
Adopters who need any of the above implement :class:AuditSink
directly against a transactional store (write the audit row in the
same DB transaction as the mutation it audits) and skip
:func:make_audit_middleware() for those code paths. The reference impls
in this module are for SecOps observability — the operator-facing "what
just happened" stream — not the system-of-record auditors will pull on.
Security Notes
-
:class:
SlackAlertSinkroutes outbound HTTP through :func:~adcp.signing.ip_pinned_transport.build_async_ip_pinned_transport— the same SSRF-hardened transport every other outbound call in this SDK uses.trust_env=FalseblocksHTTPS_PROXYexfiltration. -
:class:
SlackAlertSinkfilters :attr:AuditEvent.detailsthrough an explicitallowed_fieldsallowlist — defaultfrozenset(), meaning nodetailscontent reaches Slack unless the adopter opts specific keys in. Prevents accidental egress of financial fields, PII, or buyer-supplied free text into a third-party chat surface. -
:class:
SlackAlertSink.__repr__redacts the webhook URL (it's a bearer token); the URL never appears in tracebacks orlogger.exceptionoutput. -
The middleware does NOT capture
paramsor response bodies into :attr:AuditEvent.details. Adopters who need richer audit content build their own middleware that constructs :class:AuditEventwith whitelisted fields fromparams— this module deliberately keeps the default surface narrow.
Functions
def make_audit_middleware(sinks: Sequence[AuditSink],
*,
sink_timeout_seconds: float = 5.0) ‑> SkillMiddleware-
Expand source code
def make_audit_middleware( sinks: Sequence[AuditSink], *, sink_timeout_seconds: float = 5.0, ) -> SkillMiddleware: """Compose one or more :class:`AuditSink` instances into a :data:`~adcp.server.SkillMiddleware`. The middleware records one :class:`AuditEvent` per dispatch: * On success: ``success=True``, no error fields. * On exception: ``success=False`` with ``error_type`` / ``error_message`` populated, then re-raises so the executor's normal error path runs unchanged. Each sink is bounded by ``sink_timeout_seconds`` and wrapped in log-and-swallow — a sink timeout or raise NEVER affects dispatch. Composition reminder (see :data:`~adcp.server.SkillMiddleware` docstring): put the audit middleware **outermost** in your middleware list. Middleware that short-circuits (rate limiter, feature-gate) never calls ``call_next()``; if audit sits inside the short-circuit, rejected calls disappear from the audit trail — often the most interesting events for security review. :param sinks: Sinks to dispatch every event to, in order. Empty sequence is allowed (audit middleware becomes a no-op passthrough — useful when the seller has audit toggled off but wants to keep the middleware list shape stable across configs). :param sink_timeout_seconds: Per-sink timeout ceiling. Default 5s matches :class:`~adcp.webhook_supervisor.RetryPolicy`. Example:: from adcp.audit_sink import ( LoggingAuditSink, SlackAlertSink, make_audit_middleware, ) audit = make_audit_middleware([ LoggingAuditSink(), SlackAlertSink( webhook_url=os.environ["SLACK_ALERT_WEBHOOK"], sensitive_operations=frozenset({ "create_media_buy", "acquire_rights", "update_rights", }), ), ]) create_mcp_server(MyAgent(), middleware=[audit, rate_limit, metrics]) """ sinks_tuple = tuple(sinks) async def audit_middleware( skill_name: str, params: dict[str, Any], context: ToolContext, call_next: Callable[[], Awaitable[Any]], ) -> Any: try: result = await call_next() except Exception as exc: await _emit( sinks_tuple, AuditEvent( operation=skill_name, success=False, occurred_at=datetime.now(UTC), caller_identity=context.caller_identity, tenant_id=context.tenant_id, request_id=context.request_id, error_type=type(exc).__name__, error_message=str(exc)[:200], ), timeout_seconds=sink_timeout_seconds, ) raise await _emit( sinks_tuple, AuditEvent( operation=skill_name, success=True, occurred_at=datetime.now(UTC), caller_identity=context.caller_identity, tenant_id=context.tenant_id, request_id=context.request_id, ), timeout_seconds=sink_timeout_seconds, ) return result return audit_middlewareCompose one or more :class:
AuditSinkinstances into a :data:~adcp.server.SkillMiddleware.The middleware records one :class:
AuditEventper dispatch:- On success:
success=True, no error fields. - On exception:
success=Falsewitherror_type/error_messagepopulated, then re-raises so the executor's normal error path runs unchanged.
Each sink is bounded by
sink_timeout_secondsand wrapped in log-and-swallow — a sink timeout or raise NEVER affects dispatch.Composition reminder (see :data:
~adcp.server.SkillMiddlewaredocstring): put the audit middleware outermost in your middleware list. Middleware that short-circuits (rate limiter, feature-gate) never callscall_next(); if audit sits inside the short-circuit, rejected calls disappear from the audit trail — often the most interesting events for security review.:param sinks: Sinks to dispatch every event to, in order. Empty sequence is allowed (audit middleware becomes a no-op passthrough — useful when the seller has audit toggled off but wants to keep the middleware list shape stable across configs). :param sink_timeout_seconds: Per-sink timeout ceiling. Default 5s matches :class:
~adcp.webhook_supervisor.RetryPolicy.Example::
from adcp.audit_sink import ( LoggingAuditSink, SlackAlertSink, make_audit_middleware, ) audit = make_audit_middleware([ LoggingAuditSink(), SlackAlertSink( webhook_url=os.environ["SLACK_ALERT_WEBHOOK"], sensitive_operations=frozenset({ "create_media_buy", "acquire_rights", "update_rights", }), ), ]) create_mcp_server(MyAgent(), middleware=[audit, rate_limit, metrics]) - On success:
Classes
class AuditEvent (**data: Any)-
Expand source code
class AuditEvent(BaseModel): """One audit record per skill dispatch. Field naming aligns with :class:`~adcp.server.ToolContext` exactly (``caller_identity``, ``tenant_id``, ``request_id``) so the middleware constructing events copies fields without renaming. A distinct ``principal_id`` would force every adopter call site to translate. Frozen Pydantic model: every other event-shaped type in this SDK is Pydantic, so adopters get ``.model_dump(mode="json")`` for free-on-the-wire serialization, datetime validation at construction (no naive datetimes leaking into a sink), and consistency with the rest of the SDK's typed surface. :param operation: The skill name (``"create_media_buy"``, ``"acquire_rights"``) for middleware-emitted events. Adopters constructing events directly may use a coarser action label. :param success: Whether the operation completed without raising. Failure events carry ``error_type`` / ``error_message``. :param occurred_at: When the event was recorded. Timezone-aware UTC. :param caller_identity: From :attr:`ToolContext.caller_identity`. :param tenant_id: From :attr:`ToolContext.tenant_id`. :param request_id: From :attr:`ToolContext.request_id`. :param error_type: Exception class name on failure (``"IdempotencyConflictError"``). ``None`` on success. :param error_message: First 200 chars of ``str(exc)`` on failure. Truncated to bound log size; sinks that need full traces should capture them out-of-band (this field is meant for Slack-style summaries). :param details: Adopter-defined free-form fields. The middleware emits ``{}`` by default; adopters constructing events directly populate this with whatever their store needs. **Treat as potentially sensitive** — :class:`SlackAlertSink` filters through an explicit allowlist before egress. """ model_config = ConfigDict(frozen=True, extra="forbid") operation: str success: bool occurred_at: datetime caller_identity: str | None = None tenant_id: str | None = None request_id: str | None = None error_type: str | None = None error_message: str | None = None details: Mapping[str, Any] = Field(default_factory=dict)One audit record per skill dispatch.
Field naming aligns with :class:
~adcp.server.ToolContextexactly (caller_identity,tenant_id,request_id) so the middleware constructing events copies fields without renaming. A distinctprincipal_idwould force every adopter call site to translate.Frozen Pydantic model: every other event-shaped type in this SDK is Pydantic, so adopters get
.model_dump(mode="json")for free-on-the-wire serialization, datetime validation at construction (no naive datetimes leaking into a sink), and consistency with the rest of the SDK's typed surface.:param operation: The skill name (
"create_media_buy","acquire_rights") for middleware-emitted events. Adopters constructing events directly may use a coarser action label. :param success: Whether the operation completed without raising. Failure events carryerror_type/error_message. :param occurred_at: When the event was recorded. Timezone-aware UTC. :param caller_identity: From :attr:ToolContext.caller_identity. :param tenant_id: From :attr:ToolContext.tenant_id. :param request_id: From :attr:ToolContext.request_id. :param error_type: Exception class name on failure ("IdempotencyConflictError").Noneon success. :param error_message: First 200 chars ofstr(exc)on failure. Truncated to bound log size; sinks that need full traces should capture them out-of-band (this field is meant for Slack-style summaries). :param details: Adopter-defined free-form fields. The middleware emits{}by default; adopters constructing events directly populate this with whatever their store needs. Treat as potentially sensitive — :class:SlackAlertSinkfilters through an explicit allowlist before egress.Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var caller_identity : str | Nonevar details : Mapping[str, typing.Any]var error_message : str | Nonevar error_type : str | Nonevar model_configvar occurred_at : datetime.datetimevar operation : strvar request_id : str | Nonevar success : boolvar tenant_id : str | None
class AuditSink (*args, **kwargs)-
Expand source code
@runtime_checkable class AuditSink(Protocol): """Persistence hook for :class:`AuditEvent` records. Called once per dispatch (success or failure) when wired via :func:`make_audit_middleware`. Implementations MUST be safe to call concurrently — the middleware composes with other middleware and sees concurrent skill calls under load. Failures inside ``record`` are bounded and swallowed by the middleware (see module docstring). Implementations should still avoid raising for transient conditions — log and return. """ async def record(self, event: AuditEvent) -> None: ...Persistence hook for :class:
AuditEventrecords.Called once per dispatch (success or failure) when wired via :func:
make_audit_middleware(). Implementations MUST be safe to call concurrently — the middleware composes with other middleware and sees concurrent skill calls under load.Failures inside
recordare bounded and swallowed by the middleware (see module docstring). Implementations should still avoid raising for transient conditions — log and return.Ancestors
- typing.Protocol
- typing.Generic
Methods
async def record(self,
event: AuditEvent) ‑> None-
Expand source code
async def record(self, event: AuditEvent) -> None: ...
class LoggingAuditSink (*, logger_: logging.Logger | None = None, level: int = 20)-
Expand source code
class LoggingAuditSink: """Writes :class:`AuditEvent` as structured JSON via stdlib :mod:`logging`. Sufficient for development and single-instance servers whose log aggregation pipeline already ingests ``logger.info`` output (Vector, Fluent Bit, journald → Loki, etc.). Adopters with a dedicated audit table implement :class:`AuditSink` directly. All :attr:`AuditEvent.details` keys are emitted verbatim — this sink runs in-process under the adopter's control and there's no third party to exfiltrate to. Contrast with :class:`SlackAuditSink` which requires explicit allowlisting. """ def __init__( self, *, logger_: logging.Logger | None = None, level: int = logging.INFO, ) -> None: self._logger = logger_ or logging.getLogger("adcp.audit") self._level = level async def record(self, event: AuditEvent) -> None: self._logger.log( self._level, event.model_dump_json(), )Writes :class:
AuditEventas structured JSON via stdlib :mod:logging.Sufficient for development and single-instance servers whose log aggregation pipeline already ingests
logger.infooutput (Vector, Fluent Bit, journald → Loki, etc.). Adopters with a dedicated audit table implement :class:AuditSinkdirectly.All :attr:
AuditEvent.detailskeys are emitted verbatim — this sink runs in-process under the adopter's control and there's no third party to exfiltrate to. Contrast with :class:SlackAuditSinkwhich requires explicit allowlisting.Methods
async def record(self,
event: AuditEvent) ‑> None-
Expand source code
async def record(self, event: AuditEvent) -> None: self._logger.log( self._level, event.model_dump_json(), )
class SlackAlertSink (webhook_url: str,
*,
sensitive_operations: frozenset[str] | None = None,
allowed_fields: frozenset[str] = frozenset(),
timeout_seconds: float = 5.0,
allow_private_destinations: bool = False,
allowed_destination_ports: frozenset[int] | None = None)-
Expand source code
class SlackAlertSink: """Posts :class:`AuditEvent` to a Slack incoming-webhook URL on a sensitive subset of events — operator alerting, NOT audit-of-record. Slack is universally used in ad-tech ops as an alerting channel for SecOps (failed-auth bursts, large media buys, contract-tier overages) — never as the system of record for compliance audit. This sink is named for that role: it implements the :class:`AuditSink` Protocol so it composes through :func:`make_audit_middleware`, but the intent is "tee a high-signal subset to a chat channel," not "satisfy the auditor." Pair with :class:`LoggingAuditSink` (or a custom sink writing to your audit table) for the durable trail; route only the sensitive subset through here via ``sensitive_operations``. :param webhook_url: Slack incoming-webhook URL. MUST be HTTPS. The URL is a bearer token; it appears redacted in :meth:`__repr__`. :param sensitive_operations: Optional whitelist of operations that trigger a Slack post. ``None`` means post every event (rarely what you want — Slack will rate-limit). Pass e.g. ``frozenset({"create_media_buy", "acquire_rights", "update_rights"})`` to post only mutations. :param allowed_fields: Allowlist of :attr:`AuditEvent.details` keys that may be included in the Slack message body. Default ``frozenset()`` — no ``details`` content is posted, only the operation/identity/error summary. Prevents accidental egress of financial fields (budgets, credit limits), PII (contact info), or buyer-supplied free text. :param timeout_seconds: Per-call HTTP timeout. The middleware also applies its own ``sink_timeout_seconds`` ceiling; the tighter of the two governs. :param allow_private_destinations: Pass-through to the IP-pinned transport. Default ``False`` (block private/loopback IPs). Override only for testing against a local stub. :param allowed_destination_ports: Pass-through to the IP-pinned transport. Default ``None`` (allow 443 only). Slack's webhook host serves on 443; overriding is rarely correct. """ def __init__( self, webhook_url: str, *, sensitive_operations: frozenset[str] | None = None, allowed_fields: frozenset[str] = frozenset(), timeout_seconds: float = 5.0, allow_private_destinations: bool = False, allowed_destination_ports: frozenset[int] | None = None, ) -> None: if not webhook_url.startswith("https://"): raise ValueError( "SlackAlertSink: webhook_url must be HTTPS — Slack rejects " "plaintext webhooks and the URL is a bearer token." ) self._webhook_url = webhook_url self._sensitive_operations = sensitive_operations self._allowed_fields = allowed_fields self._timeout = timeout_seconds self._allow_private = allow_private_destinations self._allowed_ports = allowed_destination_ports def __repr__(self) -> str: # Explicit repr so the webhook URL (a bearer token) never reaches # tracebacks, ``logger.exception`` output, or auto-rendered # ``__dict__`` dumps. Mirrors WebhookSender.__repr__. ops_repr = "all" if self._sensitive_operations is None else len(self._sensitive_operations) return f"SlackAlertSink(sensitive_operations={ops_repr})" async def record(self, event: AuditEvent) -> None: if ( self._sensitive_operations is not None and event.operation not in self._sensitive_operations ): return # Lazy import: keep the audit_sink module importable in # environments that don't have httpx wired (e.g., type-only # consumers). The httpx + transport import only runs when an # adopter actually constructs a SlackAuditSink and emits. import httpx from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport transport = build_async_ip_pinned_transport( self._webhook_url, allow_private=self._allow_private, allowed_ports=self._allowed_ports, ) payload = {"text": self._format(event)} async with httpx.AsyncClient( transport=transport, timeout=self._timeout, follow_redirects=False, trust_env=False, ) as client: response = await client.post(self._webhook_url, json=payload) # Slack returns 200 with body ``"ok"`` on success. Non-2xx # raises so the middleware's swallow path logs a warning — # Slack rate-limit (429) and webhook-revoked (404) need to # surface in operator logs even if they can't fail dispatch. response.raise_for_status() def _format(self, event: AuditEvent) -> str: marker = "OK" if event.success else "FAIL" parts = [ f"[{marker}] {event.operation}", f"caller={event.caller_identity or '?'}", ] if event.tenant_id: parts.append(f"tenant={event.tenant_id}") if event.request_id: parts.append(f"request_id={event.request_id}") if not event.success and event.error_type: parts.append(f"error={event.error_type}") if event.error_message: parts.append(f"msg={event.error_message}") if self._allowed_fields: filtered = {k: v for k, v in event.details.items() if k in self._allowed_fields} if filtered: parts.append(f"details={json.dumps(filtered, default=str, sort_keys=True)}") return " ".join(parts)Posts :class:
AuditEventto a Slack incoming-webhook URL on a sensitive subset of events — operator alerting, NOT audit-of-record.Slack is universally used in ad-tech ops as an alerting channel for SecOps (failed-auth bursts, large media buys, contract-tier overages) — never as the system of record for compliance audit. This sink is named for that role: it implements the :class:
AuditSinkProtocol so it composes through :func:make_audit_middleware(), but the intent is "tee a high-signal subset to a chat channel," not "satisfy the auditor."Pair with :class:
LoggingAuditSink(or a custom sink writing to your audit table) for the durable trail; route only the sensitive subset through here viasensitive_operations.:param webhook_url: Slack incoming-webhook URL. MUST be HTTPS. The URL is a bearer token; it appears redacted in :meth:
__repr__. :param sensitive_operations: Optional whitelist of operations that trigger a Slack post.Nonemeans post every event (rarely what you want — Slack will rate-limit). Pass e.g.frozenset({"create_media_buy", "acquire_rights", "update_rights"})to post only mutations. :param allowed_fields: Allowlist of :attr:AuditEvent.detailskeys that may be included in the Slack message body. Defaultfrozenset()— nodetailscontent is posted, only the operation/identity/error summary. Prevents accidental egress of financial fields (budgets, credit limits), PII (contact info), or buyer-supplied free text. :param timeout_seconds: Per-call HTTP timeout. The middleware also applies its ownsink_timeout_secondsceiling; the tighter of the two governs. :param allow_private_destinations: Pass-through to the IP-pinned transport. DefaultFalse(block private/loopback IPs). Override only for testing against a local stub. :param allowed_destination_ports: Pass-through to the IP-pinned transport. DefaultNone(allow 443 only). Slack's webhook host serves on 443; overriding is rarely correct.Methods
async def record(self,
event: AuditEvent) ‑> None-
Expand source code
async def record(self, event: AuditEvent) -> None: if ( self._sensitive_operations is not None and event.operation not in self._sensitive_operations ): return # Lazy import: keep the audit_sink module importable in # environments that don't have httpx wired (e.g., type-only # consumers). The httpx + transport import only runs when an # adopter actually constructs a SlackAuditSink and emits. import httpx from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport transport = build_async_ip_pinned_transport( self._webhook_url, allow_private=self._allow_private, allowed_ports=self._allowed_ports, ) payload = {"text": self._format(event)} async with httpx.AsyncClient( transport=transport, timeout=self._timeout, follow_redirects=False, trust_env=False, ) as client: response = await client.post(self._webhook_url, json=payload) # Slack returns 200 with body ``"ok"`` on success. Non-2xx # raises so the middleware's swallow path logs a warning — # Slack rate-limit (429) and webhook-revoked (404) need to # surface in operator logs even if they can't fail dispatch. response.raise_for_status()