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}