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 new file mode 100644 index 0000000..c9b4107 --- /dev/null +++ b/docs/credential-custody-boundary.md @@ -0,0 +1,235 @@ +# 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` 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 + 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. +- `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 + 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` supplies one verifier with the exact audience, + 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 + 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`. +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 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. + +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 +`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. +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 + +- 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. +- 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. + +## 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 +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. + +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 + 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. +3. A custody-internal resolver implementation, potentially backed by OpenBao + after license, deployment, assurance, and operational review. +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. + +## 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..2fd21a8 --- /dev/null +++ b/src/baton/credential_custody.py @@ -0,0 +1,954 @@ +"""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 collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import StrEnum +from typing import Protocol + +from .delegated_connector import ( + AuthorizationDenied as DelegatedAuthorizationDenied, +) +from .delegated_connector import ( + CapabilityReference, + Channel, + ConnectorRoute, + CustodiedProviderInvoker, + DeliveryStatus, + DispatchClaim, + DispatchRequest, + ProviderAttemptOutcome, + VerifiedDispatchGrant, + dispatch_request_fingerprint, +) + +_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.""" + + +class CustodyMonitoringUnavailable(CustodyError): + """Durable sanitized custody audit or failure notification is unavailable.""" + + +@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 + dispatch_claim_id: 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", + "dispatch_claim_id", + ): + 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") + 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) +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 (type(self.max_uses) is not int or self.max_uses < 1): + raise ValueError("max_uses must be positive when bounded") + if type(self.max_provider_attempts) is not int or self.max_provider_attempts < 1: + 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 + 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", + "issuer_policy_ref", + "rotation_policy_ref", + ): + 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 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: + """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.""" + + 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 + dispatch_claim_id: 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 + dispatch_claim_id: str + reservation_id: str + max_provider_attempts: int + not_before: datetime + not_after: datetime + + 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") + 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, + dispatch_claim_id=self.dispatch_claim_id, + 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 = "" + 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: + 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") + 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) +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: + ... + + 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.""" + + 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, + 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. + + 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(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 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 + ): + 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( + "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, + 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, + ) + + +class CredentialCustodyAuthorizer: + """Verify or accept one trusted outcome, then reserve its exact operation.""" + + def __init__( + self, + verifier: SignedWorkloadAuthorizationVerifier | None, + 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(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, + handle: ProviderCredentialHandle, + request: CredentialUseRequest, + *, + provider_attempt_budget: int | None = None, + required_connector_ids: frozenset[str] | None = None, + ) -> 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, + 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.authorization_id, + kind=CustodyAuditKind.RESERVATION_REQUIRED, + failure_code="reservation_required", + ) + raise + except CustodyAuthorizationDenied: + await self._record_failure( + request, + handle, + authorization_id=authorization.authorization_id, + kind=CustodyAuditKind.AUTHORIZATION_DENIED, + failure_code="authorization_denied", + ) + raise + except Exception as exc: + await self._record_failure( + request, + handle, + authorization_id=authorization.authorization_id, + kind=CustodyAuditKind.AUTHORIZATION_DENIED, + failure_code="authorization_unavailable", + ) + raise CustodyAuthorizationDenied("workload authorization reservation 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: + 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, + 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.""" + + 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.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 + ): + raise DelegatedAuthorizationDenied("connector custody binding is invalid") + return handle + + async def prepare( + self, + _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) + 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, + dispatch_claim_id=claim.claim_id, + request_fingerprint=request.request_fingerprint, + ) + try: + reserved_dispatch = await self._authorizer.authorize_verified( + grant.as_workload_authorization(), + 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( + authorizer=self._authorizer, + resolver=self._resolver, + handles=self._handles, + dispatch=reserved_dispatch, + request=request, + ) + + +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 + 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.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 + ): + 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: # noqa: BLE001 - contain provider/backend failures without leaking material. + 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, + 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 new file mode 100644 index 0000000..353eed4 --- /dev/null +++ b/src/baton/delegated_connector.py @@ -0,0 +1,717 @@ +"""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 +``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 + +import asyncio +import hashlib +import json +import re +import time +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import StrEnum +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}$") + + +class Channel(StrEnum): + SMS = "sms" + 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" + FAILED = "failed" + EXHAUSTED = "exhausted" + + +class DispatchSignalKind(StrEnum): + AUTHORIZATION_DENIED = "authorization_denied" + IDEMPOTENCY_CONFLICT = "idempotency_conflict" + 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 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.""" + + +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.""" + + 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 ( + "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") + 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) +class VerifiedDispatchGrant: + """Verified authorization scope returned by a trusted verifier.""" + + principal: str + channel: Channel + allowed_connectors: frozenset[str] + not_after: datetime + max_attempts: int + request_fingerprint: str + + 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 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") + + +@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 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") + 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 + connector_id: str + provider_key: str + status: DeliveryStatus + attempt_count: int + failover_used: bool + audit_ref: str + 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 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. + + 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 + 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 CustodiedProviderInvokerFactory(Protocol): + """Prepare a custody-bound invoker after idempotency begins, before sending.""" + + async def prepare( + self, + capability: CapabilityReference, + request: DispatchRequest, + grant: VerifiedDispatchGrant, + claim: DispatchClaim, + 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. + + 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, binding: DispatchBinding) -> DispatchClaim | None: + ... + + async def renew(self, claim: DispatchClaim) -> None: + ... + + async def complete(self, claim: DispatchClaim, outcome: DeliveryOutcome) -> None: + ... + + async def abort(self, claim: DispatchClaim) -> 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_factory: CustodiedProviderInvokerFactory, + 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_factory = invoker_factory + self._journal = journal + self._signal_sink = signal_sink + 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] = {} + + @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 once per key and immutable request fingerprint after 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, + dispatch_id=request.dispatch_id, + failure_code="authorization_denied", + ) + ) + if isinstance(exc, AuthorizationDenied): + raise + raise AuthorizationDenied("dispatch authorization denied") from exc + + 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 + try: + claim = await self._journal.begin(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 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, claim, request) + except AuthorizationDenied: + 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 + + 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 + + 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: + 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, + capability: CapabilityReference, + grant: VerifiedDispatchGrant, + claim: DispatchClaim, + request: DispatchRequest, + ) -> DeliveryOutcome: + 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, + 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, + 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, + dispatch_id=request.dispatch_id, + connector_id=route.connector_id, + provider_key=route.provider_key, + attempt_count=attempt_count, + ) + ) + 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, + ) + + 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 + 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): + self._record_success(route) + return DeliveryOutcome( + 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, + 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, + dispatch_id=request.dispatch_id, + 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=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, + failover_used=failover_used, + audit_ref=last_audit_ref, + failure_code=last_failure, + ) + + async def _invoke( + self, + invoker: CustodiedProviderInvoker, + route: ConnectorRoute, + request: DispatchRequest, + ) -> ProviderAttemptOutcome: + try: + return await asyncio.wait_for( + invoker.invoke(route, request), + timeout=route.timeout_ms / 1000, + ) + except AuthorizationDenied: + raise + except TimeoutError: + return ProviderAttemptOutcome( + status=DeliveryStatus.FAILED, + audit_ref=f"timeout:{request.dispatch_id}:{route.connector_id}", + failure_code="provider_timeout", + retryable=True, + failover_allowed=True, + counts_toward_circuit=True, + ) + 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}", + 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, + connector_id=outcome.connector_id, + provider_key=outcome.provider_key, + attempt_count=outcome.attempt_count, + failure_code=outcome.failure_code, + ) + ) diff --git a/src/baton/delegated_runtime.py b/src/baton/delegated_runtime.py new file mode 100644 index 0000000..98fb5d6 --- /dev/null +++ b/src/baton/delegated_runtime.py @@ -0,0 +1,1177 @@ +"""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 UTC, datetime, timedelta +from enum import Enum +from pathlib import Path +from typing import Any, Protocol + +from baton.credential_custody import ( + AuthorizationConsumptionLedger, + AuthorizedCredentialUse, + ConsumptionReservation, + ConsumptionReservationRequired, + CredentialCustodyAuthorizer, + CredentialCustodyInvokerFactory, + CredentialUseRequest, + CustodiedReferenceResolver, + CustodyAuditEvent, + CustodyAuditSink, + CustodyAuthorizationDenied, + CustodyFailureNotifier, + ProviderCredentialHandle, + SanitizedCustodyOutcome, + VerifiedDelegatedAuthorization, + VerifiedWorkloadAuthorization, +) +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + ConnectorRoute, + DelegatedConnectorExecutor, + DeliveryOutcome, + DeliveryStatus, + DispatchBinding, + DispatchBindingConflict, + DispatchClaim, + DispatchJournal, + DispatchRequest, + DispatchSignal, + DispatchSignalSink, + DispatchStateUnavailable, + ScopedAuthorizationVerifier, +) + +_ZERO_HASH = "0" * 64 +MAX_DELEGATED_PROVIDER_ATTEMPTS = 3 +MAX_DELEGATED_PROVIDER_LIFETIME_SECONDS = 15 * 60 + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime) -> str: + if value.tzinfo is None: + raise ValueError("timestamp must be timezone-aware") + return value.astimezone(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 DelegatedAuthorizationContext: + """Exact runtime and trust-policy context supplied to a trusted verifier.""" + + audience: str + workload_id: str + purpose: str + 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 ( + "audience", + "workload_id", + "purpose", + "issuer_policy_ref", + "rotation_policy_ref", + ): + 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") + 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 ( + type(self.authorization_lifetime_ceiling_seconds) is int + and 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): + """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 + audience: str + workload_id: str + purpose: str + 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( + self, + capability: CapabilityReference, + request: DispatchRequest, + ) -> VerifiedDelegatedAuthorization: + 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, + 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): + raise AuthorizationDenied( + "delegated verifier did not return the required shared authorization outcome" + ) + if outcome.audience != context.audience: + raise AuthorizationDenied("authorization audience is outside runtime scope") + 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 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") + 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") + return outcome + + +@dataclass(frozen=True) +class ConfiguredVerifierBundle: + """One verifier plus the exact externally managed trust-policy context.""" + + verifier: DelegatedAuthorizationVerifier + 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 ( + 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 ( + type(self.authorization_lifetime_ceiling_seconds) is int + and 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, + *, + 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, + self.audience, + workload_id, + purpose, + 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, + ) + + +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: + 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 + 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: + 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) + 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: + 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 + 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.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 + ): + raise ValueError(f"missing exact custody handle for route {route.connector_id}") + + authorizer = CredentialCustodyAuthorizer.for_verified_outcomes( + 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.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, + 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/src/baton/signet_delegated_authorization.py b/src/baton/signet_delegated_authorization.py new file mode 100644 index 0000000..060735b --- /dev/null +++ b/src/baton/signet_delegated_authorization.py @@ -0,0 +1,212 @@ +"""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 UTC, datetime, timedelta +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 + provider_attempt_ceiling: int + authorization_lifetime_ceiling_seconds: int + + +@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(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, + 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) + + 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 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") + + 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 type(outcome.max_uses) is not int or outcome.max_uses != 1: + raise AuthorizationDenied("Signet authorization must be single-use") + 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") + 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(UTC) diff --git a/tests/test_credential_custody.py b/tests/test_credential_custody.py new file mode 100644 index 0000000..fc28929 --- /dev/null +++ b/tests/test_credential_custody.py @@ -0,0 +1,573 @@ +"""Credential-free contract checks for the provider custody boundary.""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from baton.credential_custody import ( + ConsumptionReservation, + ConsumptionReservationRequired, + CredentialCustodyAuthorizer, + CredentialCustodyInvokerFactory, + CredentialUseRequest, + CustodyAuthorizationDenied, + CustodyResultStatus, + ProviderChannel, + ProviderCredentialHandle, + SanitizedCustodyOutcome, + VerifiedDelegatedAuthorization, + VerifiedWorkloadAuthorization, + WorkloadAuthorizationReference, + authorize_provider_dispatch, +) +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + ConnectorRoute, + DeliveryStatus, + DispatchBinding, + DispatchClaim, + DispatchRequest, + VerifiedDispatchGrant, + dispatch_request_fingerprint, +) + +NOW = datetime(2026, 6, 4, tzinfo=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( + 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", + "dispatch_claim_id": "claim-1", + } + 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) + + +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] = [] + self.outcomes: list[SanitizedCustodyOutcome] = [] + self.attempt_count = 0 + self.max_provider_attempts = 0 + + async def reserve_once( + self, + _authorization: VerifiedWorkloadAuthorization, + 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): + self.authorization = authorization + + async def verify( + self, + _reference: WorkloadAuthorizationReference, + _request: CredentialUseRequest, + ) -> VerifiedWorkloadAuthorization: + 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, + *, + allowed_connectors: frozenset[str] | None = None, +) -> VerifiedDelegatedAuthorization: + return VerifiedDelegatedAuthorization( + principal="mea-comms", + channel=Channel.SMS, + 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", + 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, + ) + + +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(), + _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(): + changed_request = _request( + dispatch_id="dispatch-2", + idempotency_key="dispatch-once-2", + ) + with pytest.raises(CustodyAuthorizationDenied): + await authorize_provider_dispatch( + _handle(), + changed_request, + _authorization(max_uses=1), + 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( + _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")) + + +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(), + _dispatch_claim(), + 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_rejects_independent_dispatch_grant(): + 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(), + 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,), + ) + 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(allowed_connectors=frozenset({"sms-primary"})), + _dispatch_claim(), + primary, + (primary, backup), + ) + assert ledger.requests == [] + + +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" + assert result.retryable is False + + 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", + ) + + 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, + ) + + +@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 new file mode 100644 index 0000000..55cc630 --- /dev/null +++ b/tests/test_delegated_connector.py @@ -0,0 +1,552 @@ +"""Tests for provider dispatch orchestration without credential material.""" + +from __future__ import annotations + +import asyncio +import dataclasses +from datetime import UTC, datetime, timedelta + +import pytest + +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + ConnectorRoute, + DelegatedConnectorExecutor, + DeliveryOutcome, + DeliveryStatus, + DispatchBinding, + DispatchBindingConflict, + DispatchClaim, + DispatchInProgress, + DispatchRequest, + DispatchSignal, + DispatchSignalKind, + DispatchStateUnavailable, + MonitoringUnavailable, + ProviderAttemptOutcome, + VerifiedDispatchGrant, + dispatch_request_fingerprint, +) + + +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 | None = None, +) -> DispatchRequest: + resolved_dispatch_id = dispatch_id or f"dispatch-{idempotency_key}" + return DispatchRequest( + 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 + 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, + *, + 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(UTC) + timedelta(minutes=5), + max_attempts=4, + request_fingerprint=request.request_fingerprint, + ) + + +class DeniedVerifier: + async def verify( + self, _capability: CapabilityReference, _request: DispatchRequest + ) -> VerifiedDispatchGrant: + 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) + 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 OutcomeInvokerFactory: + def __init__(self, invoker): + self.invoker = invoker + self.prepare_calls: list[str] = [] + + async def prepare( + self, + _capability: CapabilityReference, + _request: DispatchRequest, + _grant: VerifiedDispatchGrant, + _claim: DispatchClaim, + 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, + claim: DispatchClaim, + initial_route: ConnectorRoute, + authorized_routes: tuple[ConnectorRoute, ...], + ): + assert request.idempotency_key in self.journal.running + return await super().prepare( + capability, request, grant, claim, initial_route, authorized_routes + ) + + +class MemoryJournal: + 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) + if fingerprint is not None and fingerprint != binding.request_fingerprint: + raise DispatchBindingConflict("idempotency key is bound to another request") + + 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) -> DispatchClaim | None: + self._assert_binding(binding) + if binding.idempotency_key in self.running: + return None + self.fingerprints[binding.idempotency_key] = binding.request_fingerprint + self.running.add(binding.idempotency_key) + 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(UTC) + timedelta(minutes=1), + ) + + 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 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: + 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, factory=None) -> DelegatedConnectorExecutor: + return DelegatedConnectorExecutor( + routes or [_route("primary", 1), _route("backup", 2)], + AcceptedVerifier(), + factory or OutcomeInvokerFactory(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", + "connector_id", + "provider_key", + "status", + "attempt_count", + "failover_used", + "audit_ref", + "failure_code", + } + 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(), + factory, + MemoryJournal(), + sink, + ) + + with pytest.raises(AuthorizationDenied): + 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(), + factory, + MemoryJournal(), + sink, + ) + + with pytest.raises(AuthorizationDenied): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + assert invoker.calls == [] + assert factory.prepare_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, + ] + 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(): + 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()]) + factory = OutcomeInvokerFactory(invoker) + journal = MemoryJournal() + 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(): + 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(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): + await executor.dispatch(CapabilityReference("authorization-ref-1"), _request()) + + +async def test_signal_failure_prevents_unaudited_authorization_denial(): + executor = DelegatedConnectorExecutor( + [_route("primary", 1)], + DeniedVerifier(), + OutcomeInvokerFactory(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] + + +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..d98df91 --- /dev/null +++ b/tests/test_delegated_runtime.py @@ -0,0 +1,875 @@ +"""Durable delegated connector runtime tests without credential material.""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from dataclasses import dataclass, replace +from datetime import UTC, datetime, timedelta + +import pytest + +from baton.credential_custody import ( + AuthorizedCredentialUse, + ConsumptionReservationRequired, + CredentialUseRequest, + CustodyAuditEvent, + CustodyAuditKind, + CustodyAuthorizationDenied, + CustodyResultStatus, + ProviderChannel, + ProviderCredentialHandle, + SanitizedCustodyOutcome, + VerifiedDelegatedAuthorization, + VerifiedWorkloadAuthorization, +) +from baton.delegated_connector import ( + AuthorizationDenied, + CapabilityReference, + Channel, + ConnectorRoute, + DeliveryOutcome, + DeliveryStatus, + DispatchBinding, + DispatchRequest, + DispatchSignal, + DispatchSignalKind, + DispatchStateUnavailable, + VerifiedDispatchGrant, + dispatch_request_fingerprint, +) +from baton.delegated_runtime import ( + ConfiguredVerifierBundle, + DelegatedAuthorizationContext, + DelegatedAuthorizationVerifier, + DelegatedConnectorRuntime, + DelegatedRuntimeComponents, + SinglePurposeCustodiedResolver, + SqliteDelegatedRuntimeState, +) + +NOW = datetime(2026, 6, 4, tzinfo=UTC) + + +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 +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", +) -> DispatchRequest: + dispatch_id = f"dispatch-{idempotency_key}" + return DispatchRequest( + 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(idempotency_key, dispatch_id=dispatch_id), + ) + + +def _custody_request( + *, + idempotency_key: str = "dispatch-once-1", + fingerprint: str | None = None, + claim_id: str = "claim-1", +) -> CredentialUseRequest: + dispatch_id = f"dispatch-{idempotency_key}" + return CredentialUseRequest( + operation_id="operation-1", + dispatch_id=dispatch_id, + 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 + or _fingerprint(idempotency_key, dispatch_id=dispatch_id), + ) + + +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: + 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, + 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, + ) + + +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 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 + 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, + verifier: DelegatedAuthorizationVerifier | None = None, +) -> DelegatedConnectorRuntime: + return DelegatedConnectorRuntime.build_sqlite_reference( + routes=[_route()], + handles={"sms-primary": _handle()}, + verifiers=ConfiguredVerifierBundle( + 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", + provider_attempt_ceiling=3, + authorization_lifetime_ceiling_seconds=15 * 60, + ), + operation_factory=operation_factory, + state_path=tmp_path / "delegated-runtime.sqlite3", + workload_id="mea-comms", + purpose="case_notification", + clock=clock, + ) + + +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", + 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 + + +@pytest.mark.parametrize( + "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)}, + {"max_attempts": 4}, + {"not_after": NOW + timedelta(minutes=16)}, + ], +) +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) + + 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 = _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 + 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( + verifier=DispatchVerifier(), + 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( + 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( + verifier=DispatchVerifier(), + 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", + 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( + verifier=DispatchVerifier(), + 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", + workload_id="mea-comms", + 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 new file mode 100644 index 0000000..2358f20 --- /dev/null +++ b/tests/test_signet_delegated_authorization.py @@ -0,0 +1,194 @@ +"""Key-free contract checks for the trusted Signet-to-Baton adapter.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +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=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", + provider_attempt_ceiling=2, + authorization_lifetime_ceiling_seconds=10 * 60, + ) + + +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", + provider_attempt_ceiling=2, + authorization_lifetime_ceiling_seconds=10 * 60, + ), + ) + ] + + +@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}, + {"max_provider_attempts": 3}, + {"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": (NOW + timedelta(minutes=6)).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"}) + + +@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())