Skip to main content

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;