Skip to main content

axess_core/device/
lifecycle_resolver.rs

1//! [`LifecycleDeviceResolver`]: turn-key [`DeviceResolver`] that wires
2//! a [`DeviceFingerprintExtractor`] + [`DeviceLifecycleService`] into
3//! the per-request flow the
4//! [`SessionLayer`](crate::session::SessionLayer) expects.
5//!
6//! # Why this exists
7//!
8//! Without this, every consumer that wants device tracking has to
9//! re-implement the same six-line glue: pull tenant, pull client IP,
10//! call the extractor, ensure-or-create via the lifecycle, return the
11//! id. That's the "SessionLayer composition glue" gap.
12//! `LifecycleDeviceResolver` collapses it to a single
13//! [`SessionLayer::with_device_resolver`](crate::session::SessionLayer::with_device_resolver)
14//! call.
15//!
16//! # Customisation hooks
17//!
18//! Four small functions distinguish a deployment:
19//!
20//! | Hook | Default | When to override |
21//! |------|---------|------------------|
22//! | `tenant_fn` | `parts.extensions.get::<TenantId>()` | when tenant lives elsewhere (subdomain, JWT claim, …) |
23//! | `client_ip_fn` | always `None` | use ``parts.extensions.get::<axum::extract::ConnectInfo<SocketAddr>>().map(\|c\| c.0.ip())`` if your app calls `into_make_service_with_connect_info`, or read `X-Forwarded-For` if behind a trusted proxy |
24//! | `user_fn` | always `None` | when an upstream auth layer has already injected a `UserId` extension |
25//! | `new_id_fn` | `uuid::Uuid::new_v4().to_string()` | when you have a deterministic id scheme (e.g. DST tests with `MockRng`) |
26//!
27//! # Tenant-less requests
28//!
29//! If `tenant_fn` returns `None`, the resolver short-circuits to
30//! `Ok(None)`: there is no meaningful "device" without a tenant scope
31//! (see `docs/identity/device.md` §10 on cross-tenant correlation).
32//! Same for fingerprint computation: if the extractor returns `None`
33//! (e.g. missing `User-Agent`), we skip lifecycle entirely.
34
35use std::net::IpAddr;
36use std::sync::Arc;
37
38use axess_clock::Clock;
39use axum::http::request::Parts;
40
41use crate::authn::ids::{DeviceId, TenantId, UserId};
42use crate::device::fingerprint::DeviceFingerprintExtractor;
43use crate::device::lifecycle::DeviceLifecycleService;
44use crate::device::resolver::DeviceResolver;
45use crate::device::store::DeviceStore;
46
47/// Function shape: pull a [`TenantId`] from request [`Parts`]. Default
48/// reads `parts.extensions.get::<TenantId>()`.
49type TenantFn = Arc<dyn Fn(&Parts) -> Option<TenantId> + Send + Sync>;
50
51/// Function shape: pull the client IP from request [`Parts`]. Default
52/// returns `None`; see module docs for the recommended `ConnectInfo`
53/// / `X-Forwarded-For` overrides.
54type ClientIpFn = Arc<dyn Fn(&Parts) -> Option<IpAddr> + Send + Sync>;
55
56/// Function shape: pull a [`UserId`] from request [`Parts`]. Default
57/// returns `None` (the resolver runs *before* authn, so no user yet).
58type UserFn = Arc<dyn Fn(&Parts) -> Option<UserId> + Send + Sync>;
59
60/// Function shape: mint a fresh [`DeviceId`]. Default uses
61/// `uuid::Uuid::new_v4()`.
62type NewIdFn = Arc<dyn Fn() -> DeviceId + Send + Sync>;
63
64/// Built-in [`DeviceResolver`] composing
65/// [`DeviceFingerprintExtractor`] + [`DeviceLifecycleService`] with
66/// the four pluggable hooks documented in the module-level docs.
67///
68/// Cheap to clone (every field is `Arc` or `Clone`); construct once at
69/// startup, hand to
70/// [`SessionLayer::with_device_resolver`](crate::session::SessionLayer::with_device_resolver).
71pub struct LifecycleDeviceResolver<E, S, C>
72where
73    E: DeviceFingerprintExtractor,
74    S: DeviceStore,
75    C: Clock,
76{
77    extractor: Arc<E>,
78    lifecycle: DeviceLifecycleService<S>,
79    clock: Arc<C>,
80    tenant_fn: TenantFn,
81    client_ip_fn: ClientIpFn,
82    user_fn: UserFn,
83    new_id_fn: NewIdFn,
84}
85
86impl<E, S, C> Clone for LifecycleDeviceResolver<E, S, C>
87where
88    E: DeviceFingerprintExtractor,
89    S: DeviceStore,
90    C: Clock,
91{
92    fn clone(&self) -> Self {
93        Self {
94            extractor: self.extractor.clone(),
95            lifecycle: self.lifecycle.clone(),
96            clock: self.clock.clone(),
97            tenant_fn: self.tenant_fn.clone(),
98            client_ip_fn: self.client_ip_fn.clone(),
99            user_fn: self.user_fn.clone(),
100            new_id_fn: self.new_id_fn.clone(),
101        }
102    }
103}
104
105impl<E, S, C> LifecycleDeviceResolver<E, S, C>
106where
107    E: DeviceFingerprintExtractor,
108    S: DeviceStore,
109    C: Clock,
110{
111    /// Construct with the three required collaborators and the
112    /// documented defaults for the four pluggable hooks. Override any
113    /// of them with `with_tenant_fn` / `with_client_ip_fn` /
114    /// `with_user_fn` / `with_new_id_fn` before passing the resolver
115    /// to the session layer.
116    pub fn new(extractor: E, lifecycle: DeviceLifecycleService<S>, clock: C) -> Self {
117        Self {
118            extractor: Arc::new(extractor),
119            lifecycle,
120            clock: Arc::new(clock),
121            tenant_fn: default_tenant_fn(),
122            client_ip_fn: default_client_ip_fn(),
123            user_fn: default_user_fn(),
124            new_id_fn: default_new_id_fn(),
125        }
126    }
127
128    /// Override the tenant-extraction strategy.
129    pub fn with_tenant_fn<F>(mut self, f: F) -> Self
130    where
131        F: Fn(&Parts) -> Option<TenantId> + Send + Sync + 'static,
132    {
133        self.tenant_fn = Arc::new(f);
134        self
135    }
136
137    /// Override the client-IP extraction strategy.
138    pub fn with_client_ip_fn<F>(mut self, f: F) -> Self
139    where
140        F: Fn(&Parts) -> Option<IpAddr> + Send + Sync + 'static,
141    {
142        self.client_ip_fn = Arc::new(f);
143        self
144    }
145
146    /// Override the user-extraction strategy. Useful when an upstream
147    /// auth layer has already injected a [`UserId`] into the request
148    /// extensions and you want the device's `user_id` field populated
149    /// at creation time. (Without this, devices are created at
150    /// `user_id = None` and become "owned" only when the application
151    /// updates them post-authn.)
152    pub fn with_user_fn<F>(mut self, f: F) -> Self
153    where
154        F: Fn(&Parts) -> Option<UserId> + Send + Sync + 'static,
155    {
156        self.user_fn = Arc::new(f);
157        self
158    }
159
160    /// Override the new-id minting strategy. Default uses
161    /// `uuid::Uuid::new_v4()`. Override for DST tests to inject a
162    /// deterministic generator.
163    pub fn with_new_id_fn<F>(mut self, f: F) -> Self
164    where
165        F: Fn() -> DeviceId + Send + Sync + 'static,
166    {
167        self.new_id_fn = Arc::new(f);
168        self
169    }
170}
171
172impl<E, S, C> DeviceResolver for LifecycleDeviceResolver<E, S, C>
173where
174    E: DeviceFingerprintExtractor,
175    S: DeviceStore,
176    C: Clock,
177{
178    type Error = S::Error;
179
180    async fn resolve(&self, parts: &Parts) -> Result<Option<DeviceId>, Self::Error> {
181        // No tenant → no scoped device. Silent no-op.
182        let Some(tenant) = (self.tenant_fn)(parts) else {
183            return Ok(None);
184        };
185        let client_ip = (self.client_ip_fn)(parts);
186        // Extractor returned None → request too thin to fingerprint
187        // (e.g. no User-Agent). Silent no-op per the trait contract.
188        let Some(fp) = self.extractor.extract(&tenant, parts, client_ip) else {
189            return Ok(None);
190        };
191        let user = (self.user_fn)(parts);
192        let now = self.clock.now();
193        let new_id_fn = self.new_id_fn.clone();
194        let id = self
195            .lifecycle
196            .ensure_device(&tenant, user.as_ref(), fp, now, move || (new_id_fn)())
197            .await?;
198        Ok(Some(id))
199    }
200}
201
202// ── Defaults ─────────────────────────────────────────────────────────
203
204fn default_tenant_fn() -> TenantFn {
205    Arc::new(|parts: &Parts| parts.extensions.get::<TenantId>().cloned())
206}
207
208fn default_client_ip_fn() -> ClientIpFn {
209    // Default ignores request shape; observe `parts` via a cheap no-op
210    // method so the closure param isn't underscore-prefixed.
211    Arc::new(|parts: &Parts| {
212        parts.uri.host();
213        None
214    })
215}
216
217fn default_user_fn() -> UserFn {
218    Arc::new(|parts: &Parts| {
219        parts.uri.host();
220        None
221    })
222}
223
224fn default_new_id_fn() -> NewIdFn {
225    Arc::new(|| {
226        // `try_new` validates against control chars / oversize; a UUID
227        // string never trips either, so the unwrap is total.
228        DeviceId::try_new(uuid::Uuid::new_v4().to_string())
229            .expect("uuid::Uuid::new_v4() always produces a DeviceId-valid string")
230    })
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::device::fingerprint::DefaultFingerprintExtractor;
237    use crate::device::store::MemoryDeviceStore;
238    use crate::device::types::DeviceTrustLevel;
239    use axess_clock::testing::MockClock;
240    use axum::body::Body;
241    use axum::http::Request;
242    use chrono::{TimeZone, Utc};
243    use std::net::{Ipv4Addr, SocketAddr};
244
245    fn fixed_pepper() -> super::super::fingerprint::TenantPepperResolver {
246        Arc::new(|t: &TenantId| {
247            t.as_uuid();
248            [42u8; 32]
249        })
250    }
251
252    fn make_resolver()
253    -> LifecycleDeviceResolver<DefaultFingerprintExtractor, MemoryDeviceStore, MockClock> {
254        let store = MemoryDeviceStore::new();
255        let lifecycle = DeviceLifecycleService::new(store);
256        let clock = MockClock::at(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
257        let extractor = DefaultFingerprintExtractor::new(fixed_pepper());
258        LifecycleDeviceResolver::new(extractor, lifecycle, clock)
259    }
260
261    /// Stash the client IP under a private wrapper type in extensions
262    /// so a per-test `client_ip_fn` can pull it back out. Mirrors what
263    /// real apps do with `ConnectInfo<SocketAddr>` (which axess-core
264    /// can't depend on directly; see module docs).
265    #[derive(Clone)]
266    struct TestClientIp(SocketAddr);
267
268    fn req_with(ua: Option<&str>, tenant: Option<TenantId>, ip: Option<IpAddr>) -> Parts {
269        let mut req: Request<Body> = Request::new(Body::empty());
270        if let Some(v) = ua {
271            req.headers_mut().insert(
272                axum::http::header::USER_AGENT,
273                axum::http::HeaderValue::from_str(v).unwrap(),
274            );
275        }
276        if let Some(t) = tenant {
277            req.extensions_mut().insert(t);
278        }
279        if let Some(ip) = ip {
280            req.extensions_mut()
281                .insert(TestClientIp(SocketAddr::new(ip, 8080)));
282        }
283        req.into_parts().0
284    }
285
286    fn ip_from_test_extension(parts: &Parts) -> Option<IpAddr> {
287        parts.extensions.get::<TestClientIp>().map(|c| c.0.ip())
288    }
289
290    /// Pin: no tenant in request → resolver returns None (no device
291    /// created). Devices are tenant-scoped by design.
292    #[tokio::test]
293    async fn missing_tenant_yields_none() {
294        let resolver = make_resolver();
295        let parts = req_with(Some("Mozilla/5.0"), None, None);
296        let resolved = resolver.resolve(&parts).await.unwrap();
297        assert_eq!(resolved, None);
298    }
299
300    /// Pin: missing User-Agent (extractor returns None) → resolver
301    /// returns None without touching the store.
302    #[tokio::test]
303    async fn missing_user_agent_yields_none() {
304        let resolver = make_resolver();
305        let tenant = crate::authn::ids::testing::tenant("t1");
306        let parts = req_with(None, Some(tenant), None);
307        let resolved = resolver.resolve(&parts).await.unwrap();
308        assert_eq!(resolved, None);
309    }
310
311    /// Pin: full happy path. UA + tenant + IP → resolver creates a
312    /// device at Unknown and returns its id. Re-running the same
313    /// request returns the same id (find-by-fingerprint hits).
314    #[tokio::test]
315    async fn happy_path_creates_then_finds() {
316        let resolver = make_resolver();
317        let tenant = crate::authn::ids::testing::tenant("t1");
318        let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)));
319
320        let parts1 = req_with(Some("Mozilla/5.0"), Some(tenant), ip);
321        let id1 = resolver.resolve(&parts1).await.unwrap().expect("created");
322
323        // Same request again; must find the existing device, not create a new one.
324        let parts2 = req_with(Some("Mozilla/5.0"), Some(tenant), ip);
325        let id2 = resolver.resolve(&parts2).await.unwrap().expect("found");
326        assert_eq!(id1, id2, "second resolve must return the same DeviceId");
327
328        // Confirm trust level is Unknown.
329        let device = resolver
330            .lifecycle
331            .store()
332            .load(&tenant, &id1)
333            .await
334            .unwrap()
335            .unwrap();
336        assert_eq!(device.trust_level, DeviceTrustLevel::Unknown);
337    }
338
339    /// Pin: `new_id_fn` override. Swap in a deterministic id generator
340    /// (the DST use case) and confirm the resolver uses it on the
341    /// create path.
342    #[tokio::test]
343    async fn new_id_fn_override_is_honoured() {
344        let resolver =
345            make_resolver().with_new_id_fn(|| crate::authn::ids::testing::device("dev-fixed"));
346        let tenant = crate::authn::ids::testing::tenant("t1");
347        let parts = req_with(Some("Mozilla/5.0"), Some(tenant), None);
348        let id = resolver.resolve(&parts).await.unwrap().expect("created");
349        assert_eq!(id, crate::authn::ids::testing::device("dev-fixed"));
350    }
351
352    /// Pin: `tenant_fn` override. Read tenant from a custom location
353    /// (here: a header) instead of extensions. Confirms the hook
354    /// actually changes the lookup path.
355    #[tokio::test]
356    async fn tenant_fn_override_is_honoured() {
357        let resolver = make_resolver().with_tenant_fn(|parts: &Parts| {
358            parts
359                .headers
360                .get("x-tenant")
361                .and_then(|v| v.to_str().ok())
362                .map(crate::authn::ids::testing::tenant)
363        });
364        let mut req: Request<Body> = Request::new(Body::empty());
365        req.headers_mut().insert(
366            axum::http::header::USER_AGENT,
367            axum::http::HeaderValue::from_static("UA"),
368        );
369        req.headers_mut().insert(
370            "x-tenant",
371            axum::http::HeaderValue::from_static("from-header"),
372        );
373        let parts = req.into_parts().0;
374        let id = resolver
375            .resolve(&parts)
376            .await
377            .unwrap()
378            .expect("created from header tenant");
379        let device = resolver
380            .lifecycle
381            .store()
382            .load(&crate::authn::ids::testing::tenant("from-header"), &id)
383            .await
384            .unwrap()
385            .expect("device persisted under header-derived tenant");
386        assert_eq!(
387            device.tenant_id,
388            crate::authn::ids::testing::tenant("from-header"),
389            "override must drive which tenant scope receives the device"
390        );
391    }
392
393    /// Pin: `user_fn` override. When set, the created Device row
394    /// carries the resolved UserId instead of None.
395    #[tokio::test]
396    async fn user_fn_override_populates_device_user_id() {
397        let resolver = make_resolver().with_user_fn(|parts: &Parts| {
398            parts.uri.host();
399            Some(crate::authn::ids::testing::user("u-from-extension"))
400        });
401        let tenant = crate::authn::ids::testing::tenant("t1");
402        let parts = req_with(Some("Mozilla/5.0"), Some(tenant), None);
403        let id = resolver.resolve(&parts).await.unwrap().expect("created");
404        let device = resolver
405            .lifecycle
406            .store()
407            .load(&tenant, &id)
408            .await
409            .unwrap()
410            .unwrap();
411        assert_eq!(
412            device.user_id,
413            Some(crate::authn::ids::testing::user("u-from-extension")),
414            "user_fn must populate device.user_id at creation time"
415        );
416    }
417
418    /// Pin: a custom `client_ip_fn` (the recommended pattern when not
419    /// using `ConnectInfo`) feeds the fingerprint. Two requests with
420    /// the same UA but different /24s produce different devices.
421    #[tokio::test]
422    async fn custom_client_ip_fn_distinguishes_subnets() {
423        let resolver = make_resolver().with_client_ip_fn(ip_from_test_extension);
424        let tenant = crate::authn::ids::testing::tenant("t1");
425        let p1 = req_with(
426            Some("Mozilla/5.0"),
427            Some(tenant),
428            Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
429        );
430        let p2 = req_with(
431            Some("Mozilla/5.0"),
432            Some(tenant),
433            Some(IpAddr::V4(Ipv4Addr::new(10, 0, 99, 1))),
434        );
435        let id_a = resolver.resolve(&p1).await.unwrap().expect("a");
436        let id_b = resolver.resolve(&p2).await.unwrap().expect("b");
437        assert_ne!(
438            id_a, id_b,
439            "different /24s on the same UA must yield different DeviceIds"
440        );
441    }
442
443    /// Pin: default `client_ip_fn` returns None: IP doesn't influence
444    /// fingerprint without an explicit override. Two requests on the
445    /// same UA must collide regardless of (uninspected) IP.
446    #[tokio::test]
447    async fn default_client_ip_fn_returns_none_so_ip_does_not_change_device() {
448        let resolver = make_resolver();
449        let tenant = crate::authn::ids::testing::tenant("t1");
450        let p1 = req_with(
451            Some("Mozilla/5.0"),
452            Some(tenant),
453            Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
454        );
455        let p2 = req_with(
456            Some("Mozilla/5.0"),
457            Some(tenant),
458            Some(IpAddr::V4(Ipv4Addr::new(10, 0, 99, 1))),
459        );
460        let id_a = resolver.resolve(&p1).await.unwrap().expect("a");
461        let id_b = resolver.resolve(&p2).await.unwrap().expect("b");
462        assert_eq!(
463            id_a, id_b,
464            "default extractor ignores IP unless client_ip_fn is overridden"
465        );
466    }
467
468    /// Pin: clock injection. The create path uses `clock.now()` for
469    /// `first_seen_at` / `last_seen_at`. Confirms DST drives time.
470    #[tokio::test]
471    async fn create_uses_injected_clock_for_timestamps() {
472        let store = MemoryDeviceStore::new();
473        let lifecycle = DeviceLifecycleService::new(store);
474        let frozen = Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap();
475        let clock = MockClock::at(frozen);
476        let extractor = DefaultFingerprintExtractor::new(fixed_pepper());
477        let resolver = LifecycleDeviceResolver::new(extractor, lifecycle, clock);
478
479        let tenant = crate::authn::ids::testing::tenant("t1");
480        let parts = req_with(Some("Mozilla/5.0"), Some(tenant), None);
481        let id = resolver.resolve(&parts).await.unwrap().expect("created");
482        let device = resolver
483            .lifecycle
484            .store()
485            .load(&tenant, &id)
486            .await
487            .unwrap()
488            .unwrap();
489        assert_eq!(device.first_seen_at, frozen);
490        assert_eq!(device.last_seen_at, frozen);
491    }
492}