From d4254fec0d50962499b06273d513e35b17776c68 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 17:48:51 -0500 Subject: [PATCH 1/9] feat(connector): add delegated provider executor --- src/baton/delegated_connector.py | 524 ++++++++++++++++++++++++++++++ tests/test_delegated_connector.py | 325 ++++++++++++++++++ 2 files changed, 849 insertions(+) create mode 100644 src/baton/delegated_connector.py create mode 100644 tests/test_delegated_connector.py diff --git a/src/baton/delegated_connector.py b/src/baton/delegated_connector.py new file mode 100644 index 0000000..c56c5cd --- /dev/null +++ b/src/baton/delegated_connector.py @@ -0,0 +1,524 @@ +"""Cloud-neutral delegated external-provider dispatch orchestration. + +This module is the Baton-side runtime boundary for provider-backed delivery. +It accepts opaque authorization and connector references, orchestrates retries, +failover, and circuit breakers, and returns sanitized outcomes only. The +``CustodiedProviderInvoker`` implementation is the single-purpose boundary +allowed to resolve and use provider credential material internally. +""" + +from __future__ import annotations + +import asyncio +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import StrEnum +from typing import Awaitable, Callable, Protocol, Sequence +from uuid import uuid4 + + +_SAFE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") + + +class Channel(StrEnum): + SMS = "sms" + EMAIL = "email" + + +class DeliveryStatus(StrEnum): + ACCEPTED = "accepted" + DELIVERED = "delivered" + FAILED = "failed" + EXHAUSTED = "exhausted" + + +class DispatchSignalKind(StrEnum): + AUTHORIZATION_DENIED = "authorization_denied" + ATTEMPT_FAILED = "attempt_failed" + ATTEMPT_SUCCEEDED = "attempt_succeeded" + CIRCUIT_OPEN = "circuit_open" + FAILOVER_USED = "failover_used" + DELIVERY_EXHAUSTED = "delivery_exhausted" + + +class DelegatedConnectorError(Exception): + """Base error for dispatch decisions that never expose provider material.""" + + +class AuthorizationDenied(DelegatedConnectorError): + """A capability cannot authorize this dispatch.""" + + +class DispatchInProgress(DelegatedConnectorError): + """The idempotency key is already executing elsewhere.""" + + +class MonitoringUnavailable(DelegatedConnectorError): + """A required sanitized signal could not be persisted.""" + + +class InvalidConnectorPolicy(ValueError): + """Connector routes do not form a valid ordered provider policy.""" + + +@dataclass(frozen=True) +class CapabilityReference: + """Opaque reference to an authorization proof verified inside the stack.""" + + reference: str + + def __post_init__(self) -> None: + if not self.reference: + raise ValueError("capability reference is required") + + +@dataclass(frozen=True) +class DispatchRequest: + """A provider dispatch instruction containing references, never material.""" + + workflow_id: str + channel: Channel + recipient_ref: str + payload_ref: str + idempotency_key: str + + def __post_init__(self) -> None: + for name in ("workflow_id", "recipient_ref", "payload_ref", "idempotency_key"): + if not getattr(self, name): + raise ValueError(f"{name} is required") + + +@dataclass(frozen=True) +class VerifiedDispatchGrant: + """Verified authorization scope returned by a trusted verifier.""" + + principal: str + channel: Channel + allowed_connectors: frozenset[str] + not_after: datetime + max_attempts: int + + def __post_init__(self) -> None: + if not self.principal: + raise ValueError("principal is required") + if self.not_after.tzinfo is None: + raise ValueError("not_after must be timezone-aware") + if self.max_attempts < 1: + raise ValueError("max_attempts must be positive") + + +@dataclass(frozen=True) +class ConnectorRoute: + """Enabled provider metadata and opaque custody binding.""" + + connector_id: str + provider_key: str + channel: Channel + credential_handle: str + priority: int + enabled: bool = True + timeout_ms: int = 5000 + max_attempts: int = 1 + retry_backoff_ms: int = 100 + circuit_breaker_threshold: int = 3 + circuit_reset_seconds: float = 60.0 + + def __post_init__(self) -> None: + for name in ("connector_id", "provider_key", "credential_handle"): + if not getattr(self, name): + raise ValueError(f"{name} is required") + if self.priority < 0: + raise ValueError("priority cannot be negative") + if self.timeout_ms < 1: + raise ValueError("timeout_ms must be positive") + if self.max_attempts < 1: + raise ValueError("max_attempts must be positive") + if self.retry_backoff_ms < 0: + raise ValueError("retry_backoff_ms cannot be negative") + if self.circuit_breaker_threshold < 0: + raise ValueError("circuit_breaker_threshold cannot be negative") + if self.circuit_reset_seconds < 0: + raise ValueError("circuit_reset_seconds cannot be negative") + + +@dataclass(frozen=True) +class ProviderAttemptOutcome: + """Sanitized result returned by the custody-internal provider invoker.""" + + status: DeliveryStatus + audit_ref: str + failure_code: str = "" + retryable: bool = False + failover_allowed: bool = False + counts_toward_circuit: bool = False + + def __post_init__(self) -> None: + if self.status not in (DeliveryStatus.ACCEPTED, DeliveryStatus.DELIVERED, DeliveryStatus.FAILED): + raise ValueError("attempt status must be accepted, delivered, or failed") + if not self.audit_ref: + raise ValueError("audit_ref is required") + if self.failure_code and not _SAFE_CODE_RE.fullmatch(self.failure_code): + raise ValueError("failure_code must contain sanitized identifier characters only") + if self.status is DeliveryStatus.FAILED and not self.failure_code: + raise ValueError("failed attempts require a sanitized failure_code") + if self.status is not DeliveryStatus.FAILED and self.failure_code: + raise ValueError("successful attempts cannot include a failure_code") + + +@dataclass(frozen=True) +class DeliveryOutcome: + """Sanitized result safe to return to a caller such as MEA comms.""" + + dispatch_id: str + workflow_id: str + channel: Channel + provider_key: str + status: DeliveryStatus + attempt_count: int + failover_used: bool + audit_ref: str + failure_code: str = "" + + +@dataclass(frozen=True) +class DispatchSignal: + """Non-sensitive operational event for audit and alert pipelines. + + Terminal signals carry ``dispatch_id`` so a sink can persist them + idempotently when a caller retries after monitoring is unavailable. + """ + + kind: DispatchSignalKind + workflow_id: str + channel: Channel + dispatch_id: str = "" + connector_id: str = "" + provider_key: str = "" + attempt_count: int = 0 + failure_code: str = "" + + +class ScopedAuthorizationVerifier(Protocol): + """Verifies capability origin and dispatch scope against trusted policy.""" + + async def verify( + self, capability: CapabilityReference, request: DispatchRequest + ) -> VerifiedDispatchGrant: + ... + + +class CustodiedProviderInvoker(Protocol): + """Executes one provider operation inside the credential custody boundary.""" + + async def invoke( + self, route: ConnectorRoute, request: DispatchRequest + ) -> ProviderAttemptOutcome: + """Use only the matching handle internally and return sanitized metadata.""" + ... + + +class DispatchJournal(Protocol): + """Atomic idempotency journal required before any provider invocation.""" + + async def completed(self, idempotency_key: str) -> DeliveryOutcome | None: + ... + + async def begin(self, idempotency_key: str) -> bool: + ... + + async def complete(self, idempotency_key: str, outcome: DeliveryOutcome) -> None: + ... + + async def abort(self, idempotency_key: str) -> None: + ... + + +class DispatchSignalSink(Protocol): + """Durably accepts sanitized events used for audit and alerting. + + Terminal events must be idempotent by ``(kind, dispatch_id)`` because + completed dispatches retry notification delivery without resending. + """ + + async def emit(self, signal: DispatchSignal) -> None: + ... + + +@dataclass +class _CircuitState: + failures: int = 0 + opened_at: float | None = None + + +class DelegatedConnectorExecutor: + """Dispatches through authorized ordered connectors with fail-closed controls.""" + + def __init__( + self, + routes: Sequence[ConnectorRoute], + verifier: ScopedAuthorizationVerifier, + invoker: CustodiedProviderInvoker, + journal: DispatchJournal, + signal_sink: DispatchSignalSink, + *, + clock: Callable[[], datetime] | None = None, + monotonic: Callable[[], float] | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + ): + self._routes = self._validate_routes(routes) + self._verifier = verifier + self._invoker = invoker + self._journal = journal + self._signal_sink = signal_sink + self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._monotonic = monotonic or time.monotonic + self._sleep = sleep or asyncio.sleep + self._circuits: dict[str, _CircuitState] = {} + + @staticmethod + def _validate_routes(routes: Sequence[ConnectorRoute]) -> tuple[ConnectorRoute, ...]: + ids: set[str] = set() + priorities: set[tuple[Channel, int]] = set() + for route in routes: + if route.connector_id in ids: + raise InvalidConnectorPolicy(f"duplicate connector id: {route.connector_id}") + ids.add(route.connector_id) + if route.enabled: + priority_key = (route.channel, route.priority) + if priority_key in priorities: + raise InvalidConnectorPolicy( + f"duplicate active priority for channel: {route.channel.value}/{route.priority}" + ) + priorities.add(priority_key) + return tuple(sorted(routes, key=lambda route: (route.channel.value, route.priority))) + + async def dispatch( + self, capability: CapabilityReference, request: DispatchRequest + ) -> DeliveryOutcome: + """Dispatch exactly once per idempotency key after scoped verification.""" + try: + grant = await self._verifier.verify(capability, request) + self._validate_grant(grant, request) + except Exception as exc: + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.AUTHORIZATION_DENIED, + workflow_id=request.workflow_id, + channel=request.channel, + failure_code="authorization_denied", + ) + ) + if isinstance(exc, AuthorizationDenied): + raise + raise AuthorizationDenied("dispatch authorization denied") from exc + + completed = await self._journal.completed(request.idempotency_key) + if completed is not None: + await self._emit_terminal(completed) + return completed + if not await self._journal.begin(request.idempotency_key): + raise DispatchInProgress("dispatch with this idempotency key is already in progress") + + try: + outcome = await self._dispatch_authorized(grant, request) + await self._journal.complete(request.idempotency_key, outcome) + except Exception: + await self._journal.abort(request.idempotency_key) + raise + + await self._emit_terminal(outcome) + return outcome + + def _validate_grant(self, grant: VerifiedDispatchGrant, request: DispatchRequest) -> None: + if grant.channel is not request.channel: + raise AuthorizationDenied("dispatch channel is outside authorization scope") + if self._clock() >= grant.not_after: + raise AuthorizationDenied("dispatch authorization has expired") + enabled = { + route.connector_id + for route in self._routes + if route.channel is request.channel and route.enabled + } + if not enabled.intersection(grant.allowed_connectors): + raise AuthorizationDenied("no authorized active connector for dispatch") + + async def _dispatch_authorized( + self, grant: VerifiedDispatchGrant, request: DispatchRequest + ) -> DeliveryOutcome: + routes = [ + route + for route in self._routes + if route.channel is request.channel + and route.enabled + and route.connector_id in grant.allowed_connectors + ] + attempt_count = 0 + last_provider = "" + last_audit_ref = "" + last_failure = "provider_unavailable" + failover_used = False + attempted_or_skipped_route = False + allow_next_route = True + + for route in routes: + if not allow_next_route: + break + if self._circuit_is_open(route): + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.CIRCUIT_OPEN, + workflow_id=request.workflow_id, + channel=request.channel, + connector_id=route.connector_id, + provider_key=route.provider_key, + attempt_count=attempt_count, + failure_code="circuit_open", + ) + ) + attempted_or_skipped_route = True + continue + if attempted_or_skipped_route: + failover_used = True + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.FAILOVER_USED, + workflow_id=request.workflow_id, + channel=request.channel, + connector_id=route.connector_id, + provider_key=route.provider_key, + attempt_count=attempt_count, + ) + ) + attempted_or_skipped_route = True + allow_next_route = False + + for local_attempt in range(route.max_attempts): + if attempt_count >= grant.max_attempts: + break + attempt_count += 1 + last_provider = route.provider_key + attempt = await self._invoke(route, request) + last_audit_ref = attempt.audit_ref + if attempt.status in (DeliveryStatus.ACCEPTED, DeliveryStatus.DELIVERED): + self._record_success(route) + return DeliveryOutcome( + dispatch_id=f"dispatch-{uuid4().hex}", + workflow_id=request.workflow_id, + channel=request.channel, + provider_key=route.provider_key, + status=attempt.status, + attempt_count=attempt_count, + failover_used=failover_used, + audit_ref=attempt.audit_ref, + ) + + last_failure = attempt.failure_code + allow_next_route = attempt.failover_allowed + if attempt.counts_toward_circuit: + self._record_failure(route) + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.ATTEMPT_FAILED, + workflow_id=request.workflow_id, + channel=request.channel, + connector_id=route.connector_id, + provider_key=route.provider_key, + attempt_count=attempt_count, + failure_code=attempt.failure_code, + ) + ) + if not attempt.retryable: + break + if local_attempt < route.max_attempts - 1 and attempt_count < grant.max_attempts: + await self._sleep(route.retry_backoff_ms / 1000) + + if attempt_count >= grant.max_attempts: + break + + return DeliveryOutcome( + dispatch_id=f"dispatch-{uuid4().hex}", + workflow_id=request.workflow_id, + channel=request.channel, + provider_key=last_provider, + status=DeliveryStatus.EXHAUSTED, + attempt_count=attempt_count, + failover_used=failover_used, + audit_ref=last_audit_ref, + failure_code=last_failure, + ) + + async def _invoke( + self, route: ConnectorRoute, request: DispatchRequest + ) -> ProviderAttemptOutcome: + try: + return await asyncio.wait_for( + self._invoker.invoke(route, request), + timeout=route.timeout_ms / 1000, + ) + except TimeoutError: + return ProviderAttemptOutcome( + status=DeliveryStatus.FAILED, + audit_ref=f"timeout:{request.workflow_id}:{route.connector_id}", + failure_code="provider_timeout", + retryable=True, + failover_allowed=True, + counts_toward_circuit=True, + ) + except Exception: + return ProviderAttemptOutcome( + status=DeliveryStatus.FAILED, + audit_ref=f"error:{request.workflow_id}:{route.connector_id}", + failure_code="provider_error", + retryable=True, + failover_allowed=True, + counts_toward_circuit=True, + ) + + def _circuit_is_open(self, route: ConnectorRoute) -> bool: + if route.circuit_breaker_threshold == 0: + return False + state = self._circuits.get(route.connector_id) + if state is None or state.opened_at is None: + return False + if self._monotonic() - state.opened_at >= route.circuit_reset_seconds: + state.opened_at = None + return False + return True + + def _record_success(self, route: ConnectorRoute) -> None: + self._circuits[route.connector_id] = _CircuitState() + + def _record_failure(self, route: ConnectorRoute) -> None: + state = self._circuits.setdefault(route.connector_id, _CircuitState()) + state.failures += 1 + if ( + route.circuit_breaker_threshold > 0 + and state.failures >= route.circuit_breaker_threshold + ): + state.opened_at = self._monotonic() + + async def _emit(self, signal: DispatchSignal) -> None: + try: + await self._signal_sink.emit(signal) + except Exception as exc: + raise MonitoringUnavailable("sanitized dispatch signal persistence failed") from exc + + async def _emit_terminal(self, outcome: DeliveryOutcome) -> None: + kind = ( + DispatchSignalKind.DELIVERY_EXHAUSTED + if outcome.status is DeliveryStatus.EXHAUSTED + else DispatchSignalKind.ATTEMPT_SUCCEEDED + ) + await self._emit( + DispatchSignal( + kind=kind, + dispatch_id=outcome.dispatch_id, + workflow_id=outcome.workflow_id, + channel=outcome.channel, + provider_key=outcome.provider_key, + attempt_count=outcome.attempt_count, + failure_code=outcome.failure_code, + ) + ) diff --git a/tests/test_delegated_connector.py b/tests/test_delegated_connector.py new file mode 100644 index 0000000..7ec8ce6 --- /dev/null +++ b/tests/test_delegated_connector.py @@ -0,0 +1,325 @@ +"""Tests for provider dispatch orchestration without credential material.""" + +from __future__ import annotations + +import asyncio +import dataclasses +from datetime import datetime, timedelta, timezone + +import pytest + +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + ConnectorRoute, + DelegatedConnectorExecutor, + DeliveryOutcome, + DeliveryStatus, + DispatchInProgress, + DispatchRequest, + DispatchSignal, + DispatchSignalKind, + MonitoringUnavailable, + ProviderAttemptOutcome, + VerifiedDispatchGrant, +) + + +def _request(idempotency_key: str = "dispatch-once-1") -> DispatchRequest: + return DispatchRequest( + workflow_id="workflow-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key=idempotency_key, + ) + + +def _route( + connector_id: str, + priority: int, + *, + max_attempts: int = 1, + threshold: int = 3, + timeout_ms: int = 5000, +) -> ConnectorRoute: + return ConnectorRoute( + connector_id=connector_id, + provider_key=f"provider-{connector_id}", + channel=Channel.SMS, + credential_handle=f"opaque-handle-{connector_id}", + priority=priority, + max_attempts=max_attempts, + circuit_breaker_threshold=threshold, + timeout_ms=timeout_ms, + ) + + +class AcceptedVerifier: + async def verify( + self, _capability: CapabilityReference, request: DispatchRequest + ) -> VerifiedDispatchGrant: + return VerifiedDispatchGrant( + principal="comms-runtime", + channel=request.channel, + allowed_connectors=frozenset({"primary", "backup"}), + not_after=datetime.now(timezone.utc) + timedelta(minutes=5), + max_attempts=4, + ) + + +class DeniedVerifier: + async def verify( + self, _capability: CapabilityReference, _request: DispatchRequest + ) -> VerifiedDispatchGrant: + raise AuthorizationDenied("denied") + + +class OutcomeInvoker: + def __init__(self, outcomes: list[ProviderAttemptOutcome]): + self._outcomes = list(outcomes) + self.calls: list[str] = [] + + async def invoke( + self, route: ConnectorRoute, _request: DispatchRequest + ) -> ProviderAttemptOutcome: + self.calls.append(route.connector_id) + return self._outcomes.pop(0) + + +class HangingInvoker: + async def invoke( + self, _route: ConnectorRoute, _request: DispatchRequest + ) -> ProviderAttemptOutcome: + await asyncio.sleep(60) + raise AssertionError("unreachable") + + +class MemoryJournal: + def __init__(self): + self.running: set[str] = set() + self.results: dict[str, DeliveryOutcome] = {} + + async def completed(self, idempotency_key: str) -> DeliveryOutcome | None: + return self.results.get(idempotency_key) + + async def begin(self, idempotency_key: str) -> bool: + if idempotency_key in self.running: + return False + self.running.add(idempotency_key) + return True + + async def complete(self, idempotency_key: str, outcome: DeliveryOutcome) -> None: + self.running.discard(idempotency_key) + self.results[idempotency_key] = outcome + + async def abort(self, idempotency_key: str) -> None: + self.running.discard(idempotency_key) + + +class SignalSink: + def __init__(self, failures_remaining: int = 0): + self.failures_remaining = failures_remaining + self.signals: list[DispatchSignal] = [] + + async def emit(self, signal: DispatchSignal) -> None: + if self.failures_remaining: + self.failures_remaining -= 1 + raise RuntimeError("signal unavailable") + self.signals.append(signal) + + +def _success(audit_ref: str = "audit-success") -> ProviderAttemptOutcome: + return ProviderAttemptOutcome( + status=DeliveryStatus.ACCEPTED, + audit_ref=audit_ref, + ) + + +def _failure( + code: str = "provider_unavailable", + *, + retryable: bool = False, + failover_allowed: bool = False, + counts_toward_circuit: bool = False, +) -> ProviderAttemptOutcome: + return ProviderAttemptOutcome( + status=DeliveryStatus.FAILED, + audit_ref=f"audit-{code}", + failure_code=code, + retryable=retryable, + failover_allowed=failover_allowed, + counts_toward_circuit=counts_toward_circuit, + ) + + +def _executor(invoker, journal=None, sink=None, routes=None) -> DelegatedConnectorExecutor: + return DelegatedConnectorExecutor( + routes or [_route("primary", 1), _route("backup", 2)], + AcceptedVerifier(), + invoker, + journal or MemoryJournal(), + sink or SignalSink(), + sleep=lambda _delay: asyncio.sleep(0), + ) + + +async def test_success_returns_sanitized_outcome_only(): + invoker = OutcomeInvoker([_success()]) + sink = SignalSink() + outcome = await _executor(invoker, sink=sink).dispatch( + CapabilityReference("authorization-ref-1"), _request() + ) + + assert outcome.status is DeliveryStatus.ACCEPTED + assert outcome.provider_key == "provider-primary" + assert outcome.attempt_count == 1 + fields = {field.name for field in dataclasses.fields(outcome)} + assert fields == { + "dispatch_id", + "workflow_id", + "channel", + "provider_key", + "status", + "attempt_count", + "failover_used", + "audit_ref", + "failure_code", + } + assert [signal.kind for signal in sink.signals] == [DispatchSignalKind.ATTEMPT_SUCCEEDED] + + +async def test_authorization_denial_prevents_invocation_and_emits_signal(): + invoker = OutcomeInvoker([_success()]) + sink = SignalSink() + executor = DelegatedConnectorExecutor( + [_route("primary", 1)], + DeniedVerifier(), + invoker, + MemoryJournal(), + sink, + ) + + with pytest.raises(AuthorizationDenied): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert invoker.calls == [] + assert sink.signals[0].kind is DispatchSignalKind.AUTHORIZATION_DENIED + + +async def test_failed_primary_fails_over_to_backup_with_sanitized_signals(): + invoker = OutcomeInvoker([_failure(failover_allowed=True), _success("audit-backup")]) + sink = SignalSink() + outcome = await _executor(invoker, sink=sink).dispatch( + CapabilityReference("authorization-ref-1"), _request() + ) + + assert invoker.calls == ["primary", "backup"] + assert outcome.provider_key == "provider-backup" + assert outcome.failover_used is True + assert outcome.attempt_count == 2 + assert [signal.kind for signal in sink.signals] == [ + DispatchSignalKind.ATTEMPT_FAILED, + DispatchSignalKind.FAILOVER_USED, + DispatchSignalKind.ATTEMPT_SUCCEEDED, + ] + + +async def test_provider_failure_without_failover_approval_does_not_send_to_backup(): + invoker = OutcomeInvoker([_failure("invalid_payload")]) + outcome = await _executor(invoker).dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert outcome.status is DeliveryStatus.EXHAUSTED + assert invoker.calls == ["primary"] + assert outcome.failover_used is False + + +async def test_open_circuit_skips_failing_primary_on_next_dispatch(): + invoker = OutcomeInvoker( + [_failure(failover_allowed=True, counts_toward_circuit=True), _success(), _success()] + ) + executor = _executor( + invoker, + routes=[_route("primary", 1, threshold=1), _route("backup", 2)], + ) + first = await executor.dispatch(CapabilityReference("authorization-ref-1"), _request("one")) + second = await executor.dispatch(CapabilityReference("authorization-ref-2"), _request("two")) + + assert first.provider_key == "provider-backup" + assert second.provider_key == "provider-backup" + assert invoker.calls == ["primary", "backup", "backup"] + + +async def test_delivery_rejection_does_not_open_provider_circuit(): + invoker = OutcomeInvoker([_failure("invalid_payload"), _success()]) + executor = _executor( + invoker, + routes=[_route("primary", 1, threshold=1), _route("backup", 2)], + ) + first = await executor.dispatch(CapabilityReference("authorization-ref-1"), _request("one")) + second = await executor.dispatch(CapabilityReference("authorization-ref-2"), _request("two")) + + assert first.status is DeliveryStatus.EXHAUSTED + assert second.provider_key == "provider-primary" + assert invoker.calls == ["primary", "primary"] + + +async def test_timeout_is_sanitized_and_fails_over(): + invoker = HangingInvoker() + executor = _executor( + invoker, + routes=[_route("primary", 1, timeout_ms=1)], + ) + outcome = await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert outcome.status is DeliveryStatus.EXHAUSTED + assert outcome.failure_code == "provider_timeout" + + +async def test_completed_idempotency_key_returns_without_second_invocation(): + invoker = OutcomeInvoker([_success()]) + journal = MemoryJournal() + executor = _executor(invoker, journal=journal) + first = await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + second = await executor.dispatch(CapabilityReference("authorization-ref-2"), _request()) + + assert first == second + assert invoker.calls == ["primary"] + + +async def test_in_progress_idempotency_key_fails_closed(): + journal = MemoryJournal() + journal.running.add("dispatch-once-1") + executor = _executor(OutcomeInvoker([_success()]), journal=journal) + + with pytest.raises(DispatchInProgress): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + +async def test_signal_failure_prevents_unaudited_authorization_denial(): + executor = DelegatedConnectorExecutor( + [_route("primary", 1)], + DeniedVerifier(), + OutcomeInvoker([_success()]), + MemoryJournal(), + SignalSink(failures_remaining=1), + ) + + with pytest.raises(MonitoringUnavailable): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + +async def test_success_is_not_resent_when_terminal_signal_must_be_retried(): + invoker = OutcomeInvoker([_success()]) + journal = MemoryJournal() + sink = SignalSink(failures_remaining=1) + executor = _executor(invoker, journal=journal, sink=sink) + + with pytest.raises(MonitoringUnavailable): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + outcome = await executor.dispatch(CapabilityReference("authorization-ref-2"), _request()) + + assert outcome.status is DeliveryStatus.ACCEPTED + assert invoker.calls == ["primary"] + assert [signal.kind for signal in sink.signals] == [DispatchSignalKind.ATTEMPT_SUCCEEDED] From 7b12b920ad3fbc4d509957ec531916be02c42889 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 19:18:01 -0500 Subject: [PATCH 2/9] feat(custody): define opaque provider-use authorization boundary --- docs/credential-custody-boundary.md | 77 +++++ src/baton/credential_custody.py | 424 ++++++++++++++++++++++++++++ tests/test_credential_custody.py | 266 +++++++++++++++++ 3 files changed, 767 insertions(+) create mode 100644 docs/credential-custody-boundary.md create mode 100644 src/baton/credential_custody.py create mode 100644 tests/test_credential_custody.py diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md new file mode 100644 index 0000000..5c47903 --- /dev/null +++ b/docs/credential-custody-boundary.md @@ -0,0 +1,77 @@ +# Provider Credential Custody Boundary + +## Purpose + +Baton owns the cloud-neutral egress control boundary for provider-backed +operations. A business workflow may select a connector and provide opaque +recipient and payload references, but it must never receive or invoke with an email, +telephony, or other external-provider credential value. + +`src/baton/credential_custody.py` defines the reusable boundary: + +- `ProviderCredentialHandle` is an administrator-configured opaque handle, + not a secret-store URI or credential value. +- `SignedWorkloadAuthorizationVerifier` converts a signed authorization + reference into a verified, credential-free outcome. +- `CredentialCustodyAuthorizer` obtains a trusted verifier outcome and + `authorize_provider_dispatch` binds it to the exact workload, initial + connector handle, channel, purpose, opaque recipient and payload references, + request fingerprint, and provider-attempt budget before consuming the + reservation. +- `AuthorizationConsumptionLedger` is required for every provider operation and + is invoked inside authorization to atomically bind authorization, request + fingerprint, and idempotency key. +- `CustodiedReferenceResolver` is the only permitted provider-operation + boundary. It resolves material internally and returns a sanitized outcome. +- The resulting `AuthorizedProviderDispatch` carries one ledger reservation + across primary and backup attempts, but can select only connector handles + already included in its verified scope and is bounded by its verified + provider-attempt budget. + +## Failure Semantics + +- Missing, expired, mismatched, or out-of-scope authority is denied before any + provider operation. +- Only single-dispatch authority with an exact durable ledger reservation is + accepted. This prevents a provider send from accepting reusable authority or + a `one_time` claim without replay enforcement. +- Sanitized outcomes carry provider identity, status, correlation identifiers, + audit reference, and bounded failure code only. They do not carry material, + provider response bodies, recipient data, or message data. + +## Integration Gate + +This module is a contract surface, not a configured production custody store. +MEA integration remains blocked until all of the following exist: + +1. A trusted Signet-compatible verifier implementation with issuer and rotation + policy. +2. A durable consumption ledger with atomic reserve/complete/replay behavior. +3. A custody-internal resolver implementation, potentially backed by OpenBao + after license, deployment, assurance, and operational review. +4. Tamper-evident audit and failure notification sink implementations. +5. Key-free executable evidence for each boundary and the provider executor. + +No provider credential values or signing keys are used or packaged by this +contract or its tests. + +## OpenBao Candidate Evidence + +OpenBao is a candidate implementation backend for the internal resolver, not +an adopted dependency in this change. Official project documentation checked +on 2026-06-04 records: + +- Source licensing as MPL-2.0 with OSI and FSF recognition: + +- Versioned arbitrary secret storage and ACL separation through KV v2: + +- Cryptographic processing without retaining submitted data through Transit: + +- Request audit-device behavior: + +- JWT/OIDC authentication capability: + + +These documents establish candidate fit and commercial open-source eligibility; +they do not establish certification, deployment security, high availability, +unseal policy, vulnerability disposition, or approval for MEA production use. diff --git a/src/baton/credential_custody.py b/src/baton/credential_custody.py new file mode 100644 index 0000000..b3d3431 --- /dev/null +++ b/src/baton/credential_custody.py @@ -0,0 +1,424 @@ +"""Cloud-neutral provider credential custody contracts. + +This module defines the boundary between a business workflow and a +single-purpose provider executor. No public type carries credential values. +A concrete resolver may access provider material only while executing an +already authorized operation inside the custody boundary. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import StrEnum +from collections.abc import Callable +from typing import Protocol + + +_FINGERPRINT_RE = re.compile(r"^[a-f0-9]{64}$") +_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") + + +class ProviderChannel(StrEnum): + SMS = "sms" + EMAIL = "email" + + +class CustodyResultStatus(StrEnum): + ACCEPTED = "accepted" + DELIVERED = "delivered" + FAILED = "failed" + + +class CustodyAuditKind(StrEnum): + AUTHORIZATION_DENIED = "authorization_denied" + AUTHORIZATION_ACCEPTED = "authorization_accepted" + RESERVATION_REQUIRED = "reservation_required" + REPLAY_REJECTED = "replay_rejected" + INVOCATION_COMPLETED = "invocation_completed" + INVOCATION_FAILED = "invocation_failed" + + +class CustodyError(Exception): + """Base error for the non-material custody boundary.""" + + +class CustodyAuthorizationDenied(CustodyError): + """A verified outcome cannot authorize this provider operation.""" + + +class ConsumptionReservationRequired(CustodyAuthorizationDenied): + """Bounded-use authority lacks an atomic consumption reservation.""" + + +@dataclass(frozen=True) +class ProviderCredentialHandle: + """Opaque lookup handle configured by an administrator. + + ``handle_id`` is not a provider credential, secret-store URI, or ciphertext. + The custody implementation owns the mapping to a concrete backend record. + """ + + handle_id: str + connector_id: str + provider_key: str + channel: ProviderChannel + version_ref: str + + def __post_init__(self) -> None: + for name in ("handle_id", "connector_id", "provider_key", "version_ref"): + if not getattr(self, name): + raise ValueError(f"{name} is required") + + +@dataclass(frozen=True) +class WorkloadAuthorizationReference: + """Opaque reference to a signed authorization checked by a trusted verifier.""" + + reference: str + + def __post_init__(self) -> None: + if not self.reference: + raise ValueError("authorization reference is required") + + +@dataclass(frozen=True) +class CredentialUseRequest: + """Immutable provider-use scope supplied to the custody boundary.""" + + operation_id: str + dispatch_id: str + workload_id: str + connector_id: str + channel: ProviderChannel + purpose: str + recipient_ref: str + payload_ref: str + idempotency_key: str + request_fingerprint: str + + def __post_init__(self) -> None: + for name in ( + "operation_id", + "dispatch_id", + "workload_id", + "connector_id", + "purpose", + "recipient_ref", + "payload_ref", + "idempotency_key", + ): + if not getattr(self, name): + raise ValueError(f"{name} is required") + if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): + raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") + + +@dataclass(frozen=True) +class VerifiedWorkloadAuthorization: + """Credential-free outcome returned after trusted signature verification.""" + + authorization_id: str + workload_id: str + allowed_connector_ids: frozenset[str] + allowed_channels: frozenset[ProviderChannel] + allowed_purposes: frozenset[str] + request_fingerprint: str + not_before: datetime + not_after: datetime + max_uses: int | None + max_provider_attempts: int + + def __post_init__(self) -> None: + if not self.authorization_id or not self.workload_id: + raise ValueError("authorization_id and workload_id are required") + if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): + raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") + if self.not_before.tzinfo is None or self.not_after.tzinfo is None: + raise ValueError("authorization timestamps must be timezone-aware") + if self.not_after <= self.not_before: + raise ValueError("authorization expiry must be after not_before") + if self.max_uses is not None and self.max_uses < 1: + raise ValueError("max_uses must be positive when bounded") + if self.max_provider_attempts < 1: + raise ValueError("max_provider_attempts must be positive") + + +@dataclass(frozen=True) +class ConsumptionReservation: + """Atomic bounded-use reservation produced by a durable ledger.""" + + reservation_id: str + authorization_id: str + request_fingerprint: str + idempotency_key: str + + def __post_init__(self) -> None: + if not self.reservation_id or not self.authorization_id or not self.idempotency_key: + raise ValueError("reservation identifiers are required") + if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): + raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") + + +@dataclass(frozen=True) +class AuthorizedCredentialUse: + """Non-material authorization passed to a custody-internal invoker.""" + + authorization_id: str + handle_id: str + connector_id: str + provider_key: str + operation_id: str + dispatch_id: str + recipient_ref: str + payload_ref: str + idempotency_key: str + request_fingerprint: str + reservation_id: str = "" + + +@dataclass(frozen=True) +class AuthorizedProviderDispatch: + """Reserved provider dispatch that may select only scoped connector handles.""" + + authorization_id: str + workload_id: str + allowed_connector_ids: frozenset[str] + channel: ProviderChannel + purpose: str + operation_id: str + dispatch_id: str + recipient_ref: str + payload_ref: str + idempotency_key: str + request_fingerprint: str + reservation_id: str + max_provider_attempts: int + + def authorize_handle(self, handle: ProviderCredentialHandle) -> AuthorizedCredentialUse: + if handle.connector_id not in self.allowed_connector_ids: + raise CustodyAuthorizationDenied("connector is outside authorization scope") + if handle.channel is not self.channel: + raise CustodyAuthorizationDenied("channel is outside authorization scope") + return AuthorizedCredentialUse( + authorization_id=self.authorization_id, + handle_id=handle.handle_id, + connector_id=handle.connector_id, + provider_key=handle.provider_key, + operation_id=self.operation_id, + dispatch_id=self.dispatch_id, + recipient_ref=self.recipient_ref, + payload_ref=self.payload_ref, + idempotency_key=self.idempotency_key, + request_fingerprint=self.request_fingerprint, + reservation_id=self.reservation_id, + ) + + +@dataclass(frozen=True) +class SanitizedCustodyOutcome: + """Result safe to emit outside the provider custody boundary.""" + + operation_id: str + dispatch_id: str + provider_key: str + status: CustodyResultStatus + audit_ref: str + failure_code: str = "" + + def __post_init__(self) -> None: + if not self.operation_id or not self.dispatch_id or not self.provider_key: + raise ValueError("outcome identity fields are required") + if not self.audit_ref: + raise ValueError("audit_ref is required") + if self.failure_code and not _CODE_RE.fullmatch(self.failure_code): + raise ValueError("failure_code must be a sanitized identifier") + if self.status is CustodyResultStatus.FAILED and not self.failure_code: + raise ValueError("failed outcomes require a failure_code") + if self.status is not CustodyResultStatus.FAILED and self.failure_code: + raise ValueError("successful outcomes cannot contain a failure_code") + + +@dataclass(frozen=True) +class CustodyAuditEvent: + """Sanitized accountability event, suitable for alerting and audit sinks.""" + + kind: CustodyAuditKind + operation_id: str + dispatch_id: str + workload_id: str + connector_id: str + provider_key: str + authorization_id: str = "" + failure_code: str = "" + + def __post_init__(self) -> None: + for name in ( + "operation_id", + "dispatch_id", + "workload_id", + "connector_id", + "provider_key", + ): + if not getattr(self, name): + raise ValueError(f"{name} is required") + if self.failure_code and not _CODE_RE.fullmatch(self.failure_code): + raise ValueError("failure_code must be a sanitized identifier") + + +class SignedWorkloadAuthorizationVerifier(Protocol): + """Validate origin and scope of a signed workload authorization.""" + + async def verify( + self, + reference: WorkloadAuthorizationReference, + request: CredentialUseRequest, + ) -> VerifiedWorkloadAuthorization: + ... + + +class AuthorizationConsumptionLedger(Protocol): + """Reserve bounded authority atomically and persist terminal outcomes. + + A concrete implementation must reject reuse with a different request + fingerprint. The delegated executor journal is responsible for returning + an already completed sanitized delivery outcome rather than invoking twice. + """ + + async def reserve_once( + self, + authorization: VerifiedWorkloadAuthorization, + request: CredentialUseRequest, + ) -> ConsumptionReservation: + ... + + async def complete( + self, + reservation: ConsumptionReservation, + outcome: SanitizedCustodyOutcome, + ) -> None: + ... + + +class CustodiedReferenceResolver(Protocol): + """Invoke a provider operation while keeping resolved material internal.""" + + async def invoke( + self, + handle: ProviderCredentialHandle, + authorized_use: AuthorizedCredentialUse, + ) -> SanitizedCustodyOutcome: + ... + + +class CustodyAuditSink(Protocol): + """Persist sanitized custody transitions for accountability.""" + + async def emit(self, event: CustodyAuditEvent) -> None: + ... + + +class CustodyFailureNotifier(Protocol): + """Raise sanitized operational notifications for custody failures.""" + + async def notify(self, event: CustodyAuditEvent) -> None: + ... + + +async def authorize_provider_dispatch( + initial_handle: ProviderCredentialHandle, + request: CredentialUseRequest, + authorization: VerifiedWorkloadAuthorization, + *, + ledger: AuthorizationConsumptionLedger, + now: datetime | None = None, +) -> AuthorizedProviderDispatch: + """Validate and reserve a credential-free verifier outcome for one dispatch. + + Provider-use authority must describe exactly one dispatch and cannot cross + this boundary unless a ledger has atomically reserved the exact + authorization, request fingerprint, and idempotency key. + """ + + current_time = now or datetime.now(timezone.utc) + if current_time < authorization.not_before or current_time >= authorization.not_after: + raise CustodyAuthorizationDenied("workload authorization is outside its validity window") + if authorization.workload_id != request.workload_id: + raise CustodyAuthorizationDenied("workload identity is outside authorization scope") + if authorization.request_fingerprint != request.request_fingerprint: + raise CustodyAuthorizationDenied("request fingerprint is outside authorization scope") + if initial_handle.connector_id != request.connector_id: + raise CustodyAuthorizationDenied( + "initial credential handle does not match requested connector" + ) + if request.connector_id not in authorization.allowed_connector_ids: + raise CustodyAuthorizationDenied("initial connector is outside authorization scope") + if ( + initial_handle.channel is not request.channel + or request.channel not in authorization.allowed_channels + ): + raise CustodyAuthorizationDenied("channel is outside authorization scope") + if request.purpose not in authorization.allowed_purposes: + raise CustodyAuthorizationDenied("purpose is outside authorization scope") + + if authorization.max_uses != 1: + raise CustodyAuthorizationDenied( + "provider invocation requires single-dispatch authorization" + ) + reservation = await ledger.reserve_once(authorization, request) + if ( + reservation.authorization_id != authorization.authorization_id + or reservation.request_fingerprint != request.request_fingerprint + or reservation.idempotency_key != request.idempotency_key + ): + raise ConsumptionReservationRequired( + "ledger reservation does not bind this authorization request" + ) + + return AuthorizedProviderDispatch( + authorization_id=authorization.authorization_id, + workload_id=request.workload_id, + allowed_connector_ids=authorization.allowed_connector_ids, + channel=request.channel, + purpose=request.purpose, + operation_id=request.operation_id, + dispatch_id=request.dispatch_id, + recipient_ref=request.recipient_ref, + payload_ref=request.payload_ref, + idempotency_key=request.idempotency_key, + request_fingerprint=request.request_fingerprint, + reservation_id=reservation.reservation_id, + max_provider_attempts=authorization.max_provider_attempts, + ) + + +class CredentialCustodyAuthorizer: + """Obtain verified authority and reserve its exact provider operation.""" + + def __init__( + self, + verifier: SignedWorkloadAuthorizationVerifier, + ledger: AuthorizationConsumptionLedger, + *, + clock: Callable[[], datetime] | None = None, + ): + self._verifier = verifier + self._ledger = ledger + self._clock = clock or (lambda: datetime.now(timezone.utc)) + + async def authorize( + self, + reference: WorkloadAuthorizationReference, + handle: ProviderCredentialHandle, + request: CredentialUseRequest, + ) -> AuthorizedProviderDispatch: + authorization = await self._verifier.verify(reference, request) + reserved_dispatch = await authorize_provider_dispatch( + handle, + request, + authorization, + ledger=self._ledger, + now=self._clock(), + ) + return reserved_dispatch diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py new file mode 100644 index 0000000..08b1a13 --- /dev/null +++ b/tests/test_credential_custody.py @@ -0,0 +1,266 @@ +"""Credential-free contract checks for the provider custody boundary.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from baton.credential_custody import ( + ConsumptionReservation, + ConsumptionReservationRequired, + CredentialUseRequest, + CredentialCustodyAuthorizer, + CustodyAuthorizationDenied, + CustodyResultStatus, + ProviderChannel, + ProviderCredentialHandle, + SanitizedCustodyOutcome, + VerifiedWorkloadAuthorization, + WorkloadAuthorizationReference, + authorize_provider_dispatch, +) + + +FINGERPRINT = "a" * 64 +NOW = datetime(2026, 6, 4, tzinfo=timezone.utc) + + +def _handle( + connector_id: str = "sms-primary", + provider_key: str = "provider-primary", +) -> ProviderCredentialHandle: + return ProviderCredentialHandle( + handle_id=f"handle-{connector_id}-v3", + connector_id=connector_id, + provider_key=provider_key, + channel=ProviderChannel.SMS, + version_ref="active-v3", + ) + + +def _request(**overrides) -> CredentialUseRequest: + fields = { + "operation_id": "operation-1", + "dispatch_id": "dispatch-1", + "workload_id": "mea-comms", + "connector_id": "sms-primary", + "channel": ProviderChannel.SMS, + "purpose": "case_notification", + "recipient_ref": "recipient-ref-1", + "payload_ref": "payload-ref-1", + "idempotency_key": "dispatch-once-1", + "request_fingerprint": FINGERPRINT, + } + fields.update(overrides) + return CredentialUseRequest(**fields) + + +def _authorization(**overrides) -> VerifiedWorkloadAuthorization: + fields = { + "authorization_id": "authorization-1", + "workload_id": "mea-comms", + "allowed_connector_ids": frozenset({"sms-primary", "sms-backup"}), + "allowed_channels": frozenset({ProviderChannel.SMS}), + "allowed_purposes": frozenset({"case_notification"}), + "request_fingerprint": FINGERPRINT, + "not_before": NOW - timedelta(minutes=1), + "not_after": NOW + timedelta(minutes=5), + "max_uses": None, + "max_provider_attempts": 3, + } + fields.update(overrides) + return VerifiedWorkloadAuthorization(**fields) + + +def _reservation(**overrides) -> ConsumptionReservation: + fields = { + "reservation_id": "reservation-1", + "authorization_id": "authorization-1", + "request_fingerprint": FINGERPRINT, + "idempotency_key": "dispatch-once-1", + } + fields.update(overrides) + return ConsumptionReservation(**fields) + + +class OutcomeLedger: + def __init__(self, reservation: ConsumptionReservation | None = None): + self.reservation = reservation or _reservation() + self.requests: list[CredentialUseRequest] = [] + + async def reserve_once( + self, + _authorization: VerifiedWorkloadAuthorization, + request: CredentialUseRequest, + ) -> ConsumptionReservation: + self.requests.append(request) + return self.reservation + + +class OutcomeVerifier: + def __init__(self, authorization: VerifiedWorkloadAuthorization): + self.authorization = authorization + + async def verify( + self, + _reference: WorkloadAuthorizationReference, + _request: CredentialUseRequest, + ) -> VerifiedWorkloadAuthorization: + return self.authorization + + +async def test_single_dispatch_reservation_authorizes_only_opaque_handle_use(): + dispatch = await authorize_provider_dispatch( + _handle(), + _request(), + _authorization(max_uses=1), + ledger=OutcomeLedger(), + now=NOW, + ) + result = dispatch.authorize_handle(_handle()) + + assert result.handle_id == "handle-sms-primary-v3" + assert result.dispatch_id == "dispatch-1" + assert result.recipient_ref == "recipient-ref-1" + assert result.payload_ref == "payload-ref-1" + assert result.reservation_id == "reservation-1" + assert dispatch.max_provider_attempts == 3 + assert not hasattr(result, "material") + + +async def test_request_fingerprint_change_is_denied(): + with pytest.raises(CustodyAuthorizationDenied): + await authorize_provider_dispatch( + _handle(), + _request(request_fingerprint="b" * 64), + _authorization(max_uses=1), + ledger=OutcomeLedger(_reservation(request_fingerprint="b" * 64)), + now=NOW, + ) + + +async def test_connector_or_purpose_outside_scope_is_denied(): + with pytest.raises(CustodyAuthorizationDenied): + await authorize_provider_dispatch( + _handle(), + _request(purpose="unapproved_operation"), + _authorization(max_uses=1), + ledger=OutcomeLedger(), + now=NOW, + ) + + +async def test_expired_authorization_is_denied(): + with pytest.raises(CustodyAuthorizationDenied): + await authorize_provider_dispatch( + _handle(), + _request(), + _authorization(max_uses=1, not_after=NOW), + ledger=OutcomeLedger(), + now=NOW, + ) + + +async def test_unbounded_authorization_is_denied_before_ledger_reservation(): + ledger = OutcomeLedger() + with pytest.raises(CustodyAuthorizationDenied): + await authorize_provider_dispatch( + _handle(), _request(), _authorization(), ledger=ledger, now=NOW + ) + assert ledger.requests == [] + + +async def test_bounded_authorization_reserves_through_ledger(): + ledger = OutcomeLedger() + result = await authorize_provider_dispatch( + _handle(), + _request(), + _authorization(max_uses=1), + ledger=ledger, + now=NOW, + ) + assert result.reservation_id == "reservation-1" + assert ledger.requests == [_request()] + + +async def test_bounded_authorization_rejects_reservation_for_other_request(): + with pytest.raises(ConsumptionReservationRequired): + await authorize_provider_dispatch( + _handle(), + _request(), + _authorization(max_uses=1), + ledger=OutcomeLedger(_reservation(request_fingerprint="b" * 64)), + now=NOW, + ) + + +async def test_authorizer_obtains_verified_outcome_then_reserves_it(): + authorizer = CredentialCustodyAuthorizer( + OutcomeVerifier(_authorization(max_uses=1)), + OutcomeLedger(), + clock=lambda: NOW, + ) + result = await authorizer.authorize( + WorkloadAuthorizationReference("authorization-ref-1"), + _handle(), + _request(), + ) + assert result.authorization_id == "authorization-1" + + +async def test_invalid_initial_handle_is_denied_before_ledger_reservation(): + ledger = OutcomeLedger() + with pytest.raises(CustodyAuthorizationDenied): + await authorize_provider_dispatch( + _handle("sms-backup", "provider-backup"), + _request(), + _authorization(max_uses=1), + ledger=ledger, + now=NOW, + ) + assert ledger.requests == [] + + +async def test_one_reserved_dispatch_allows_scoped_primary_to_backup_selection(): + ledger = OutcomeLedger() + dispatch = await authorize_provider_dispatch( + _handle(), + _request(), + _authorization(max_uses=1), + ledger=ledger, + now=NOW, + ) + + primary = dispatch.authorize_handle(_handle()) + backup = dispatch.authorize_handle(_handle("sms-backup", "provider-backup")) + assert primary.reservation_id == backup.reservation_id == "reservation-1" + assert len(ledger.requests) == 1 + + with pytest.raises(CustodyAuthorizationDenied): + dispatch.authorize_handle(_handle("sms-unapproved", "provider-unapproved")) + + +def test_authorization_rejects_zero_provider_attempt_budget(): + with pytest.raises(ValueError): + _authorization(max_provider_attempts=0) + + +def test_outcome_allows_only_sanitized_failure_identifier(): + result = SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-1", + provider_key="provider-primary", + status=CustodyResultStatus.FAILED, + audit_ref="audit-1", + failure_code="provider_timeout", + ) + assert result.failure_code == "provider_timeout" + + with pytest.raises(ValueError): + SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-1", + provider_key="provider-primary", + status=CustodyResultStatus.FAILED, + audit_ref="audit-1", + failure_code="raw upstream detail: refused", + ) From 56bc5c2567227c07dc868bf5881ba4402b5984ee Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 19:38:32 -0500 Subject: [PATCH 3/9] fix(connector): bind dispatch journal entries to request scope --- src/baton/delegated_connector.py | 103 ++++++++++++++++++++++++------ tests/test_delegated_connector.py | 94 +++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 29 deletions(-) diff --git a/src/baton/delegated_connector.py b/src/baton/delegated_connector.py index c56c5cd..65a0ed7 100644 --- a/src/baton/delegated_connector.py +++ b/src/baton/delegated_connector.py @@ -16,10 +16,10 @@ from datetime import datetime, timezone from enum import StrEnum from typing import Awaitable, Callable, Protocol, Sequence -from uuid import uuid4 _SAFE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_FINGERPRINT_RE = re.compile(r"^[a-f0-9]{64}$") class Channel(StrEnum): @@ -36,6 +36,7 @@ class DeliveryStatus(StrEnum): class DispatchSignalKind(StrEnum): AUTHORIZATION_DENIED = "authorization_denied" + IDEMPOTENCY_CONFLICT = "idempotency_conflict" ATTEMPT_FAILED = "attempt_failed" ATTEMPT_SUCCEEDED = "attempt_succeeded" CIRCUIT_OPEN = "circuit_open" @@ -55,6 +56,10 @@ class DispatchInProgress(DelegatedConnectorError): """The idempotency key is already executing elsewhere.""" +class DispatchBindingConflict(DelegatedConnectorError): + """An idempotency key was reused for different immutable request content.""" + + class MonitoringUnavailable(DelegatedConnectorError): """A required sanitized signal could not be persisted.""" @@ -78,16 +83,26 @@ def __post_init__(self) -> None: class DispatchRequest: """A provider dispatch instruction containing references, never material.""" + dispatch_id: str workflow_id: str channel: Channel recipient_ref: str payload_ref: str idempotency_key: str + request_fingerprint: str def __post_init__(self) -> None: - for name in ("workflow_id", "recipient_ref", "payload_ref", "idempotency_key"): + for name in ( + "dispatch_id", + "workflow_id", + "recipient_ref", + "payload_ref", + "idempotency_key", + ): if not getattr(self, name): raise ValueError(f"{name} is required") + if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): + raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") @dataclass(frozen=True) @@ -99,6 +114,7 @@ class VerifiedDispatchGrant: allowed_connectors: frozenset[str] not_after: datetime max_attempts: int + request_fingerprint: str def __post_init__(self) -> None: if not self.principal: @@ -107,6 +123,8 @@ def __post_init__(self) -> None: raise ValueError("not_after must be timezone-aware") if self.max_attempts < 1: raise ValueError("max_attempts must be positive") + if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): + raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") @dataclass(frozen=True) @@ -182,18 +200,32 @@ class DeliveryOutcome: failure_code: str = "" +@dataclass(frozen=True) +class DispatchBinding: + """Immutable journal binding for one idempotent dispatch instruction.""" + + idempotency_key: str + request_fingerprint: str + + def __post_init__(self) -> None: + if not self.idempotency_key: + raise ValueError("idempotency_key is required") + if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): + raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") + + @dataclass(frozen=True) class DispatchSignal: """Non-sensitive operational event for audit and alert pipelines. - Terminal signals carry ``dispatch_id`` so a sink can persist them - idempotently when a caller retries after monitoring is unavailable. + Every signal carries ``dispatch_id`` so failures, failover, and terminal + delivery can be correlated without exposing provider material. """ kind: DispatchSignalKind workflow_id: str channel: Channel - dispatch_id: str = "" + dispatch_id: str connector_id: str = "" provider_key: str = "" attempt_count: int = 0 @@ -220,18 +252,22 @@ async def invoke( class DispatchJournal(Protocol): - """Atomic idempotency journal required before any provider invocation.""" + """Atomic idempotency journal required before any provider invocation. - async def completed(self, idempotency_key: str) -> DeliveryOutcome | None: + Implementations must raise ``DispatchBindingConflict`` when an + idempotency key was previously bound to a different request fingerprint. + """ + + async def completed(self, binding: DispatchBinding) -> DeliveryOutcome | None: ... - async def begin(self, idempotency_key: str) -> bool: + async def begin(self, binding: DispatchBinding) -> bool: ... - async def complete(self, idempotency_key: str, outcome: DeliveryOutcome) -> None: + async def complete(self, binding: DispatchBinding, outcome: DeliveryOutcome) -> None: ... - async def abort(self, idempotency_key: str) -> None: + async def abort(self, binding: DispatchBinding) -> None: ... @@ -297,7 +333,7 @@ def _validate_routes(routes: Sequence[ConnectorRoute]) -> tuple[ConnectorRoute, async def dispatch( self, capability: CapabilityReference, request: DispatchRequest ) -> DeliveryOutcome: - """Dispatch exactly once per idempotency key after scoped verification.""" + """Dispatch once per key and immutable request fingerprint after verification.""" try: grant = await self._verifier.verify(capability, request) self._validate_grant(grant, request) @@ -307,6 +343,7 @@ async def dispatch( kind=DispatchSignalKind.AUTHORIZATION_DENIED, workflow_id=request.workflow_id, channel=request.channel, + dispatch_id=request.dispatch_id, failure_code="authorization_denied", ) ) @@ -314,24 +351,51 @@ async def dispatch( raise raise AuthorizationDenied("dispatch authorization denied") from exc - completed = await self._journal.completed(request.idempotency_key) + binding = DispatchBinding(request.idempotency_key, request.request_fingerprint) + try: + completed = await self._journal.completed(binding) + except DispatchBindingConflict: + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.IDEMPOTENCY_CONFLICT, + workflow_id=request.workflow_id, + channel=request.channel, + dispatch_id=request.dispatch_id, + failure_code="idempotency_binding_conflict", + ) + ) + raise if completed is not None: await self._emit_terminal(completed) return completed - if not await self._journal.begin(request.idempotency_key): - raise DispatchInProgress("dispatch with this idempotency key is already in progress") + try: + if not await self._journal.begin(binding): + raise DispatchInProgress("dispatch with this idempotency key is already in progress") + except DispatchBindingConflict: + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.IDEMPOTENCY_CONFLICT, + workflow_id=request.workflow_id, + channel=request.channel, + dispatch_id=request.dispatch_id, + failure_code="idempotency_binding_conflict", + ) + ) + raise try: outcome = await self._dispatch_authorized(grant, request) - await self._journal.complete(request.idempotency_key, outcome) + await self._journal.complete(binding, outcome) except Exception: - await self._journal.abort(request.idempotency_key) + await self._journal.abort(binding) raise await self._emit_terminal(outcome) return outcome def _validate_grant(self, grant: VerifiedDispatchGrant, request: DispatchRequest) -> None: + if grant.request_fingerprint != request.request_fingerprint: + raise AuthorizationDenied("dispatch fingerprint is outside authorization scope") if grant.channel is not request.channel: raise AuthorizationDenied("dispatch channel is outside authorization scope") if self._clock() >= grant.not_after: @@ -371,6 +435,7 @@ async def _dispatch_authorized( kind=DispatchSignalKind.CIRCUIT_OPEN, workflow_id=request.workflow_id, channel=request.channel, + dispatch_id=request.dispatch_id, connector_id=route.connector_id, provider_key=route.provider_key, attempt_count=attempt_count, @@ -386,6 +451,7 @@ async def _dispatch_authorized( kind=DispatchSignalKind.FAILOVER_USED, workflow_id=request.workflow_id, channel=request.channel, + dispatch_id=request.dispatch_id, connector_id=route.connector_id, provider_key=route.provider_key, attempt_count=attempt_count, @@ -404,7 +470,7 @@ async def _dispatch_authorized( if attempt.status in (DeliveryStatus.ACCEPTED, DeliveryStatus.DELIVERED): self._record_success(route) return DeliveryOutcome( - dispatch_id=f"dispatch-{uuid4().hex}", + dispatch_id=request.dispatch_id, workflow_id=request.workflow_id, channel=request.channel, provider_key=route.provider_key, @@ -423,6 +489,7 @@ async def _dispatch_authorized( kind=DispatchSignalKind.ATTEMPT_FAILED, workflow_id=request.workflow_id, channel=request.channel, + dispatch_id=request.dispatch_id, connector_id=route.connector_id, provider_key=route.provider_key, attempt_count=attempt_count, @@ -438,7 +505,7 @@ async def _dispatch_authorized( break return DeliveryOutcome( - dispatch_id=f"dispatch-{uuid4().hex}", + dispatch_id=request.dispatch_id, workflow_id=request.workflow_id, channel=request.channel, provider_key=last_provider, diff --git a/tests/test_delegated_connector.py b/tests/test_delegated_connector.py index 7ec8ce6..5a30564 100644 --- a/tests/test_delegated_connector.py +++ b/tests/test_delegated_connector.py @@ -16,6 +16,8 @@ DelegatedConnectorExecutor, DeliveryOutcome, DeliveryStatus, + DispatchBinding, + DispatchBindingConflict, DispatchInProgress, DispatchRequest, DispatchSignal, @@ -26,13 +28,23 @@ ) -def _request(idempotency_key: str = "dispatch-once-1") -> DispatchRequest: +FINGERPRINT = "f" * 64 + + +def _request( + idempotency_key: str = "dispatch-once-1", + *, + dispatch_id: str | None = None, + request_fingerprint: str = FINGERPRINT, +) -> DispatchRequest: return DispatchRequest( + dispatch_id=dispatch_id or f"dispatch-{idempotency_key}", workflow_id="workflow-1", channel=Channel.SMS, recipient_ref="recipient-ref-1", payload_ref="payload-ref-1", idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, ) @@ -66,6 +78,7 @@ async def verify( allowed_connectors=frozenset({"primary", "backup"}), not_after=datetime.now(timezone.utc) + timedelta(minutes=5), max_attempts=4, + request_fingerprint=request.request_fingerprint, ) @@ -76,6 +89,14 @@ async def verify( raise AuthorizationDenied("denied") +class MismatchedFingerprintVerifier(AcceptedVerifier): + async def verify( + self, capability: CapabilityReference, request: DispatchRequest + ) -> VerifiedDispatchGrant: + grant = await super().verify(capability, request) + return dataclasses.replace(grant, request_fingerprint="e" * 64) + + class OutcomeInvoker: def __init__(self, outcomes: list[ProviderAttemptOutcome]): self._outcomes = list(outcomes) @@ -100,22 +121,33 @@ class MemoryJournal: def __init__(self): self.running: set[str] = set() self.results: dict[str, DeliveryOutcome] = {} + self.fingerprints: dict[str, str] = {} - async def completed(self, idempotency_key: str) -> DeliveryOutcome | None: - return self.results.get(idempotency_key) + def _assert_binding(self, binding: DispatchBinding) -> None: + fingerprint = self.fingerprints.get(binding.idempotency_key) + if fingerprint is not None and fingerprint != binding.request_fingerprint: + raise DispatchBindingConflict("idempotency key is bound to another request") - async def begin(self, idempotency_key: str) -> bool: - if idempotency_key in self.running: + async def completed(self, binding: DispatchBinding) -> DeliveryOutcome | None: + self._assert_binding(binding) + return self.results.get(binding.idempotency_key) + + async def begin(self, binding: DispatchBinding) -> bool: + self._assert_binding(binding) + if binding.idempotency_key in self.running: return False - self.running.add(idempotency_key) + self.fingerprints[binding.idempotency_key] = binding.request_fingerprint + self.running.add(binding.idempotency_key) return True - async def complete(self, idempotency_key: str, outcome: DeliveryOutcome) -> None: - self.running.discard(idempotency_key) - self.results[idempotency_key] = outcome + async def complete(self, binding: DispatchBinding, outcome: DeliveryOutcome) -> None: + self._assert_binding(binding) + self.running.discard(binding.idempotency_key) + self.results[binding.idempotency_key] = outcome - async def abort(self, idempotency_key: str) -> None: - self.running.discard(idempotency_key) + async def abort(self, binding: DispatchBinding) -> None: + self._assert_binding(binding) + self.running.discard(binding.idempotency_key) class SignalSink: @@ -188,6 +220,7 @@ async def test_success_returns_sanitized_outcome_only(): "failure_code", } assert [signal.kind for signal in sink.signals] == [DispatchSignalKind.ATTEMPT_SUCCEEDED] + assert sink.signals[0].dispatch_id == "dispatch-dispatch-once-1" async def test_authorization_denial_prevents_invocation_and_emits_signal(): @@ -206,6 +239,25 @@ async def test_authorization_denial_prevents_invocation_and_emits_signal(): assert invoker.calls == [] assert sink.signals[0].kind is DispatchSignalKind.AUTHORIZATION_DENIED + assert sink.signals[0].dispatch_id == "dispatch-dispatch-once-1" + + +async def test_authorization_fingerprint_mismatch_prevents_invocation(): + invoker = OutcomeInvoker([_success()]) + sink = SignalSink() + executor = DelegatedConnectorExecutor( + [_route("primary", 1)], + MismatchedFingerprintVerifier(), + invoker, + MemoryJournal(), + sink, + ) + + with pytest.raises(AuthorizationDenied): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert invoker.calls == [] + assert sink.signals[0].kind is DispatchSignalKind.AUTHORIZATION_DENIED async def test_failed_primary_fails_over_to_backup_with_sanitized_signals(): @@ -224,6 +276,7 @@ async def test_failed_primary_fails_over_to_backup_with_sanitized_signals(): DispatchSignalKind.FAILOVER_USED, DispatchSignalKind.ATTEMPT_SUCCEEDED, ] + assert {signal.dispatch_id for signal in sink.signals} == {"dispatch-dispatch-once-1"} async def test_provider_failure_without_failover_approval_does_not_send_to_backup(): @@ -288,9 +341,28 @@ async def test_completed_idempotency_key_returns_without_second_invocation(): assert invoker.calls == ["primary"] +async def test_changed_request_fingerprint_cannot_reuse_completed_idempotency_key(): + invoker = OutcomeInvoker([_success()]) + journal = MemoryJournal() + sink = SignalSink() + executor = _executor(invoker, journal=journal, sink=sink) + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + with pytest.raises(DispatchBindingConflict): + await executor.dispatch( + CapabilityReference("authorization-ref-2"), + _request(request_fingerprint="e" * 64, dispatch_id="dispatch-changed"), + ) + + assert invoker.calls == ["primary"] + assert sink.signals[-1].kind is DispatchSignalKind.IDEMPOTENCY_CONFLICT + assert sink.signals[-1].dispatch_id == "dispatch-changed" + + async def test_in_progress_idempotency_key_fails_closed(): journal = MemoryJournal() journal.running.add("dispatch-once-1") + journal.fingerprints["dispatch-once-1"] = FINGERPRINT executor = _executor(OutcomeInvoker([_success()]), journal=journal) with pytest.raises(DispatchInProgress): From f956fbe6c4ab1d94c0a197c49a7e0c47ef922ee0 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 20:25:25 -0500 Subject: [PATCH 4/9] Enforce credential custody preparation in delegated dispatch --- docs/credential-custody-boundary.md | 19 ++- src/baton/credential_custody.py | 167 +++++++++++++++++++++++- src/baton/delegated_connector.py | 82 ++++++++++-- tests/test_credential_custody.py | 195 ++++++++++++++++++++++++++++ tests/test_delegated_connector.py | 85 +++++++++++- 5 files changed, 526 insertions(+), 22 deletions(-) diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md index 5c47903..cb9a3ce 100644 --- a/docs/credential-custody-boundary.md +++ b/docs/credential-custody-boundary.md @@ -23,6 +23,12 @@ telephony, or other external-provider credential value. fingerprint, and idempotency key. - `CustodiedReferenceResolver` is the only permitted provider-operation boundary. It resolves material internally and returns a sanitized outcome. +- `CredentialCustodyInvokerFactory` is the adapter required by + `DelegatedConnectorExecutor`: after dispatch idempotency begins and only + immediately before an actual provider attempt, it validates route-to-handle + bindings and the complete configured failover scope, consumes one + authorization reservation, and returns an invoker that can call only + `CustodiedReferenceResolver`. - The resulting `AuthorizedProviderDispatch` carries one ledger reservation across primary and backup attempts, but can select only connector handles already included in its verified scope and is bounded by its verified @@ -38,15 +44,24 @@ telephony, or other external-provider credential value. - Sanitized outcomes carry provider identity, status, correlation identifiers, audit reference, and bounded failure code only. They do not carry material, provider response bodies, recipient data, or message data. +- Delegated terminal outcomes carry connector identity as well as provider + identity, so audits and failure notifications can identify the configured + connector without revealing custody material. ## Integration Gate -This module is a contract surface, not a configured production custody store. +These modules are a connected contract surface, not a configured production +custody store. The delegated executor no longer accepts a direct provider +invoker; production construction must supply `CredentialCustodyInvokerFactory` +or an equivalently audited custody implementation. Its protocol seam exists +for testing and alternate approved backends, so deployment wiring remains an +enforcement gate. MEA integration remains blocked until all of the following exist: 1. A trusted Signet-compatible verifier implementation with issuer and rotation policy. -2. A durable consumption ledger with atomic reserve/complete/replay behavior. +2. A durable consumption ledger and dispatch journal with defined atomic + reserve/complete/replay and crash-recovery behavior. 3. A custody-internal resolver implementation, potentially backed by OpenBao after license, deployment, assurance, and operational review. 4. Tamper-evident audit and failure notification sink implementations. diff --git a/src/baton/credential_custody.py b/src/baton/credential_custody.py index b3d3431..809ca3b 100644 --- a/src/baton/credential_custody.py +++ b/src/baton/credential_custody.py @@ -12,9 +12,20 @@ from dataclasses import dataclass from datetime import datetime, timezone from enum import StrEnum -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Protocol +from .delegated_connector import ( + AuthorizationDenied as DelegatedAuthorizationDenied, + CapabilityReference, + ConnectorRoute, + CustodiedProviderInvoker, + DeliveryStatus, + DispatchRequest, + ProviderAttemptOutcome, + VerifiedDispatchGrant, +) + _FINGERPRINT_RE = re.compile(r"^[a-f0-9]{64}$") _CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") @@ -226,6 +237,9 @@ class SanitizedCustodyOutcome: status: CustodyResultStatus audit_ref: str failure_code: str = "" + retryable: bool = False + failover_allowed: bool = False + counts_toward_circuit: bool = False def __post_init__(self) -> None: if not self.operation_id or not self.dispatch_id or not self.provider_key: @@ -238,6 +252,10 @@ def __post_init__(self) -> None: raise ValueError("failed outcomes require a failure_code") if self.status is not CustodyResultStatus.FAILED and self.failure_code: raise ValueError("successful outcomes cannot contain a failure_code") + if self.status is not CustodyResultStatus.FAILED and ( + self.retryable or self.failover_allowed or self.counts_toward_circuit + ): + raise ValueError("successful outcomes cannot contain failure policy flags") @dataclass(frozen=True) @@ -332,6 +350,8 @@ async def authorize_provider_dispatch( authorization: VerifiedWorkloadAuthorization, *, ledger: AuthorizationConsumptionLedger, + provider_attempt_budget: int | None = None, + required_connector_ids: frozenset[str] | None = None, now: datetime | None = None, ) -> AuthorizedProviderDispatch: """Validate and reserve a credential-free verifier outcome for one dispatch. @@ -354,6 +374,10 @@ async def authorize_provider_dispatch( ) if request.connector_id not in authorization.allowed_connector_ids: raise CustodyAuthorizationDenied("initial connector is outside authorization scope") + if required_connector_ids and not required_connector_ids.issubset( + authorization.allowed_connector_ids + ): + raise CustodyAuthorizationDenied("route policy is outside authorization scope") if ( initial_handle.channel is not request.channel or request.channel not in authorization.allowed_channels @@ -361,6 +385,13 @@ async def authorize_provider_dispatch( raise CustodyAuthorizationDenied("channel is outside authorization scope") if request.purpose not in authorization.allowed_purposes: raise CustodyAuthorizationDenied("purpose is outside authorization scope") + if ( + provider_attempt_budget is not None + and provider_attempt_budget > authorization.max_provider_attempts + ): + raise CustodyAuthorizationDenied( + "dispatch attempt budget exceeds credential custody scope" + ) if authorization.max_uses != 1: raise CustodyAuthorizationDenied( @@ -412,6 +443,9 @@ async def authorize( reference: WorkloadAuthorizationReference, handle: ProviderCredentialHandle, request: CredentialUseRequest, + *, + provider_attempt_budget: int | None = None, + required_connector_ids: frozenset[str] | None = None, ) -> AuthorizedProviderDispatch: authorization = await self._verifier.verify(reference, request) reserved_dispatch = await authorize_provider_dispatch( @@ -419,6 +453,137 @@ async def authorize( request, authorization, ledger=self._ledger, + provider_attempt_budget=provider_attempt_budget, + required_connector_ids=required_connector_ids, now=self._clock(), ) return reserved_dispatch + + +class CredentialCustodyInvokerFactory: + """Connect delegated dispatch to a single reserved custody execution path.""" + + def __init__( + self, + authorizer: CredentialCustodyAuthorizer, + resolver: CustodiedReferenceResolver, + handles: Mapping[str, ProviderCredentialHandle], + *, + workload_id: str, + purpose: str, + ): + if not workload_id or not purpose: + raise ValueError("workload_id and purpose are required") + self._authorizer = authorizer + self._resolver = resolver + self._handles = dict(handles) + self._workload_id = workload_id + self._purpose = purpose + + def _handle_for_route(self, route: ConnectorRoute) -> ProviderCredentialHandle: + handle = self._handles.get(route.connector_id) + expected_channel = ProviderChannel(route.channel.value) + if ( + handle is None + or handle.handle_id != route.credential_handle + or handle.provider_key != route.provider_key + or handle.channel is not expected_channel + ): + raise DelegatedAuthorizationDenied("connector custody binding is invalid") + return handle + + async def prepare( + self, + capability: CapabilityReference, + request: DispatchRequest, + grant: VerifiedDispatchGrant, + initial_route: ConnectorRoute, + authorized_routes: tuple[ConnectorRoute, ...], + ) -> CustodiedProviderInvoker: + if initial_route not in authorized_routes: + raise DelegatedAuthorizationDenied("initial route is outside dispatch policy") + initial_handle = self._handle_for_route(initial_route) + for route in authorized_routes: + self._handle_for_route(route) + custody_request = CredentialUseRequest( + operation_id=request.workflow_id, + dispatch_id=request.dispatch_id, + workload_id=self._workload_id, + connector_id=initial_route.connector_id, + channel=ProviderChannel(request.channel.value), + purpose=self._purpose, + recipient_ref=request.recipient_ref, + payload_ref=request.payload_ref, + idempotency_key=request.idempotency_key, + request_fingerprint=request.request_fingerprint, + ) + try: + reserved_dispatch = await self._authorizer.authorize( + WorkloadAuthorizationReference(capability.reference), + initial_handle, + custody_request, + provider_attempt_budget=grant.max_attempts, + required_connector_ids=frozenset( + route.connector_id for route in authorized_routes + ), + ) + except CustodyAuthorizationDenied as exc: + raise DelegatedAuthorizationDenied("credential custody authorization denied") from exc + return _ReservedCustodiedProviderInvoker( + resolver=self._resolver, + handles=self._handles, + dispatch=reserved_dispatch, + request=request, + ) + + +class _ReservedCustodiedProviderInvoker: + def __init__( + self, + *, + resolver: CustodiedReferenceResolver, + handles: Mapping[str, ProviderCredentialHandle], + dispatch: AuthorizedProviderDispatch, + request: DispatchRequest, + ): + self._resolver = resolver + self._handles = dict(handles) + self._dispatch = dispatch + self._request = request + + async def invoke( + self, route: ConnectorRoute, request: DispatchRequest + ) -> ProviderAttemptOutcome: + if ( + request.dispatch_id != self._request.dispatch_id + or request.request_fingerprint != self._request.request_fingerprint + or request.idempotency_key != self._request.idempotency_key + ): + raise DelegatedAuthorizationDenied("prepared custody dispatch was rebound") + handle = self._handles.get(route.connector_id) + if ( + handle is None + or handle.handle_id != route.credential_handle + or handle.provider_key != route.provider_key + or handle.channel.value != route.channel.value + ): + raise DelegatedAuthorizationDenied("connector custody binding is invalid") + try: + authorized_use = self._dispatch.authorize_handle(handle) + outcome = await self._resolver.invoke(handle, authorized_use) + except CustodyAuthorizationDenied as exc: + raise DelegatedAuthorizationDenied("provider custody invocation denied") from exc + if ( + outcome.operation_id != self._dispatch.operation_id + or outcome.dispatch_id != request.dispatch_id + or outcome.provider_key != route.provider_key + ): + raise DelegatedAuthorizationDenied("custody outcome binding is invalid") + return ProviderAttemptOutcome( + status=DeliveryStatus(outcome.status.value), + audit_ref=outcome.audit_ref, + failure_code=outcome.failure_code, + retryable=outcome.retryable, + failover_allowed=outcome.failover_allowed, + counts_toward_circuit=outcome.counts_toward_circuit, + ) diff --git a/src/baton/delegated_connector.py b/src/baton/delegated_connector.py index 65a0ed7..c6205b6 100644 --- a/src/baton/delegated_connector.py +++ b/src/baton/delegated_connector.py @@ -3,8 +3,10 @@ This module is the Baton-side runtime boundary for provider-backed delivery. It accepts opaque authorization and connector references, orchestrates retries, failover, and circuit breakers, and returns sanitized outcomes only. The -``CustodiedProviderInvoker`` implementation is the single-purpose boundary -allowed to resolve and use provider credential material internally. +``CustodiedProviderInvokerFactory`` prepares a custody-authorized invoker +immediately before a provider attempt. Its prepared invoker is the +single-purpose boundary allowed to resolve and use provider credential +material internally. """ from __future__ import annotations @@ -192,6 +194,7 @@ class DeliveryOutcome: dispatch_id: str workflow_id: str channel: Channel + connector_id: str provider_key: str status: DeliveryStatus attempt_count: int @@ -251,6 +254,21 @@ async def invoke( ... +class CustodiedProviderInvokerFactory(Protocol): + """Prepare a custody-bound invoker after idempotency begins, before sending.""" + + async def prepare( + self, + capability: CapabilityReference, + request: DispatchRequest, + grant: VerifiedDispatchGrant, + initial_route: ConnectorRoute, + authorized_routes: tuple[ConnectorRoute, ...], + ) -> CustodiedProviderInvoker: + """Reserve authority for the attempted route policy without using material.""" + ... + + class DispatchJournal(Protocol): """Atomic idempotency journal required before any provider invocation. @@ -295,7 +313,7 @@ def __init__( self, routes: Sequence[ConnectorRoute], verifier: ScopedAuthorizationVerifier, - invoker: CustodiedProviderInvoker, + invoker_factory: CustodiedProviderInvokerFactory, journal: DispatchJournal, signal_sink: DispatchSignalSink, *, @@ -305,7 +323,7 @@ def __init__( ): self._routes = self._validate_routes(routes) self._verifier = verifier - self._invoker = invoker + self._invoker_factory = invoker_factory self._journal = journal self._signal_sink = signal_sink self._clock = clock or (lambda: datetime.now(timezone.utc)) @@ -384,8 +402,20 @@ async def dispatch( raise try: - outcome = await self._dispatch_authorized(grant, request) + outcome = await self._dispatch_authorized(capability, grant, request) await self._journal.complete(binding, outcome) + except AuthorizationDenied: + await self._journal.abort(binding) + await self._emit( + DispatchSignal( + kind=DispatchSignalKind.AUTHORIZATION_DENIED, + workflow_id=request.workflow_id, + channel=request.channel, + dispatch_id=request.dispatch_id, + failure_code="authorization_denied", + ) + ) + raise except Exception: await self._journal.abort(binding) raise @@ -409,27 +439,36 @@ def _validate_grant(self, grant: VerifiedDispatchGrant, request: DispatchRequest raise AuthorizationDenied("no authorized active connector for dispatch") async def _dispatch_authorized( - self, grant: VerifiedDispatchGrant, request: DispatchRequest + self, + capability: CapabilityReference, + grant: VerifiedDispatchGrant, + request: DispatchRequest, ) -> DeliveryOutcome: - routes = [ + routes = tuple( route for route in self._routes if route.channel is request.channel and route.enabled and route.connector_id in grant.allowed_connectors - ] + ) attempt_count = 0 + last_connector = "" last_provider = "" last_audit_ref = "" last_failure = "provider_unavailable" failover_used = False attempted_or_skipped_route = False allow_next_route = True + invoker: CustodiedProviderInvoker | None = None for route in routes: if not allow_next_route: break if self._circuit_is_open(route): + last_connector = route.connector_id + last_provider = route.provider_key + last_audit_ref = f"circuit:{request.dispatch_id}:{route.connector_id}" + last_failure = "circuit_open" await self._emit( DispatchSignal( kind=DispatchSignalKind.CIRCUIT_OPEN, @@ -459,13 +498,22 @@ async def _dispatch_authorized( ) attempted_or_skipped_route = True allow_next_route = False + if invoker is None: + invoker = await self._invoker_factory.prepare( + capability, + request, + grant, + route, + routes, + ) for local_attempt in range(route.max_attempts): if attempt_count >= grant.max_attempts: break attempt_count += 1 + last_connector = route.connector_id last_provider = route.provider_key - attempt = await self._invoke(route, request) + attempt = await self._invoke(invoker, route, request) last_audit_ref = attempt.audit_ref if attempt.status in (DeliveryStatus.ACCEPTED, DeliveryStatus.DELIVERED): self._record_success(route) @@ -473,6 +521,7 @@ async def _dispatch_authorized( dispatch_id=request.dispatch_id, workflow_id=request.workflow_id, channel=request.channel, + connector_id=route.connector_id, provider_key=route.provider_key, status=attempt.status, attempt_count=attempt_count, @@ -508,6 +557,7 @@ async def _dispatch_authorized( dispatch_id=request.dispatch_id, workflow_id=request.workflow_id, channel=request.channel, + connector_id=last_connector, provider_key=last_provider, status=DeliveryStatus.EXHAUSTED, attempt_count=attempt_count, @@ -517,17 +567,22 @@ async def _dispatch_authorized( ) async def _invoke( - self, route: ConnectorRoute, request: DispatchRequest + self, + invoker: CustodiedProviderInvoker, + route: ConnectorRoute, + request: DispatchRequest, ) -> ProviderAttemptOutcome: try: return await asyncio.wait_for( - self._invoker.invoke(route, request), + invoker.invoke(route, request), timeout=route.timeout_ms / 1000, ) + except AuthorizationDenied: + raise except TimeoutError: return ProviderAttemptOutcome( status=DeliveryStatus.FAILED, - audit_ref=f"timeout:{request.workflow_id}:{route.connector_id}", + audit_ref=f"timeout:{request.dispatch_id}:{route.connector_id}", failure_code="provider_timeout", retryable=True, failover_allowed=True, @@ -536,7 +591,7 @@ async def _invoke( except Exception: return ProviderAttemptOutcome( status=DeliveryStatus.FAILED, - audit_ref=f"error:{request.workflow_id}:{route.connector_id}", + audit_ref=f"error:{request.dispatch_id}:{route.connector_id}", failure_code="provider_error", retryable=True, failover_allowed=True, @@ -584,6 +639,7 @@ async def _emit_terminal(self, outcome: DeliveryOutcome) -> None: dispatch_id=outcome.dispatch_id, workflow_id=outcome.workflow_id, channel=outcome.channel, + connector_id=outcome.connector_id, provider_key=outcome.provider_key, attempt_count=outcome.attempt_count, failure_code=outcome.failure_code, diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py index 08b1a13..1917216 100644 --- a/tests/test_credential_custody.py +++ b/tests/test_credential_custody.py @@ -9,6 +9,7 @@ ConsumptionReservationRequired, CredentialUseRequest, CredentialCustodyAuthorizer, + CredentialCustodyInvokerFactory, CustodyAuthorizationDenied, CustodyResultStatus, ProviderChannel, @@ -18,6 +19,15 @@ WorkloadAuthorizationReference, authorize_provider_dispatch, ) +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + ConnectorRoute, + DeliveryStatus, + DispatchRequest, + VerifiedDispatchGrant, +) FINGERPRINT = "a" * 64 @@ -108,6 +118,59 @@ async def verify( return self.authorization +class OutcomeResolver: + def __init__(self, outcome: SanitizedCustodyOutcome): + self.outcome = outcome + self.calls = [] + + async def invoke(self, handle, authorized_use) -> SanitizedCustodyOutcome: + self.calls.append((handle, authorized_use)) + return self.outcome + + +def _delegated_request() -> DispatchRequest: + return DispatchRequest( + dispatch_id="dispatch-1", + workflow_id="operation-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key="dispatch-once-1", + request_fingerprint=FINGERPRINT, + ) + + +def _delegated_route() -> ConnectorRoute: + return ConnectorRoute( + connector_id="sms-primary", + provider_key="provider-primary", + channel=Channel.SMS, + credential_handle="handle-sms-primary-v3", + priority=1, + ) + + +def _delegated_backup_route() -> ConnectorRoute: + return ConnectorRoute( + connector_id="sms-backup", + provider_key="provider-backup", + channel=Channel.SMS, + credential_handle="handle-sms-backup-v3", + priority=2, + ) + + +def _delegated_grant(max_attempts: int = 1) -> VerifiedDispatchGrant: + return VerifiedDispatchGrant( + principal="mea-comms", + channel=Channel.SMS, + allowed_connectors=frozenset({"sms-primary", "sms-backup"}), + not_after=NOW + timedelta(minutes=5), + max_attempts=max_attempts, + request_fingerprint=FINGERPRINT, + ) + + async def test_single_dispatch_reservation_authorizes_only_opaque_handle_use(): dispatch = await authorize_provider_dispatch( _handle(), @@ -239,6 +302,127 @@ async def test_one_reserved_dispatch_allows_scoped_primary_to_backup_selection() dispatch.authorize_handle(_handle("sms-unapproved", "provider-unapproved")) +async def test_delegated_factory_reserves_before_custodied_provider_invocation(): + ledger = OutcomeLedger() + authorizer = CredentialCustodyAuthorizer( + OutcomeVerifier(_authorization(max_uses=1)), + ledger, + clock=lambda: NOW, + ) + resolver = OutcomeResolver( + SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-1", + provider_key="provider-primary", + status=CustodyResultStatus.FAILED, + audit_ref="audit-1", + failure_code="provider_timeout", + retryable=True, + failover_allowed=True, + counts_toward_circuit=True, + ) + ) + factory = CredentialCustodyInvokerFactory( + authorizer, + resolver, + {"sms-primary": _handle()}, + workload_id="mea-comms", + purpose="case_notification", + ) + route = _delegated_route() + invoker = await factory.prepare( + CapabilityReference("authorization-ref-1"), + _delegated_request(), + _delegated_grant(), + route, + (route,), + ) + result = await invoker.invoke(route, _delegated_request()) + + assert ledger.requests[0].dispatch_id == "dispatch-1" + assert ledger.requests[0].connector_id == "sms-primary" + assert resolver.calls[0][1].reservation_id == "reservation-1" + assert result.status is DeliveryStatus.FAILED + assert result.retryable is True + assert result.failover_allowed is True + assert result.counts_toward_circuit is True + + +async def test_delegated_factory_denies_attempt_budget_above_custody_authority(): + ledger = OutcomeLedger() + factory = CredentialCustodyInvokerFactory( + CredentialCustodyAuthorizer( + OutcomeVerifier(_authorization(max_uses=1, max_provider_attempts=1)), + ledger, + clock=lambda: NOW, + ), + OutcomeResolver( + SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-1", + provider_key="provider-primary", + status=CustodyResultStatus.ACCEPTED, + audit_ref="audit-1", + ) + ), + {"sms-primary": _handle()}, + workload_id="mea-comms", + purpose="case_notification", + ) + route = _delegated_route() + with pytest.raises(AuthorizationDenied): + await factory.prepare( + CapabilityReference("authorization-ref-1"), + _delegated_request(), + _delegated_grant(max_attempts=2), + route, + (route,), + ) + assert ledger.requests == [] + + +async def test_delegated_factory_denies_failover_policy_outside_custody_scope_before_reserving(): + ledger = OutcomeLedger() + factory = CredentialCustodyInvokerFactory( + CredentialCustodyAuthorizer( + OutcomeVerifier( + _authorization( + max_uses=1, + allowed_connector_ids=frozenset({"sms-primary"}), + ) + ), + ledger, + clock=lambda: NOW, + ), + OutcomeResolver( + SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-1", + provider_key="provider-primary", + status=CustodyResultStatus.ACCEPTED, + audit_ref="audit-1", + ) + ), + { + "sms-primary": _handle(), + "sms-backup": _handle("sms-backup", "provider-backup"), + }, + workload_id="mea-comms", + purpose="case_notification", + ) + primary = _delegated_route() + backup = _delegated_backup_route() + with pytest.raises(AuthorizationDenied): + await factory.prepare( + CapabilityReference("authorization-ref-1"), + _delegated_request(), + _delegated_grant(), + primary, + (primary, backup), + ) + assert ledger.requests == [] + + def test_authorization_rejects_zero_provider_attempt_budget(): with pytest.raises(ValueError): _authorization(max_provider_attempts=0) @@ -254,6 +438,7 @@ def test_outcome_allows_only_sanitized_failure_identifier(): failure_code="provider_timeout", ) assert result.failure_code == "provider_timeout" + assert result.retryable is False with pytest.raises(ValueError): SanitizedCustodyOutcome( @@ -264,3 +449,13 @@ def test_outcome_allows_only_sanitized_failure_identifier(): audit_ref="audit-1", failure_code="raw upstream detail: refused", ) + + with pytest.raises(ValueError): + SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-1", + provider_key="provider-primary", + status=CustodyResultStatus.ACCEPTED, + audit_ref="audit-1", + retryable=True, + ) diff --git a/tests/test_delegated_connector.py b/tests/test_delegated_connector.py index 5a30564..cd14c33 100644 --- a/tests/test_delegated_connector.py +++ b/tests/test_delegated_connector.py @@ -117,6 +117,42 @@ async def invoke( raise AssertionError("unreachable") +class OutcomeInvokerFactory: + def __init__(self, invoker): + self.invoker = invoker + self.prepare_calls: list[str] = [] + + async def prepare( + self, + _capability: CapabilityReference, + _request: DispatchRequest, + _grant: VerifiedDispatchGrant, + initial_route: ConnectorRoute, + _authorized_routes: tuple[ConnectorRoute, ...], + ): + self.prepare_calls.append(initial_route.connector_id) + return self.invoker + + +class JournalCheckingFactory(OutcomeInvokerFactory): + def __init__(self, invoker, journal): + super().__init__(invoker) + self.journal = journal + + async def prepare( + self, + capability: CapabilityReference, + request: DispatchRequest, + grant: VerifiedDispatchGrant, + initial_route: ConnectorRoute, + authorized_routes: tuple[ConnectorRoute, ...], + ): + assert request.idempotency_key in self.journal.running + return await super().prepare( + capability, request, grant, initial_route, authorized_routes + ) + + class MemoryJournal: def __init__(self): self.running: set[str] = set() @@ -186,11 +222,11 @@ def _failure( ) -def _executor(invoker, journal=None, sink=None, routes=None) -> DelegatedConnectorExecutor: +def _executor(invoker, journal=None, sink=None, routes=None, factory=None) -> DelegatedConnectorExecutor: return DelegatedConnectorExecutor( routes or [_route("primary", 1), _route("backup", 2)], AcceptedVerifier(), - invoker, + factory or OutcomeInvokerFactory(invoker), journal or MemoryJournal(), sink or SignalSink(), sleep=lambda _delay: asyncio.sleep(0), @@ -212,6 +248,7 @@ async def test_success_returns_sanitized_outcome_only(): "dispatch_id", "workflow_id", "channel", + "connector_id", "provider_key", "status", "attempt_count", @@ -221,15 +258,17 @@ async def test_success_returns_sanitized_outcome_only(): } assert [signal.kind for signal in sink.signals] == [DispatchSignalKind.ATTEMPT_SUCCEEDED] assert sink.signals[0].dispatch_id == "dispatch-dispatch-once-1" + assert sink.signals[0].connector_id == "primary" async def test_authorization_denial_prevents_invocation_and_emits_signal(): invoker = OutcomeInvoker([_success()]) + factory = OutcomeInvokerFactory(invoker) sink = SignalSink() executor = DelegatedConnectorExecutor( [_route("primary", 1)], DeniedVerifier(), - invoker, + factory, MemoryJournal(), sink, ) @@ -238,17 +277,19 @@ async def test_authorization_denial_prevents_invocation_and_emits_signal(): await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) assert invoker.calls == [] + assert factory.prepare_calls == [] assert sink.signals[0].kind is DispatchSignalKind.AUTHORIZATION_DENIED assert sink.signals[0].dispatch_id == "dispatch-dispatch-once-1" async def test_authorization_fingerprint_mismatch_prevents_invocation(): invoker = OutcomeInvoker([_success()]) + factory = OutcomeInvokerFactory(invoker) sink = SignalSink() executor = DelegatedConnectorExecutor( [_route("primary", 1)], MismatchedFingerprintVerifier(), - invoker, + factory, MemoryJournal(), sink, ) @@ -257,6 +298,7 @@ async def test_authorization_fingerprint_mismatch_prevents_invocation(): await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) assert invoker.calls == [] + assert factory.prepare_calls == [] assert sink.signals[0].kind is DispatchSignalKind.AUTHORIZATION_DENIED @@ -332,13 +374,44 @@ async def test_timeout_is_sanitized_and_fails_over(): async def test_completed_idempotency_key_returns_without_second_invocation(): invoker = OutcomeInvoker([_success()]) + factory = OutcomeInvokerFactory(invoker) journal = MemoryJournal() - executor = _executor(invoker, journal=journal) + executor = _executor(invoker, journal=journal, factory=factory) first = await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) second = await executor.dispatch(CapabilityReference("authorization-ref-2"), _request()) assert first == second assert invoker.calls == ["primary"] + assert factory.prepare_calls == ["primary"] + + +async def test_custody_preparation_occurs_only_after_idempotency_begin(): + invoker = OutcomeInvoker([_success()]) + journal = MemoryJournal() + factory = JournalCheckingFactory(invoker, journal) + outcome = await _executor(invoker, journal=journal, factory=factory).dispatch( + CapabilityReference("authorization-ref-1"), _request() + ) + + assert outcome.status is DeliveryStatus.ACCEPTED + assert factory.prepare_calls == ["primary"] + + +async def test_open_circuit_without_attempt_does_not_prepare_custody_invoker(): + invoker = OutcomeInvoker([_failure(counts_toward_circuit=True)]) + factory = OutcomeInvokerFactory(invoker) + executor = _executor( + invoker, + routes=[_route("primary", 1, threshold=1)], + factory=factory, + ) + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request("one")) + outcome = await executor.dispatch(CapabilityReference("authorization-ref-2"), _request("two")) + + assert outcome.status is DeliveryStatus.EXHAUSTED + assert outcome.failure_code == "circuit_open" + assert outcome.connector_id == "primary" + assert factory.prepare_calls == ["primary"] async def test_changed_request_fingerprint_cannot_reuse_completed_idempotency_key(): @@ -373,7 +446,7 @@ async def test_signal_failure_prevents_unaudited_authorization_denial(): executor = DelegatedConnectorExecutor( [_route("primary", 1)], DeniedVerifier(), - OutcomeInvoker([_success()]), + OutcomeInvokerFactory(OutcomeInvoker([_success()])), MemoryJournal(), SignalSink(failures_remaining=1), ) From 8afa68336b52c6d300baf950e243619ae7b5f7a6 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 21:36:52 -0500 Subject: [PATCH 5/9] feat(connector): add durable delegated runtime --- docs/credential-custody-boundary.md | 87 ++- src/baton/credential_custody.py | 234 ++++++- src/baton/delegated_connector.py | 56 +- src/baton/delegated_runtime.py | 1002 +++++++++++++++++++++++++++ tests/test_credential_custody.py | 31 + tests/test_delegated_connector.py | 73 +- tests/test_delegated_runtime.py | 661 ++++++++++++++++++ 7 files changed, 2103 insertions(+), 41 deletions(-) create mode 100644 src/baton/delegated_runtime.py create mode 100644 tests/test_delegated_runtime.py diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md index cb9a3ce..1e7ff66 100644 --- a/docs/credential-custody-boundary.md +++ b/docs/credential-custody-boundary.md @@ -33,6 +33,31 @@ telephony, or other external-provider credential value. across primary and backup attempts, but can select only connector handles already included in its verified scope and is bounded by its verified provider-attempt budget. +- Every custody request and provider-attempt reservation is bound to the exact + active dispatch claim. An expired or reclaimed claim cannot reserve authority, + invoke a provider, renew itself, or complete the dispatch. + +## Runtime Composition + +`src/baton/delegated_runtime.py` supplies the reusable composition boundary: + +- `DelegatedRuntimeComponents` requires an explicit durable dispatch journal, + authorization ledger, dispatch signal sink, custody audit sink, failure + notifier, and the journal's real claim-lease duration. +- `DelegatedConnectorRuntime.compose` validates route-to-handle bindings and + requires the claim lease to exceed every bounded provider-attempt timeout + before constructing an executor. +- `SinglePurposeProviderOperationFactory` is the only operation factory used by + the concrete resolver. Provider material may exist inside its prepared + operation, but no Baton request, authorization, outcome, event, or runtime + state type contains that material. +- `ConfiguredVerifierBundle` requires explicit issuer and rotation policy + references. Those references make trust configuration visible; they are not + proof that the supplied verifier performs trusted signature verification. +- `SqliteDelegatedRuntimeState` and + `DelegatedConnectorRuntime.build_sqlite_reference` are single-node reference + implementations for executable recovery evidence. They are not a + multi-replica production state backend. ## Failure Semantics @@ -47,25 +72,67 @@ telephony, or other external-provider credential value. - Delegated terminal outcomes carry connector identity as well as provider identity, so audits and failure notifications can identify the configured connector without revealing custody material. +- A provider attempt is atomically reserved in the durable authorization ledger + before invocation. Its verified attempt budget remains consumed across + process crashes, dispatch aborts, and stale-claim recovery. +- A successful provider operation followed by custody outcome or audit + persistence failure returns non-retryable `custody_state_unavailable`; the + executor does not automatically send again. +- A provider operation followed by dispatch-journal completion failure raises + `DispatchStateUnavailable` and leaves the claim for explicit recovery. It + does not erase or abort the post-provider claim. +- After a dispatch claim is acquired, exceptional execution paths do not + automatically abort it. Because the executor may not know whether a provider + operation occurred, the lease remains in place until controlled recovery. + +## Recovery Windows + +The runtime deliberately fails closed around these crash windows: + +1. Before provider-attempt reservation, an expired claim may be reclaimed. The + old claim can no longer reserve or invoke. +2. After provider-attempt reservation but before provider invocation, the + durable budget remains consumed. Recovery does not blindly repeat that + attempt. +3. After provider invocation but before custody outcome or dispatch-journal + completion, delivery is uncertain. The custody-internal provider operation + must enforce the supplied idempotency key; without that provider-side + guarantee, at-least-once recovery can duplicate delivery. +4. After dispatch-journal completion but before terminal signal persistence, a + replay returns the completed sanitized outcome and retries the idempotent + signal without invoking the provider again. + +The SQLite reference hash chain detects mutation when verified against its +current anchor, but it is tamper-evident rather than tamper-proof. A privileged +actor could rewrite the database and recompute the chain. Its failure queue is +durable and acknowledgeable, but does not prove that an external operator was +paged. ## Integration Gate -These modules are a connected contract surface, not a configured production -custody store. The delegated executor no longer accepts a direct provider -invoker; production construction must supply `CredentialCustodyInvokerFactory` -or an equivalently audited custody implementation. Its protocol seam exists -for testing and alternate approved backends, so deployment wiring remains an -enforcement gate. +These modules are a connected runtime and executable reference, not a +configured production custody deployment. The delegated executor no longer +accepts a direct provider invoker. Production construction must use explicit +durable components and an audited custody implementation; the protocol seams +exist for approved cloud-neutral backends. + MEA integration remains blocked until all of the following exist: 1. A trusted Signet-compatible verifier implementation with issuer and rotation policy. -2. A durable consumption ledger and dispatch journal with defined atomic - reserve/complete/replay and crash-recovery behavior. +2. A shared, highly available multi-replica implementation of the durable + component contracts, including atomic claim, reservation, attempt-budget, + completion, replay, and recovery behavior. 3. A custody-internal resolver implementation, potentially backed by OpenBao after license, deployment, assurance, and operational review. -4. Tamper-evident audit and failure notification sink implementations. -5. Key-free executable evidence for each boundary and the provider executor. +4. An externally anchored audit pipeline and an operated failure-notification + consumer with tested acknowledgement and escalation. +5. Provider-operation idempotency and operator reconciliation for uncertain + post-invocation outcomes. +6. A shared circuit-breaker implementation or an explicit decision to accept + process-local circuit state. The current executor's circuit state is local + to one process. +7. Key-free executable evidence for each boundary and the provider executor. No provider credential values or signing keys are used or packaged by this contract or its tests. diff --git a/src/baton/credential_custody.py b/src/baton/credential_custody.py index 809ca3b..b047e3a 100644 --- a/src/baton/credential_custody.py +++ b/src/baton/credential_custody.py @@ -21,6 +21,7 @@ ConnectorRoute, CustodiedProviderInvoker, DeliveryStatus, + DispatchClaim, DispatchRequest, ProviderAttemptOutcome, VerifiedDispatchGrant, @@ -63,6 +64,10 @@ class ConsumptionReservationRequired(CustodyAuthorizationDenied): """Bounded-use authority lacks an atomic consumption reservation.""" +class CustodyMonitoringUnavailable(CustodyError): + """Durable sanitized custody audit or failure notification is unavailable.""" + + @dataclass(frozen=True) class ProviderCredentialHandle: """Opaque lookup handle configured by an administrator. @@ -107,6 +112,7 @@ class CredentialUseRequest: recipient_ref: str payload_ref: str idempotency_key: str + dispatch_claim_id: str request_fingerprint: str def __post_init__(self) -> None: @@ -119,6 +125,7 @@ def __post_init__(self) -> None: "recipient_ref", "payload_ref", "idempotency_key", + "dispatch_claim_id", ): if not getattr(self, name): raise ValueError(f"{name} is required") @@ -186,6 +193,7 @@ class AuthorizedCredentialUse: payload_ref: str idempotency_key: str request_fingerprint: str + dispatch_claim_id: str reservation_id: str = "" @@ -204,9 +212,18 @@ class AuthorizedProviderDispatch: payload_ref: str idempotency_key: str request_fingerprint: str + dispatch_claim_id: str reservation_id: str max_provider_attempts: int + def reservation(self) -> ConsumptionReservation: + return ConsumptionReservation( + reservation_id=self.reservation_id, + authorization_id=self.authorization_id, + request_fingerprint=self.request_fingerprint, + idempotency_key=self.idempotency_key, + ) + def authorize_handle(self, handle: ProviderCredentialHandle) -> AuthorizedCredentialUse: if handle.connector_id not in self.allowed_connector_ids: raise CustodyAuthorizationDenied("connector is outside authorization scope") @@ -223,6 +240,7 @@ def authorize_handle(self, handle: ProviderCredentialHandle) -> AuthorizedCreden payload_ref=self.payload_ref, idempotency_key=self.idempotency_key, request_fingerprint=self.request_fingerprint, + dispatch_claim_id=self.dispatch_claim_id, reservation_id=self.reservation_id, ) @@ -318,6 +336,14 @@ async def complete( ) -> None: ... + async def reserve_attempt( + self, + reservation: ConsumptionReservation, + authorized_use: AuthorizedCredentialUse, + ) -> int: + """Atomically reserve and return the next provider-attempt number.""" + ... + class CustodiedReferenceResolver(Protocol): """Invoke a provider operation while keeping resolved material internal.""" @@ -419,6 +445,7 @@ async def authorize_provider_dispatch( payload_ref=request.payload_ref, idempotency_key=request.idempotency_key, request_fingerprint=request.request_fingerprint, + dispatch_claim_id=request.dispatch_claim_id, reservation_id=reservation.reservation_id, max_provider_attempts=authorization.max_provider_attempts, ) @@ -432,10 +459,14 @@ def __init__( verifier: SignedWorkloadAuthorizationVerifier, ledger: AuthorizationConsumptionLedger, *, + audit_sink: CustodyAuditSink | None = None, + failure_notifier: CustodyFailureNotifier | None = None, clock: Callable[[], datetime] | None = None, ): self._verifier = verifier self._ledger = ledger + self._audit_sink = audit_sink + self._failure_notifier = failure_notifier self._clock = clock or (lambda: datetime.now(timezone.utc)) async def authorize( @@ -447,18 +478,166 @@ async def authorize( provider_attempt_budget: int | None = None, required_connector_ids: frozenset[str] | None = None, ) -> AuthorizedProviderDispatch: - authorization = await self._verifier.verify(reference, request) - reserved_dispatch = await authorize_provider_dispatch( - handle, - request, - authorization, - ledger=self._ledger, - provider_attempt_budget=provider_attempt_budget, - required_connector_ids=required_connector_ids, - now=self._clock(), + authorization_id = "" + try: + authorization = await self._verifier.verify(reference, request) + authorization_id = authorization.authorization_id + reserved_dispatch = await authorize_provider_dispatch( + handle, + request, + authorization, + ledger=self._ledger, + provider_attempt_budget=provider_attempt_budget, + required_connector_ids=required_connector_ids, + now=self._clock(), + ) + except ConsumptionReservationRequired: + await self._record_failure( + request, + handle, + authorization_id=authorization_id, + kind=CustodyAuditKind.RESERVATION_REQUIRED, + failure_code="reservation_required", + ) + raise + except CustodyAuthorizationDenied: + await self._record_failure( + request, + handle, + authorization_id=authorization_id, + kind=CustodyAuditKind.AUTHORIZATION_DENIED, + failure_code="authorization_denied", + ) + raise + except Exception as exc: + await self._record_failure( + request, + handle, + authorization_id=authorization_id, + kind=CustodyAuditKind.AUTHORIZATION_DENIED, + failure_code="verification_unavailable", + ) + raise CustodyAuthorizationDenied("workload authorization verification failed") from exc + + await self._emit_audit( + CustodyAuditEvent( + kind=CustodyAuditKind.AUTHORIZATION_ACCEPTED, + operation_id=request.operation_id, + dispatch_id=request.dispatch_id, + workload_id=request.workload_id, + connector_id=handle.connector_id, + provider_key=handle.provider_key, + authorization_id=reserved_dispatch.authorization_id, + ) ) return reserved_dispatch + async def record_outcome( + self, + dispatch: AuthorizedProviderDispatch, + handle: ProviderCredentialHandle, + outcome: SanitizedCustodyOutcome, + ) -> None: + await self._ledger.complete(dispatch.reservation(), outcome) + kind = ( + CustodyAuditKind.INVOCATION_FAILED + if outcome.status is CustodyResultStatus.FAILED + else CustodyAuditKind.INVOCATION_COMPLETED + ) + event = CustodyAuditEvent( + kind=kind, + operation_id=dispatch.operation_id, + dispatch_id=dispatch.dispatch_id, + workload_id=dispatch.workload_id, + connector_id=handle.connector_id, + provider_key=handle.provider_key, + authorization_id=dispatch.authorization_id, + failure_code=outcome.failure_code, + ) + if outcome.status is CustodyResultStatus.FAILED: + await self._emit_and_notify_failure(event) + else: + await self._emit_audit(event) + + async def reserve_attempt( + self, + dispatch: AuthorizedProviderDispatch, + authorized_use: AuthorizedCredentialUse, + ) -> int: + return await self._ledger.reserve_attempt(dispatch.reservation(), authorized_use) + + async def record_invocation_failure( + self, + dispatch: AuthorizedProviderDispatch, + handle: ProviderCredentialHandle, + failure_code: str, + ) -> None: + event = CustodyAuditEvent( + kind=CustodyAuditKind.INVOCATION_FAILED, + operation_id=dispatch.operation_id, + dispatch_id=dispatch.dispatch_id, + workload_id=dispatch.workload_id, + connector_id=handle.connector_id, + provider_key=handle.provider_key, + authorization_id=dispatch.authorization_id, + failure_code=failure_code, + ) + await self._emit_and_notify_failure(event) + + async def _record_failure( + self, + request: CredentialUseRequest, + handle: ProviderCredentialHandle, + *, + authorization_id: str, + kind: CustodyAuditKind, + failure_code: str, + ) -> None: + event = CustodyAuditEvent( + kind=kind, + operation_id=request.operation_id, + dispatch_id=request.dispatch_id, + workload_id=request.workload_id, + connector_id=handle.connector_id, + provider_key=handle.provider_key, + authorization_id=authorization_id, + failure_code=failure_code, + ) + await self._emit_and_notify_failure(event) + + async def _emit_and_notify_failure(self, event: CustodyAuditEvent) -> None: + audit_error: CustodyMonitoringUnavailable | None = None + try: + await self._emit_audit(event) + except CustodyMonitoringUnavailable as exc: + audit_error = exc + try: + await self._notify_failure(event) + except CustodyMonitoringUnavailable: + if audit_error is not None: + raise CustodyMonitoringUnavailable( + "custody audit and failure notification persistence failed" + ) from audit_error + raise + if audit_error is not None: + raise audit_error + + async def _emit_audit(self, event: CustodyAuditEvent) -> None: + if self._audit_sink is None: + return + try: + await self._audit_sink.emit(event) + except Exception as exc: + raise CustodyMonitoringUnavailable("custody audit persistence failed") from exc + + async def _notify_failure(self, event: CustodyAuditEvent) -> None: + if self._failure_notifier is None: + return + try: + await self._failure_notifier.notify(event) + except Exception as exc: + raise CustodyMonitoringUnavailable("custody failure notification persistence failed") from exc + class CredentialCustodyInvokerFactory: """Connect delegated dispatch to a single reserved custody execution path.""" @@ -497,6 +676,7 @@ async def prepare( capability: CapabilityReference, request: DispatchRequest, grant: VerifiedDispatchGrant, + claim: DispatchClaim, initial_route: ConnectorRoute, authorized_routes: tuple[ConnectorRoute, ...], ) -> CustodiedProviderInvoker: @@ -515,6 +695,7 @@ async def prepare( recipient_ref=request.recipient_ref, payload_ref=request.payload_ref, idempotency_key=request.idempotency_key, + dispatch_claim_id=claim.claim_id, request_fingerprint=request.request_fingerprint, ) try: @@ -530,6 +711,7 @@ async def prepare( except CustodyAuthorizationDenied as exc: raise DelegatedAuthorizationDenied("credential custody authorization denied") from exc return _ReservedCustodiedProviderInvoker( + authorizer=self._authorizer, resolver=self._resolver, handles=self._handles, dispatch=reserved_dispatch, @@ -541,11 +723,13 @@ class _ReservedCustodiedProviderInvoker: def __init__( self, *, + authorizer: CredentialCustodyAuthorizer, resolver: CustodiedReferenceResolver, handles: Mapping[str, ProviderCredentialHandle], dispatch: AuthorizedProviderDispatch, request: DispatchRequest, ): + self._authorizer = authorizer self._resolver = resolver self._handles = dict(handles) self._dispatch = dispatch @@ -570,15 +754,47 @@ async def invoke( raise DelegatedAuthorizationDenied("connector custody binding is invalid") try: authorized_use = self._dispatch.authorize_handle(handle) + await self._authorizer.reserve_attempt(self._dispatch, authorized_use) outcome = await self._resolver.invoke(handle, authorized_use) except CustodyAuthorizationDenied as exc: + await self._authorizer.record_invocation_failure( + self._dispatch, + handle, + "provider_custody_denied", + ) raise DelegatedAuthorizationDenied("provider custody invocation denied") from exc + except Exception: + await self._authorizer.record_invocation_failure( + self._dispatch, + handle, + "resolver_unavailable", + ) + raise if ( outcome.operation_id != self._dispatch.operation_id or outcome.dispatch_id != request.dispatch_id or outcome.provider_key != route.provider_key ): raise DelegatedAuthorizationDenied("custody outcome binding is invalid") + try: + await self._authorizer.record_outcome(self._dispatch, handle, outcome) + except Exception: + try: + await self._authorizer.record_invocation_failure( + self._dispatch, + handle, + "custody_state_unavailable", + ) + except CustodyMonitoringUnavailable: + pass + return ProviderAttemptOutcome( + status=DeliveryStatus.FAILED, + audit_ref=outcome.audit_ref, + failure_code="custody_state_unavailable", + retryable=False, + failover_allowed=False, + counts_toward_circuit=False, + ) return ProviderAttemptOutcome( status=DeliveryStatus(outcome.status.value), audit_ref=outcome.audit_ref, diff --git a/src/baton/delegated_connector.py b/src/baton/delegated_connector.py index c6205b6..76afebe 100644 --- a/src/baton/delegated_connector.py +++ b/src/baton/delegated_connector.py @@ -62,6 +62,10 @@ class DispatchBindingConflict(DelegatedConnectorError): """An idempotency key was reused for different immutable request content.""" +class DispatchStateUnavailable(DelegatedConnectorError): + """Durable dispatch state could not record a post-invocation transition.""" + + class MonitoringUnavailable(DelegatedConnectorError): """A required sanitized signal could not be persisted.""" @@ -217,6 +221,21 @@ def __post_init__(self) -> None: raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") +@dataclass(frozen=True) +class DispatchClaim: + """Lease-bearing ownership token for one durable dispatch attempt.""" + + claim_id: str + binding: DispatchBinding + lease_expires_at: datetime + + def __post_init__(self) -> None: + if not self.claim_id: + raise ValueError("claim_id is required") + if self.lease_expires_at.tzinfo is None: + raise ValueError("lease_expires_at must be timezone-aware") + + @dataclass(frozen=True) class DispatchSignal: """Non-sensitive operational event for audit and alert pipelines. @@ -262,6 +281,7 @@ async def prepare( capability: CapabilityReference, request: DispatchRequest, grant: VerifiedDispatchGrant, + claim: DispatchClaim, initial_route: ConnectorRoute, authorized_routes: tuple[ConnectorRoute, ...], ) -> CustodiedProviderInvoker: @@ -279,13 +299,16 @@ class DispatchJournal(Protocol): async def completed(self, binding: DispatchBinding) -> DeliveryOutcome | None: ... - async def begin(self, binding: DispatchBinding) -> bool: + async def begin(self, binding: DispatchBinding) -> DispatchClaim | None: ... - async def complete(self, binding: DispatchBinding, outcome: DeliveryOutcome) -> None: + async def renew(self, claim: DispatchClaim) -> None: ... - async def abort(self, binding: DispatchBinding) -> None: + async def complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: + ... + + async def abort(self, claim: DispatchClaim) -> None: ... @@ -387,8 +410,7 @@ async def dispatch( await self._emit_terminal(completed) return completed try: - if not await self._journal.begin(binding): - raise DispatchInProgress("dispatch with this idempotency key is already in progress") + claim = await self._journal.begin(binding) except DispatchBindingConflict: await self._emit( DispatchSignal( @@ -400,12 +422,16 @@ async def dispatch( ) ) raise + if claim is None: + completed = await self._journal.completed(binding) + if completed is not None: + await self._emit_terminal(completed) + return completed + raise DispatchInProgress("dispatch with this idempotency key is already in progress") try: - outcome = await self._dispatch_authorized(capability, grant, request) - await self._journal.complete(binding, outcome) + outcome = await self._dispatch_authorized(capability, grant, claim, request) except AuthorizationDenied: - await self._journal.abort(binding) await self._emit( DispatchSignal( kind=DispatchSignalKind.AUTHORIZATION_DENIED, @@ -416,9 +442,13 @@ async def dispatch( ) ) raise - except Exception: - await self._journal.abort(binding) - raise + + try: + await self._journal.complete(claim, outcome) + except Exception as exc: + raise DispatchStateUnavailable( + "durable dispatch outcome persistence failed after provider execution" + ) from exc await self._emit_terminal(outcome) return outcome @@ -442,6 +472,7 @@ async def _dispatch_authorized( self, capability: CapabilityReference, grant: VerifiedDispatchGrant, + claim: DispatchClaim, request: DispatchRequest, ) -> DeliveryOutcome: routes = tuple( @@ -499,10 +530,12 @@ async def _dispatch_authorized( attempted_or_skipped_route = True allow_next_route = False if invoker is None: + await self._journal.renew(claim) invoker = await self._invoker_factory.prepare( capability, request, grant, + claim, route, routes, ) @@ -513,6 +546,7 @@ async def _dispatch_authorized( attempt_count += 1 last_connector = route.connector_id last_provider = route.provider_key + await self._journal.renew(claim) attempt = await self._invoke(invoker, route, request) last_audit_ref = attempt.audit_ref if attempt.status in (DeliveryStatus.ACCEPTED, DeliveryStatus.DELIVERED): diff --git a/src/baton/delegated_runtime.py b/src/baton/delegated_runtime.py new file mode 100644 index 0000000..9753e14 --- /dev/null +++ b/src/baton/delegated_runtime.py @@ -0,0 +1,1002 @@ +"""Durable composition for delegated provider connector execution. + +The runtime persists only opaque identifiers, sanitized outcomes, and +hash-chained operational evidence. Provider credential material remains behind +the single-purpose provider operation factory. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import sqlite3 +import uuid +from collections.abc import Callable, Mapping, Sequence +from contextlib import closing +from dataclasses import asdict, dataclass, is_dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Protocol + +from baton.credential_custody import ( + AuthorizationConsumptionLedger, + AuthorizedCredentialUse, + ConsumptionReservation, + ConsumptionReservationRequired, + CredentialUseRequest, + CredentialCustodyAuthorizer, + CredentialCustodyInvokerFactory, + CustodiedReferenceResolver, + CustodyAuditEvent, + CustodyAuditSink, + CustodyAuthorizationDenied, + CustodyFailureNotifier, + ProviderCredentialHandle, + SanitizedCustodyOutcome, + SignedWorkloadAuthorizationVerifier, + VerifiedWorkloadAuthorization, +) +from baton.delegated_connector import ( + ConnectorRoute, + Channel, + DelegatedConnectorExecutor, + DeliveryOutcome, + DeliveryStatus, + DispatchBinding, + DispatchBindingConflict, + DispatchClaim, + DispatchJournal, + DispatchSignal, + DispatchSignalSink, + DispatchStateUnavailable, + ScopedAuthorizationVerifier, +) + + +_ZERO_HASH = "0" * 64 + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + if value.tzinfo is None: + raise ValueError("timestamp must be timezone-aware") + return value.astimezone(timezone.utc).isoformat() + + +def _parse_time(value: str) -> datetime: + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + raise ValueError("persisted timestamp must be timezone-aware") + return parsed + + +def _primitive(value: Any) -> Any: + if isinstance(value, Enum): + return value.value + if isinstance(value, datetime): + return _iso(value) + if is_dataclass(value): + return _primitive(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _primitive(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_primitive(item) for item in value] + if isinstance(value, (set, frozenset)): + return sorted(_primitive(item) for item in value) + return value + + +def _canonical_json(value: Any) -> str: + return json.dumps(_primitive(value), sort_keys=True, separators=(",", ":")) + + +def _delivery_outcome_payload(outcome: DeliveryOutcome) -> dict[str, Any]: + return _primitive(outcome) + + +def _delivery_outcome_from_payload(payload: str) -> DeliveryOutcome: + data = json.loads(payload) + return DeliveryOutcome( + dispatch_id=data["dispatch_id"], + workflow_id=data["workflow_id"], + channel=Channel(data["channel"]), + connector_id=data["connector_id"], + provider_key=data["provider_key"], + status=DeliveryStatus(data["status"]), + attempt_count=data["attempt_count"], + failover_used=data["failover_used"], + audit_ref=data["audit_ref"], + failure_code=data["failure_code"], + ) + + +@dataclass(frozen=True) +class ConfiguredVerifierBundle: + """Explicit verifier wiring and its externally managed trust-policy refs.""" + + dispatch_verifier: ScopedAuthorizationVerifier + custody_verifier: SignedWorkloadAuthorizationVerifier + issuer_policy_ref: str + rotation_policy_ref: str + + def __post_init__(self) -> None: + if not self.issuer_policy_ref or not self.rotation_policy_ref: + raise ValueError("issuer_policy_ref and rotation_policy_ref are required") + + +class SinglePurposeProviderOperation(Protocol): + """Custody-internal operation prepared for one exact authorized use.""" + + async def invoke(self) -> SanitizedCustodyOutcome: + ... + + +class SinglePurposeProviderOperationFactory(Protocol): + """Prepare one operation while keeping any resolved material internal.""" + + async def prepare( + self, + handle: ProviderCredentialHandle, + authorized_use: AuthorizedCredentialUse, + ) -> SinglePurposeProviderOperation: + ... + + +class SinglePurposeCustodiedResolver(CustodiedReferenceResolver): + """Concrete resolver that exposes only a one-operation invocation object.""" + + def __init__(self, operation_factory: SinglePurposeProviderOperationFactory): + self._operation_factory = operation_factory + + async def invoke( + self, + handle: ProviderCredentialHandle, + authorized_use: AuthorizedCredentialUse, + ) -> SanitizedCustodyOutcome: + operation = await self._operation_factory.prepare(handle, authorized_use) + return await operation.invoke() + + +@dataclass(frozen=True) +class HashChainVerification: + ok: bool + event_count: int + anchor: str + broken_sequence: int | None = None + + +class SqliteDelegatedRuntimeState: + """Single-node durable reference state for dispatch, custody, audit, and alerts.""" + + def __init__( + self, + path: str | Path, + *, + claim_lease_seconds: int = 60, + clock: Callable[[], datetime] | None = None, + ): + self.path = Path(path) + if str(self.path) == ":memory:": + raise ValueError("durable delegated runtime state cannot use :memory:") + if claim_lease_seconds < 1: + raise ValueError("claim_lease_seconds must be positive") + self._claim_lease_seconds = claim_lease_seconds + self._clock = clock or _utc_now + self.path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + self.journal = SqliteDispatchJournal(self) + self.ledger = SqliteAuthorizationLedger(self) + self.sink = SqliteOperationalSink(self) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=10, isolation_level=None) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("PRAGMA synchronous = FULL") + return connection + + def _initialize(self) -> None: + with closing(self._connect()) as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS dispatch_journal ( + idempotency_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('running', 'completed', 'aborted')), + claim_id TEXT, + lease_expires_at TEXT, + outcome_json TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS authorization_reservations ( + authorization_id TEXT PRIMARY KEY, + reservation_id TEXT NOT NULL UNIQUE, + request_fingerprint TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + dispatch_id TEXT NOT NULL, + workload_id TEXT NOT NULL, + max_provider_attempts INTEGER NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + outcome_json TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS operational_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + stream TEXT NOT NULL, + event_key TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL, + previous_hash TEXT NOT NULL, + event_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS failure_notifications ( + notification_key TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'acknowledged')), + created_at TEXT NOT NULL, + acknowledged_at TEXT + ); + """ + ) + try: + os.chmod(self.path, 0o600) + except OSError: + pass + + async def verify_event_chain(self) -> HashChainVerification: + return await asyncio.to_thread(self._verify_event_chain) + + def _verify_event_chain(self) -> HashChainVerification: + previous_hash = _ZERO_HASH + event_count = 0 + with closing(self._connect()) as connection: + rows = connection.execute( + """ + SELECT sequence, stream, event_key, payload_json, previous_hash, event_hash + FROM operational_events + ORDER BY sequence + """ + ).fetchall() + for row in rows: + event_count += 1 + expected = self._event_hash( + row["stream"], + row["event_key"], + row["payload_json"], + previous_hash, + ) + if row["previous_hash"] != previous_hash or row["event_hash"] != expected: + return HashChainVerification( + ok=False, + event_count=event_count, + anchor=previous_hash, + broken_sequence=row["sequence"], + ) + previous_hash = row["event_hash"] + return HashChainVerification(ok=True, event_count=event_count, anchor=previous_hash) + + async def pending_failure_notifications(self) -> list[dict[str, Any]]: + return await asyncio.to_thread(self._pending_failure_notifications) + + def _pending_failure_notifications(self) -> list[dict[str, Any]]: + with closing(self._connect()) as connection: + rows = connection.execute( + """ + SELECT notification_key, payload_json, created_at + FROM failure_notifications + WHERE status = 'pending' + ORDER BY created_at, notification_key + """ + ).fetchall() + return [ + { + "notification_key": row["notification_key"], + "payload": json.loads(row["payload_json"]), + "created_at": row["created_at"], + } + for row in rows + ] + + async def acknowledge_failure_notification(self, notification_key: str) -> bool: + if not notification_key: + raise ValueError("notification_key is required") + return await asyncio.to_thread( + self._acknowledge_failure_notification, + notification_key, + ) + + def _acknowledge_failure_notification(self, notification_key: str) -> bool: + with closing(self._connect()) as connection: + result = connection.execute( + """ + UPDATE failure_notifications + SET status = 'acknowledged', acknowledged_at = ? + WHERE notification_key = ? AND status = 'pending' + """, + (_iso(self._clock()), notification_key), + ) + return result.rowcount == 1 + + @staticmethod + def _event_hash(stream: str, event_key: str, payload_json: str, previous_hash: str) -> str: + canonical = _canonical_json( + { + "stream": stream, + "event_key": event_key, + "payload_json": payload_json, + "previous_hash": previous_hash, + } + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def _append_event( + self, + connection: sqlite3.Connection, + *, + stream: str, + event_key: str, + payload: Any, + ) -> None: + if connection.execute( + "SELECT 1 FROM operational_events WHERE event_key = ?", + (event_key,), + ).fetchone(): + return + row = connection.execute( + "SELECT event_hash FROM operational_events ORDER BY sequence DESC LIMIT 1" + ).fetchone() + previous_hash = row["event_hash"] if row is not None else _ZERO_HASH + payload_json = _canonical_json(payload) + event_hash = self._event_hash(stream, event_key, payload_json, previous_hash) + connection.execute( + """ + INSERT INTO operational_events ( + stream, event_key, payload_json, previous_hash, event_hash, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + stream, + event_key, + payload_json, + previous_hash, + event_hash, + _iso(self._clock()), + ), + ) + + +class SqliteDispatchJournal(DispatchJournal): + def __init__(self, state: SqliteDelegatedRuntimeState): + self._state = state + + async def completed(self, binding: DispatchBinding) -> DeliveryOutcome | None: + return await asyncio.to_thread(self._completed, binding) + + def _completed(self, binding: DispatchBinding) -> DeliveryOutcome | None: + with closing(self._state._connect()) as connection: + row = connection.execute( + "SELECT request_fingerprint, state, outcome_json FROM dispatch_journal " + "WHERE idempotency_key = ?", + (binding.idempotency_key,), + ).fetchone() + if row is None: + return None + self._assert_fingerprint(row["request_fingerprint"], binding) + if row["state"] != "completed": + return None + if not row["outcome_json"]: + raise DispatchStateUnavailable("completed dispatch is missing its outcome") + return _delivery_outcome_from_payload(row["outcome_json"]) + + async def begin(self, binding: DispatchBinding) -> DispatchClaim | None: + return await asyncio.to_thread(self._begin, binding) + + def _begin(self, binding: DispatchBinding) -> DispatchClaim | None: + now = self._state._clock() + lease_expires_at = now + timedelta(seconds=self._state._claim_lease_seconds) + claim = DispatchClaim( + claim_id=str(uuid.uuid4()), + binding=binding, + lease_expires_at=lease_expires_at, + ) + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + row = connection.execute( + """ + SELECT request_fingerprint, state, lease_expires_at + FROM dispatch_journal + WHERE idempotency_key = ? + """, + (binding.idempotency_key,), + ).fetchone() + if row is None: + connection.execute( + """ + INSERT INTO dispatch_journal ( + idempotency_key, request_fingerprint, state, claim_id, + lease_expires_at, updated_at + ) VALUES (?, ?, 'running', ?, ?, ?) + """, + ( + binding.idempotency_key, + binding.request_fingerprint, + claim.claim_id, + _iso(claim.lease_expires_at), + _iso(now), + ), + ) + connection.commit() + return claim + + self._assert_fingerprint(row["request_fingerprint"], binding) + if row["state"] == "completed": + connection.commit() + return None + if ( + row["state"] == "running" + and row["lease_expires_at"] + and _parse_time(row["lease_expires_at"]) > now + ): + connection.commit() + return None + connection.execute( + """ + UPDATE dispatch_journal + SET state = 'running', claim_id = ?, lease_expires_at = ?, + outcome_json = NULL, updated_at = ? + WHERE idempotency_key = ? + """, + ( + claim.claim_id, + _iso(claim.lease_expires_at), + _iso(now), + binding.idempotency_key, + ), + ) + connection.commit() + return claim + except Exception: + connection.rollback() + raise + + async def complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: + await asyncio.to_thread(self._complete, claim, outcome) + + async def renew(self, claim: DispatchClaim) -> None: + await asyncio.to_thread(self._renew, claim) + + def _renew(self, claim: DispatchClaim) -> None: + now = self._state._clock() + lease_expires_at = now + timedelta(seconds=self._state._claim_lease_seconds) + with closing(self._state._connect()) as connection: + result = connection.execute( + """ + UPDATE dispatch_journal + SET lease_expires_at = ?, updated_at = ? + WHERE idempotency_key = ? AND request_fingerprint = ? + AND state = 'running' AND claim_id = ? AND lease_expires_at > ? + """, + ( + _iso(lease_expires_at), + _iso(now), + claim.binding.idempotency_key, + claim.binding.request_fingerprint, + claim.claim_id, + _iso(now), + ), + ) + if result.rowcount != 1: + raise DispatchStateUnavailable("dispatch claim is no longer active") + + def _complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: + now = self._state._clock() + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + row = connection.execute( + """ + SELECT request_fingerprint, state, claim_id, lease_expires_at + FROM dispatch_journal + WHERE idempotency_key = ? + """, + (claim.binding.idempotency_key,), + ).fetchone() + if row is None: + raise DispatchStateUnavailable("dispatch claim is missing") + self._assert_fingerprint(row["request_fingerprint"], claim.binding) + if ( + row["state"] != "running" + or row["claim_id"] != claim.claim_id + or not row["lease_expires_at"] + or _parse_time(row["lease_expires_at"]) <= now + ): + raise DispatchStateUnavailable("dispatch claim is no longer active") + connection.execute( + """ + UPDATE dispatch_journal + SET state = 'completed', claim_id = NULL, lease_expires_at = NULL, + outcome_json = ?, updated_at = ? + WHERE idempotency_key = ? + """, + ( + _canonical_json(_delivery_outcome_payload(outcome)), + _iso(now), + claim.binding.idempotency_key, + ), + ) + connection.commit() + except Exception: + connection.rollback() + raise + + async def abort(self, claim: DispatchClaim) -> None: + await asyncio.to_thread(self._abort, claim) + + def _abort(self, claim: DispatchClaim) -> None: + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + row = connection.execute( + """ + SELECT request_fingerprint, state, claim_id + FROM dispatch_journal + WHERE idempotency_key = ? + """, + (claim.binding.idempotency_key,), + ).fetchone() + if row is None: + connection.commit() + return + self._assert_fingerprint(row["request_fingerprint"], claim.binding) + if row["state"] == "running" and row["claim_id"] == claim.claim_id: + connection.execute( + """ + UPDATE dispatch_journal + SET state = 'aborted', claim_id = NULL, lease_expires_at = NULL, + updated_at = ? + WHERE idempotency_key = ? + """, + (_iso(self._state._clock()), claim.binding.idempotency_key), + ) + connection.commit() + except Exception: + connection.rollback() + raise + + @staticmethod + def _assert_fingerprint(persisted: str, binding: DispatchBinding) -> None: + if persisted != binding.request_fingerprint: + raise DispatchBindingConflict("idempotency key is bound to another request") + + +class SqliteAuthorizationLedger(AuthorizationConsumptionLedger): + def __init__(self, state: SqliteDelegatedRuntimeState): + self._state = state + + async def reserve_once( + self, + authorization: VerifiedWorkloadAuthorization, + request: CredentialUseRequest, + ) -> ConsumptionReservation: + return await asyncio.to_thread(self._reserve_once, authorization, request) + + def _reserve_once( + self, + authorization: VerifiedWorkloadAuthorization, + request: CredentialUseRequest, + ) -> ConsumptionReservation: + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + dispatch = connection.execute( + """ + SELECT request_fingerprint, state, claim_id, lease_expires_at + FROM dispatch_journal + WHERE idempotency_key = ? + """, + (request.idempotency_key,), + ).fetchone() + if ( + dispatch is None + or dispatch["request_fingerprint"] != request.request_fingerprint + or dispatch["state"] != "running" + or dispatch["claim_id"] != request.dispatch_claim_id + or not dispatch["lease_expires_at"] + or _parse_time(dispatch["lease_expires_at"]) <= self._state._clock() + ): + raise ConsumptionReservationRequired( + "active exact dispatch journal claim is required" + ) + + row = connection.execute( + """ + SELECT reservation_id, request_fingerprint, idempotency_key, + dispatch_id, workload_id, max_provider_attempts + FROM authorization_reservations + WHERE authorization_id = ? + """, + (authorization.authorization_id,), + ).fetchone() + if row is not None: + if ( + row["request_fingerprint"] != request.request_fingerprint + or row["idempotency_key"] != request.idempotency_key + or row["dispatch_id"] != request.dispatch_id + or row["workload_id"] != request.workload_id + or row["max_provider_attempts"] != authorization.max_provider_attempts + ): + raise CustodyAuthorizationDenied( + "authorization is already reserved for another dispatch" + ) + connection.commit() + return ConsumptionReservation( + reservation_id=row["reservation_id"], + authorization_id=authorization.authorization_id, + request_fingerprint=request.request_fingerprint, + idempotency_key=request.idempotency_key, + ) + + reservation = ConsumptionReservation( + reservation_id=str(uuid.uuid4()), + authorization_id=authorization.authorization_id, + request_fingerprint=request.request_fingerprint, + idempotency_key=request.idempotency_key, + ) + connection.execute( + """ + INSERT INTO authorization_reservations ( + authorization_id, reservation_id, request_fingerprint, + idempotency_key, dispatch_id, workload_id, + max_provider_attempts, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + authorization.authorization_id, + reservation.reservation_id, + request.request_fingerprint, + request.idempotency_key, + request.dispatch_id, + request.workload_id, + authorization.max_provider_attempts, + _iso(self._state._clock()), + ), + ) + connection.commit() + return reservation + except Exception: + connection.rollback() + raise + + async def complete( + self, + reservation: ConsumptionReservation, + outcome: SanitizedCustodyOutcome, + ) -> None: + await asyncio.to_thread(self._complete, reservation, outcome) + + def _complete( + self, + reservation: ConsumptionReservation, + outcome: SanitizedCustodyOutcome, + ) -> None: + with closing(self._state._connect()) as connection: + result = connection.execute( + """ + UPDATE authorization_reservations + SET outcome_json = ?, updated_at = ? + WHERE authorization_id = ? AND reservation_id = ? + AND request_fingerprint = ? AND idempotency_key = ? + AND dispatch_id = ? + """, + ( + _canonical_json(outcome), + _iso(self._state._clock()), + reservation.authorization_id, + reservation.reservation_id, + reservation.request_fingerprint, + reservation.idempotency_key, + outcome.dispatch_id, + ), + ) + if result.rowcount != 1: + raise ConsumptionReservationRequired("authorization reservation is missing") + + async def reserve_attempt( + self, + reservation: ConsumptionReservation, + authorized_use: AuthorizedCredentialUse, + ) -> int: + return await asyncio.to_thread(self._reserve_attempt, reservation, authorized_use) + + def _reserve_attempt( + self, + reservation: ConsumptionReservation, + authorized_use: AuthorizedCredentialUse, + ) -> int: + if ( + authorized_use.authorization_id != reservation.authorization_id + or authorized_use.reservation_id != reservation.reservation_id + or authorized_use.request_fingerprint != reservation.request_fingerprint + or authorized_use.idempotency_key != reservation.idempotency_key + ): + raise ConsumptionReservationRequired( + "authorized use does not bind the authorization reservation" + ) + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + dispatch = connection.execute( + """ + SELECT request_fingerprint, state, claim_id, lease_expires_at + FROM dispatch_journal + WHERE idempotency_key = ? + """, + (authorized_use.idempotency_key,), + ).fetchone() + if ( + dispatch is None + or dispatch["request_fingerprint"] != authorized_use.request_fingerprint + or dispatch["state"] != "running" + or dispatch["claim_id"] != authorized_use.dispatch_claim_id + or not dispatch["lease_expires_at"] + or _parse_time(dispatch["lease_expires_at"]) <= self._state._clock() + ): + raise ConsumptionReservationRequired( + "active exact dispatch journal claim is required for provider attempt" + ) + row = connection.execute( + """ + SELECT attempt_count, max_provider_attempts + FROM authorization_reservations + WHERE authorization_id = ? AND reservation_id = ? + AND request_fingerprint = ? AND idempotency_key = ? + AND dispatch_id = ? + """, + ( + reservation.authorization_id, + reservation.reservation_id, + reservation.request_fingerprint, + reservation.idempotency_key, + authorized_use.dispatch_id, + ), + ).fetchone() + if row is None: + raise ConsumptionReservationRequired("authorization reservation is missing") + if row["attempt_count"] >= row["max_provider_attempts"]: + raise CustodyAuthorizationDenied("provider attempt budget is exhausted") + attempt_count = row["attempt_count"] + 1 + connection.execute( + """ + UPDATE authorization_reservations + SET attempt_count = ?, updated_at = ? + WHERE authorization_id = ? + """, + ( + attempt_count, + _iso(self._state._clock()), + reservation.authorization_id, + ), + ) + connection.commit() + return attempt_count + except Exception: + connection.rollback() + raise + + +class SqliteOperationalSink(DispatchSignalSink, CustodyAuditSink, CustodyFailureNotifier): + def __init__(self, state: SqliteDelegatedRuntimeState): + self._state = state + + async def emit(self, event: DispatchSignal | CustodyAuditEvent) -> None: + await asyncio.to_thread(self._emit, event) + + def _emit(self, event: DispatchSignal | CustodyAuditEvent) -> None: + stream, event_key = self._identity(event) + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + self._state._append_event( + connection, + stream=stream, + event_key=event_key, + payload=event, + ) + connection.commit() + except Exception: + connection.rollback() + raise + + async def notify(self, event: CustodyAuditEvent) -> None: + await asyncio.to_thread(self._notify, event) + + def _notify(self, event: CustodyAuditEvent) -> None: + _, base_key = self._identity(event) + notification_key = f"failure:{base_key}" + payload_json = _canonical_json(event) + with closing(self._state._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + try: + connection.execute( + """ + INSERT OR IGNORE INTO failure_notifications ( + notification_key, payload_json, status, created_at + ) VALUES (?, ?, 'pending', ?) + """, + (notification_key, payload_json, _iso(self._state._clock())), + ) + self._state._append_event( + connection, + stream="failure_notification", + event_key=notification_key, + payload=event, + ) + connection.commit() + except Exception: + connection.rollback() + raise + + @staticmethod + def _identity(event: DispatchSignal | CustodyAuditEvent) -> tuple[str, str]: + if isinstance(event, DispatchSignal): + return ( + "dispatch_signal", + ( + f"dispatch:{event.kind.value}:{event.dispatch_id}:" + f"{event.connector_id}:{event.attempt_count}" + ), + ) + if isinstance(event, CustodyAuditEvent): + return ( + "custody_audit", + ( + f"custody:{event.kind.value}:{event.dispatch_id}:" + f"{event.connector_id}:{event.authorization_id}:{event.failure_code}" + ), + ) + raise TypeError("unsupported operational event") + + +@dataclass(frozen=True) +class DelegatedRuntimeComponents: + """Explicit durable components required to compose a delegated runtime.""" + + journal: DispatchJournal + ledger: AuthorizationConsumptionLedger + signal_sink: DispatchSignalSink + audit_sink: CustodyAuditSink + failure_notifier: CustodyFailureNotifier + claim_lease_seconds: int + + def __post_init__(self) -> None: + if any( + component is None + for component in ( + self.journal, + self.ledger, + self.signal_sink, + self.audit_sink, + self.failure_notifier, + ) + ): + raise ValueError("all delegated runtime components are required") + if self.claim_lease_seconds < 1: + raise ValueError("claim_lease_seconds must be positive") + + +@dataclass(frozen=True) +class DelegatedConnectorRuntime: + """Composed delegated connector runtime with explicit durable controls.""" + + executor: DelegatedConnectorExecutor + verifier_policy: ConfiguredVerifierBundle + reference_state: SqliteDelegatedRuntimeState | None = None + + @classmethod + def compose( + cls, + *, + routes: Sequence[ConnectorRoute], + handles: Mapping[str, ProviderCredentialHandle], + verifiers: ConfiguredVerifierBundle, + operation_factory: SinglePurposeProviderOperationFactory, + components: DelegatedRuntimeComponents, + workload_id: str, + purpose: str, + clock: Callable[[], datetime] | None = None, + ) -> DelegatedConnectorRuntime: + if not workload_id or not purpose: + raise ValueError("workload_id and purpose are required") + enabled_routes = [route for route in routes if route.enabled] + if enabled_routes and components.claim_lease_seconds * 1000 <= max( + route.timeout_ms for route in enabled_routes + ): + raise ValueError("claim lease must exceed every provider attempt timeout") + for route in enabled_routes: + handle = handles.get(route.connector_id) + if ( + handle is None + or handle.handle_id != route.credential_handle + or handle.provider_key != route.provider_key + or handle.channel.value != route.channel.value + ): + raise ValueError(f"missing exact custody handle for route {route.connector_id}") + + authorizer = CredentialCustodyAuthorizer( + verifiers.custody_verifier, + components.ledger, + audit_sink=components.audit_sink, + failure_notifier=components.failure_notifier, + clock=clock, + ) + invoker_factory = CredentialCustodyInvokerFactory( + authorizer, + SinglePurposeCustodiedResolver(operation_factory), + handles, + workload_id=workload_id, + purpose=purpose, + ) + executor = DelegatedConnectorExecutor( + routes, + verifiers.dispatch_verifier, + invoker_factory, + components.journal, + components.signal_sink, + clock=clock, + ) + return cls(executor=executor, verifier_policy=verifiers) + + @classmethod + def build_sqlite_reference( + cls, + *, + routes: Sequence[ConnectorRoute], + handles: Mapping[str, ProviderCredentialHandle], + verifiers: ConfiguredVerifierBundle, + operation_factory: SinglePurposeProviderOperationFactory, + state_path: str | Path, + workload_id: str, + purpose: str, + claim_lease_seconds: int = 60, + clock: Callable[[], datetime] | None = None, + ) -> DelegatedConnectorRuntime: + """Build the single-node SQLite reference, not a multi-replica backend.""" + + state = SqliteDelegatedRuntimeState( + state_path, + claim_lease_seconds=claim_lease_seconds, + clock=clock, + ) + runtime = cls.compose( + routes=routes, + handles=handles, + verifiers=verifiers, + operation_factory=operation_factory, + components=DelegatedRuntimeComponents( + journal=state.journal, + ledger=state.ledger, + signal_sink=state.sink, + audit_sink=state.sink, + failure_notifier=state.sink, + claim_lease_seconds=claim_lease_seconds, + ), + workload_id=workload_id, + purpose=purpose, + clock=clock, + ) + return cls( + executor=runtime.executor, + verifier_policy=runtime.verifier_policy, + reference_state=state, + ) diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py index 1917216..0b9339b 100644 --- a/tests/test_credential_custody.py +++ b/tests/test_credential_custody.py @@ -25,6 +25,8 @@ Channel, ConnectorRoute, DeliveryStatus, + DispatchBinding, + DispatchClaim, DispatchRequest, VerifiedDispatchGrant, ) @@ -58,6 +60,7 @@ def _request(**overrides) -> CredentialUseRequest: "recipient_ref": "recipient-ref-1", "payload_ref": "payload-ref-1", "idempotency_key": "dispatch-once-1", + "dispatch_claim_id": "claim-1", "request_fingerprint": FINGERPRINT, } fields.update(overrides) @@ -96,6 +99,9 @@ class OutcomeLedger: def __init__(self, reservation: ConsumptionReservation | None = None): self.reservation = reservation or _reservation() self.requests: list[CredentialUseRequest] = [] + self.outcomes: list[SanitizedCustodyOutcome] = [] + self.attempt_count = 0 + self.max_provider_attempts = 0 async def reserve_once( self, @@ -103,8 +109,22 @@ async def reserve_once( request: CredentialUseRequest, ) -> ConsumptionReservation: self.requests.append(request) + self.max_provider_attempts = _authorization.max_provider_attempts return self.reservation + async def reserve_attempt(self, _reservation, _authorized_use) -> int: + if self.attempt_count >= self.max_provider_attempts: + raise CustodyAuthorizationDenied("provider attempt budget is exhausted") + self.attempt_count += 1 + return self.attempt_count + + async def complete( + self, + _reservation: ConsumptionReservation, + outcome: SanitizedCustodyOutcome, + ) -> None: + self.outcomes.append(outcome) + class OutcomeVerifier: def __init__(self, authorization: VerifiedWorkloadAuthorization): @@ -171,6 +191,14 @@ def _delegated_grant(max_attempts: int = 1) -> VerifiedDispatchGrant: ) +def _dispatch_claim() -> DispatchClaim: + return DispatchClaim( + claim_id="claim-1", + binding=DispatchBinding("dispatch-once-1", FINGERPRINT), + lease_expires_at=NOW + timedelta(minutes=1), + ) + + async def test_single_dispatch_reservation_authorizes_only_opaque_handle_use(): dispatch = await authorize_provider_dispatch( _handle(), @@ -334,6 +362,7 @@ async def test_delegated_factory_reserves_before_custodied_provider_invocation() CapabilityReference("authorization-ref-1"), _delegated_request(), _delegated_grant(), + _dispatch_claim(), route, (route,), ) @@ -375,6 +404,7 @@ async def test_delegated_factory_denies_attempt_budget_above_custody_authority() CapabilityReference("authorization-ref-1"), _delegated_request(), _delegated_grant(max_attempts=2), + _dispatch_claim(), route, (route,), ) @@ -417,6 +447,7 @@ async def test_delegated_factory_denies_failover_policy_outside_custody_scope_be CapabilityReference("authorization-ref-1"), _delegated_request(), _delegated_grant(), + _dispatch_claim(), primary, (primary, backup), ) diff --git a/tests/test_delegated_connector.py b/tests/test_delegated_connector.py index cd14c33..6abc35a 100644 --- a/tests/test_delegated_connector.py +++ b/tests/test_delegated_connector.py @@ -18,10 +18,12 @@ DeliveryStatus, DispatchBinding, DispatchBindingConflict, + DispatchClaim, DispatchInProgress, DispatchRequest, DispatchSignal, DispatchSignalKind, + DispatchStateUnavailable, MonitoringUnavailable, ProviderAttemptOutcome, VerifiedDispatchGrant, @@ -127,6 +129,7 @@ async def prepare( _capability: CapabilityReference, _request: DispatchRequest, _grant: VerifiedDispatchGrant, + _claim: DispatchClaim, initial_route: ConnectorRoute, _authorized_routes: tuple[ConnectorRoute, ...], ): @@ -144,12 +147,13 @@ async def prepare( capability: CapabilityReference, request: DispatchRequest, grant: VerifiedDispatchGrant, + claim: DispatchClaim, initial_route: ConnectorRoute, authorized_routes: tuple[ConnectorRoute, ...], ): assert request.idempotency_key in self.journal.running return await super().prepare( - capability, request, grant, initial_route, authorized_routes + capability, request, grant, claim, initial_route, authorized_routes ) @@ -158,6 +162,7 @@ def __init__(self): self.running: set[str] = set() self.results: dict[str, DeliveryOutcome] = {} self.fingerprints: dict[str, str] = {} + self.claims: dict[str, str] = {} def _assert_binding(self, binding: DispatchBinding) -> None: fingerprint = self.fingerprints.get(binding.idempotency_key) @@ -168,22 +173,39 @@ async def completed(self, binding: DispatchBinding) -> DeliveryOutcome | None: self._assert_binding(binding) return self.results.get(binding.idempotency_key) - async def begin(self, binding: DispatchBinding) -> bool: + async def begin(self, binding: DispatchBinding) -> DispatchClaim | None: self._assert_binding(binding) if binding.idempotency_key in self.running: - return False + return None self.fingerprints[binding.idempotency_key] = binding.request_fingerprint self.running.add(binding.idempotency_key) - return True + claim_id = f"claim-{binding.idempotency_key}" + self.claims[binding.idempotency_key] = claim_id + return DispatchClaim( + claim_id=claim_id, + binding=binding, + lease_expires_at=datetime.now(timezone.utc) + timedelta(minutes=1), + ) - async def complete(self, binding: DispatchBinding, outcome: DeliveryOutcome) -> None: - self._assert_binding(binding) - self.running.discard(binding.idempotency_key) - self.results[binding.idempotency_key] = outcome + async def complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: + self._assert_binding(claim.binding) + assert self.claims[claim.binding.idempotency_key] == claim.claim_id + self.running.discard(claim.binding.idempotency_key) + self.results[claim.binding.idempotency_key] = outcome - async def abort(self, binding: DispatchBinding) -> None: - self._assert_binding(binding) - self.running.discard(binding.idempotency_key) + async def renew(self, claim: DispatchClaim) -> None: + self._assert_binding(claim.binding) + assert self.claims[claim.binding.idempotency_key] == claim.claim_id + + async def abort(self, claim: DispatchClaim) -> None: + self._assert_binding(claim.binding) + if self.claims.get(claim.binding.idempotency_key) == claim.claim_id: + self.running.discard(claim.binding.idempotency_key) + + +class CompleteFailingJournal(MemoryJournal): + async def complete(self, _claim: DispatchClaim, _outcome: DeliveryOutcome) -> None: + raise RuntimeError("injected durable completion failure") class SignalSink: @@ -468,3 +490,32 @@ async def test_success_is_not_resent_when_terminal_signal_must_be_retried(): assert outcome.status is DeliveryStatus.ACCEPTED assert invoker.calls == ["primary"] assert [signal.kind for signal in sink.signals] == [DispatchSignalKind.ATTEMPT_SUCCEEDED] + + +async def test_post_attempt_failure_does_not_abort_claim_or_immediately_resend(): + invoker = OutcomeInvoker([_failure("provider_unavailable")]) + journal = MemoryJournal() + sink = SignalSink(failures_remaining=1) + executor = _executor(invoker, journal=journal, sink=sink) + + with pytest.raises(MonitoringUnavailable): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert "dispatch-once-1" in journal.running + with pytest.raises(DispatchInProgress): + await executor.dispatch(CapabilityReference("authorization-ref-2"), _request()) + assert invoker.calls == ["primary"] + + +async def test_post_provider_journal_failure_keeps_claim_and_does_not_resend(): + invoker = OutcomeInvoker([_success()]) + journal = CompleteFailingJournal() + executor = _executor(invoker, journal=journal) + + with pytest.raises(DispatchStateUnavailable): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert "dispatch-once-1" in journal.running + with pytest.raises(DispatchInProgress): + await executor.dispatch(CapabilityReference("authorization-ref-2"), _request()) + assert invoker.calls == ["primary"] diff --git a/tests/test_delegated_runtime.py b/tests/test_delegated_runtime.py new file mode 100644 index 0000000..6f2831c --- /dev/null +++ b/tests/test_delegated_runtime.py @@ -0,0 +1,661 @@ +"""Durable delegated connector runtime tests without credential material.""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +import pytest + +from baton.credential_custody import ( + AuthorizedCredentialUse, + ConsumptionReservationRequired, + CredentialUseRequest, + CustodyAuditEvent, + CustodyAuditKind, + CustodyAuthorizationDenied, + CustodyResultStatus, + ProviderChannel, + ProviderCredentialHandle, + SanitizedCustodyOutcome, + VerifiedWorkloadAuthorization, +) +from baton.delegated_connector import ( + CapabilityReference, + Channel, + ConnectorRoute, + DeliveryOutcome, + DeliveryStatus, + DispatchBinding, + DispatchRequest, + DispatchSignal, + DispatchSignalKind, + DispatchStateUnavailable, + VerifiedDispatchGrant, +) +from baton.delegated_runtime import ( + ConfiguredVerifierBundle, + DelegatedConnectorRuntime, + DelegatedRuntimeComponents, + SinglePurposeCustodiedResolver, + SqliteDelegatedRuntimeState, +) + + +NOW = datetime(2026, 6, 4, tzinfo=timezone.utc) +FINGERPRINT = "a" * 64 + + +@dataclass +class MutableClock: + now: datetime = NOW + + def __call__(self) -> datetime: + return self.now + + +def _route() -> ConnectorRoute: + return ConnectorRoute( + connector_id="sms-primary", + provider_key="provider-primary", + channel=Channel.SMS, + credential_handle="handle-sms-primary-v3", + priority=1, + ) + + +def _handle() -> ProviderCredentialHandle: + return ProviderCredentialHandle( + handle_id="handle-sms-primary-v3", + connector_id="sms-primary", + provider_key="provider-primary", + channel=ProviderChannel.SMS, + version_ref="active-v3", + ) + + +def _request( + *, + idempotency_key: str = "dispatch-once-1", + fingerprint: str = FINGERPRINT, +) -> DispatchRequest: + return DispatchRequest( + dispatch_id=f"dispatch-{idempotency_key}", + workflow_id="operation-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + ) + + +def _custody_request( + *, + idempotency_key: str = "dispatch-once-1", + fingerprint: str = FINGERPRINT, + claim_id: str = "claim-1", +) -> CredentialUseRequest: + return CredentialUseRequest( + operation_id="operation-1", + dispatch_id=f"dispatch-{idempotency_key}", + workload_id="mea-comms", + connector_id="sms-primary", + channel=ProviderChannel.SMS, + purpose="case_notification", + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key=idempotency_key, + dispatch_claim_id=claim_id, + request_fingerprint=fingerprint, + ) + + +def _delivery(dispatch_id: str = "dispatch-dispatch-once-1") -> DeliveryOutcome: + return DeliveryOutcome( + dispatch_id=dispatch_id, + workflow_id="operation-1", + channel=Channel.SMS, + connector_id="sms-primary", + provider_key="provider-primary", + status=DeliveryStatus.ACCEPTED, + attempt_count=1, + failover_used=False, + audit_ref="audit-provider-accepted", + ) + + +def _authorized_use( + *, + claim_id: str, + idempotency_key: str = "dispatch-once-1", + fingerprint: str = FINGERPRINT, + reservation_id: str | None = None, +) -> AuthorizedCredentialUse: + return AuthorizedCredentialUse( + authorization_id=f"authorization-{idempotency_key}", + handle_id="handle-sms-primary-v3", + connector_id="sms-primary", + provider_key="provider-primary", + operation_id="operation-1", + dispatch_id=f"dispatch-{idempotency_key}", + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + dispatch_claim_id=claim_id, + reservation_id=reservation_id or f"reservation-{idempotency_key}", + ) + + +class DispatchVerifier: + async def verify(self, _reference, request: DispatchRequest) -> VerifiedDispatchGrant: + return VerifiedDispatchGrant( + principal="mea-comms", + channel=request.channel, + allowed_connectors=frozenset({"sms-primary"}), + not_after=NOW + timedelta(minutes=10), + max_attempts=1, + request_fingerprint=request.request_fingerprint, + ) + + +class CustodyVerifier: + async def verify(self, _reference, request: CredentialUseRequest) -> VerifiedWorkloadAuthorization: + return VerifiedWorkloadAuthorization( + authorization_id=f"authorization-{request.idempotency_key}", + workload_id=request.workload_id, + allowed_connector_ids=frozenset({"sms-primary"}), + allowed_channels=frozenset({ProviderChannel.SMS}), + allowed_purposes=frozenset({"case_notification"}), + request_fingerprint=request.request_fingerprint, + not_before=NOW - timedelta(minutes=1), + not_after=NOW + timedelta(minutes=10), + max_uses=1, + max_provider_attempts=1, + ) + + +class StaticOperation: + def __init__(self, outcome: SanitizedCustodyOutcome): + self.outcome = outcome + self.calls = 0 + + async def invoke(self) -> SanitizedCustodyOutcome: + self.calls += 1 + return self.outcome + + +class OperationFactory: + def __init__(self, outcome: SanitizedCustodyOutcome): + self.outcome = outcome + self.prepared = [] + self.operations: list[StaticOperation] = [] + + async def prepare(self, handle, authorized_use) -> StaticOperation: + self.prepared.append((handle, authorized_use)) + operation = StaticOperation(self.outcome) + self.operations.append(operation) + return operation + + +class CompleteFailingLedger: + def __init__(self, ledger): + self._ledger = ledger + + async def reserve_once(self, authorization, request): + return await self._ledger.reserve_once(authorization, request) + + async def reserve_attempt(self, reservation, authorized_use): + return await self._ledger.reserve_attempt(reservation, authorized_use) + + async def complete(self, _reservation, _outcome): + raise RuntimeError("injected durable outcome persistence failure") + + +class OutcomeFailingAuditSink: + def __init__(self, sink): + self._sink = sink + + async def emit(self, event): + if event.kind in ( + CustodyAuditKind.INVOCATION_COMPLETED, + CustodyAuditKind.INVOCATION_FAILED, + ): + raise RuntimeError("injected durable custody audit failure") + await self._sink.emit(event) + + +def _accepted_outcome() -> SanitizedCustodyOutcome: + return SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-dispatch-once-1", + provider_key="provider-primary", + status=CustodyResultStatus.ACCEPTED, + audit_ref="audit-provider-accepted", + ) + + +def _runtime(tmp_path, operation_factory, *, clock=None) -> DelegatedConnectorRuntime: + return DelegatedConnectorRuntime.build_sqlite_reference( + routes=[_route()], + handles={"sms-primary": _handle()}, + verifiers=ConfiguredVerifierBundle( + dispatch_verifier=DispatchVerifier(), + custody_verifier=CustodyVerifier(), + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ), + operation_factory=operation_factory, + state_path=tmp_path / "delegated-runtime.sqlite3", + workload_id="mea-comms", + purpose="case_notification", + clock=clock, + ) + + +async def test_runtime_replays_completed_dispatch_without_second_provider_operation(tmp_path): + first_factory = OperationFactory(_accepted_outcome()) + first_runtime = _runtime(tmp_path, first_factory, clock=lambda: NOW) + + first = await first_runtime.executor.dispatch( + CapabilityReference("opaque-authorization-ref"), + _request(), + ) + + second_factory = OperationFactory(_accepted_outcome()) + second_runtime = _runtime(tmp_path, second_factory, clock=lambda: NOW) + second = await second_runtime.executor.dispatch( + CapabilityReference("another-opaque-ref"), + _request(), + ) + + assert first == second + assert len(first_factory.prepared) == 1 + assert second_factory.prepared == [] + assert not hasattr(first_factory.prepared[0][1], "material") + assert second_runtime.reference_state is not None + verification = await second_runtime.reference_state.verify_event_chain() + assert verification.ok is True + assert verification.event_count >= 3 + + +async def test_stale_claim_is_reclaimed_and_old_worker_cannot_complete(tmp_path): + clock = MutableClock() + state = SqliteDelegatedRuntimeState( + tmp_path / "runtime.sqlite3", + claim_lease_seconds=5, + clock=clock, + ) + binding = DispatchBinding("dispatch-once-1", FINGERPRINT) + first_claim = await state.journal.begin(binding) + assert first_claim is not None + + clock.now += timedelta(seconds=6) + second_claim = await state.journal.begin(binding) + assert second_claim is not None + assert second_claim.claim_id != first_claim.claim_id + + with pytest.raises(DispatchStateUnavailable): + await state.journal.complete(first_claim, _delivery()) + + await state.journal.complete(second_claim, _delivery()) + assert await state.journal.completed(binding) == _delivery() + + +async def test_sqlite_reference_serializes_competing_claims_and_attempts(tmp_path): + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=lambda: NOW) + binding = DispatchBinding("dispatch-once-1", FINGERPRINT) + + claims = await asyncio.gather( + state.journal.begin(binding), + state.journal.begin(binding), + ) + active_claims = [claim for claim in claims if claim is not None] + assert len(active_claims) == 1 + assert claims.count(None) == 1 + + claim = active_claims[0] + request = _custody_request(claim_id=claim.claim_id) + authorization = await CustodyVerifier().verify(None, request) + reservation = await state.ledger.reserve_once(authorization, request) + use = _authorized_use( + claim_id=claim.claim_id, + reservation_id=reservation.reservation_id, + ) + + attempts = await asyncio.gather( + state.ledger.reserve_attempt(reservation, use), + state.ledger.reserve_attempt(reservation, use), + return_exceptions=True, + ) + assert attempts.count(1) == 1 + assert sum(isinstance(result, CustodyAuthorizationDenied) for result in attempts) == 1 + + +async def test_expired_claim_is_inactive_before_another_worker_reclaims_it(tmp_path): + clock = MutableClock() + state = SqliteDelegatedRuntimeState( + tmp_path / "runtime.sqlite3", + claim_lease_seconds=5, + clock=clock, + ) + binding = DispatchBinding("dispatch-once-1", FINGERPRINT) + claim = await state.journal.begin(binding) + assert claim is not None + request = _custody_request(claim_id=claim.claim_id) + authorization = await CustodyVerifier().verify(None, request) + reservation = await state.ledger.reserve_once(authorization, request) + + clock.now += timedelta(seconds=6) + + with pytest.raises(DispatchStateUnavailable, match="no longer active"): + await state.journal.renew(claim) + with pytest.raises(DispatchStateUnavailable, match="no longer active"): + await state.journal.complete(claim, _delivery()) + with pytest.raises(ConsumptionReservationRequired, match="active exact"): + await state.ledger.reserve_once(authorization, request) + with pytest.raises(ConsumptionReservationRequired, match="active exact"): + await state.ledger.reserve_attempt( + reservation, + _authorized_use( + claim_id=claim.claim_id, + reservation_id=reservation.reservation_id, + ), + ) + + +async def test_durable_ledger_reuses_exact_reservation_and_rejects_rebinding(tmp_path): + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=lambda: NOW) + first_binding = DispatchBinding("dispatch-once-1", FINGERPRINT) + first_claim = await state.journal.begin(first_binding) + assert first_claim is not None + first_request = _custody_request(claim_id=first_claim.claim_id) + first_authorization = await CustodyVerifier().verify(None, first_request) + + first = await state.ledger.reserve_once(first_authorization, first_request) + replay = await state.ledger.reserve_once(first_authorization, first_request) + assert replay == first + + other_fingerprint = "b" * 64 + other_binding = DispatchBinding("dispatch-once-2", other_fingerprint) + other_claim = await state.journal.begin(other_binding) + assert other_claim is not None + other_request = _custody_request( + idempotency_key="dispatch-once-2", + fingerprint=other_fingerprint, + claim_id=other_claim.claim_id, + ) + rebound_authorization = VerifiedWorkloadAuthorization( + authorization_id=first_authorization.authorization_id, + workload_id=other_request.workload_id, + allowed_connector_ids=frozenset({"sms-primary"}), + allowed_channels=frozenset({ProviderChannel.SMS}), + allowed_purposes=frozenset({"case_notification"}), + request_fingerprint=other_fingerprint, + not_before=NOW - timedelta(minutes=1), + not_after=NOW + timedelta(minutes=10), + max_uses=1, + max_provider_attempts=1, + ) + with pytest.raises(CustodyAuthorizationDenied): + await state.ledger.reserve_once(rebound_authorization, other_request) + + +async def test_provider_attempt_budget_survives_dispatch_claim_recovery(tmp_path): + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=lambda: NOW) + binding = DispatchBinding("dispatch-once-1", FINGERPRINT) + first_claim = await state.journal.begin(binding) + assert first_claim is not None + request = _custody_request(claim_id=first_claim.claim_id) + authorization = await CustodyVerifier().verify(None, request) + reservation = await state.ledger.reserve_once(authorization, request) + first_use = _authorized_use( + claim_id=first_claim.claim_id, + reservation_id=reservation.reservation_id, + ) + + with pytest.raises(ConsumptionReservationRequired, match="does not bind"): + await state.ledger.reserve_attempt( + reservation, + _authorized_use( + claim_id=first_claim.claim_id, + idempotency_key="dispatch-once-2", + fingerprint="b" * 64, + reservation_id=reservation.reservation_id, + ), + ) + assert await state.ledger.reserve_attempt(reservation, first_use) == 1 + await state.journal.abort(first_claim) + + second_claim = await state.journal.begin(binding) + assert second_claim is not None + replay_request = _custody_request(claim_id=second_claim.claim_id) + replay_reservation = await state.ledger.reserve_once(authorization, replay_request) + second_use = _authorized_use( + claim_id=second_claim.claim_id, + reservation_id=reservation.reservation_id, + ) + + assert replay_reservation == reservation + with pytest.raises(CustodyAuthorizationDenied, match="budget is exhausted"): + await state.ledger.reserve_attempt(replay_reservation, second_use) + + +async def test_stale_claim_cannot_reserve_authorization_or_provider_attempt(tmp_path): + clock = MutableClock() + state = SqliteDelegatedRuntimeState( + tmp_path / "runtime.sqlite3", + claim_lease_seconds=5, + clock=clock, + ) + binding = DispatchBinding("dispatch-once-1", FINGERPRINT) + first_claim = await state.journal.begin(binding) + assert first_claim is not None + first_request = _custody_request(claim_id=first_claim.claim_id) + authorization = await CustodyVerifier().verify(None, first_request) + reservation = await state.ledger.reserve_once(authorization, first_request) + + clock.now += timedelta(seconds=6) + second_claim = await state.journal.begin(binding) + assert second_claim is not None + + with pytest.raises(ConsumptionReservationRequired, match="active exact"): + await state.ledger.reserve_once(authorization, first_request) + with pytest.raises(ConsumptionReservationRequired, match="active exact"): + await state.ledger.reserve_attempt( + reservation, + _authorized_use( + claim_id=first_claim.claim_id, + reservation_id=reservation.reservation_id, + ), + ) + + assert await state.ledger.reserve_attempt( + reservation, + _authorized_use( + claim_id=second_claim.claim_id, + reservation_id=reservation.reservation_id, + ), + ) == 1 + + +async def test_hash_chain_detects_tampering(tmp_path): + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=lambda: NOW) + await state.sink.emit( + DispatchSignal( + kind=DispatchSignalKind.ATTEMPT_SUCCEEDED, + workflow_id="operation-1", + channel=Channel.SMS, + dispatch_id="dispatch-1", + connector_id="sms-primary", + provider_key="provider-primary", + attempt_count=1, + ) + ) + assert (await state.verify_event_chain()).ok is True + + with sqlite3.connect(state.path) as connection: + connection.execute( + "UPDATE operational_events SET payload_json = ? WHERE sequence = 1", + ('{"tampered":true}',), + ) + + verification = await state.verify_event_chain() + assert verification.ok is False + assert verification.broken_sequence == 1 + + +async def test_failure_notifications_are_durable_and_acknowledgeable(tmp_path): + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=lambda: NOW) + event = CustodyAuditEvent( + kind=CustodyAuditKind.INVOCATION_FAILED, + operation_id="operation-1", + dispatch_id="dispatch-1", + workload_id="mea-comms", + connector_id="sms-primary", + provider_key="provider-primary", + authorization_id="authorization-1", + failure_code="provider_timeout", + ) + + await state.sink.notify(event) + await state.sink.notify(event) + pending = await state.pending_failure_notifications() + + assert len(pending) == 1 + assert pending[0]["payload"]["failure_code"] == "provider_timeout" + assert await state.acknowledge_failure_notification(pending[0]["notification_key"]) + assert await state.pending_failure_notifications() == [] + assert (await state.verify_event_chain()).ok is True + + +async def test_runtime_failure_persists_sanitized_notification(tmp_path): + factory = OperationFactory( + SanitizedCustodyOutcome( + operation_id="operation-1", + dispatch_id="dispatch-dispatch-once-1", + provider_key="provider-primary", + status=CustodyResultStatus.FAILED, + audit_ref="audit-provider-timeout", + failure_code="provider_timeout", + ) + ) + runtime = _runtime(tmp_path, factory, clock=lambda: NOW) + + outcome = await runtime.executor.dispatch( + CapabilityReference("opaque-authorization-ref"), + _request(), + ) + + assert outcome.status is DeliveryStatus.EXHAUSTED + assert outcome.failure_code == "provider_timeout" + assert runtime.reference_state is not None + pending = await runtime.reference_state.pending_failure_notifications() + assert len(pending) == 1 + assert pending[0]["payload"]["failure_code"] == "provider_timeout" + assert "recipient-ref-1" not in str(pending) + assert "payload-ref-1" not in str(pending) + + +@pytest.mark.parametrize("failure_mode", ["ledger", "audit"]) +async def test_post_provider_state_failure_is_non_retryable_and_does_not_resend( + tmp_path, + failure_mode, +): + factory = OperationFactory(_accepted_outcome()) + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=lambda: NOW) + ledger = CompleteFailingLedger(state.ledger) if failure_mode == "ledger" else state.ledger + audit_sink = OutcomeFailingAuditSink(state.sink) if failure_mode == "audit" else state.sink + runtime = DelegatedConnectorRuntime.compose( + routes=[_route()], + handles={"sms-primary": _handle()}, + verifiers=ConfiguredVerifierBundle( + dispatch_verifier=DispatchVerifier(), + custody_verifier=CustodyVerifier(), + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ), + operation_factory=factory, + components=DelegatedRuntimeComponents( + journal=state.journal, + ledger=ledger, + signal_sink=state.sink, + audit_sink=audit_sink, + failure_notifier=state.sink, + claim_lease_seconds=60, + ), + workload_id="mea-comms", + purpose="case_notification", + clock=lambda: NOW, + ) + + outcome = await runtime.executor.dispatch( + CapabilityReference("opaque-authorization-ref"), + _request(), + ) + + assert outcome.status is DeliveryStatus.EXHAUSTED + assert outcome.failure_code == "custody_state_unavailable" + assert outcome.attempt_count == 1 + assert sum(operation.calls for operation in factory.operations) == 1 + assert runtime.reference_state is None + pending = await state.pending_failure_notifications() + assert any( + notification["payload"]["failure_code"] == "custody_state_unavailable" + for notification in pending + ) + assert "recipient-ref-1" not in str(pending) + assert "payload-ref-1" not in str(pending) + + +async def test_single_purpose_resolver_returns_only_sanitized_outcome(): + factory = OperationFactory(_accepted_outcome()) + resolver = SinglePurposeCustodiedResolver(factory) + use = _authorized_use(claim_id="claim-1") + + outcome = await resolver.invoke(_handle(), use) + + assert outcome == _accepted_outcome() + assert not hasattr(factory.prepared[0][0], "material") + assert not hasattr(factory.prepared[0][1], "material") + + +def test_runtime_builder_rejects_non_durable_or_incomplete_construction(tmp_path): + with pytest.raises(ValueError, match=":memory:"): + SqliteDelegatedRuntimeState(":memory:") + + with pytest.raises(ValueError, match="missing exact custody handle"): + DelegatedConnectorRuntime.build_sqlite_reference( + routes=[_route()], + handles={}, + verifiers=ConfiguredVerifierBundle( + dispatch_verifier=DispatchVerifier(), + custody_verifier=CustodyVerifier(), + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ), + operation_factory=OperationFactory(_accepted_outcome()), + state_path=tmp_path / "runtime.sqlite3", + workload_id="mea-comms", + purpose="case_notification", + ) + + with pytest.raises(ValueError, match="claim lease must exceed"): + DelegatedConnectorRuntime.build_sqlite_reference( + routes=[_route()], + handles={"sms-primary": _handle()}, + verifiers=ConfiguredVerifierBundle( + dispatch_verifier=DispatchVerifier(), + custody_verifier=CustodyVerifier(), + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ), + operation_factory=OperationFactory(_accepted_outcome()), + state_path=tmp_path / "short-lease.sqlite3", + workload_id="mea-comms", + purpose="case_notification", + claim_lease_seconds=5, + ) From 175599d4f286251fb3bf01d48a15a02a1cae3dbe Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 22:10:50 -0500 Subject: [PATCH 6/9] feat(custody): unify delegated authorization outcome --- docs/credential-custody-boundary.md | 47 ++++++- src/baton/credential_custody.py | 145 +++++++++++++++++++-- src/baton/delegated_connector.py | 36 ++++++ src/baton/delegated_runtime.py | 104 +++++++++++++-- tests/test_credential_custody.py | 66 ++++++++-- tests/test_delegated_connector.py | 41 +++++- tests/test_delegated_runtime.py | 193 +++++++++++++++++++++++++--- 7 files changed, 570 insertions(+), 62 deletions(-) diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md index 1e7ff66..1ff11b0 100644 --- a/docs/credential-custody-boundary.md +++ b/docs/credential-custody-boundary.md @@ -11,9 +11,10 @@ telephony, or other external-provider credential value. - `ProviderCredentialHandle` is an administrator-configured opaque handle, not a secret-store URI or credential value. -- `SignedWorkloadAuthorizationVerifier` converts a signed authorization - reference into a verified, credential-free outcome. -- `CredentialCustodyAuthorizer` obtains a trusted verifier outcome and +- `SignedWorkloadAuthorizationVerifier` supports standalone custody consumers. + The composed delegated runtime does not invoke it after dispatch admission. +- `CredentialCustodyAuthorizer` either obtains a standalone trusted verifier + outcome or accepts the composed runtime's already verified shared outcome. `authorize_provider_dispatch` binds it to the exact workload, initial connector handle, channel, purpose, opaque recipient and payload references, request fingerprint, and provider-attempt budget before consuming the @@ -51,14 +52,46 @@ telephony, or other external-provider credential value. the concrete resolver. Provider material may exist inside its prepared operation, but no Baton request, authorization, outcome, event, or runtime state type contains that material. -- `ConfiguredVerifierBundle` requires explicit issuer and rotation policy - references. Those references make trust configuration visible; they are not - proof that the supplied verifier performs trusted signature verification. +- `ConfiguredVerifierBundle` supplies one verifier with the exact audience, + workload, purpose, issuer-policy reference, and rotation-policy reference + before dispatch admission. The verifier must return one + `VerifiedDelegatedAuthorization`; Baton derives both dispatch and custody + views from that same immutable outcome. - `SqliteDelegatedRuntimeState` and `DelegatedConnectorRuntime.build_sqlite_reference` are single-node reference implementations for executable recovery evidence. They are not a multi-replica production state backend. +## Exact Delegated Authorization Contract + +The trusted Signet-compatible verifier receives the opaque authorization +reference, the exact `DispatchRequest`, and a `DelegatedAuthorizationContext`. +Its verified outcome must bind: + +- authorization ID and issuer; +- Baton delegated-executor audience; +- workload ID/principal; +- exactly one channel; +- allowed connector IDs and purposes; +- `not_before` and `not_after`; +- `max_uses=1` and a provider-attempt budget; and +- the exact canonical request fingerprint. + +The request fingerprint is a domain-separated SHA-256 digest over +`dispatch_id`, workflow/operation ID, channel, opaque recipient reference, +opaque payload reference, and idempotency key. `DispatchRequest` and +`CredentialUseRequest` both reject a fingerprint that does not match those +fields. Recipient and payload references therefore must identify immutable or +versioned data; the digest does not make a mutable reference immutable. + +`dispatch_claim_id` is acquired only after verification and is intentionally +outside the signed fingerprint. The durable journal and authorization ledger +bind it to the active exact dispatch before reservation or provider use. + +The verified outcome contains no provider credential, credential handle, +recipient value, payload value, or provider response. `authorization_id` is the +sanitized correlation reference carried through custody audit events. + ## Failure Semantics - Missing, expired, mismatched, or out-of-scope authority is denied before any @@ -119,7 +152,7 @@ exist for approved cloud-neutral backends. MEA integration remains blocked until all of the following exist: 1. A trusted Signet-compatible verifier implementation with issuer and rotation - policy. + policy that implements the exact delegated authorization contract above. 2. A shared, highly available multi-replica implementation of the durable component contracts, including atomic claim, reservation, attempt-budget, completion, replay, and recovery behavior. diff --git a/src/baton/credential_custody.py b/src/baton/credential_custody.py index b047e3a..e795544 100644 --- a/src/baton/credential_custody.py +++ b/src/baton/credential_custody.py @@ -18,6 +18,7 @@ from .delegated_connector import ( AuthorizationDenied as DelegatedAuthorizationDenied, CapabilityReference, + Channel, ConnectorRoute, CustodiedProviderInvoker, DeliveryStatus, @@ -25,6 +26,7 @@ DispatchRequest, ProviderAttemptOutcome, VerifiedDispatchGrant, + dispatch_request_fingerprint, ) @@ -131,6 +133,16 @@ def __post_init__(self) -> None: raise ValueError(f"{name} is required") if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") + expected_fingerprint = dispatch_request_fingerprint( + dispatch_id=self.dispatch_id, + workflow_id=self.operation_id, + channel=Channel(self.channel.value), + recipient_ref=self.recipient_ref, + payload_ref=self.payload_ref, + idempotency_key=self.idempotency_key, + ) + if self.request_fingerprint != expected_fingerprint: + raise ValueError("request_fingerprint does not bind the immutable dispatch request") @dataclass(frozen=True) @@ -163,6 +175,53 @@ def __post_init__(self) -> None: raise ValueError("max_provider_attempts must be positive") +@dataclass(frozen=True) +class VerifiedDelegatedAuthorization(VerifiedDispatchGrant): + """One verified outcome shared by dispatch admission and credential custody.""" + + authorization_id: str + issuer: str + audience: str + allowed_purposes: frozenset[str] + not_before: datetime + max_uses: int + + def __post_init__(self) -> None: + super().__post_init__() + for name in ("authorization_id", "issuer", "audience"): + if not getattr(self, name): + raise ValueError(f"{name} is required") + if ( + not self.allowed_connectors + or not self.allowed_purposes + or not all(self.allowed_connectors) + or not all(self.allowed_purposes) + ): + raise ValueError("allowed connectors and purposes are required") + if self.not_before.tzinfo is None: + raise ValueError("not_before must be timezone-aware") + if self.not_after <= self.not_before: + raise ValueError("authorization expiry must be after not_before") + if self.max_uses != 1: + raise ValueError("delegated provider authorization must be single-use") + + def as_workload_authorization(self) -> VerifiedWorkloadAuthorization: + """Derive the custody view without repeating signature verification.""" + + return VerifiedWorkloadAuthorization( + authorization_id=self.authorization_id, + workload_id=self.principal, + allowed_connector_ids=self.allowed_connectors, + allowed_channels=frozenset({ProviderChannel(self.channel.value)}), + allowed_purposes=self.allowed_purposes, + request_fingerprint=self.request_fingerprint, + not_before=self.not_before, + not_after=self.not_after, + max_uses=self.max_uses, + max_provider_attempts=self.max_attempts, + ) + + @dataclass(frozen=True) class ConsumptionReservation: """Atomic bounded-use reservation produced by a durable ledger.""" @@ -452,11 +511,11 @@ async def authorize_provider_dispatch( class CredentialCustodyAuthorizer: - """Obtain verified authority and reserve its exact provider operation.""" + """Verify or accept one trusted outcome, then reserve its exact operation.""" def __init__( self, - verifier: SignedWorkloadAuthorizationVerifier, + verifier: SignedWorkloadAuthorizationVerifier | None, ledger: AuthorizationConsumptionLedger, *, audit_sink: CustodyAuditSink | None = None, @@ -469,6 +528,25 @@ def __init__( self._failure_notifier = failure_notifier self._clock = clock or (lambda: datetime.now(timezone.utc)) + @classmethod + def for_verified_outcomes( + cls, + ledger: AuthorizationConsumptionLedger, + *, + audit_sink: CustodyAuditSink | None = None, + failure_notifier: CustodyFailureNotifier | None = None, + clock: Callable[[], datetime] | None = None, + ) -> CredentialCustodyAuthorizer: + """Construct the reservation path used after one trusted verification.""" + + return cls( + None, + ledger, + audit_sink=audit_sink, + failure_notifier=failure_notifier, + clock=clock, + ) + async def authorize( self, reference: WorkloadAuthorizationReference, @@ -480,8 +558,51 @@ async def authorize( ) -> AuthorizedProviderDispatch: authorization_id = "" try: + if self._verifier is None: + raise CustodyAuthorizationDenied( + "signed workload authorization verifier is not configured" + ) authorization = await self._verifier.verify(reference, request) authorization_id = authorization.authorization_id + except CustodyAuthorizationDenied: + await self._record_failure( + request, + handle, + authorization_id=authorization_id, + kind=CustodyAuditKind.AUTHORIZATION_DENIED, + failure_code="authorization_denied", + ) + raise + except Exception as exc: + await self._record_failure( + request, + handle, + authorization_id=authorization_id, + kind=CustodyAuditKind.AUTHORIZATION_DENIED, + failure_code="verification_unavailable", + ) + raise CustodyAuthorizationDenied("workload authorization verification failed") from exc + + return await self.authorize_verified( + authorization, + handle, + request, + provider_attempt_budget=provider_attempt_budget, + required_connector_ids=required_connector_ids, + ) + + async def authorize_verified( + self, + authorization: VerifiedWorkloadAuthorization, + handle: ProviderCredentialHandle, + request: CredentialUseRequest, + *, + provider_attempt_budget: int | None = None, + required_connector_ids: frozenset[str] | None = None, + ) -> AuthorizedProviderDispatch: + """Reserve a previously verified outcome without invoking another verifier.""" + + try: reserved_dispatch = await authorize_provider_dispatch( handle, request, @@ -495,7 +616,7 @@ async def authorize( await self._record_failure( request, handle, - authorization_id=authorization_id, + authorization_id=authorization.authorization_id, kind=CustodyAuditKind.RESERVATION_REQUIRED, failure_code="reservation_required", ) @@ -504,7 +625,7 @@ async def authorize( await self._record_failure( request, handle, - authorization_id=authorization_id, + authorization_id=authorization.authorization_id, kind=CustodyAuditKind.AUTHORIZATION_DENIED, failure_code="authorization_denied", ) @@ -513,11 +634,11 @@ async def authorize( await self._record_failure( request, handle, - authorization_id=authorization_id, + authorization_id=authorization.authorization_id, kind=CustodyAuditKind.AUTHORIZATION_DENIED, - failure_code="verification_unavailable", + failure_code="authorization_unavailable", ) - raise CustodyAuthorizationDenied("workload authorization verification failed") from exc + raise CustodyAuthorizationDenied("workload authorization reservation failed") from exc await self._emit_audit( CustodyAuditEvent( @@ -673,13 +794,17 @@ def _handle_for_route(self, route: ConnectorRoute) -> ProviderCredentialHandle: async def prepare( self, - capability: CapabilityReference, + _capability: CapabilityReference, request: DispatchRequest, grant: VerifiedDispatchGrant, claim: DispatchClaim, initial_route: ConnectorRoute, authorized_routes: tuple[ConnectorRoute, ...], ) -> CustodiedProviderInvoker: + if not isinstance(grant, VerifiedDelegatedAuthorization): + raise DelegatedAuthorizationDenied( + "credential custody requires one shared verified authorization outcome" + ) if initial_route not in authorized_routes: raise DelegatedAuthorizationDenied("initial route is outside dispatch policy") initial_handle = self._handle_for_route(initial_route) @@ -699,8 +824,8 @@ async def prepare( request_fingerprint=request.request_fingerprint, ) try: - reserved_dispatch = await self._authorizer.authorize( - WorkloadAuthorizationReference(capability.reference), + reserved_dispatch = await self._authorizer.authorize_verified( + grant.as_workload_authorization(), initial_handle, custody_request, provider_attempt_budget=grant.max_attempts, diff --git a/src/baton/delegated_connector.py b/src/baton/delegated_connector.py index 76afebe..41ee590 100644 --- a/src/baton/delegated_connector.py +++ b/src/baton/delegated_connector.py @@ -12,6 +12,8 @@ from __future__ import annotations import asyncio +import hashlib +import json import re import time from dataclasses import dataclass @@ -29,6 +31,30 @@ class Channel(StrEnum): EMAIL = "email" +def dispatch_request_fingerprint( + *, + dispatch_id: str, + workflow_id: str, + channel: Channel, + recipient_ref: str, + payload_ref: str, + idempotency_key: str, +) -> str: + """Return the domain-separated digest for one immutable dispatch request.""" + + payload = { + "channel": channel.value, + "dispatch_id": dispatch_id, + "idempotency_key": idempotency_key, + "payload_ref": payload_ref, + "recipient_ref": recipient_ref, + "schema": "baton.delegated-dispatch.v1", + "workflow_id": workflow_id, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + class DeliveryStatus(StrEnum): ACCEPTED = "accepted" DELIVERED = "delivered" @@ -109,6 +135,16 @@ def __post_init__(self) -> None: raise ValueError(f"{name} is required") if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") + expected_fingerprint = dispatch_request_fingerprint( + dispatch_id=self.dispatch_id, + workflow_id=self.workflow_id, + channel=self.channel, + recipient_ref=self.recipient_ref, + payload_ref=self.payload_ref, + idempotency_key=self.idempotency_key, + ) + if self.request_fingerprint != expected_fingerprint: + raise ValueError("request_fingerprint does not bind the immutable dispatch request") @dataclass(frozen=True) diff --git a/src/baton/delegated_runtime.py b/src/baton/delegated_runtime.py index 9753e14..5759a09 100644 --- a/src/baton/delegated_runtime.py +++ b/src/baton/delegated_runtime.py @@ -36,10 +36,12 @@ CustodyFailureNotifier, ProviderCredentialHandle, SanitizedCustodyOutcome, - SignedWorkloadAuthorizationVerifier, + VerifiedDelegatedAuthorization, VerifiedWorkloadAuthorization, ) from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, ConnectorRoute, Channel, DelegatedConnectorExecutor, @@ -49,6 +51,7 @@ DispatchBindingConflict, DispatchClaim, DispatchJournal, + DispatchRequest, DispatchSignal, DispatchSignalSink, DispatchStateUnavailable, @@ -116,18 +119,102 @@ def _delivery_outcome_from_payload(payload: str) -> DeliveryOutcome: ) +@dataclass(frozen=True) +class DelegatedAuthorizationContext: + """Exact runtime and trust-policy context supplied to a trusted verifier.""" + + audience: str + workload_id: str + purpose: str + issuer_policy_ref: str + rotation_policy_ref: str + + def __post_init__(self) -> None: + for name in ( + "audience", + "workload_id", + "purpose", + "issuer_policy_ref", + "rotation_policy_ref", + ): + if not getattr(self, name): + raise ValueError(f"{name} is required") + + +class DelegatedAuthorizationVerifier(Protocol): + """Verify one delegated-provider authorization against exact runtime policy.""" + + async def verify( + self, + capability: CapabilityReference, + request: DispatchRequest, + context: DelegatedAuthorizationContext, + ) -> VerifiedDelegatedAuthorization: + ... + + +@dataclass(frozen=True) +class _ContextBoundDelegatedAuthorizationVerifier: + verifier: DelegatedAuthorizationVerifier + context: DelegatedAuthorizationContext + clock: Callable[[], datetime] + + async def verify( + self, + capability: CapabilityReference, + request: DispatchRequest, + ) -> VerifiedDelegatedAuthorization: + outcome = await self.verifier.verify(capability, request, self.context) + if not isinstance(outcome, VerifiedDelegatedAuthorization): + raise AuthorizationDenied( + "delegated verifier did not return the required shared authorization outcome" + ) + if outcome.audience != self.context.audience: + raise AuthorizationDenied("authorization audience is outside runtime scope") + if outcome.principal != self.context.workload_id: + raise AuthorizationDenied("authorization principal is outside runtime scope") + if self.context.purpose not in outcome.allowed_purposes: + raise AuthorizationDenied("authorization purpose is outside runtime scope") + current_time = self.clock() + if current_time < outcome.not_before or current_time >= outcome.not_after: + raise AuthorizationDenied("authorization is outside its validity window") + return outcome + + @dataclass(frozen=True) class ConfiguredVerifierBundle: - """Explicit verifier wiring and its externally managed trust-policy refs.""" + """One verifier plus the exact externally managed trust-policy context.""" - dispatch_verifier: ScopedAuthorizationVerifier - custody_verifier: SignedWorkloadAuthorizationVerifier + verifier: DelegatedAuthorizationVerifier + audience: str issuer_policy_ref: str rotation_policy_ref: str def __post_init__(self) -> None: - if not self.issuer_policy_ref or not self.rotation_policy_ref: - raise ValueError("issuer_policy_ref and rotation_policy_ref are required") + for name in ("audience", "issuer_policy_ref", "rotation_policy_ref"): + if not getattr(self, name): + raise ValueError(f"{name} is required") + + def bind( + self, + *, + workload_id: str, + purpose: str, + clock: Callable[[], datetime] | None = None, + ) -> ScopedAuthorizationVerifier: + """Bind trust policy and runtime scope before any dispatch admission.""" + + return _ContextBoundDelegatedAuthorizationVerifier( + self.verifier, + DelegatedAuthorizationContext( + audience=self.audience, + workload_id=workload_id, + purpose=purpose, + issuer_policy_ref=self.issuer_policy_ref, + rotation_policy_ref=self.rotation_policy_ref, + ), + clock or _utc_now, + ) class SinglePurposeProviderOperation(Protocol): @@ -933,8 +1020,7 @@ def compose( ): raise ValueError(f"missing exact custody handle for route {route.connector_id}") - authorizer = CredentialCustodyAuthorizer( - verifiers.custody_verifier, + authorizer = CredentialCustodyAuthorizer.for_verified_outcomes( components.ledger, audit_sink=components.audit_sink, failure_notifier=components.failure_notifier, @@ -949,7 +1035,7 @@ def compose( ) executor = DelegatedConnectorExecutor( routes, - verifiers.dispatch_verifier, + verifiers.bind(workload_id=workload_id, purpose=purpose, clock=clock), invoker_factory, components.journal, components.signal_sink, diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py index 0b9339b..f09f0de 100644 --- a/tests/test_credential_custody.py +++ b/tests/test_credential_custody.py @@ -15,6 +15,7 @@ ProviderChannel, ProviderCredentialHandle, SanitizedCustodyOutcome, + VerifiedDelegatedAuthorization, VerifiedWorkloadAuthorization, WorkloadAuthorizationReference, authorize_provider_dispatch, @@ -29,11 +30,19 @@ DispatchClaim, DispatchRequest, VerifiedDispatchGrant, + dispatch_request_fingerprint, ) -FINGERPRINT = "a" * 64 NOW = datetime(2026, 6, 4, tzinfo=timezone.utc) +FINGERPRINT = dispatch_request_fingerprint( + dispatch_id="dispatch-1", + workflow_id="operation-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key="dispatch-once-1", +) def _handle( @@ -61,9 +70,19 @@ def _request(**overrides) -> CredentialUseRequest: "payload_ref": "payload-ref-1", "idempotency_key": "dispatch-once-1", "dispatch_claim_id": "claim-1", - "request_fingerprint": FINGERPRINT, } fields.update(overrides) + fields.setdefault( + "request_fingerprint", + dispatch_request_fingerprint( + dispatch_id=fields["dispatch_id"], + workflow_id=fields["operation_id"], + channel=Channel(fields["channel"].value), + recipient_ref=fields["recipient_ref"], + payload_ref=fields["payload_ref"], + idempotency_key=fields["idempotency_key"], + ), + ) return CredentialUseRequest(**fields) @@ -180,14 +199,25 @@ def _delegated_backup_route() -> ConnectorRoute: ) -def _delegated_grant(max_attempts: int = 1) -> VerifiedDispatchGrant: - return VerifiedDispatchGrant( +def _delegated_grant( + max_attempts: int = 1, + *, + allowed_connectors: frozenset[str] | None = None, +) -> VerifiedDelegatedAuthorization: + return VerifiedDelegatedAuthorization( principal="mea-comms", channel=Channel.SMS, - allowed_connectors=frozenset({"sms-primary", "sms-backup"}), + allowed_connectors=allowed_connectors + or frozenset({"sms-primary", "sms-backup"}), not_after=NOW + timedelta(minutes=5), max_attempts=max_attempts, request_fingerprint=FINGERPRINT, + authorization_id="authorization-1", + issuer="signet://issuer/mea", + audience="baton://delegated-provider-executor", + allowed_purposes=frozenset({"case_notification"}), + not_before=NOW - timedelta(minutes=1), + max_uses=1, ) @@ -219,16 +249,25 @@ async def test_single_dispatch_reservation_authorizes_only_opaque_handle_use(): async def test_request_fingerprint_change_is_denied(): + changed_request = _request( + dispatch_id="dispatch-2", + idempotency_key="dispatch-once-2", + ) with pytest.raises(CustodyAuthorizationDenied): await authorize_provider_dispatch( _handle(), - _request(request_fingerprint="b" * 64), + changed_request, _authorization(max_uses=1), - ledger=OutcomeLedger(_reservation(request_fingerprint="b" * 64)), + ledger=OutcomeLedger(), now=NOW, ) +def test_credential_request_rejects_noncanonical_fingerprint(): + with pytest.raises(ValueError, match="does not bind"): + _request(request_fingerprint="b" * 64) + + async def test_connector_or_purpose_outside_scope_is_denied(): with pytest.raises(CustodyAuthorizationDenied): await authorize_provider_dispatch( @@ -377,7 +416,7 @@ async def test_delegated_factory_reserves_before_custodied_provider_invocation() assert result.counts_toward_circuit is True -async def test_delegated_factory_denies_attempt_budget_above_custody_authority(): +async def test_delegated_factory_rejects_independent_dispatch_grant(): ledger = OutcomeLedger() factory = CredentialCustodyInvokerFactory( CredentialCustodyAuthorizer( @@ -403,7 +442,14 @@ async def test_delegated_factory_denies_attempt_budget_above_custody_authority() await factory.prepare( CapabilityReference("authorization-ref-1"), _delegated_request(), - _delegated_grant(max_attempts=2), + VerifiedDispatchGrant( + principal="mea-comms", + channel=Channel.SMS, + allowed_connectors=frozenset({"sms-primary"}), + not_after=NOW + timedelta(minutes=5), + max_attempts=1, + request_fingerprint=FINGERPRINT, + ), _dispatch_claim(), route, (route,), @@ -446,7 +492,7 @@ async def test_delegated_factory_denies_failover_policy_outside_custody_scope_be await factory.prepare( CapabilityReference("authorization-ref-1"), _delegated_request(), - _delegated_grant(), + _delegated_grant(allowed_connectors=frozenset({"sms-primary"})), _dispatch_claim(), primary, (primary, backup), diff --git a/tests/test_delegated_connector.py b/tests/test_delegated_connector.py index 6abc35a..97694bc 100644 --- a/tests/test_delegated_connector.py +++ b/tests/test_delegated_connector.py @@ -27,29 +27,60 @@ MonitoringUnavailable, ProviderAttemptOutcome, VerifiedDispatchGrant, + dispatch_request_fingerprint, ) -FINGERPRINT = "f" * 64 +def _fingerprint( + idempotency_key: str = "dispatch-once-1", + *, + dispatch_id: str | None = None, +) -> str: + return dispatch_request_fingerprint( + dispatch_id=dispatch_id or f"dispatch-{idempotency_key}", + workflow_id="workflow-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key=idempotency_key, + ) + + +FINGERPRINT = _fingerprint() def _request( idempotency_key: str = "dispatch-once-1", *, dispatch_id: str | None = None, - request_fingerprint: str = FINGERPRINT, + request_fingerprint: str | None = None, ) -> DispatchRequest: + resolved_dispatch_id = dispatch_id or f"dispatch-{idempotency_key}" return DispatchRequest( - dispatch_id=dispatch_id or f"dispatch-{idempotency_key}", + dispatch_id=resolved_dispatch_id, workflow_id="workflow-1", channel=Channel.SMS, recipient_ref="recipient-ref-1", payload_ref="payload-ref-1", idempotency_key=idempotency_key, - request_fingerprint=request_fingerprint, + request_fingerprint=request_fingerprint + or _fingerprint(idempotency_key, dispatch_id=resolved_dispatch_id), ) +def test_dispatch_request_rejects_noncanonical_fingerprint(): + with pytest.raises(ValueError, match="does not bind"): + DispatchRequest( + dispatch_id="dispatch-once-1", + workflow_id="workflow-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key="dispatch-once-1", + request_fingerprint="f" * 64, + ) + + def _route( connector_id: str, priority: int, @@ -446,7 +477,7 @@ async def test_changed_request_fingerprint_cannot_reuse_completed_idempotency_ke with pytest.raises(DispatchBindingConflict): await executor.dispatch( CapabilityReference("authorization-ref-2"), - _request(request_fingerprint="e" * 64, dispatch_id="dispatch-changed"), + _request(dispatch_id="dispatch-changed"), ) assert invoker.calls == ["primary"] diff --git a/tests/test_delegated_runtime.py b/tests/test_delegated_runtime.py index 6f2831c..e3a51bb 100644 --- a/tests/test_delegated_runtime.py +++ b/tests/test_delegated_runtime.py @@ -4,7 +4,7 @@ import asyncio import sqlite3 -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timedelta, timezone import pytest @@ -20,9 +20,11 @@ ProviderChannel, ProviderCredentialHandle, SanitizedCustodyOutcome, + VerifiedDelegatedAuthorization, VerifiedWorkloadAuthorization, ) from baton.delegated_connector import ( + AuthorizationDenied, CapabilityReference, Channel, ConnectorRoute, @@ -34,9 +36,12 @@ DispatchSignalKind, DispatchStateUnavailable, VerifiedDispatchGrant, + dispatch_request_fingerprint, ) from baton.delegated_runtime import ( + DelegatedAuthorizationVerifier, ConfiguredVerifierBundle, + DelegatedAuthorizationContext, DelegatedConnectorRuntime, DelegatedRuntimeComponents, SinglePurposeCustodiedResolver, @@ -45,7 +50,24 @@ NOW = datetime(2026, 6, 4, tzinfo=timezone.utc) -FINGERPRINT = "a" * 64 + + +def _fingerprint( + idempotency_key: str = "dispatch-once-1", + *, + dispatch_id: str | None = None, +) -> str: + return dispatch_request_fingerprint( + dispatch_id=dispatch_id or f"dispatch-{idempotency_key}", + workflow_id="operation-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key=idempotency_key, + ) + + +FINGERPRINT = _fingerprint() @dataclass @@ -79,28 +101,29 @@ def _handle() -> ProviderCredentialHandle: def _request( *, idempotency_key: str = "dispatch-once-1", - fingerprint: str = FINGERPRINT, ) -> DispatchRequest: + dispatch_id = f"dispatch-{idempotency_key}" return DispatchRequest( - dispatch_id=f"dispatch-{idempotency_key}", + dispatch_id=dispatch_id, workflow_id="operation-1", channel=Channel.SMS, recipient_ref="recipient-ref-1", payload_ref="payload-ref-1", idempotency_key=idempotency_key, - request_fingerprint=fingerprint, + request_fingerprint=_fingerprint(idempotency_key, dispatch_id=dispatch_id), ) def _custody_request( *, idempotency_key: str = "dispatch-once-1", - fingerprint: str = FINGERPRINT, + fingerprint: str | None = None, claim_id: str = "claim-1", ) -> CredentialUseRequest: + dispatch_id = f"dispatch-{idempotency_key}" return CredentialUseRequest( operation_id="operation-1", - dispatch_id=f"dispatch-{idempotency_key}", + dispatch_id=dispatch_id, workload_id="mea-comms", connector_id="sms-primary", channel=ProviderChannel.SMS, @@ -109,7 +132,8 @@ def _custody_request( payload_ref="payload-ref-1", idempotency_key=idempotency_key, dispatch_claim_id=claim_id, - request_fingerprint=fingerprint, + request_fingerprint=fingerprint + or _fingerprint(idempotency_key, dispatch_id=dispatch_id), ) @@ -151,19 +175,38 @@ def _authorized_use( class DispatchVerifier: - async def verify(self, _reference, request: DispatchRequest) -> VerifiedDispatchGrant: - return VerifiedDispatchGrant( + def __init__(self): + self.contexts: list[DelegatedAuthorizationContext] = [] + + async def verify( + self, + _reference, + request: DispatchRequest, + context: DelegatedAuthorizationContext, + ) -> VerifiedDelegatedAuthorization: + self.contexts.append(context) + return VerifiedDelegatedAuthorization( principal="mea-comms", channel=request.channel, allowed_connectors=frozenset({"sms-primary"}), not_after=NOW + timedelta(minutes=10), max_attempts=1, request_fingerprint=request.request_fingerprint, + authorization_id=f"authorization-{request.idempotency_key}", + issuer="signet://issuer/mea", + audience=context.audience, + allowed_purposes=frozenset({"case_notification"}), + not_before=NOW - timedelta(minutes=1), + max_uses=1, ) class CustodyVerifier: - async def verify(self, _reference, request: CredentialUseRequest) -> VerifiedWorkloadAuthorization: + async def verify( + self, + _reference, + request: CredentialUseRequest, + ) -> VerifiedWorkloadAuthorization: return VerifiedWorkloadAuthorization( authorization_id=f"authorization-{request.idempotency_key}", workload_id=request.workload_id, @@ -178,6 +221,38 @@ async def verify(self, _reference, request: CredentialUseRequest) -> VerifiedWor ) +class RebindingDispatchVerifier(DispatchVerifier): + def __init__(self, **replacements): + super().__init__() + self.replacements = replacements + + async def verify( + self, + reference, + request: DispatchRequest, + context: DelegatedAuthorizationContext, + ) -> VerifiedDelegatedAuthorization: + outcome = await super().verify(reference, request, context) + return replace(outcome, **self.replacements) + + +class IndependentDispatchVerifier: + async def verify( + self, + _reference, + request: DispatchRequest, + _context: DelegatedAuthorizationContext, + ) -> VerifiedDispatchGrant: + return VerifiedDispatchGrant( + principal="mea-comms", + channel=request.channel, + allowed_connectors=frozenset({"sms-primary"}), + not_after=NOW + timedelta(minutes=10), + max_attempts=1, + request_fingerprint=request.request_fingerprint, + ) + + class StaticOperation: def __init__(self, outcome: SanitizedCustodyOutcome): self.outcome = outcome @@ -238,13 +313,19 @@ def _accepted_outcome() -> SanitizedCustodyOutcome: ) -def _runtime(tmp_path, operation_factory, *, clock=None) -> DelegatedConnectorRuntime: +def _runtime( + tmp_path, + operation_factory, + *, + clock=None, + verifier: DelegatedAuthorizationVerifier | None = None, +) -> DelegatedConnectorRuntime: return DelegatedConnectorRuntime.build_sqlite_reference( routes=[_route()], handles={"sms-primary": _handle()}, verifiers=ConfiguredVerifierBundle( - dispatch_verifier=DispatchVerifier(), - custody_verifier=CustodyVerifier(), + verifier=verifier or DispatchVerifier(), + audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", ), @@ -256,6 +337,76 @@ def _runtime(tmp_path, operation_factory, *, clock=None) -> DelegatedConnectorRu ) +async def test_runtime_verifies_once_and_binds_exact_policy_context(tmp_path): + verifier = DispatchVerifier() + factory = OperationFactory(_accepted_outcome()) + runtime = _runtime(tmp_path, factory, clock=lambda: NOW, verifier=verifier) + + await runtime.executor.dispatch( + CapabilityReference("opaque-authorization-ref"), + _request(), + ) + + assert verifier.contexts == [ + DelegatedAuthorizationContext( + audience="baton://delegated-provider-executor", + workload_id="mea-comms", + purpose="case_notification", + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ) + ] + assert len(factory.prepared) == 1 + + +@pytest.mark.parametrize( + "replacements", + [ + {"audience": "baton://other-executor"}, + {"principal": "other-workload"}, + {"allowed_purposes": frozenset({"other_purpose"})}, + {"not_before": NOW + timedelta(minutes=1)}, + ], +) +async def test_runtime_rejects_rebound_verified_context_before_provider_use( + tmp_path, + replacements, +): + factory = OperationFactory(_accepted_outcome()) + runtime = _runtime( + tmp_path, + factory, + clock=lambda: NOW, + verifier=RebindingDispatchVerifier(**replacements), + ) + + with pytest.raises(AuthorizationDenied): + await runtime.executor.dispatch( + CapabilityReference("opaque-authorization-ref"), + _request(), + ) + + assert factory.prepared == [] + + +async def test_runtime_rejects_independent_dispatch_only_verifier_outcome(tmp_path): + factory = OperationFactory(_accepted_outcome()) + runtime = _runtime( + tmp_path, + factory, + clock=lambda: NOW, + verifier=IndependentDispatchVerifier(), + ) + + with pytest.raises(AuthorizationDenied): + await runtime.executor.dispatch( + CapabilityReference("opaque-authorization-ref"), + _request(), + ) + + assert factory.prepared == [] + + async def test_runtime_replays_completed_dispatch_without_second_provider_operation(tmp_path): first_factory = OperationFactory(_accepted_outcome()) first_runtime = _runtime(tmp_path, first_factory, clock=lambda: NOW) @@ -379,7 +530,7 @@ async def test_durable_ledger_reuses_exact_reservation_and_rejects_rebinding(tmp replay = await state.ledger.reserve_once(first_authorization, first_request) assert replay == first - other_fingerprint = "b" * 64 + other_fingerprint = _fingerprint("dispatch-once-2") other_binding = DispatchBinding("dispatch-once-2", other_fingerprint) other_claim = await state.journal.begin(other_binding) assert other_claim is not None @@ -573,8 +724,8 @@ async def test_post_provider_state_failure_is_non_retryable_and_does_not_resend( routes=[_route()], handles={"sms-primary": _handle()}, verifiers=ConfiguredVerifierBundle( - dispatch_verifier=DispatchVerifier(), - custody_verifier=CustodyVerifier(), + verifier=DispatchVerifier(), + audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", ), @@ -632,8 +783,8 @@ def test_runtime_builder_rejects_non_durable_or_incomplete_construction(tmp_path routes=[_route()], handles={}, verifiers=ConfiguredVerifierBundle( - dispatch_verifier=DispatchVerifier(), - custody_verifier=CustodyVerifier(), + verifier=DispatchVerifier(), + audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", ), @@ -648,8 +799,8 @@ def test_runtime_builder_rejects_non_durable_or_incomplete_construction(tmp_path routes=[_route()], handles={"sms-primary": _handle()}, verifiers=ConfiguredVerifierBundle( - dispatch_verifier=DispatchVerifier(), - custody_verifier=CustodyVerifier(), + verifier=DispatchVerifier(), + audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", ), From fa84a9b0e73a2435c6d33d7cc5456a247606be94 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Thu, 4 Jun 2026 22:21:34 -0500 Subject: [PATCH 7/9] feat(custody): add key-free Signet scope adapter --- docs/credential-custody-boundary.md | 30 ++- src/baton/credential_custody.py | 10 +- src/baton/delegated_runtime.py | 63 ++++-- src/baton/signet_delegated_authorization.py | 202 +++++++++++++++++++ tests/test_credential_custody.py | 2 + tests/test_delegated_runtime.py | 7 + tests/test_signet_delegated_authorization.py | 182 +++++++++++++++++ 7 files changed, 477 insertions(+), 19 deletions(-) create mode 100644 src/baton/signet_delegated_authorization.py create mode 100644 tests/test_signet_delegated_authorization.py diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md index 1ff11b0..3a44f2e 100644 --- a/docs/credential-custody-boundary.md +++ b/docs/credential-custody-boundary.md @@ -53,10 +53,21 @@ telephony, or other external-provider credential value. operation, but no Baton request, authorization, outcome, event, or runtime state type contains that material. - `ConfiguredVerifierBundle` supplies one verifier with the exact audience, - workload, purpose, issuer-policy reference, and rotation-policy reference - before dispatch admission. The verifier must return one + workload, purpose, per-channel available-connector ceiling, issuer-policy + reference, and rotation-policy reference before dispatch admission. The + verifier must return one `VerifiedDelegatedAuthorization`; Baton derives both dispatch and custody views from that same immutable outcome. +- `SignetDelegatedAuthorizationAdapter` sends the exact runtime context to a + trusted Signet delegated-provider verifier client, requires its verified + policy metadata to match, and maps the wire-shaped outcome into Baton's + shared authorization. It is key-free mapping evidence, not a cryptographic + verifier or trusted transport implementation. + The runtime derives the per-channel available-connector ceiling from its + enabled routes; a Signet outcome may narrow that set but cannot authorize a + connector outside it. Issuer and trust-policy configuration must come from + an audited high-administration control plane; the adapter constructor is not + that control plane. - `SqliteDelegatedRuntimeState` and `DelegatedConnectorRuntime.build_sqlite_reference` are single-node reference implementations for executable recovery evidence. They are not a @@ -66,13 +77,18 @@ telephony, or other external-provider credential value. The trusted Signet-compatible verifier receives the opaque authorization reference, the exact `DispatchRequest`, and a `DelegatedAuthorizationContext`. +The context's per-channel available connector IDs are the runtime ceiling: the +verified authorization may narrow to a non-empty subset but cannot authorize +an unavailable connector. Its verified outcome must bind: - authorization ID and issuer; - Baton delegated-executor audience; +- the exact issuer-policy and rotation-policy references applied by the + verifier; - workload ID/principal; - exactly one channel; -- allowed connector IDs and purposes; +- allowed connector IDs and exactly one purpose matching the runtime; - `not_before` and `not_after`; - `max_uses=1` and a provider-attempt budget; and - the exact canonical request fingerprint. @@ -91,6 +107,9 @@ bind it to the active exact dispatch before reservation or provider use. The verified outcome contains no provider credential, credential handle, recipient value, payload value, or provider response. `authorization_id` is the sanitized correlation reference carried through custody audit events. +Issuer-policy and rotation-policy references are verified trust metadata, not +signed provider-operation claims; they must exactly match the configured +verifier bundle. ## Failure Semantics @@ -151,8 +170,9 @@ exist for approved cloud-neutral backends. MEA integration remains blocked until all of the following exist: -1. A trusted Signet-compatible verifier implementation with issuer and rotation - policy that implements the exact delegated authorization contract above. +1. A concrete trusted Signet delegated-provider verifier client and transport + with issuer and rotation enforcement. The key-free Baton adapter does not + establish that trust. 2. A shared, highly available multi-replica implementation of the durable component contracts, including atomic claim, reservation, attempt-budget, completion, replay, and recovery behavior. diff --git a/src/baton/credential_custody.py b/src/baton/credential_custody.py index e795544..0bcfeb1 100644 --- a/src/baton/credential_custody.py +++ b/src/baton/credential_custody.py @@ -182,13 +182,21 @@ class VerifiedDelegatedAuthorization(VerifiedDispatchGrant): authorization_id: str issuer: str audience: str + issuer_policy_ref: str + rotation_policy_ref: str allowed_purposes: frozenset[str] not_before: datetime max_uses: int def __post_init__(self) -> None: super().__post_init__() - for name in ("authorization_id", "issuer", "audience"): + for name in ( + "authorization_id", + "issuer", + "audience", + "issuer_policy_ref", + "rotation_policy_ref", + ): if not getattr(self, name): raise ValueError(f"{name} is required") if ( diff --git a/src/baton/delegated_runtime.py b/src/baton/delegated_runtime.py index 5759a09..b9de118 100644 --- a/src/baton/delegated_runtime.py +++ b/src/baton/delegated_runtime.py @@ -126,6 +126,7 @@ class DelegatedAuthorizationContext: audience: str workload_id: str purpose: str + available_connector_ids: frozenset[str] issuer_policy_ref: str rotation_policy_ref: str @@ -139,6 +140,8 @@ def __post_init__(self) -> None: ): if not getattr(self, name): raise ValueError(f"{name} is required") + if not self.available_connector_ids or not all(self.available_connector_ids): + raise ValueError("available_connector_ids are required") class DelegatedAuthorizationVerifier(Protocol): @@ -156,7 +159,12 @@ async def verify( @dataclass(frozen=True) class _ContextBoundDelegatedAuthorizationVerifier: verifier: DelegatedAuthorizationVerifier - context: DelegatedAuthorizationContext + audience: str + workload_id: str + purpose: str + available_connector_ids_by_channel: Mapping[Channel, frozenset[str]] + issuer_policy_ref: str + rotation_policy_ref: str clock: Callable[[], datetime] async def verify( @@ -164,17 +172,34 @@ async def verify( capability: CapabilityReference, request: DispatchRequest, ) -> VerifiedDelegatedAuthorization: - outcome = await self.verifier.verify(capability, request, self.context) + available_connector_ids = self.available_connector_ids_by_channel.get(request.channel) + if not available_connector_ids: + raise AuthorizationDenied("no available connector policy exists for dispatch channel") + context = DelegatedAuthorizationContext( + audience=self.audience, + workload_id=self.workload_id, + purpose=self.purpose, + available_connector_ids=available_connector_ids, + issuer_policy_ref=self.issuer_policy_ref, + rotation_policy_ref=self.rotation_policy_ref, + ) + outcome = await self.verifier.verify(capability, request, context) if not isinstance(outcome, VerifiedDelegatedAuthorization): raise AuthorizationDenied( "delegated verifier did not return the required shared authorization outcome" ) - if outcome.audience != self.context.audience: + if outcome.audience != context.audience: raise AuthorizationDenied("authorization audience is outside runtime scope") - if outcome.principal != self.context.workload_id: + if outcome.issuer_policy_ref != context.issuer_policy_ref: + raise AuthorizationDenied("authorization issuer policy is outside runtime scope") + if outcome.rotation_policy_ref != context.rotation_policy_ref: + raise AuthorizationDenied("authorization rotation policy is outside runtime scope") + if outcome.principal != context.workload_id: raise AuthorizationDenied("authorization principal is outside runtime scope") - if self.context.purpose not in outcome.allowed_purposes: + if outcome.allowed_purposes != frozenset({context.purpose}): raise AuthorizationDenied("authorization purpose is outside runtime scope") + if not outcome.allowed_connectors.issubset(context.available_connector_ids): + raise AuthorizationDenied("authorization connector scope exceeds runtime policy") current_time = self.clock() if current_time < outcome.not_before or current_time >= outcome.not_after: raise AuthorizationDenied("authorization is outside its validity window") @@ -200,19 +225,19 @@ def bind( *, workload_id: str, purpose: str, + available_connector_ids_by_channel: Mapping[Channel, frozenset[str]], clock: Callable[[], datetime] | None = None, ) -> ScopedAuthorizationVerifier: """Bind trust policy and runtime scope before any dispatch admission.""" return _ContextBoundDelegatedAuthorizationVerifier( self.verifier, - DelegatedAuthorizationContext( - audience=self.audience, - workload_id=workload_id, - purpose=purpose, - issuer_policy_ref=self.issuer_policy_ref, - rotation_policy_ref=self.rotation_policy_ref, - ), + self.audience, + workload_id, + purpose, + dict(available_connector_ids_by_channel), + self.issuer_policy_ref, + self.rotation_policy_ref, clock or _utc_now, ) @@ -1035,7 +1060,19 @@ def compose( ) executor = DelegatedConnectorExecutor( routes, - verifiers.bind(workload_id=workload_id, purpose=purpose, clock=clock), + verifiers.bind( + workload_id=workload_id, + purpose=purpose, + available_connector_ids_by_channel={ + channel: frozenset( + route.connector_id + for route in enabled_routes + if route.channel is channel + ) + for channel in {route.channel for route in enabled_routes} + }, + clock=clock, + ), invoker_factory, components.journal, components.signal_sink, diff --git a/src/baton/signet_delegated_authorization.py b/src/baton/signet_delegated_authorization.py new file mode 100644 index 0000000..f346165 --- /dev/null +++ b/src/baton/signet_delegated_authorization.py @@ -0,0 +1,202 @@ +"""Key-free adapter from a trusted Signet verifier outcome into Baton scope.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Protocol + +from baton.credential_custody import VerifiedDelegatedAuthorization +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + DispatchRequest, +) +from baton.delegated_runtime import DelegatedAuthorizationContext + + +@dataclass(frozen=True) +class SignetDelegatedProviderVerificationContext: + """Exact context sent to a trusted Signet delegated-provider verifier.""" + + issuer: str + audience: str + workload_id: str + channel: str + available_connector_ids: tuple[str, ...] + purpose: str + request_fingerprint: str + issuer_policy_ref: str + rotation_policy_ref: str + + +@dataclass(frozen=True) +class VerifiedSignetDelegatedProviderAuthorization: + """Wire-shaped result returned only after trusted Signet verification.""" + + authorization_id: str + issuer: str + audience: str + workload_id: str + issued_at: str + not_before: str + not_after: str + channel: str + allowed_connector_ids: tuple[str, ...] + allowed_purposes: tuple[str, ...] + request_fingerprint: str + max_uses: int + max_provider_attempts: int + issuer_policy_ref: str + rotation_policy_ref: str + + +class TrustedSignetDelegatedProviderClient(Protocol): + """Trusted transport that verifies a Signet envelope before returning scope.""" + + async def verify( + self, + reference: str, + context: SignetDelegatedProviderVerificationContext, + ) -> VerifiedSignetDelegatedProviderAuthorization: + ... + + +class SignetDelegatedAuthorizationAdapter: + """Map one trusted Signet result into Baton's shared verified outcome.""" + + def __init__( + self, + client: TrustedSignetDelegatedProviderClient, + *, + issuer: str, + clock: Callable[[], datetime] | None = None, + ): + if not issuer: + raise ValueError("issuer is required") + self._client = client + self._issuer = issuer + self._clock = clock or (lambda: datetime.now(timezone.utc)) + + async def verify( + self, + capability: CapabilityReference, + request: DispatchRequest, + context: DelegatedAuthorizationContext, + ) -> VerifiedDelegatedAuthorization: + signet_context = SignetDelegatedProviderVerificationContext( + issuer=self._issuer, + audience=context.audience, + workload_id=context.workload_id, + channel=request.channel.value, + available_connector_ids=tuple(sorted(context.available_connector_ids)), + purpose=context.purpose, + request_fingerprint=request.request_fingerprint, + issuer_policy_ref=context.issuer_policy_ref, + rotation_policy_ref=context.rotation_policy_ref, + ) + outcome = await self._client.verify(capability.reference, signet_context) + return self._map_verified_outcome(outcome, signet_context) + + def _map_verified_outcome( + self, + outcome: VerifiedSignetDelegatedProviderAuthorization, + context: SignetDelegatedProviderVerificationContext, + ) -> VerifiedDelegatedAuthorization: + self._validate_exact_context(outcome, context) + issued_at = _parse_time(outcome.issued_at, "issued_at") + not_before = _parse_time(outcome.not_before, "not_before") + not_after = _parse_time(outcome.not_after, "not_after") + current_time = self._clock() + if current_time.tzinfo is None: + raise AuthorizationDenied("Signet adapter clock must be timezone-aware") + if issued_at > current_time: + raise AuthorizationDenied("Signet authorization was issued in the future") + if not_before < issued_at or not_after <= not_before: + raise AuthorizationDenied("Signet authorization has an inconsistent time window") + if current_time < not_before or current_time >= not_after: + raise AuthorizationDenied("Signet authorization is outside its validity window") + + try: + channel = Channel(outcome.channel) + return VerifiedDelegatedAuthorization( + principal=outcome.workload_id, + channel=channel, + allowed_connectors=frozenset(outcome.allowed_connector_ids), + not_after=not_after, + max_attempts=outcome.max_provider_attempts, + request_fingerprint=outcome.request_fingerprint, + authorization_id=outcome.authorization_id, + issuer=outcome.issuer, + audience=outcome.audience, + issuer_policy_ref=outcome.issuer_policy_ref, + rotation_policy_ref=outcome.rotation_policy_ref, + allowed_purposes=frozenset(outcome.allowed_purposes), + not_before=not_before, + max_uses=outcome.max_uses, + ) + except (TypeError, ValueError) as exc: + raise AuthorizationDenied("Signet authorization outcome is invalid") from exc + + @staticmethod + def _validate_exact_context( + outcome: VerifiedSignetDelegatedProviderAuthorization, + context: SignetDelegatedProviderVerificationContext, + ) -> None: + for name in ( + "authorization_id", + "issuer", + "audience", + "workload_id", + "issued_at", + "not_before", + "not_after", + "channel", + "request_fingerprint", + "issuer_policy_ref", + "rotation_policy_ref", + ): + if not getattr(outcome, name): + raise AuthorizationDenied(f"Signet authorization is missing {name}") + if outcome.issuer != context.issuer: + raise AuthorizationDenied("Signet authorization issuer is outside configured scope") + if outcome.audience != context.audience: + raise AuthorizationDenied("Signet authorization audience is outside configured scope") + if outcome.workload_id != context.workload_id: + raise AuthorizationDenied("Signet authorization workload is outside configured scope") + if outcome.channel != context.channel: + raise AuthorizationDenied("Signet authorization channel is outside configured scope") + if outcome.request_fingerprint != context.request_fingerprint: + raise AuthorizationDenied("Signet authorization fingerprint is outside configured scope") + if outcome.issuer_policy_ref != context.issuer_policy_ref: + raise AuthorizationDenied("Signet issuer policy is outside configured scope") + if outcome.rotation_policy_ref != context.rotation_policy_ref: + raise AuthorizationDenied("Signet rotation policy is outside configured scope") + if outcome.max_uses != 1: + raise AuthorizationDenied("Signet authorization must be single-use") + if outcome.max_provider_attempts < 1: + raise AuthorizationDenied("Signet provider-attempt budget must be positive") + if not outcome.allowed_connector_ids or len(set(outcome.allowed_connector_ids)) != len( + outcome.allowed_connector_ids + ): + raise AuthorizationDenied("Signet connector scope is empty or contains duplicates") + if not outcome.allowed_purposes or len(set(outcome.allowed_purposes)) != len( + outcome.allowed_purposes + ): + raise AuthorizationDenied("Signet purpose scope is empty or contains duplicates") + if not set(outcome.allowed_connector_ids).issubset(context.available_connector_ids): + raise AuthorizationDenied("Signet connector scope exceeds runtime policy") + if set(outcome.allowed_purposes) != {context.purpose}: + raise AuthorizationDenied("Signet purpose scope does not exactly match runtime policy") + + +def _parse_time(value: str, name: str) -> datetime: + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise AuthorizationDenied(f"Signet authorization has invalid {name}") from exc + if parsed.tzinfo is None: + raise AuthorizationDenied(f"Signet authorization {name} must be timezone-aware") + return parsed.astimezone(timezone.utc) diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py index f09f0de..9349a69 100644 --- a/tests/test_credential_custody.py +++ b/tests/test_credential_custody.py @@ -215,6 +215,8 @@ def _delegated_grant( authorization_id="authorization-1", issuer="signet://issuer/mea", audience="baton://delegated-provider-executor", + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", allowed_purposes=frozenset({"case_notification"}), not_before=NOW - timedelta(minutes=1), max_uses=1, diff --git a/tests/test_delegated_runtime.py b/tests/test_delegated_runtime.py index e3a51bb..65f59f3 100644 --- a/tests/test_delegated_runtime.py +++ b/tests/test_delegated_runtime.py @@ -195,6 +195,8 @@ async def verify( authorization_id=f"authorization-{request.idempotency_key}", issuer="signet://issuer/mea", audience=context.audience, + issuer_policy_ref=context.issuer_policy_ref, + rotation_policy_ref=context.rotation_policy_ref, allowed_purposes=frozenset({"case_notification"}), not_before=NOW - timedelta(minutes=1), max_uses=1, @@ -352,6 +354,7 @@ async def test_runtime_verifies_once_and_binds_exact_policy_context(tmp_path): audience="baton://delegated-provider-executor", workload_id="mea-comms", purpose="case_notification", + available_connector_ids=frozenset({"sms-primary"}), issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", ) @@ -363,8 +366,12 @@ async def test_runtime_verifies_once_and_binds_exact_policy_context(tmp_path): "replacements", [ {"audience": "baton://other-executor"}, + {"issuer_policy_ref": "signet://issuer-policy/other"}, + {"rotation_policy_ref": "signet://rotation-policy/other"}, {"principal": "other-workload"}, + {"allowed_connectors": frozenset({"sms-primary", "sms-unapproved"})}, {"allowed_purposes": frozenset({"other_purpose"})}, + {"allowed_purposes": frozenset({"case_notification", "other_purpose"})}, {"not_before": NOW + timedelta(minutes=1)}, ], ) diff --git a/tests/test_signet_delegated_authorization.py b/tests/test_signet_delegated_authorization.py new file mode 100644 index 0000000..69a15d9 --- /dev/null +++ b/tests/test_signet_delegated_authorization.py @@ -0,0 +1,182 @@ +"""Key-free contract checks for the trusted Signet-to-Baton adapter.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest + +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + DispatchRequest, + dispatch_request_fingerprint, +) +from baton.delegated_runtime import DelegatedAuthorizationContext +from baton.signet_delegated_authorization import ( + SignetDelegatedAuthorizationAdapter, + SignetDelegatedProviderVerificationContext, + VerifiedSignetDelegatedProviderAuthorization, +) + + +NOW = datetime(2026, 6, 4, 22, 5, tzinfo=timezone.utc) +FINGERPRINT = dispatch_request_fingerprint( + dispatch_id="dispatch-1", + workflow_id="operation-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key="dispatch-once-1", +) + + +def _request() -> DispatchRequest: + return DispatchRequest( + dispatch_id="dispatch-1", + workflow_id="operation-1", + channel=Channel.SMS, + recipient_ref="recipient-ref-1", + payload_ref="payload-ref-1", + idempotency_key="dispatch-once-1", + request_fingerprint=FINGERPRINT, + ) + + +def _runtime_context() -> DelegatedAuthorizationContext: + return DelegatedAuthorizationContext( + audience="baton://delegated-provider-executor", + workload_id="mea-comms", + purpose="case_notification", + available_connector_ids=frozenset({"sms-primary", "sms-backup"}), + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ) + + +def _outcome() -> VerifiedSignetDelegatedProviderAuthorization: + return VerifiedSignetDelegatedProviderAuthorization( + authorization_id="authorization-1", + issuer="signet://issuer/mea", + audience="baton://delegated-provider-executor", + workload_id="mea-comms", + issued_at=(NOW - timedelta(minutes=5)).isoformat(), + not_before=(NOW - timedelta(minutes=4)).isoformat(), + not_after=(NOW + timedelta(minutes=5)).isoformat(), + channel="sms", + allowed_connector_ids=("sms-primary", "sms-backup"), + allowed_purposes=("case_notification",), + request_fingerprint=FINGERPRINT, + max_uses=1, + max_provider_attempts=2, + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ) + + +class StaticSignetClient: + def __init__(self, outcome: VerifiedSignetDelegatedProviderAuthorization): + self.outcome = outcome + self.calls: list[tuple[str, SignetDelegatedProviderVerificationContext]] = [] + + async def verify( + self, + reference: str, + context: SignetDelegatedProviderVerificationContext, + ) -> VerifiedSignetDelegatedProviderAuthorization: + self.calls.append((reference, context)) + return self.outcome + + +def _adapter(client: StaticSignetClient) -> SignetDelegatedAuthorizationAdapter: + return SignetDelegatedAuthorizationAdapter( + client, + issuer="signet://issuer/mea", + clock=lambda: NOW, + ) + + +async def test_maps_one_trusted_signet_outcome_into_shared_baton_authorization(): + client = StaticSignetClient(_outcome()) + adapter = _adapter(client) + + verified = await adapter.verify( + CapabilityReference("opaque-signet-authorization-ref"), + _request(), + _runtime_context(), + ) + + assert verified.authorization_id == "authorization-1" + assert verified.principal == "mea-comms" + assert verified.channel is Channel.SMS + assert verified.allowed_connectors == frozenset({"sms-primary", "sms-backup"}) + assert verified.allowed_purposes == frozenset({"case_notification"}) + assert verified.max_uses == 1 + assert verified.max_attempts == 2 + assert verified.issuer_policy_ref == "signet://issuer-policy/mea-comms" + assert verified.rotation_policy_ref == "signet://rotation-policy/mea-comms" + assert client.calls == [ + ( + "opaque-signet-authorization-ref", + SignetDelegatedProviderVerificationContext( + issuer="signet://issuer/mea", + audience="baton://delegated-provider-executor", + workload_id="mea-comms", + channel="sms", + available_connector_ids=("sms-backup", "sms-primary"), + purpose="case_notification", + request_fingerprint=FINGERPRINT, + issuer_policy_ref="signet://issuer-policy/mea-comms", + rotation_policy_ref="signet://rotation-policy/mea-comms", + ), + ) + ] + + +@pytest.mark.parametrize( + "replacements", + [ + {"issuer": "signet://issuer/other"}, + {"audience": "baton://other-executor"}, + {"workload_id": "other-workload"}, + {"channel": "email"}, + {"request_fingerprint": "b" * 64}, + {"issuer_policy_ref": "signet://issuer-policy/other"}, + {"rotation_policy_ref": "signet://rotation-policy/other"}, + {"max_uses": 2}, + {"max_provider_attempts": 0}, + {"allowed_connector_ids": ("sms-primary", "sms-unapproved")}, + {"allowed_connector_ids": ("sms-primary", "sms-primary")}, + {"allowed_purposes": ("other_purpose",)}, + {"allowed_purposes": ("case_notification", "other_purpose")}, + {"allowed_purposes": ("case_notification", "case_notification")}, + {"issued_at": (NOW + timedelta(minutes=1)).isoformat()}, + {"not_before": (NOW + timedelta(minutes=1)).isoformat()}, + {"not_after": NOW.isoformat()}, + {"not_after": "not-a-time"}, + ], +) +async def test_rejects_rebound_or_invalid_signet_outcome(replacements): + adapter = _adapter(StaticSignetClient(replace(_outcome(), **replacements))) + + with pytest.raises(AuthorizationDenied): + await adapter.verify( + CapabilityReference("opaque-signet-authorization-ref"), + _request(), + _runtime_context(), + ) + + +async def test_accepts_signet_scope_narrower_than_runtime_connector_ceiling(): + client = StaticSignetClient(replace(_outcome(), allowed_connector_ids=("sms-primary",))) + adapter = _adapter(client) + + verified = await adapter.verify( + CapabilityReference("opaque-signet-authorization-ref"), + _request(), + _runtime_context(), + ) + + assert verified.allowed_connectors == frozenset({"sms-primary"}) From 9570aa30b0e1675a7e961a18e6c57d1cd2e8d0c0 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Sat, 20 Jun 2026 00:18:21 -0500 Subject: [PATCH 8/9] fix(custody): bound Signet authorization acceptance --- docs/credential-custody-boundary.md | 6 ++++ src/baton/delegated_runtime.py | 38 ++++++++++++++++++++ src/baton/signet_delegated_authorization.py | 12 ++++++- tests/test_delegated_runtime.py | 12 +++++++ tests/test_signet_delegated_authorization.py | 6 ++++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md index 3a44f2e..e17d63e 100644 --- a/docs/credential-custody-boundary.md +++ b/docs/credential-custody-boundary.md @@ -93,6 +93,12 @@ Its verified outcome must bind: - `max_uses=1` and a provider-attempt budget; and - the exact canonical request fingerprint. +The configured Baton verifier bundle must also supply a positive provider-attempt +ceiling no greater than `3` and an authorization-lifetime ceiling no greater +than `15` minutes. Baton sends those ceilings to the Signet verifier, rejects a +Signet result that exceeds either one, and rechecks the bounded verified result +before dispatch admission. + The request fingerprint is a domain-separated SHA-256 digest over `dispatch_id`, workflow/operation ID, channel, opaque recipient reference, opaque payload reference, and idempotency key. `DispatchRequest` and diff --git a/src/baton/delegated_runtime.py b/src/baton/delegated_runtime.py index b9de118..63bca19 100644 --- a/src/baton/delegated_runtime.py +++ b/src/baton/delegated_runtime.py @@ -60,6 +60,8 @@ _ZERO_HASH = "0" * 64 +MAX_DELEGATED_PROVIDER_ATTEMPTS = 3 +MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS = 15 * 60 def _utc_now() -> datetime: @@ -129,6 +131,8 @@ class DelegatedAuthorizationContext: available_connector_ids: frozenset[str] issuer_policy_ref: str rotation_policy_ref: str + provider_attempt_ceiling: int + authorization_lifetime_ceiling_seconds: int def __post_init__(self) -> None: for name in ( @@ -142,6 +146,16 @@ def __post_init__(self) -> None: raise ValueError(f"{name} is required") if not self.available_connector_ids or not all(self.available_connector_ids): raise ValueError("available_connector_ids are required") + if not 1 <= self.provider_attempt_ceiling <= MAX_DELEGATED_PROVIDER_ATTEMPTS: + raise ValueError("provider_attempt_ceiling is outside the hard delegated-provider limit") + if not ( + 1 + <= self.authorization_lifetime_ceiling_seconds + <= MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS + ): + raise ValueError( + "authorization_lifetime_ceiling_seconds is outside the hard delegated-provider limit" + ) class DelegatedAuthorizationVerifier(Protocol): @@ -165,6 +179,8 @@ class _ContextBoundDelegatedAuthorizationVerifier: available_connector_ids_by_channel: Mapping[Channel, frozenset[str]] issuer_policy_ref: str rotation_policy_ref: str + provider_attempt_ceiling: int + authorization_lifetime_ceiling_seconds: int clock: Callable[[], datetime] async def verify( @@ -182,6 +198,8 @@ async def verify( available_connector_ids=available_connector_ids, issuer_policy_ref=self.issuer_policy_ref, rotation_policy_ref=self.rotation_policy_ref, + provider_attempt_ceiling=self.provider_attempt_ceiling, + authorization_lifetime_ceiling_seconds=self.authorization_lifetime_ceiling_seconds, ) outcome = await self.verifier.verify(capability, request, context) if not isinstance(outcome, VerifiedDelegatedAuthorization): @@ -200,6 +218,12 @@ async def verify( raise AuthorizationDenied("authorization purpose is outside runtime scope") if not outcome.allowed_connectors.issubset(context.available_connector_ids): raise AuthorizationDenied("authorization connector scope exceeds runtime policy") + if outcome.max_attempts > context.provider_attempt_ceiling: + raise AuthorizationDenied("authorization attempt budget exceeds runtime policy") + if outcome.not_after - outcome.not_before > timedelta( + seconds=context.authorization_lifetime_ceiling_seconds + ): + raise AuthorizationDenied("authorization validity window exceeds runtime policy") current_time = self.clock() if current_time < outcome.not_before or current_time >= outcome.not_after: raise AuthorizationDenied("authorization is outside its validity window") @@ -214,11 +238,23 @@ class ConfiguredVerifierBundle: audience: str issuer_policy_ref: str rotation_policy_ref: str + provider_attempt_ceiling: int + authorization_lifetime_ceiling_seconds: int def __post_init__(self) -> None: for name in ("audience", "issuer_policy_ref", "rotation_policy_ref"): if not getattr(self, name): raise ValueError(f"{name} is required") + if not 1 <= self.provider_attempt_ceiling <= MAX_DELEGATED_PROVIDER_ATTEMPTS: + raise ValueError("provider_attempt_ceiling is outside the hard delegated-provider limit") + if not ( + 1 + <= self.authorization_lifetime_ceiling_seconds + <= MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS + ): + raise ValueError( + "authorization_lifetime_ceiling_seconds is outside the hard delegated-provider limit" + ) def bind( self, @@ -238,6 +274,8 @@ def bind( dict(available_connector_ids_by_channel), self.issuer_policy_ref, self.rotation_policy_ref, + self.provider_attempt_ceiling, + self.authorization_lifetime_ceiling_seconds, clock or _utc_now, ) diff --git a/src/baton/signet_delegated_authorization.py b/src/baton/signet_delegated_authorization.py index f346165..cd1e410 100644 --- a/src/baton/signet_delegated_authorization.py +++ b/src/baton/signet_delegated_authorization.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Protocol from baton.credential_custody import VerifiedDelegatedAuthorization @@ -30,6 +30,8 @@ class SignetDelegatedProviderVerificationContext: request_fingerprint: str issuer_policy_ref: str rotation_policy_ref: str + provider_attempt_ceiling: int + authorization_lifetime_ceiling_seconds: int @dataclass(frozen=True) @@ -96,6 +98,8 @@ async def verify( request_fingerprint=request.request_fingerprint, issuer_policy_ref=context.issuer_policy_ref, rotation_policy_ref=context.rotation_policy_ref, + provider_attempt_ceiling=context.provider_attempt_ceiling, + authorization_lifetime_ceiling_seconds=context.authorization_lifetime_ceiling_seconds, ) outcome = await self._client.verify(capability.reference, signet_context) return self._map_verified_outcome(outcome, signet_context) @@ -116,6 +120,10 @@ def _map_verified_outcome( raise AuthorizationDenied("Signet authorization was issued in the future") if not_before < issued_at or not_after <= not_before: raise AuthorizationDenied("Signet authorization has an inconsistent time window") + if not_after - issued_at > timedelta( + seconds=context.authorization_lifetime_ceiling_seconds + ): + raise AuthorizationDenied("Signet authorization lifetime exceeds runtime policy") if current_time < not_before or current_time >= not_after: raise AuthorizationDenied("Signet authorization is outside its validity window") @@ -178,6 +186,8 @@ def _validate_exact_context( raise AuthorizationDenied("Signet authorization must be single-use") if outcome.max_provider_attempts < 1: raise AuthorizationDenied("Signet provider-attempt budget must be positive") + if outcome.max_provider_attempts > context.provider_attempt_ceiling: + raise AuthorizationDenied("Signet provider-attempt budget exceeds runtime policy") if not outcome.allowed_connector_ids or len(set(outcome.allowed_connector_ids)) != len( outcome.allowed_connector_ids ): diff --git a/tests/test_delegated_runtime.py b/tests/test_delegated_runtime.py index 65f59f3..7b632ed 100644 --- a/tests/test_delegated_runtime.py +++ b/tests/test_delegated_runtime.py @@ -330,6 +330,8 @@ def _runtime( audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=3, + authorization_lifetime_ceiling_seconds=15 * 60, ), operation_factory=operation_factory, state_path=tmp_path / "delegated-runtime.sqlite3", @@ -357,6 +359,8 @@ async def test_runtime_verifies_once_and_binds_exact_policy_context(tmp_path): available_connector_ids=frozenset({"sms-primary"}), issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=3, + authorization_lifetime_ceiling_seconds=15 * 60, ) ] assert len(factory.prepared) == 1 @@ -373,6 +377,8 @@ async def test_runtime_verifies_once_and_binds_exact_policy_context(tmp_path): {"allowed_purposes": frozenset({"other_purpose"})}, {"allowed_purposes": frozenset({"case_notification", "other_purpose"})}, {"not_before": NOW + timedelta(minutes=1)}, + {"max_attempts": 4}, + {"not_after": NOW + timedelta(minutes=16)}, ], ) async def test_runtime_rejects_rebound_verified_context_before_provider_use( @@ -735,6 +741,8 @@ async def test_post_provider_state_failure_is_non_retryable_and_does_not_resend( audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=3, + authorization_lifetime_ceiling_seconds=15 * 60, ), operation_factory=factory, components=DelegatedRuntimeComponents( @@ -794,6 +802,8 @@ def test_runtime_builder_rejects_non_durable_or_incomplete_construction(tmp_path audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=3, + authorization_lifetime_ceiling_seconds=15 * 60, ), operation_factory=OperationFactory(_accepted_outcome()), state_path=tmp_path / "runtime.sqlite3", @@ -810,6 +820,8 @@ def test_runtime_builder_rejects_non_durable_or_incomplete_construction(tmp_path audience="baton://delegated-provider-executor", issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=3, + authorization_lifetime_ceiling_seconds=15 * 60, ), operation_factory=OperationFactory(_accepted_outcome()), state_path=tmp_path / "short-lease.sqlite3", diff --git a/tests/test_signet_delegated_authorization.py b/tests/test_signet_delegated_authorization.py index 69a15d9..acdecb4 100644 --- a/tests/test_signet_delegated_authorization.py +++ b/tests/test_signet_delegated_authorization.py @@ -53,6 +53,8 @@ def _runtime_context() -> DelegatedAuthorizationContext: available_connector_ids=frozenset({"sms-primary", "sms-backup"}), issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=2, + authorization_lifetime_ceiling_seconds=10 * 60, ) @@ -130,6 +132,8 @@ async def test_maps_one_trusted_signet_outcome_into_shared_baton_authorization() request_fingerprint=FINGERPRINT, issuer_policy_ref="signet://issuer-policy/mea-comms", rotation_policy_ref="signet://rotation-policy/mea-comms", + provider_attempt_ceiling=2, + authorization_lifetime_ceiling_seconds=10 * 60, ), ) ] @@ -147,6 +151,7 @@ async def test_maps_one_trusted_signet_outcome_into_shared_baton_authorization() {"rotation_policy_ref": "signet://rotation-policy/other"}, {"max_uses": 2}, {"max_provider_attempts": 0}, + {"max_provider_attempts": 3}, {"allowed_connector_ids": ("sms-primary", "sms-unapproved")}, {"allowed_connector_ids": ("sms-primary", "sms-primary")}, {"allowed_purposes": ("other_purpose",)}, @@ -155,6 +160,7 @@ async def test_maps_one_trusted_signet_outcome_into_shared_baton_authorization() {"issued_at": (NOW + timedelta(minutes=1)).isoformat()}, {"not_before": (NOW + timedelta(minutes=1)).isoformat()}, {"not_after": NOW.isoformat()}, + {"not_after": (NOW + timedelta(minutes=6)).isoformat()}, {"not_after": "not-a-time"}, ], ) From f5d04f0cf6a3c3c48799a597f4e89ff24e1b5542 Mon Sep 17 00:00:00 2001 From: Jeremy McEntire Date: Sat, 12 Sep 2026 23:34:34 -0500 Subject: [PATCH 9/9] fix(custody): enforce validity at provider use and after lock waits Reject expired reservations and stale journal leases after durable-state waits, enforce exact route handle identities and integer budgets, and document the opt-in library release boundary. New regressions reproduce each defect before the fixes. Co-Authored-By: Codex --- README.md | 11 +++ docs/credential-custody-boundary.md | 21 ++++- src/baton/credential_custody.py | 36 +++++--- src/baton/delegated_connector.py | 14 +-- src/baton/delegated_runtime.py | 90 +++++++++++--------- src/baton/signet_delegated_authorization.py | 10 +-- tests/test_credential_custody.py | 41 ++++++++- tests/test_delegated_connector.py | 6 +- tests/test_delegated_runtime.py | 52 ++++++++++- tests/test_signet_delegated_authorization.py | 12 ++- 10 files changed, 217 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index d4e7897..b1f0440 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,17 @@ baton-mcp # start MCP server Exposes resources (`baton://status`, `baton://topology`, `baton://node/{name}`), tools (`circuit_status`, `list_nodes`, `show_routes`, `show_metrics`, `show_signals`), and prompts (`circuit_overview`). +## Delegated Provider Library + +Python integrations can opt into the delegated connector and credential custody +APIs, including a single-node SQLite reference for durable dispatch, bounded +provider attempts, recovery, audit events, and failure notifications. Callers +must supply their own trusted verifier and custody-internal provider operation +factory. No default CLI path enables provider delivery. + +See [the custody boundary](docs/credential-custody-boundary.md) for the exact +contract, reference limitations, and production integration prerequisites. + ## Service Manifests Each service can self-describe with a `baton-service.yaml`: diff --git a/docs/credential-custody-boundary.md b/docs/credential-custody-boundary.md index e17d63e..c9b4107 100644 --- a/docs/credential-custody-boundary.md +++ b/docs/credential-custody-boundary.md @@ -166,7 +166,22 @@ actor could rewrite the database and recompute the chain. Its failure queue is durable and acknowledgeable, but does not prove that an external operator was paged. -## Integration Gate +## Standalone Library Boundary + +The delegated modules are opt-in Python library APIs. No CLI command, default +configuration, or adapter invokes them automatically. Consumers supply a +trusted verifier and a custody-internal operation factory explicitly; the +SQLite constructor supplies a durable single-node reference implementation. +The included tests exercise the contracts with credential-free doubles and +SQLite recovery, without claiming live signature verification or provider use. + +Each provider attempt rechecks the verified validity window before and after +waiting for its durable budget reservation. An expired reservation cannot be +used to invoke a provider, and a budget consumed while waiting stays consumed. +Journal renewal and completion evaluate lease time only after acquiring the +SQLite write lock, so contention cannot revive an expired claim. + +## Production Integration Prerequisites These modules are a connected runtime and executable reference, not a configured production custody deployment. The delegated executor no longer @@ -174,7 +189,9 @@ accepts a direct provider invoker. Production construction must use explicit durable components and an audited custody implementation; the protocol seams exist for approved cloud-neutral backends. -MEA integration remains blocked until all of the following exist: +A production integrator must supply and qualify the following before enabling +provider delivery. These deployment requirements do not imply that the library +includes those external services: 1. A concrete trusted Signet delegated-provider verifier client and transport with issuer and rotation enforcement. The key-free Baton adapter does not diff --git a/src/baton/credential_custody.py b/src/baton/credential_custody.py index 0bcfeb1..2fd21a8 100644 --- a/src/baton/credential_custody.py +++ b/src/baton/credential_custody.py @@ -9,14 +9,16 @@ from __future__ import annotations import re +from collections.abc import Callable, Mapping from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import StrEnum -from collections.abc import Callable, Mapping from typing import Protocol from .delegated_connector import ( AuthorizationDenied as DelegatedAuthorizationDenied, +) +from .delegated_connector import ( CapabilityReference, Channel, ConnectorRoute, @@ -29,7 +31,6 @@ dispatch_request_fingerprint, ) - _FINGERPRINT_RE = re.compile(r"^[a-f0-9]{64}$") _CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") @@ -169,9 +170,9 @@ def __post_init__(self) -> None: raise ValueError("authorization timestamps must be timezone-aware") if self.not_after <= self.not_before: raise ValueError("authorization expiry must be after not_before") - if self.max_uses is not None and self.max_uses < 1: + if self.max_uses is not None and (type(self.max_uses) is not int or self.max_uses < 1): raise ValueError("max_uses must be positive when bounded") - if self.max_provider_attempts < 1: + if type(self.max_provider_attempts) is not int or self.max_provider_attempts < 1: raise ValueError("max_provider_attempts must be positive") @@ -210,7 +211,7 @@ def __post_init__(self) -> None: raise ValueError("not_before must be timezone-aware") if self.not_after <= self.not_before: raise ValueError("authorization expiry must be after not_before") - if self.max_uses != 1: + if type(self.max_uses) is not int or self.max_uses != 1: raise ValueError("delegated provider authorization must be single-use") def as_workload_authorization(self) -> VerifiedWorkloadAuthorization: @@ -282,6 +283,8 @@ class AuthorizedProviderDispatch: dispatch_claim_id: str reservation_id: str max_provider_attempts: int + not_before: datetime + not_after: datetime def reservation(self) -> ConsumptionReservation: return ConsumptionReservation( @@ -454,7 +457,7 @@ async def authorize_provider_dispatch( authorization, request fingerprint, and idempotency key. """ - current_time = now or datetime.now(timezone.utc) + current_time = now or datetime.now(UTC) if current_time < authorization.not_before or current_time >= authorization.not_after: raise CustodyAuthorizationDenied("workload authorization is outside its validity window") if authorization.workload_id != request.workload_id: @@ -515,6 +518,8 @@ async def authorize_provider_dispatch( dispatch_claim_id=request.dispatch_claim_id, reservation_id=reservation.reservation_id, max_provider_attempts=authorization.max_provider_attempts, + not_before=authorization.not_before, + not_after=authorization.not_after, ) @@ -534,7 +539,7 @@ def __init__( self._ledger = ledger self._audit_sink = audit_sink self._failure_notifier = failure_notifier - self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._clock = clock or (lambda: datetime.now(UTC)) @classmethod def for_verified_outcomes( @@ -693,7 +698,16 @@ async def reserve_attempt( dispatch: AuthorizedProviderDispatch, authorized_use: AuthorizedCredentialUse, ) -> int: - return await self._ledger.reserve_attempt(dispatch.reservation(), authorized_use) + self._validate_attempt_window(dispatch) + attempt = await self._ledger.reserve_attempt(dispatch.reservation(), authorized_use) + # Reserving can wait for durable state. Expiry while waiting still denies + # provider use; the consumed budget remains consumed. + self._validate_attempt_window(dispatch) + return attempt + + def _validate_attempt_window(self, dispatch: AuthorizedProviderDispatch) -> None: + if not dispatch.not_before <= self._clock() < dispatch.not_after: + raise CustodyAuthorizationDenied("provider authorization is outside its validity window") async def record_invocation_failure( self, @@ -793,6 +807,7 @@ def _handle_for_route(self, route: ConnectorRoute) -> ProviderCredentialHandle: expected_channel = ProviderChannel(route.channel.value) if ( handle is None + or handle.connector_id != route.connector_id or handle.handle_id != route.credential_handle or handle.provider_key != route.provider_key or handle.channel is not expected_channel @@ -880,6 +895,7 @@ async def invoke( handle = self._handles.get(route.connector_id) if ( handle is None + or handle.connector_id != route.connector_id or handle.handle_id != route.credential_handle or handle.provider_key != route.provider_key or handle.channel.value != route.channel.value @@ -911,7 +927,7 @@ async def invoke( raise DelegatedAuthorizationDenied("custody outcome binding is invalid") try: await self._authorizer.record_outcome(self._dispatch, handle, outcome) - except Exception: + except Exception: # noqa: BLE001 - contain provider/backend failures without leaking material. try: await self._authorizer.record_invocation_failure( self._dispatch, diff --git a/src/baton/delegated_connector.py b/src/baton/delegated_connector.py index 41ee590..353eed4 100644 --- a/src/baton/delegated_connector.py +++ b/src/baton/delegated_connector.py @@ -16,11 +16,11 @@ import json import re import time +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import StrEnum -from typing import Awaitable, Callable, Protocol, Sequence - +from typing import Protocol _SAFE_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") _FINGERPRINT_RE = re.compile(r"^[a-f0-9]{64}$") @@ -163,7 +163,7 @@ def __post_init__(self) -> None: raise ValueError("principal is required") if self.not_after.tzinfo is None: raise ValueError("not_after must be timezone-aware") - if self.max_attempts < 1: + if type(self.max_attempts) is not int or self.max_attempts < 1: raise ValueError("max_attempts must be positive") if not _FINGERPRINT_RE.fullmatch(self.request_fingerprint): raise ValueError("request_fingerprint must be a lowercase SHA-256 digest") @@ -193,7 +193,7 @@ def __post_init__(self) -> None: raise ValueError("priority cannot be negative") if self.timeout_ms < 1: raise ValueError("timeout_ms must be positive") - if self.max_attempts < 1: + if type(self.max_attempts) is not int or self.max_attempts < 1: raise ValueError("max_attempts must be positive") if self.retry_backoff_ms < 0: raise ValueError("retry_backoff_ms cannot be negative") @@ -385,7 +385,7 @@ def __init__( self._invoker_factory = invoker_factory self._journal = journal self._signal_sink = signal_sink - self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._clock = clock or (lambda: datetime.now(UTC)) self._monotonic = monotonic or time.monotonic self._sleep = sleep or asyncio.sleep self._circuits: dict[str, _CircuitState] = {} @@ -658,7 +658,7 @@ async def _invoke( failover_allowed=True, counts_toward_circuit=True, ) - except Exception: + except Exception: # noqa: BLE001 - contain provider/backend failures without leaking material. return ProviderAttemptOutcome( status=DeliveryStatus.FAILED, audit_ref=f"error:{request.dispatch_id}:{route.connector_id}", diff --git a/src/baton/delegated_runtime.py b/src/baton/delegated_runtime.py index 63bca19..98fb5d6 100644 --- a/src/baton/delegated_runtime.py +++ b/src/baton/delegated_runtime.py @@ -16,7 +16,7 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import closing from dataclasses import asdict, dataclass, is_dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from enum import Enum from pathlib import Path from typing import Any, Protocol @@ -26,9 +26,9 @@ AuthorizedCredentialUse, ConsumptionReservation, ConsumptionReservationRequired, - CredentialUseRequest, CredentialCustodyAuthorizer, CredentialCustodyInvokerFactory, + CredentialUseRequest, CustodiedReferenceResolver, CustodyAuditEvent, CustodyAuditSink, @@ -42,8 +42,8 @@ from baton.delegated_connector import ( AuthorizationDenied, CapabilityReference, - ConnectorRoute, Channel, + ConnectorRoute, DelegatedConnectorExecutor, DeliveryOutcome, DeliveryStatus, @@ -58,20 +58,19 @@ ScopedAuthorizationVerifier, ) - _ZERO_HASH = "0" * 64 MAX_DELEGATED_PROVIDER_ATTEMPTS = 3 MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS = 15 * 60 def _utc_now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def _iso(value: datetime) -> str: if value.tzinfo is None: raise ValueError("timestamp must be timezone-aware") - return value.astimezone(timezone.utc).isoformat() + return value.astimezone(UTC).isoformat() def _parse_time(value: str) -> datetime: @@ -146,10 +145,14 @@ def __post_init__(self) -> None: raise ValueError(f"{name} is required") if not self.available_connector_ids or not all(self.available_connector_ids): raise ValueError("available_connector_ids are required") - if not 1 <= self.provider_attempt_ceiling <= MAX_DELEGATED_PROVIDER_ATTEMPTS: + if ( + type(self.provider_attempt_ceiling) is not int + or not 1 <= self.provider_attempt_ceiling <= MAX_DELEGATED_PROVIDER_ATTEMPTS + ): raise ValueError("provider_attempt_ceiling is outside the hard delegated-provider limit") if not ( - 1 + type(self.authorization_lifetime_ceiling_seconds) is int + and 1 <= self.authorization_lifetime_ceiling_seconds <= MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS ): @@ -245,10 +248,14 @@ def __post_init__(self) -> None: for name in ("audience", "issuer_policy_ref", "rotation_policy_ref"): if not getattr(self, name): raise ValueError(f"{name} is required") - if not 1 <= self.provider_attempt_ceiling <= MAX_DELEGATED_PROVIDER_ATTEMPTS: + if ( + type(self.provider_attempt_ceiling) is not int + or not 1 <= self.provider_attempt_ceiling <= MAX_DELEGATED_PROVIDER_ATTEMPTS + ): raise ValueError("provider_attempt_ceiling is outside the hard delegated-provider limit") if not ( - 1 + type(self.authorization_lifetime_ceiling_seconds) is int + and 1 <= self.authorization_lifetime_ceiling_seconds <= MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS ): @@ -552,16 +559,16 @@ async def begin(self, binding: DispatchBinding) -> DispatchClaim | None: return await asyncio.to_thread(self._begin, binding) def _begin(self, binding: DispatchBinding) -> DispatchClaim | None: - now = self._state._clock() - lease_expires_at = now + timedelta(seconds=self._state._claim_lease_seconds) - claim = DispatchClaim( - claim_id=str(uuid.uuid4()), - binding=binding, - lease_expires_at=lease_expires_at, - ) with closing(self._state._connect()) as connection: connection.execute("BEGIN IMMEDIATE") try: + now = self._state._clock() + lease_expires_at = now + timedelta(seconds=self._state._claim_lease_seconds) + claim = DispatchClaim( + claim_id=str(uuid.uuid4()), + binding=binding, + lease_expires_at=lease_expires_at, + ) row = connection.execute( """ SELECT request_fingerprint, state, lease_expires_at @@ -627,33 +634,39 @@ async def renew(self, claim: DispatchClaim) -> None: await asyncio.to_thread(self._renew, claim) def _renew(self, claim: DispatchClaim) -> None: - now = self._state._clock() - lease_expires_at = now + timedelta(seconds=self._state._claim_lease_seconds) with closing(self._state._connect()) as connection: - result = connection.execute( - """ - UPDATE dispatch_journal - SET lease_expires_at = ?, updated_at = ? - WHERE idempotency_key = ? AND request_fingerprint = ? - AND state = 'running' AND claim_id = ? AND lease_expires_at > ? - """, - ( - _iso(lease_expires_at), - _iso(now), - claim.binding.idempotency_key, - claim.binding.request_fingerprint, - claim.claim_id, - _iso(now), - ), - ) - if result.rowcount != 1: - raise DispatchStateUnavailable("dispatch claim is no longer active") + connection.execute("BEGIN IMMEDIATE") + try: + now = self._state._clock() + lease_expires_at = now + timedelta(seconds=self._state._claim_lease_seconds) + result = connection.execute( + """ + UPDATE dispatch_journal + SET lease_expires_at = ?, updated_at = ? + WHERE idempotency_key = ? AND request_fingerprint = ? + AND state = 'running' AND claim_id = ? AND lease_expires_at > ? + """, + ( + _iso(lease_expires_at), + _iso(now), + claim.binding.idempotency_key, + claim.binding.request_fingerprint, + claim.claim_id, + _iso(now), + ), + ) + if result.rowcount != 1: + raise DispatchStateUnavailable("dispatch claim is no longer active") + connection.commit() + except Exception: + connection.rollback() + raise def _complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: - now = self._state._clock() with closing(self._state._connect()) as connection: connection.execute("BEGIN IMMEDIATE") try: + now = self._state._clock() row = connection.execute( """ SELECT request_fingerprint, state, claim_id, lease_expires_at @@ -1077,6 +1090,7 @@ def compose( handle = handles.get(route.connector_id) if ( handle is None + or handle.connector_id != route.connector_id or handle.handle_id != route.credential_handle or handle.provider_key != route.provider_key or handle.channel.value != route.channel.value diff --git a/src/baton/signet_delegated_authorization.py b/src/baton/signet_delegated_authorization.py index cd1e410..060735b 100644 --- a/src/baton/signet_delegated_authorization.py +++ b/src/baton/signet_delegated_authorization.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Protocol from baton.credential_custody import VerifiedDelegatedAuthorization @@ -80,7 +80,7 @@ def __init__( raise ValueError("issuer is required") self._client = client self._issuer = issuer - self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._clock = clock or (lambda: datetime.now(UTC)) async def verify( self, @@ -182,9 +182,9 @@ def _validate_exact_context( raise AuthorizationDenied("Signet issuer policy is outside configured scope") if outcome.rotation_policy_ref != context.rotation_policy_ref: raise AuthorizationDenied("Signet rotation policy is outside configured scope") - if outcome.max_uses != 1: + if type(outcome.max_uses) is not int or outcome.max_uses != 1: raise AuthorizationDenied("Signet authorization must be single-use") - if outcome.max_provider_attempts < 1: + if type(outcome.max_provider_attempts) is not int or outcome.max_provider_attempts < 1: raise AuthorizationDenied("Signet provider-attempt budget must be positive") if outcome.max_provider_attempts > context.provider_attempt_ceiling: raise AuthorizationDenied("Signet provider-attempt budget exceeds runtime policy") @@ -209,4 +209,4 @@ def _parse_time(value: str, name: str) -> datetime: raise AuthorizationDenied(f"Signet authorization has invalid {name}") from exc if parsed.tzinfo is None: raise AuthorizationDenied(f"Signet authorization {name} must be timezone-aware") - return parsed.astimezone(timezone.utc) + return parsed.astimezone(UTC) diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py index 9349a69..fc28929 100644 --- a/tests/test_credential_custody.py +++ b/tests/test_credential_custody.py @@ -1,15 +1,15 @@ """Credential-free contract checks for the provider custody boundary.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest from baton.credential_custody import ( ConsumptionReservation, ConsumptionReservationRequired, - CredentialUseRequest, CredentialCustodyAuthorizer, CredentialCustodyInvokerFactory, + CredentialUseRequest, CustodyAuthorizationDenied, CustodyResultStatus, ProviderChannel, @@ -33,8 +33,7 @@ dispatch_request_fingerprint, ) - -NOW = datetime(2026, 6, 4, tzinfo=timezone.utc) +NOW = datetime(2026, 6, 4, tzinfo=UTC) FINGERPRINT = dispatch_request_fingerprint( dispatch_id="dispatch-1", workflow_id="operation-1", @@ -538,3 +537,37 @@ def test_outcome_allows_only_sanitized_failure_identifier(): audit_ref="audit-1", retryable=True, ) + + +@pytest.mark.parametrize("expire_during_reservation", [False, True]) +async def test_reserved_invoker_rechecks_expiry_before_provider_use(expire_during_reservation): + now = [NOW] + + class DelayedLedger(OutcomeLedger): + async def reserve_attempt(self, reservation, use): + result = await super().reserve_attempt(reservation, use) + if expire_during_reservation: + now[0] += timedelta(minutes=5) + return result + + ledger = DelayedLedger() + authorizer = CredentialCustodyAuthorizer.for_verified_outcomes(ledger, clock=lambda: now[0]) + resolver = OutcomeResolver(SanitizedCustodyOutcome( + operation_id="operation-1", dispatch_id="dispatch-1", provider_key="provider-primary", + status=CustodyResultStatus.ACCEPTED, audit_ref="audit-1", + )) + factory = CredentialCustodyInvokerFactory( + authorizer, resolver, {"sms-primary": _handle()}, + workload_id="mea-comms", purpose="case_notification", + ) + route = _delegated_route() + invoker = await factory.prepare( + CapabilityReference("authorization-ref-1"), _delegated_request(), + _delegated_grant(), _dispatch_claim(), route, (route,), + ) + if not expire_during_reservation: + now[0] += timedelta(minutes=5) + with pytest.raises(AuthorizationDenied): + await invoker.invoke(route, _delegated_request()) + assert resolver.calls == [] + assert ledger.attempt_count == int(expire_during_reservation) diff --git a/tests/test_delegated_connector.py b/tests/test_delegated_connector.py index 97694bc..55cc630 100644 --- a/tests/test_delegated_connector.py +++ b/tests/test_delegated_connector.py @@ -4,7 +4,7 @@ import asyncio import dataclasses -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest @@ -109,7 +109,7 @@ async def verify( principal="comms-runtime", channel=request.channel, allowed_connectors=frozenset({"primary", "backup"}), - not_after=datetime.now(timezone.utc) + timedelta(minutes=5), + not_after=datetime.now(UTC) + timedelta(minutes=5), max_attempts=4, request_fingerprint=request.request_fingerprint, ) @@ -215,7 +215,7 @@ async def begin(self, binding: DispatchBinding) -> DispatchClaim | None: return DispatchClaim( claim_id=claim_id, binding=binding, - lease_expires_at=datetime.now(timezone.utc) + timedelta(minutes=1), + lease_expires_at=datetime.now(UTC) + timedelta(minutes=1), ) async def complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: diff --git a/tests/test_delegated_runtime.py b/tests/test_delegated_runtime.py index 7b632ed..d98df91 100644 --- a/tests/test_delegated_runtime.py +++ b/tests/test_delegated_runtime.py @@ -5,7 +5,7 @@ import asyncio import sqlite3 from dataclasses import dataclass, replace -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest @@ -39,17 +39,16 @@ dispatch_request_fingerprint, ) from baton.delegated_runtime import ( - DelegatedAuthorizationVerifier, ConfiguredVerifierBundle, DelegatedAuthorizationContext, + DelegatedAuthorizationVerifier, DelegatedConnectorRuntime, DelegatedRuntimeComponents, SinglePurposeCustodiedResolver, SqliteDelegatedRuntimeState, ) - -NOW = datetime(2026, 6, 4, tzinfo=timezone.utc) +NOW = datetime(2026, 6, 4, tzinfo=UTC) def _fingerprint( @@ -829,3 +828,48 @@ def test_runtime_builder_rejects_non_durable_or_incomplete_construction(tmp_path purpose="case_notification", claim_lease_seconds=5, ) + + +@pytest.mark.parametrize("operation", ["renew", "complete"]) +async def test_lock_wait_cannot_revive_expired_claim(tmp_path, monkeypatch, operation): + clock = MutableClock() + state = SqliteDelegatedRuntimeState(tmp_path / "runtime.sqlite3", clock=clock, claim_lease_seconds=1) + claim = await state.journal.begin(DispatchBinding("dispatch-once-1", FINGERPRINT)) + original_connect = state._connect + + class ConnectionAfterWait: + def __init__(self): + self.connection = original_connect() + self.waited = False + + def execute(self, sql, *args): + # Model time passing while acquiring a write lock, without a flaky sleep. + if not self.waited and (sql == "BEGIN IMMEDIATE" or sql.lstrip().startswith("UPDATE")): + clock.now += timedelta(seconds=2) + self.waited = True + return self.connection.execute(sql, *args) + + def __getattr__(self, name): + return getattr(self.connection, name) + + monkeypatch.setattr(state, "_connect", ConnectionAfterWait) + with pytest.raises(DispatchStateUnavailable, match="no longer active"): + if operation == "renew": + await state.journal.renew(claim) + else: + await state.journal.complete(claim, _delivery()) + + +def test_runtime_rejects_misindexed_handle(tmp_path): + with pytest.raises(ValueError, match="exact custody handle"): + DelegatedConnectorRuntime.build_sqlite_reference( + routes=[_route()], + handles={"sms-primary": replace(_handle(), connector_id="sms-backup")}, + verifiers=ConfiguredVerifierBundle( + verifier=DispatchVerifier(), audience="baton://executor", + issuer_policy_ref="issuer-policy", rotation_policy_ref="rotation-policy", + provider_attempt_ceiling=3, authorization_lifetime_ceiling_seconds=900, + ), + operation_factory=OperationFactory(_accepted_outcome()), + state_path=tmp_path / "runtime.sqlite3", workload_id="mea-comms", purpose="case_notification", + ) diff --git a/tests/test_signet_delegated_authorization.py b/tests/test_signet_delegated_authorization.py index acdecb4..2358f20 100644 --- a/tests/test_signet_delegated_authorization.py +++ b/tests/test_signet_delegated_authorization.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import replace -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest @@ -21,8 +21,7 @@ VerifiedSignetDelegatedProviderAuthorization, ) - -NOW = datetime(2026, 6, 4, 22, 5, tzinfo=timezone.utc) +NOW = datetime(2026, 6, 4, 22, 5, tzinfo=UTC) FINGERPRINT = dispatch_request_fingerprint( dispatch_id="dispatch-1", workflow_id="operation-1", @@ -186,3 +185,10 @@ async def test_accepts_signet_scope_narrower_than_runtime_connector_ceiling(): ) assert verified.allowed_connectors == frozenset({"sms-primary"}) + + +@pytest.mark.parametrize("budget", [True, 1.5, float("nan"), float("inf")]) +async def test_rejects_non_integer_provider_attempt_budget(budget): + client = StaticSignetClient(replace(_outcome(), max_provider_attempts=budget)) + with pytest.raises(AuthorizationDenied): + await _adapter(client).verify(CapabilityReference("opaque-ref"), _request(), _runtime_context())