1use 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
47type TenantFn = Arc<dyn Fn(&Parts) -> Option<TenantId> + Send + Sync>;
50
51type ClientIpFn = Arc<dyn Fn(&Parts) -> Option<IpAddr> + Send + Sync>;
55
56type UserFn = Arc<dyn Fn(&Parts) -> Option<UserId> + Send + Sync>;
59
60type NewIdFn = Arc<dyn Fn() -> DeviceId + Send + Sync>;
63
64pub 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 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 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 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 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 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 let Some(tenant) = (self.tenant_fn)(parts) else {
183 return Ok(None);
184 };
185 let client_ip = (self.client_ip_fn)(parts);
186 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
202fn default_tenant_fn() -> TenantFn {
205 Arc::new(|parts: &Parts| parts.extensions.get::<TenantId>().cloned())
206}
207
208fn default_client_ip_fn() -> ClientIpFn {
209 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}