Skip to main content

autumn_web/
edge_support.rs

1//! Origin-side glue between an Autumn app and the edge capsule (issue #1790).
2//!
3//! The edge lane's whole point is that one handler source serves from two
4//! substrates. That only works if every seam it touches is mediated by the
5//! framework, so this module supplies the origin half of the one seam the first
6//! slice mediates: a key/value read.
7//!
8//! | Substrate | Behind [`EdgeCache`] sits… | Installed by |
9//! | --- | --- | --- |
10//! | Origin | [`CacheEdgeKv`] over the app's own `Cache` | [`AppBuilder::with_edge_kv`](crate::app::AppBuilder::with_edge_kv) |
11//! | Edge | the capsule runtime's dialogue-backed reader | `autumn_edge::serve` |
12//!
13//! A handler sees neither: it takes [`EdgeCache`] and cannot tell which store
14//! answered. That is what makes the identical source portable, and it is why
15//! the adapter here reads through the *same* `insert_cached` / `get_cached`
16//! serde path the rest of the framework writes through — an origin route that
17//! caches bytes under a key has, by that act alone, published them to the edge
18//! lane.
19//!
20//! # This is not a database (ADR-0004 category 2)
21//!
22//! [`EdgeKv`] is a replica-local, opportunistic read accelerator, never a
23//! source of truth. It has no `put`; a miss is always a legal answer; staleness
24//! is expected and there is no invalidation protocol. A route whose correctness
25//! depends on the value being present, current, or authoritative does not
26//! belong in the edge lane — serve it from the origin, where the database is.
27//! [`CacheEdgeKv`] inherits exactly those properties from the cache it wraps.
28//!
29//! [`EdgeCache`]: autumn_edge::EdgeCache
30//! [`EdgeKv`]: autumn_edge::EdgeKv
31
32// This module runs on the request path: `EdgeKv::get` is called by the
33// `EdgeCache` extractor while a request is in flight, so production code here
34// must be panic-free. The deny block below IS compiled — and therefore enforced
35// — by the `lint` job's `cargo clippy --workspace --all-targets -- -D warnings`:
36// `examples/edge-greeting` is a workspace member whose native build enables
37// `autumn-web/edge`, and cargo unifies features across the graph.
38//
39// It nevertheless carries no panic-gate marker comment and no
40// `scripts/check-panic-gate.sh` manifest entry, on purpose. (Not even a
41// *mention* of the marker tag: the gate's reverse-manifest scan greps for the
42// literal tag text, so naming it here would enroll this module.) That script counts a
43// non-default feature as linted only when an enforcing
44// `cargo clippy -p autumn-web --features "…" -- -D warnings` lane names it, and
45// none does (nor should one — the workspace lane already covers this module).
46// So: the marker alone fails its reverse-manifest check, a manifest entry fails
47// its feature-reachability check, and a `:default` suffix would be exactly the
48// mislabelling that check exists to catch. Revisit only if `edge` ever gains a
49// dedicated `-p autumn-web --features` clippy lane.
50#![cfg_attr(
51    not(test),
52    deny(
53        clippy::unwrap_used,
54        clippy::expect_used,
55        clippy::panic,
56        clippy::unreachable,
57        clippy::todo,
58        clippy::unimplemented,
59        clippy::indexing_slicing,
60        clippy::string_slice,
61        clippy::arithmetic_side_effects,
62    )
63)]
64
65use std::collections::HashMap;
66use std::fmt;
67use std::future::Future;
68use std::pin::Pin;
69use std::sync::Arc;
70use std::task::{Context, Poll};
71
72use autumn_edge::EdgeKv;
73use tower::{Layer, Service};
74
75use crate::cache::Cache;
76
77pub use autumn_edge::{EdgeIdentity, EdgeRole, EdgeUserId};
78
79/// Opaque, validated identifier used while consulting an authoritative store.
80/// It deliberately has no public accessor and is never part of [`EdgeIdentity`].
81#[derive(Clone, Debug, PartialEq, Eq, Hash)]
82pub struct SessionId(String);
83
84/// Host-only authentication contract for the Autumn edge integration.
85///
86/// `Ok(None)` covers both absent and invalid credentials. `Err` is reserved
87/// for infrastructure failure so callers can fall through to the origin.
88pub trait EdgeIdentityProvider: Send + Sync + 'static {
89    /// Provider-specific infrastructure failure.
90    type Error: std::error::Error + Send + Sync + 'static;
91    /// Resolve an incoming host request without executing capsule code.
92    fn resolve(
93        &self,
94        request: &http::Request<axum::body::Body>,
95    ) -> impl Future<Output = Result<Option<EdgeIdentity>, Self::Error>> + Send;
96}
97
98/// Host middleware that resolves identity before an edge handler is extracted.
99pub struct EdgeIdentityLayer<P>(Arc<P>);
100
101impl<P> Clone for EdgeIdentityLayer<P> {
102    fn clone(&self) -> Self {
103        Self(Arc::clone(&self.0))
104    }
105}
106
107impl<P> EdgeIdentityLayer<P> {
108    /// Wrap a provider in the host request middleware.
109    #[must_use]
110    pub fn new(provider: P) -> Self {
111        Self(Arc::new(provider))
112    }
113}
114
115impl<P, Inner> Layer<Inner> for EdgeIdentityLayer<P>
116where
117    P: EdgeIdentityProvider,
118{
119    type Service = EdgeIdentityService<P, Inner>;
120    fn layer(&self, inner: Inner) -> Self::Service {
121        EdgeIdentityService {
122            provider: Arc::clone(&self.0),
123            inner,
124        }
125    }
126}
127
128/// Service produced by [`EdgeIdentityLayer`].
129pub struct EdgeIdentityService<P, Inner> {
130    provider: Arc<P>,
131    inner: Inner,
132}
133
134impl<P, Inner: Clone> Clone for EdgeIdentityService<P, Inner> {
135    fn clone(&self) -> Self {
136        Self {
137            provider: Arc::clone(&self.provider),
138            inner: self.inner.clone(),
139        }
140    }
141}
142
143impl<P, Inner> Service<axum::extract::Request> for EdgeIdentityService<P, Inner>
144where
145    P: EdgeIdentityProvider,
146    Inner: Service<axum::extract::Request, Response = axum::response::Response>
147        + Clone
148        + Send
149        + 'static,
150    Inner::Future: Send + 'static,
151    Inner::Error: Send + 'static,
152{
153    type Response = axum::response::Response;
154    type Error = Inner::Error;
155    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
156
157    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
158        self.inner.poll_ready(cx)
159    }
160
161    fn call(&mut self, mut request: axum::extract::Request) -> Self::Future {
162        let provider = Arc::clone(&self.provider);
163        let mut inner = self.inner.clone();
164        std::mem::swap(&mut self.inner, &mut inner);
165        Box::pin(async move {
166            match provider.resolve(&request).await {
167                Ok(Some(identity)) => {
168                    request.extensions_mut().insert(identity);
169                }
170                Ok(None) => {}
171                Err(error) => {
172                    tracing::warn!(%error, "edge identity infrastructure failure; falling through to origin");
173                }
174            }
175            inner.call(request).await
176        })
177    }
178}
179
180/// Projects an authoritative session map into explicitly allowed edge claims.
181pub trait EdgeIdentityProjector: Send + Sync + 'static {
182    /// Return no identity when the session is not authenticated.
183    fn project(&self, session: &HashMap<String, String>) -> Option<EdgeIdentity>;
184}
185
186/// Session-backed identity provider that shares Autumn's canonical cookie and
187/// signing-key verification with [`crate::session::SessionLayer`].
188pub struct SessionIdentityProvider<S, P> {
189    store: S,
190    projector: P,
191    cookie_name: String,
192    signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
193}
194
195impl<S, P> SessionIdentityProvider<S, P> {
196    /// Build an adapter. The caller supplies the configured cookie name and the
197    /// same optional resolved current/previous keys installed on `SessionLayer`.
198    /// Pass `None` for the unsigned development/test policy; raw session ids
199    /// are then accepted exactly as they are by the session middleware.
200    #[must_use]
201    pub fn new(
202        store: S,
203        projector: P,
204        cookie_name: impl Into<String>,
205        signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
206    ) -> Self {
207        Self {
208            store,
209            projector,
210            cookie_name: cookie_name.into(),
211            signing_keys,
212        }
213    }
214}
215
216impl<S, P> EdgeIdentityProvider for SessionIdentityProvider<S, P>
217where
218    S: crate::session::SessionStore,
219    P: EdgeIdentityProjector,
220{
221    type Error = crate::session::SessionStoreError;
222
223    fn resolve(
224        &self,
225        request: &http::Request<axum::body::Body>,
226    ) -> impl Future<Output = Result<Option<EdgeIdentity>, Self::Error>> + Send {
227        let raw_id = crate::session::session_id_from_headers(
228            request.headers(),
229            &self.cookie_name,
230            self.signing_keys.as_deref(),
231        );
232        async move {
233            let Some(raw_id) = raw_id else {
234                return Ok(None);
235            };
236            let id = SessionId(raw_id);
237            let Some(session) = self.store.load(&id.0).await? else {
238                return Ok(None);
239            };
240            Ok(self.projector.project(&session))
241        }
242    }
243}
244
245/// Default projection using the application's configured `auth.session_key`.
246pub struct AuthSessionProjector {
247    auth_session_key: String,
248}
249
250impl AuthSessionProjector {
251    /// Create a projector from `AutumnConfig::auth.session_key`.
252    #[must_use]
253    pub fn new(auth_session_key: impl Into<String>) -> Self {
254        Self {
255            auth_session_key: auth_session_key.into(),
256        }
257    }
258}
259
260impl EdgeIdentityProjector for AuthSessionProjector {
261    fn project(&self, session: &HashMap<String, String>) -> Option<EdgeIdentity> {
262        session
263            .get(&self.auth_session_key)
264            .map(|id| EdgeIdentity::new(EdgeUserId::new(id), Vec::new()))
265    }
266}
267
268/// An [`EdgeKv`] backed by the application's own cache.
269///
270/// This is the adapter that makes an `#[edge(needs(kv))]` handler work at the
271/// origin: it projects the seven-method, type-erased [`Cache`] onto the
272/// one-method byte-oriented seam the edge lane can mediate.
273///
274/// # What the origin has to do to publish a value
275///
276/// Nothing edge-specific. Write bytes through the ordinary serde-aware cache
277/// path and an edge handler reading the same key sees them:
278///
279/// ```rust
280/// use std::sync::Arc;
281///
282/// use autumn_web::CacheEdgeKv;
283/// use autumn_web::cache::{Cache, MokaCache, insert_cached};
284/// use autumn_web::edge::EdgeKv;
285///
286/// let cache = MokaCache::new(128, None);
287/// insert_cached(&cache, "banner", b"Autumn is up".to_vec(), None);
288///
289/// let kv = CacheEdgeKv::new(Arc::new(cache) as Arc<dyn Cache>);
290/// assert_eq!(kv.get("banner"), Some(b"Autumn is up".to_vec()));
291/// assert_eq!(kv.get("nothing-here"), None);
292/// ```
293///
294/// `Vec<u8>` is the wire currency on purpose: it is the only shape that
295/// survives both an in-process backend (stored as-is) and a serializing one
296/// like Redis (JSON round-tripped), so a value published on one replica reads
297/// back identically on another. A key holding some *other* type is reported as
298/// a miss rather than an error — the seam has exactly one failure mode, and a
299/// handler already has to render something sensible for it.
300pub struct CacheEdgeKv(Arc<dyn Cache>);
301
302impl CacheEdgeKv {
303    /// Adapt a cache backend into the edge key/value seam.
304    ///
305    /// Pass the same backend the app serves from — typically the one given to
306    /// [`AppBuilder::with_cache_backend`](crate::app::AppBuilder::with_cache_backend)
307    /// — so the edge lane observes what the origin publishes.
308    #[must_use]
309    pub const fn new(cache: Arc<dyn Cache>) -> Self {
310        Self(cache)
311    }
312
313    /// The cache this adapter reads through.
314    #[must_use]
315    pub fn cache(&self) -> &Arc<dyn Cache> {
316        &self.0
317    }
318}
319
320impl fmt::Debug for CacheEdgeKv {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        // `dyn Cache` is not `Debug`, and a cache's contents are the last thing
323        // that belongs in a log line anyway.
324        f.write_str("CacheEdgeKv(..)")
325    }
326}
327
328impl EdgeKv for CacheEdgeKv {
329    fn get(&self, key: &str) -> Option<Vec<u8>> {
330        crate::cache::get_cached::<Vec<u8>>(self.0.as_ref(), key)
331    }
332}
333
334#[cfg(all(test, feature = "cache-moka"))]
335mod tests {
336    use super::*;
337    use crate::cache::{MokaCache, insert_cached};
338    use crate::session::{MemoryStore, SessionStore};
339
340    #[derive(Debug, thiserror::Error)]
341    #[error("provider failed")]
342    struct TestProviderError;
343
344    struct StaticProvider(Option<EdgeIdentity>);
345
346    impl EdgeIdentityProvider for StaticProvider {
347        type Error = TestProviderError;
348
349        fn resolve(
350            &self,
351            _request: &http::Request<axum::body::Body>,
352        ) -> impl Future<Output = Result<Option<EdgeIdentity>, Self::Error>> + Send {
353            std::future::ready(Ok(self.0.clone()))
354        }
355    }
356
357    #[tokio::test]
358    async fn identity_layer_resolves_before_handler_extraction() {
359        use tower::ServiceExt as _;
360
361        async fn handler(identity: EdgeIdentity) -> String {
362            identity.user_id().as_str().to_owned()
363        }
364
365        let router = axum::Router::new()
366            .route("/", axum::routing::get(handler))
367            .layer(EdgeIdentityLayer::new(StaticProvider(Some(
368                EdgeIdentity::new(EdgeUserId::new("alice"), Vec::new()),
369            ))));
370        let response = router
371            .oneshot(
372                http::Request::builder()
373                    .uri("/")
374                    .body(axum::body::Body::empty())
375                    .expect("request"),
376            )
377            .await
378            .expect("infallible");
379        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
380            .await
381            .expect("body");
382        assert_eq!(body, "alice");
383    }
384
385    #[tokio::test]
386    async fn session_identity_projects_only_normalized_claims() {
387        let store = MemoryStore::new();
388        let mut data = HashMap::new();
389        data.insert("account_id".into(), "user-7".into());
390        data.insert("backend_token".into(), "must-not-cross".into());
391        store.save("sid-secret", data).await.expect("memory save");
392        let keys = Arc::new(crate::security::config::ResolvedSigningKeys::new(
393            b"current-secret".to_vec(),
394            Vec::new(),
395        ));
396        let signature = keys.sign(b"sid-secret");
397        let provider = SessionIdentityProvider::new(
398            store,
399            AuthSessionProjector::new("account_id"),
400            "custom.sid",
401            Some(keys),
402        );
403        let request = http::Request::builder()
404            .header(
405                http::header::COOKIE,
406                format!("custom.sid=sid-secret.{signature}"),
407            )
408            .body(axum::body::Body::empty())
409            .expect("request");
410
411        let identity = provider
412            .resolve(&request)
413            .await
414            .expect("store available")
415            .expect("authenticated");
416        assert_eq!(identity.user_id().as_str(), "user-7");
417        let wire = serde_json::to_string(&identity).expect("identity serializes");
418        assert!(!wire.contains("sid-secret"));
419        assert!(!wire.contains("backend_token"));
420        assert!(!wire.contains("must-not-cross"));
421    }
422
423    #[tokio::test]
424    async fn invalid_signature_and_destroyed_session_are_misses() {
425        let store = MemoryStore::new();
426        let keys = Arc::new(crate::security::config::ResolvedSigningKeys::new(
427            b"secret".to_vec(),
428            Vec::new(),
429        ));
430        let provider = SessionIdentityProvider::new(
431            store,
432            AuthSessionProjector::new("user_id"),
433            "autumn.sid",
434            Some(keys),
435        );
436        let request = http::Request::builder()
437            .header(http::header::COOKIE, "autumn.sid=gone.invalid")
438            .body(axum::body::Body::empty())
439            .expect("request");
440        assert_eq!(
441            provider.resolve(&request).await.expect("store available"),
442            None
443        );
444    }
445
446    #[tokio::test]
447    async fn unsigned_session_matches_unsigned_session_layer_policy() {
448        let store = MemoryStore::new();
449        store
450            .save(
451                "plain-id",
452                HashMap::from([("user_id".into(), "alice".into())]),
453            )
454            .await
455            .expect("memory save");
456        let provider = SessionIdentityProvider::new(
457            store,
458            AuthSessionProjector::new("user_id"),
459            "autumn.sid",
460            None,
461        );
462        let request = http::Request::builder()
463            .header(http::header::COOKIE, "autumn.sid=plain-id")
464            .body(axum::body::Body::empty())
465            .expect("request");
466
467        assert_eq!(
468            provider
469                .resolve(&request)
470                .await
471                .expect("store available")
472                .expect("authenticated")
473                .user_id()
474                .as_str(),
475            "alice"
476        );
477    }
478
479    fn cache_with(key: &str, value: &[u8]) -> Arc<dyn Cache> {
480        let cache = MokaCache::new(16, None);
481        insert_cached(&cache, key, value.to_vec(), None);
482        Arc::new(cache)
483    }
484
485    #[test]
486    fn reads_bytes_written_through_the_ordinary_cache_path() {
487        let kv = CacheEdgeKv::new(cache_with("banner", b"hello"));
488        assert_eq!(kv.get("banner"), Some(b"hello".to_vec()));
489    }
490
491    #[test]
492    fn an_absent_key_is_a_miss() {
493        let kv = CacheEdgeKv::new(cache_with("banner", b"hello"));
494        assert_eq!(kv.get("absent"), None);
495    }
496
497    #[test]
498    fn a_value_of_another_type_is_a_miss_not_a_failure() {
499        let cache = MokaCache::new(16, None);
500        insert_cached(&cache, "count", 7_u64, None);
501        let kv = CacheEdgeKv::new(Arc::new(cache));
502
503        assert_eq!(kv.get("count"), None);
504    }
505
506    #[test]
507    fn a_serializing_backend_round_trips_through_the_raw_bytes_path() {
508        // What a cross-replica backend (Redis) stores: JSON bytes under
509        // `RawCacheBytes`, not the concrete `Vec<u8>`. The seam must read that
510        // shape too, or a value published on one replica would vanish at the
511        // edge of another.
512        let cache = MokaCache::new(16, None);
513        let json = serde_json::to_vec(&b"hello".to_vec()).expect("Vec<u8> serializes");
514        cache.insert_value("banner", Arc::new(crate::cache::RawCacheBytes(json)));
515        let kv = CacheEdgeKv::new(Arc::new(cache));
516
517        assert_eq!(kv.get("banner"), Some(b"hello".to_vec()));
518    }
519
520    #[test]
521    fn debug_names_the_adapter_without_leaking_contents() {
522        let kv = CacheEdgeKv::new(cache_with("secret", b"s3cr3t"));
523        let rendered = format!("{kv:?}");
524
525        assert!(rendered.contains("CacheEdgeKv"), "{rendered}");
526        assert!(!rendered.contains("s3cr3t"), "{rendered}");
527    }
528}