cratestack_axum/idempotency/layer.rs
1//! Tower layer + companion `Service` constructor.
2
3use std::net::SocketAddr;
4use std::sync::{Arc, Once};
5use std::time::Duration;
6
7use axum::extract::{ConnectInfo, Request};
8use cratestack_core::CratestackError;
9use cratestack_exec::{OpAdmission, OpExecutor};
10use http::header;
11use sha2::{Digest, Sha256};
12use tower::Layer;
13
14use super::service::IdempotencyService;
15use super::store::IdempotencyStore;
16
17/// Tower layer that wires an `IdempotencyStore` into the request pipeline.
18///
19/// Since ADR 0015 slice 1 the decision itself lives at L3 in
20/// [`cratestack_exec::OpExecutor`]; this layer is the HTTP adapter around
21/// it, owning exactly the things L3 may not name — the `Idempotency-Key`
22/// header, the request fingerprint, the principal derivation, and the
23/// response shapes.
24#[derive(Clone)]
25pub struct IdempotencyLayer {
26 pub(super) executor: OpExecutor,
27 pub(super) principal_fingerprint:
28 Arc<dyn Fn(&Request) -> Result<String, CratestackError> + Send + Sync>,
29 pub(super) op_resolver: Arc<dyn Fn(&Request) -> OpAdmission + Send + Sync>,
30}
31
32impl IdempotencyLayer {
33 /// Construct with a default principal fingerprint derived from the
34 /// `Authorization` header, falling back to the verified TCP peer address
35 /// (via axum's `ConnectInfo<SocketAddr>`, requires serving through
36 /// `into_make_service_with_connect_info::<SocketAddr>()`) when it's
37 /// absent. If *neither* is available the request is refused rather than
38 /// silently placed in a shared `"anonymous"` namespace (cratestack#416)
39 /// — callers running mTLS or session-cookie auth, or who cannot serve
40 /// through `into_make_service_with_connect_info`, must supply
41 /// [`with_principal_fingerprint`] explicitly.
42 pub fn new(store: Arc<dyn IdempotencyStore>, ttl: Duration) -> Self {
43 Self {
44 executor: OpExecutor::new(Some(store), ttl),
45 principal_fingerprint: Arc::new(default_principal_fingerprint),
46 op_resolver: Arc::new(|_| OpAdmission::unresolved()),
47 }
48 }
49
50 /// Override how the layer derives a principal-scoped namespace for the
51 /// idempotency key. Without this, two callers sharing a key (across
52 /// tenants) would collide. The supplied closure is infallible by design
53 /// — a caller who opts out of the default's fail-closed behavior is
54 /// taking explicit responsibility for the namespace it returns,
55 /// including any deliberate shared bucket.
56 pub fn with_principal_fingerprint(
57 mut self,
58 f: impl Fn(&Request) -> String + Send + Sync + 'static,
59 ) -> Self {
60 self.principal_fingerprint = Arc::new(move |req| Ok(f(req)));
61 self
62 }
63
64 /// Teach the layer which schema op each request is about, so
65 /// `@no_idempotency` (and every read) can skip reservation.
66 ///
67 /// Mirrors [`crate::ratelimit::RateLimitLayer::with_should_rate_limit_fn`]
68 /// — pass [`build_rest_op_resolver`] over the generated
69 /// `ROUTE_TRANSPORTS`, or [`build_rpc_op_resolver`] over `OPS`.
70 ///
71 /// **Not installing one is a supported configuration and changes
72 /// nothing.** The default resolver reports every request as
73 /// [`OpAdmission::unresolved`], which reserves — so an existing
74 /// consumer that never calls this method reserves exactly the set of
75 /// requests it always did. That is the property ADR 0015 slice 1's
76 /// byte-identity bar rests on, and it is why this is opt-in rather
77 /// than wired automatically.
78 ///
79 /// [`build_rest_op_resolver`]: super::build_rest_op_resolver
80 /// [`build_rpc_op_resolver`]: super::build_rpc_op_resolver
81 pub fn with_op_resolver(
82 mut self,
83 f: impl Fn(&Request) -> OpAdmission + Send + Sync + 'static,
84 ) -> Self {
85 self.op_resolver = Arc::new(f);
86 self
87 }
88}
89
90/// Logged once per process, not per request — a busy misconfigured
91/// deployment would otherwise emit this thousands of times a second. See
92/// `default_principal_fingerprint` for the condition that fires it.
93static MISSING_IDENTITY_WARNING: Once = Once::new();
94
95/// cratestack#416: the pre-existing default silently collapsed every
96/// unauthenticated caller without a verified peer address onto a single
97/// shared `"anonymous"` idempotency namespace — two distinct callers reusing
98/// an `Idempotency-Key` could then replay each other's response. Refusing
99/// the request instead (`PreconditionFailed`, matching this crate's
100/// established "handled error, not an unwind" shape) makes the gap loud in
101/// staging/CI instead of silently reachable in production, per the
102/// ticket's Expected Behavior: "construction requires an explicit
103/// fingerprint function so the collision cannot be reached by accident."
104pub(super) fn default_principal_fingerprint(req: &Request) -> Result<String, CratestackError> {
105 // Prefer Authorization header for authenticated requests.
106 if let Some(auth_header) = req.headers().get(header::AUTHORIZATION)
107 && let Ok(auth_str) = auth_header.to_str()
108 {
109 let mut h = Sha256::new();
110 h.update(auth_str.as_bytes());
111 // sha2 0.11 / digest 0.11 return `hybrid_array::Array`, which (unlike
112 // digest 0.10's `GenericArray`) implements no `LowerHex`. The
113 // byte-wise `{:02x}` fold below is this repo's existing hex idiom
114 // (`cratestack-core/src/transport.rs`) and is byte-for-byte what
115 // `format!("{:x}", …)` produced — this string is persisted/keyed on,
116 // so it must not change shape.
117 return Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect());
118 }
119
120 // Fall back to the real TCP peer address for unauthenticated requests, to
121 // avoid collisions between distinct callers. This is deliberately *not*
122 // `Forwarded`/`X-Forwarded-For`: those headers are client-supplied and
123 // this crate has no trusted-proxy configuration to verify or strip them,
124 // so trusting them here would let an attacker land in another caller's
125 // idempotency namespace just by guessing/spoofing that caller's apparent
126 // IP. `ConnectInfo` is populated by axum from the actual accepted socket
127 // (when the server is served via `into_make_service_with_connect_info::<SocketAddr>()`)
128 // and cannot be spoofed by the client.
129 if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
130 return Ok(addr.ip().to_string());
131 }
132
133 // Neither Authorization nor a verified peer address is available (e.g.
134 // the server isn't wired through `into_make_service_with_connect_info`).
135 // There is no unforgeable value left to key on, so refuse rather than
136 // collapsing every such caller onto one shared namespace.
137 MISSING_IDENTITY_WARNING.call_once(|| {
138 tracing::warn!(
139 target: "cratestack",
140 cratestack_operation = "idempotency",
141 "IdempotencyLayer's default principal fingerprint has no Authorization header and \
142 no ConnectInfo<SocketAddr> peer on this request, so it cannot verify caller \
143 identity. Refusing the request rather than collapsing distinct callers onto a \
144 shared \"anonymous\" namespace (cratestack#416) — wire \
145 into_make_service_with_connect_info::<SocketAddr>() or supply \
146 IdempotencyLayer::with_principal_fingerprint(...) explicitly. Logged once per \
147 process; every matching request is refused until this is fixed.",
148 );
149 });
150 Err(CratestackError::PreconditionFailed(
151 "idempotency: no verifiable caller identity (Authorization header or ConnectInfo peer) \
152 is available for the default namespace fingerprint; the server must be served through \
153 into_make_service_with_connect_info::<SocketAddr>() or configure an explicit \
154 fingerprint function"
155 .to_owned(),
156 ))
157}
158
159impl<S> Layer<S> for IdempotencyLayer {
160 type Service = IdempotencyService<S>;
161
162 fn layer(&self, inner: S) -> Self::Service {
163 IdempotencyService {
164 inner,
165 executor: self.executor.clone(),
166 principal_fingerprint: self.principal_fingerprint.clone(),
167 op_resolver: self.op_resolver.clone(),
168 }
169 }
170}