Skip to main content

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}