axess_core/device/store.rs
1//! [`DeviceStore`] trait + [`MemoryDeviceStore`] in-memory backend.
2//!
3//! Mirrors the shape of [`SessionStore`](crate::session::SessionStore): an
4//! associated `Error` type, async methods using return-position `impl Future`,
5//! and an in-memory implementation suitable for tests and single-node examples.
6//!
7//! The retention sweep ([`DeviceStore::sweep`]) implements the full
8//! three-stage demotion ladder.
9
10use crate::authn::ids::{DeviceId, TenantId, UserId};
11use crate::device::types::{Device, DeviceTrustLevel, FingerprintHash};
12use crate::health::{HealthCheck, HealthStatus};
13use chrono::{DateTime, Utc};
14use dashmap::DashMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19
20// ── DeviceStore trait ─────────────────────────────────────────────────────────
21
22/// Typed, async device-identity backend.
23///
24/// Implementors: [`MemoryDeviceStore`], `SqliteDeviceStore`,
25/// `PostgresDeviceStore`, `ValkeyDeviceStore`.
26///
27/// All methods accept `&self`; implementations use interior mutability
28/// (`Arc<DashMap<…>>` for memory, connection pool for SQL/Valkey).
29pub trait DeviceStore: Send + Sync + Clone + 'static {
30 /// The error type returned by storage operations.
31 type Error: std::error::Error + Send + Sync + 'static;
32
33 /// Load the device by id. Returns `None` if absent.
34 fn load(
35 &self,
36 tenant_id: &TenantId,
37 id: &DeviceId,
38 ) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send;
39
40 /// Look up a device by its keyed fingerprint within a tenant. Used as
41 /// the fast-path during request handling when no `device_id` cookie is
42 /// present yet. Collisions must be vanishingly rare given a
43 /// per-tenant HMAC key, but implementations MUST scope the query by
44 /// `tenant_id` to prevent cross-tenant correlation.
45 fn find_by_fingerprint(
46 &self,
47 tenant_id: &TenantId,
48 hash: &FingerprintHash,
49 ) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send;
50
51 /// List active devices for a user, newest-sighted first. `limit` caps
52 /// the result so a high-cardinality user doesn't blow up
53 /// device-management UIs.
54 ///
55 /// Returns every device regardless of trust level. Callers driving a
56 /// device-management UI should filter to `trust_level != Revoked`, or
57 /// use [`find_active_for_user`](Self::find_active_for_user) which
58 /// applies that filter at the store layer.
59 fn find_for_user(
60 &self,
61 tenant_id: &TenantId,
62 user_id: &UserId,
63 limit: usize,
64 ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send;
65
66 /// Same as [`find_for_user`](Self::find_for_user) but excludes
67 /// `Revoked` rows. Default implementation filters in-memory after a
68 /// full `find_for_user` call; SQL backends SHOULD override with a
69 /// `WHERE trust_level != 'Revoked'` clause that the optimiser can
70 /// use against the trust-level index.
71 fn find_active_for_user(
72 &self,
73 tenant_id: &TenantId,
74 user_id: &UserId,
75 limit: usize,
76 ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
77 async move {
78 let mut all = self.find_for_user(tenant_id, user_id, limit).await?;
79 all.retain(|d| d.trust_level != DeviceTrustLevel::Revoked);
80 Ok(all)
81 }
82 }
83
84 /// Find every device in `tenant` carrying a
85 /// [`DeviceBinding::Refresh { family_id }`](crate::device::types::DeviceBinding::Refresh)
86 /// matching the supplied `family_id`. Used by the refresh-cascade
87 /// path to convert "this refresh-token family was compromised"
88 /// into the list of [`Device`]s to revoke.
89 ///
90 /// Implementations MUST scope by `tenant_id` to prevent cross-
91 /// tenant leakage and SHOULD scan or index on the `Refresh`
92 /// binding variant only.
93 fn find_by_refresh_family(
94 &self,
95 tenant_id: &TenantId,
96 family_id: &str,
97 ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send;
98
99 /// Persist a new device row, or overwrite an existing one. Idempotent.
100 fn save(&self, device: &Device) -> impl Future<Output = Result<(), Self::Error>> + Send;
101
102 /// Touch the `last_seen_at` timestamp for the given device.
103 /// Implementations SHOULD perform this as a single UPDATE rather than
104 /// a load-modify-save round trip.
105 fn record_sighting(
106 &self,
107 tenant_id: &TenantId,
108 id: &DeviceId,
109 now: DateTime<Utc>,
110 ) -> impl Future<Output = Result<(), Self::Error>> + Send;
111
112 /// Set the trust level. Drives transitions across the
113 /// [`DeviceTrustLevel`] ladder. Setting to
114 /// [`DeviceTrustLevel::Revoked`] also stamps `revoked_at = now`.
115 fn set_trust_level(
116 &self,
117 tenant_id: &TenantId,
118 id: &DeviceId,
119 level: DeviceTrustLevel,
120 now: DateTime<Utc>,
121 ) -> impl Future<Output = Result<(), Self::Error>> + Send;
122
123 /// Hard-delete the row. Idempotent. Used by the retention sweep once a
124 /// `Revoked` device has aged out of the configured grace window, and
125 /// by Art 17 erasure cascades.
126 fn delete(
127 &self,
128 tenant_id: &TenantId,
129 id: &DeviceId,
130 ) -> impl Future<Output = Result<(), Self::Error>> + Send;
131
132 /// Drive the three-stage retention ladder: Trusted → Seen (after the
133 /// configured Trusted-idle window), Seen → Revoked (after the
134 /// configured Seen-idle window), Revoked → purged (after the
135 /// configured grace window). Returns the count of rows changed at
136 /// each stage.
137 ///
138 /// Required, not defaulted: a default of `Ok(SweepCounts::default())`
139 /// would be a silent no-op: a backend that forgot to override would
140 /// fail to demote anything, and the caller would have no way to tell
141 /// "no rows aged out this tick" from "sweep is unimplemented." Every
142 /// backend must answer the question, even if the answer is
143 /// `Err(_)` with a "not yet implemented" sentinel.
144 fn sweep(
145 &self,
146 tenant_id: &TenantId,
147 now: DateTime<Utc>,
148 ) -> impl Future<Output = Result<SweepCounts, Self::Error>> + Send;
149}
150
151/// Per-stage counts returned by [`DeviceStore::sweep`].
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
153pub struct SweepCounts {
154 /// `Trusted` rows demoted to `Seen` because last-seen exceeded the
155 /// trusted-idle window.
156 pub trusted_to_seen: u64,
157 /// `Seen` rows demoted to `Revoked` because last-seen exceeded the
158 /// seen-idle window.
159 pub seen_to_revoked: u64,
160 /// `Revoked` rows hard-deleted because they aged past the grace window.
161 pub revoked_purged: u64,
162}
163
164// ── SweepConfig ───────────────────────────────────────────────────────────────
165
166/// Retention thresholds driving [`DeviceStore::sweep`].
167///
168/// The sweep cascades demotions: a device whose last-seen is older
169/// than `trusted_idle + seen_idle` will pass through both demotion
170/// stages in a single sweep call (Trusted → Seen → Revoked); a
171/// further sweep after `revoked_grace` has elapsed since the
172/// just-stamped `revoked_at` finalises the row purge. `Unknown`
173/// devices are NOT demoted by sweep; they can only graduate via an
174/// explicit promotion step; retention of stale `Unknown` rows is the
175/// application's call.
176///
177/// Defaults reflect a fintech-style retention posture suitable for
178/// PSD2 / DORA / MiFID II contexts: 90-day trusted idle, 30-day
179/// seen idle, 7-day revoked grace. Tighten or loosen via
180/// [`SweepConfig::builder`].
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct SweepConfig {
183 /// How long a `Trusted` device may go un-sighted before sweep
184 /// demotes it to `Seen`. NIST SP 800-63B-4 §5.2.6 informs the
185 /// 90-day default: a device that hasn't authenticated for a
186 /// quarter shouldn't keep its possession-factor status without a
187 /// fresh ceremony.
188 pub trusted_idle: chrono::Duration,
189 /// How long a `Seen` device may go un-sighted before sweep
190 /// demotes it to `Revoked`.
191 pub seen_idle: chrono::Duration,
192 /// How long a `Revoked` device row sticks around before hard
193 /// deletion. Provides an audit-trail window for forensics on
194 /// recently-compromised devices.
195 pub revoked_grace: chrono::Duration,
196}
197
198impl Default for SweepConfig {
199 fn default() -> Self {
200 Self {
201 trusted_idle: chrono::Duration::days(90),
202 seen_idle: chrono::Duration::days(30),
203 revoked_grace: chrono::Duration::days(7),
204 }
205 }
206}
207
208impl SweepConfig {
209 /// Construct via the builder.
210 pub fn builder() -> SweepConfigBuilder {
211 SweepConfigBuilder::default()
212 }
213}
214
215/// Builder for [`SweepConfig`]. Each setter overrides the default
216/// for one window; unset fields keep their default value.
217#[derive(Debug, Default, Clone, Copy)]
218pub struct SweepConfigBuilder {
219 trusted_idle: Option<chrono::Duration>,
220 seen_idle: Option<chrono::Duration>,
221 revoked_grace: Option<chrono::Duration>,
222}
223
224impl SweepConfigBuilder {
225 /// Set the Trusted-idle window.
226 pub fn trusted_idle(mut self, d: chrono::Duration) -> Self {
227 self.trusted_idle = Some(d);
228 self
229 }
230 /// Set the Seen-idle window.
231 pub fn seen_idle(mut self, d: chrono::Duration) -> Self {
232 self.seen_idle = Some(d);
233 self
234 }
235 /// Set the Revoked-grace window.
236 pub fn revoked_grace(mut self, d: chrono::Duration) -> Self {
237 self.revoked_grace = Some(d);
238 self
239 }
240 /// Finalise the [`SweepConfig`].
241 pub fn build(self) -> SweepConfig {
242 let d = SweepConfig::default();
243 SweepConfig {
244 trusted_idle: self.trusted_idle.unwrap_or(d.trusted_idle),
245 seen_idle: self.seen_idle.unwrap_or(d.seen_idle),
246 revoked_grace: self.revoked_grace.unwrap_or(d.revoked_grace),
247 }
248 }
249}
250
251// ── MemoryDeviceStore ─────────────────────────────────────────────────────────
252
253/// Composite primary key for the in-memory devices table: `(tenant, device)`.
254///
255/// `TenantId` and `DeviceId` are 16-byte `Copy` Uuid newtypes, so the key
256/// is 32 bytes of stack data; no heap allocation per lookup.
257type DeviceKey = (TenantId, DeviceId);
258
259/// Composite key for the in-memory fingerprint index: `(tenant, hash)`.
260type FingerprintKey = (TenantId, FingerprintHash);
261
262/// In-memory device store backed by [`DashMap`].
263///
264/// **For testing and single-node development only.** Not suitable for
265/// production: data is lost on restart, no encryption at rest, no
266/// cross-node visibility.
267///
268/// # Time injection
269///
270/// `record_sighting` and `set_trust_level` accept `now` as a parameter so
271/// the store itself does not own a [`Clock`](axess_clock::Clock);
272/// callers thread the injected clock from the surrounding service. This
273/// mirrors how `SessionStore::cycle` accepts caller-supplied state for
274/// DST testability.
275#[derive(Clone, Default)]
276pub struct MemoryDeviceStore {
277 /// Primary table: `(tenant_id, device_id) -> Device`.
278 devices: Arc<DashMap<DeviceKey, Device>>,
279 /// Secondary index: `(tenant_id, fingerprint_hash) -> device_id`. Kept
280 /// in sync by `save` / `delete`; serves the `find_by_fingerprint` fast
281 /// path without scanning the primary table.
282 fingerprint_index: Arc<DashMap<FingerprintKey, DeviceId>>,
283 /// Write counter: reserved for future auto-purge scheduling, mirrors
284 /// the `MemorySessionStore` pattern even though no auto-purge runs in
285 /// this sketch.
286 write_count: Arc<AtomicU64>,
287 /// Retention thresholds driving [`Self::sweep`]. Defaults to
288 /// [`SweepConfig::default()`] (fintech-style 90/30/7 day windows).
289 sweep_config: SweepConfig,
290}
291
292impl MemoryDeviceStore {
293 /// Create an empty in-memory device store.
294 pub fn new() -> Self {
295 Self::default()
296 }
297
298 /// Override the [`SweepConfig`] driving the retention ladder.
299 pub fn with_sweep_config(mut self, config: SweepConfig) -> Self {
300 self.sweep_config = config;
301 self
302 }
303
304 /// Number of devices currently held. Useful in tests; not part of the
305 /// trait surface.
306 pub fn len(&self) -> usize {
307 self.devices.len()
308 }
309
310 /// `true` when no devices are held.
311 pub fn is_empty(&self) -> bool {
312 self.devices.is_empty()
313 }
314
315 fn key(tenant_id: &TenantId, id: &DeviceId) -> DeviceKey {
316 (*tenant_id, *id)
317 }
318}
319
320/// Errors from [`MemoryDeviceStore`].
321#[derive(Debug, thiserror::Error)]
322pub enum MemoryDeviceStoreError {
323 /// The targeted device id was not present in the store.
324 #[error("device not found: tenant={tenant_id} id={device_id}")]
325 NotFound {
326 /// Tenant scope of the missed lookup.
327 tenant_id: String,
328 /// Opaque device id whose lookup missed.
329 device_id: String,
330 },
331}
332
333impl DeviceStore for MemoryDeviceStore {
334 type Error = MemoryDeviceStoreError;
335
336 async fn load(
337 &self,
338 tenant_id: &TenantId,
339 id: &DeviceId,
340 ) -> Result<Option<Device>, Self::Error> {
341 Ok(self
342 .devices
343 .get(&Self::key(tenant_id, id))
344 .map(|d| d.clone()))
345 }
346
347 async fn find_by_fingerprint(
348 &self,
349 tenant_id: &TenantId,
350 hash: &FingerprintHash,
351 ) -> Result<Option<Device>, Self::Error> {
352 let key = (*tenant_id, *hash);
353 let Some(device_id) = self.fingerprint_index.get(&key).map(|v| *v) else {
354 return Ok(None);
355 };
356 let pk = (*tenant_id, device_id);
357 Ok(self.devices.get(&pk).map(|d| d.clone()))
358 }
359
360 async fn find_for_user(
361 &self,
362 tenant_id: &TenantId,
363 user_id: &UserId,
364 limit: usize,
365 ) -> Result<Vec<Device>, Self::Error> {
366 let mut hits: Vec<Device> = self
367 .devices
368 .iter()
369 .filter_map(|entry| {
370 let device = entry.value();
371 let same_tenant = device.tenant_id == *tenant_id;
372 let owned_by_user = device.user_id.as_ref().is_some_and(|u| u == user_id);
373 (same_tenant && owned_by_user).then(|| device.clone())
374 })
375 .collect();
376 hits.sort_by_key(|d| std::cmp::Reverse(d.last_seen_at));
377 hits.truncate(limit);
378 Ok(hits)
379 }
380
381 async fn find_by_refresh_family(
382 &self,
383 tenant_id: &TenantId,
384 family_id: &str,
385 ) -> Result<Vec<Device>, Self::Error> {
386 let mut hits: Vec<Device> = self
387 .devices
388 .iter()
389 .filter_map(|entry| {
390 let device = entry.value();
391 if device.tenant_id != *tenant_id {
392 return None;
393 }
394 let matched = device.bindings.iter().any(|b| {
395 matches!(b, crate::device::types::DeviceBinding::Refresh { family_id: fid, .. } if fid == family_id)
396 });
397 matched.then(|| device.clone())
398 })
399 .collect();
400 // Newest-sighted first to match `find_for_user` ordering.
401 hits.sort_by_key(|d| std::cmp::Reverse(d.last_seen_at));
402 Ok(hits)
403 }
404
405 async fn save(&self, device: &Device) -> Result<(), Self::Error> {
406 let key = Self::key(&device.tenant_id, &device.id);
407 let fp_key = (device.tenant_id, device.fingerprint_hash);
408 self.devices.insert(key, device.clone());
409 self.fingerprint_index.insert(fp_key, device.id);
410 self.write_count.fetch_add(1, Ordering::Relaxed);
411 Ok(())
412 }
413
414 async fn record_sighting(
415 &self,
416 tenant_id: &TenantId,
417 id: &DeviceId,
418 now: DateTime<Utc>,
419 ) -> Result<(), Self::Error> {
420 let key = Self::key(tenant_id, id);
421 if let Some(mut entry) = self.devices.get_mut(&key) {
422 entry.last_seen_at = now;
423 return Ok(());
424 }
425 Err(MemoryDeviceStoreError::NotFound {
426 tenant_id: tenant_id.to_string(),
427 device_id: id.to_string(),
428 })
429 }
430
431 async fn set_trust_level(
432 &self,
433 tenant_id: &TenantId,
434 id: &DeviceId,
435 level: DeviceTrustLevel,
436 now: DateTime<Utc>,
437 ) -> Result<(), Self::Error> {
438 let key = Self::key(tenant_id, id);
439 if let Some(mut entry) = self.devices.get_mut(&key) {
440 entry.trust_level = level;
441 entry.revoked_at = matches!(level, DeviceTrustLevel::Revoked).then_some(now);
442 return Ok(());
443 }
444 Err(MemoryDeviceStoreError::NotFound {
445 tenant_id: tenant_id.to_string(),
446 device_id: id.to_string(),
447 })
448 }
449
450 async fn delete(&self, tenant_id: &TenantId, id: &DeviceId) -> Result<(), Self::Error> {
451 let key = Self::key(tenant_id, id);
452 if let Some((_, device)) = self.devices.remove(&key) {
453 let fp_key = (device.tenant_id, device.fingerprint_hash);
454 self.fingerprint_index.remove(&fp_key);
455 }
456 Ok(())
457 }
458
459 async fn sweep(
460 &self,
461 tenant_id: &TenantId,
462 now: DateTime<Utc>,
463 ) -> Result<SweepCounts, Self::Error> {
464 let cfg = self.sweep_config;
465 let mut counts = SweepCounts::default();
466
467 // Two passes: (1) collect transitions while only holding read
468 // refs; (2) apply mutations. Avoids re-entering `DashMap` while
469 // a `get_mut` is held, which deadlocks on contended shards.
470 enum Action {
471 DemoteToSeen,
472 DemoteToRevoked,
473 Purge,
474 }
475 let mut actions: Vec<(DeviceKey, Action)> = Vec::new();
476
477 for entry in self.devices.iter() {
478 let device = entry.value();
479 if device.tenant_id != *tenant_id {
480 continue;
481 }
482 // Trusted → Seen. last_seen_at is NOT bumped; demotion
483 // does not constitute a sighting.
484 let mut current_level = device.trust_level;
485 if current_level == DeviceTrustLevel::Trusted
486 && now.signed_duration_since(device.last_seen_at) > cfg.trusted_idle
487 {
488 actions.push((*entry.key(), Action::DemoteToSeen));
489 counts.trusted_to_seen += 1;
490 current_level = DeviceTrustLevel::Seen;
491 }
492 // Seen → Revoked (cascades from the Trusted → Seen demotion
493 // when last-seen clears both windows).
494 if current_level == DeviceTrustLevel::Seen
495 && now.signed_duration_since(device.last_seen_at) > cfg.seen_idle
496 {
497 actions.push((*entry.key(), Action::DemoteToRevoked));
498 counts.seen_to_revoked += 1;
499 current_level = DeviceTrustLevel::Revoked;
500 }
501 // Revoked → Purge. Uses the *existing* `revoked_at`, not
502 // `now`; a device demoted in this same sweep won't be
503 // purged until a future sweep where the grace has elapsed.
504 if current_level == DeviceTrustLevel::Revoked
505 && let Some(revoked_at) = device.revoked_at
506 && now.signed_duration_since(revoked_at) > cfg.revoked_grace
507 {
508 actions.push((*entry.key(), Action::Purge));
509 counts.revoked_purged += 1;
510 }
511 }
512
513 for (key, action) in actions {
514 match action {
515 Action::DemoteToSeen => {
516 if let Some(mut entry) = self.devices.get_mut(&key) {
517 entry.trust_level = DeviceTrustLevel::Seen;
518 }
519 }
520 Action::DemoteToRevoked => {
521 if let Some(mut entry) = self.devices.get_mut(&key) {
522 entry.trust_level = DeviceTrustLevel::Revoked;
523 entry.revoked_at = Some(now);
524 }
525 }
526 Action::Purge => {
527 if let Some((_, device)) = self.devices.remove(&key) {
528 let fp_key = (device.tenant_id, device.fingerprint_hash);
529 self.fingerprint_index.remove(&fp_key);
530 }
531 }
532 }
533 }
534
535 Ok(counts)
536 }
537}
538
539impl HealthCheck for MemoryDeviceStore {
540 fn check(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
541 Box::pin(async { HealthStatus::Healthy })
542 }
543}
544
545#[cfg(test)]
546mod tests;