Skip to main content

cratestack_axum/idempotency/
layer.rs

1//! Tower layer + companion `Service` constructor.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use axum::extract::Request;
7use cratestack_core::CratestackError;
8use cratestack_exec::{OpAdmission, OpExecutor};
9use tower::Layer;
10
11pub(super) use super::fingerprint::default_principal_fingerprint;
12use super::fingerprint::legacy_principal_fingerprint;
13use super::service::IdempotencyService;
14use super::store::IdempotencyStore;
15
16/// Tower layer that wires an `IdempotencyStore` into the request pipeline.
17///
18/// Since ADR 0015 slice 1 the decision itself lives at L3 in
19/// [`cratestack_exec::OpExecutor`]; this layer is the HTTP adapter around
20/// it, owning exactly the things L3 may not name — the `Idempotency-Key`
21/// header, the request fingerprint, the principal derivation, and the
22/// response shapes.
23#[derive(Clone)]
24pub struct IdempotencyLayer {
25    pub(super) executor: OpExecutor,
26    pub(super) principal_fingerprint:
27        Arc<dyn Fn(&Request) -> Result<String, CratestackError> + Send + Sync>,
28    pub(super) op_resolver: Arc<dyn Fn(&Request) -> OpAdmission + Send + Sync>,
29}
30
31impl IdempotencyLayer {
32    /// Construct with a default principal fingerprint derived from a
33    /// `VerifiedPrincipal` request extension when an upstream layer (the
34    /// COSE envelope layer, cratestack#1006) inserted one, otherwise from the
35    /// `Authorization` header, falling back to the verified TCP peer address
36    /// (via axum's `ConnectInfo<SocketAddr>`, requires serving through
37    /// `into_make_service_with_connect_info::<SocketAddr>()`) when it's
38    /// absent. If *neither* is available the request is refused rather than
39    /// silently placed in a shared `"anonymous"` namespace (cratestack#416)
40    /// — callers running mTLS or session-cookie auth, or who cannot serve
41    /// through `into_make_service_with_connect_info`, must supply
42    /// [`with_principal_fingerprint`](Self::with_principal_fingerprint)
43    /// explicitly.
44    pub fn new(store: Arc<dyn IdempotencyStore>, ttl: Duration) -> Self {
45        Self {
46            executor: OpExecutor::new(Some(store), ttl),
47            principal_fingerprint: Arc::new(default_principal_fingerprint),
48            op_resolver: Arc::new(|_| OpAdmission::unresolved()),
49        }
50    }
51
52    /// Override how the layer derives a principal-scoped namespace for the
53    /// idempotency key. Without this, two callers sharing a key (across
54    /// tenants) would collide. The supplied closure is infallible by design
55    /// — a caller who opts out of the default's fail-closed behavior is
56    /// taking explicit responsibility for the namespace it returns,
57    /// including any deliberate shared bucket.
58    pub fn with_principal_fingerprint(
59        mut self,
60        f: impl Fn(&Request) -> String + Send + Sync + 'static,
61    ) -> Self {
62        self.principal_fingerprint = Arc::new(move |req| Ok(f(req)));
63        self
64    }
65
66    /// Keep the default fingerprint as it was before cratestack#1006: the
67    /// `Authorization` header, then the `ConnectInfo` peer, then a `412`,
68    /// ignoring any `VerifiedPrincipal`. See
69    /// [`legacy_principal_fingerprint`](super::legacy_principal_fingerprint)
70    /// for when a deployment wants this across an upgrade.
71    pub fn with_legacy_principal_fingerprint(mut self) -> Self {
72        self.principal_fingerprint = Arc::new(legacy_principal_fingerprint);
73        self
74    }
75
76    /// Teach the layer which schema op each request is about, so
77    /// `@no_idempotency` (and every read) can skip reservation.
78    ///
79    /// Mirrors [`crate::ratelimit::RateLimitLayer::with_op_resolver`], which
80    /// accepts the same resolvers
81    /// — pass [`build_rest_op_resolver`] over the generated
82    /// `ROUTE_TRANSPORTS`, or [`build_rpc_op_resolver`] over `OPS`.
83    ///
84    /// **Not installing one is a supported configuration and changes
85    /// nothing.** The default resolver reports every request as
86    /// [`OpAdmission::unresolved`], which reserves — so an existing
87    /// consumer that never calls this method reserves exactly the set of
88    /// requests it always did. That is the property ADR 0015 slice 1's
89    /// byte-identity bar rests on, and it is why this is opt-in rather
90    /// than wired automatically.
91    ///
92    /// [`build_rest_op_resolver`]: super::build_rest_op_resolver
93    /// [`build_rpc_op_resolver`]: super::build_rpc_op_resolver
94    pub fn with_op_resolver(
95        mut self,
96        f: impl Fn(&Request) -> OpAdmission + Send + Sync + 'static,
97    ) -> Self {
98        self.op_resolver = Arc::new(f);
99        self
100    }
101}
102
103impl<S> Layer<S> for IdempotencyLayer {
104    type Service = IdempotencyService<S>;
105
106    fn layer(&self, inner: S) -> Self::Service {
107        IdempotencyService {
108            inner,
109            executor: self.executor.clone(),
110            principal_fingerprint: self.principal_fingerprint.clone(),
111            op_resolver: self.op_resolver.clone(),
112        }
113    }
114}