Skip to main content

axess_core/device/
lifecycle.rs

1//! [`DeviceLifecycleService`]: opinionated higher-level helper that
2//! composes [`DeviceStore`] primitives into the two operations every
3//! axess consumer needs at request/authn boundaries:
4//!
5//! 1. **Ensure-or-create**: given a fingerprint, look up the existing
6//!    `Device` row or create a fresh one with
7//!    [`DeviceTrustLevel::Unknown`]. Idempotent on the existing-device
8//!    path; bumps `last_seen_at` either way.
9//! 2. **Promote-on-authn**: atomically transition `Unknown → Seen`
10//!    when authentication succeeds. No-op when the device is already
11//!    `Seen`, `Trusted`, or `Revoked`; never re-elevates a revoked
12//!    device, never demotes a trusted one.
13//!
14//! # Why a separate helper (not a trait extension)
15//!
16//! The store trait stays focused on storage primitives so SQL / Valkey
17//! backends don't have to re-implement the lifecycle state machine.
18//! Composition over inheritance: every backend that implements
19//! [`DeviceStore`] gets the lifecycle logic for free, and the unit
20//! tests on the lifecycle live in one place.
21//!
22//! # What this helper does **not** do
23//!
24//! - **Compute the fingerprint.** The application supplies a
25//!   pre-computed [`FingerprintHash`]. Choosing the input set
26//!   (User-Agent family, IP /24, Accept-Language, …), the
27//!   tenant-scoped HMAC pepper, and the parsing strategy is an
28//!   application concern that varies by deployment. axess will
29//!   eventually ship a default extractor (separate iteration); the
30//!   lifecycle layer is fingerprint-agnostic.
31//! - **Wire itself into middleware.** The decision of *where* in the
32//!   request pipeline `ensure_device` runs (session middleware,
33//!   authn service, application-specific layer) is left to the
34//!   consumer. This keeps the helper testable in isolation and lets
35//!   apps make the cost trade-offs.
36//! - **Refresh-cascade revocation.** When a refresh-token family is
37//!   revoked, every `Device` bound to that family must transition to
38//!   `Revoked`. That requires `DeviceBinding` to track refresh-family
39//!   identifiers, which it does not yet. Tracked separately.
40
41use chrono::{DateTime, Utc};
42use std::future::Future;
43use std::sync::Arc;
44
45use crate::authn::event::{AuthEventBuilder, AuthEventType};
46use crate::authn::ids::{DeviceId, TenantId, UserId};
47use crate::authn::service::FactorOutcome;
48use crate::device::events::{DeviceEventSink, NoopDeviceEventSink};
49use crate::device::store::DeviceStore;
50use crate::device::types::{Device, DeviceTrustLevel, FingerprintHash};
51
52/// Composes [`DeviceStore`] primitives into the lifecycle operations
53/// every axess consumer needs at request/authn boundaries.
54///
55/// Cheap to clone (the inner store is `Clone` and the optional event
56/// sink is `Arc`-backed); construct once at startup and share across
57/// handlers.
58#[derive(Clone)]
59pub struct DeviceLifecycleService<S>
60where
61    S: DeviceStore,
62{
63    store: S,
64    /// Optional sink for device-lifecycle audit events.
65    /// Defaults to [`NoopDeviceEventSink`] so deployments that don't
66    /// (yet) record audit events run unchanged.
67    event_sink: Arc<dyn DeviceEventSink>,
68}
69
70impl<S> DeviceLifecycleService<S>
71where
72    S: DeviceStore,
73{
74    /// Wrap a [`DeviceStore`]. Audit events are dropped on the floor
75    /// until [`Self::with_event_sink`] is called; the underlying
76    /// device-state mutations always happen regardless.
77    pub fn new(store: S) -> Self {
78        Self {
79            store,
80            event_sink: Arc::new(NoopDeviceEventSink),
81        }
82    }
83
84    /// Wire a [`DeviceEventSink`] so device-lifecycle transitions emit
85    /// audit events. Typically wraps an
86    /// [`IdentityStore`](crate::authn::store::IdentityStore) via
87    /// [`IdentityStoreEventSink`](super::events::IdentityStoreEventSink).
88    pub fn with_event_sink<E: DeviceEventSink>(mut self, sink: E) -> Self {
89        self.event_sink = Arc::new(sink);
90        self
91    }
92
93    /// Borrow the underlying store. Use sparingly: bypasses the
94    /// lifecycle invariants this service exists to enforce.
95    pub fn store(&self) -> &S {
96        &self.store
97    }
98
99    /// Look up a device by `fingerprint` within `tenant`. If present,
100    /// bump `last_seen_at = now` and return its `device_id`. If absent,
101    /// create a new row at [`DeviceTrustLevel::Unknown`] using
102    /// `new_id_fn()` for the device_id and return it.
103    ///
104    /// `user` is `None` for guest sessions (pre-authn requests). The
105    /// `user_id` field on the created Device row is set to whatever
106    /// is passed in; updates that arrive later (e.g. when authn
107    /// completes for a previously-guest device) need to call
108    /// [`DeviceStore::save`] directly via [`Self::store`]; this
109    /// helper deliberately doesn't touch user_id on the existing-
110    /// device path to keep the create-vs-find branches symmetric.
111    ///
112    /// `new_id_fn` is the application's choice of identifier scheme.
113    /// Pass `|| DeviceId::try_new(Uuid::new_v4().to_string()).unwrap()`
114    /// for the canonical UUID-v4 shape, or a deterministic generator
115    /// driven by [`MockRng`](axess_rng::testing::MockRng) for tests.
116    /// Only called on the create path.
117    pub fn ensure_device(
118        &self,
119        tenant: &TenantId,
120        user: Option<&UserId>,
121        fingerprint: FingerprintHash,
122        now: DateTime<Utc>,
123        new_id_fn: impl FnOnce() -> DeviceId + Send,
124    ) -> impl Future<Output = Result<DeviceId, S::Error>> + Send {
125        let tenant = *tenant;
126        let user = user.cloned();
127        async move {
128            // Fast path: device already known by this fingerprint.
129            if let Some(existing) = self
130                .store
131                .find_by_fingerprint(&tenant, &fingerprint)
132                .await?
133            {
134                self.store
135                    .record_sighting(&tenant, &existing.id, now)
136                    .await?;
137                return Ok(existing.id);
138            }
139
140            // Create path: brand-new device at Unknown.
141            let id = new_id_fn();
142            let device = Device {
143                id,
144                tenant_id: tenant,
145                user_id: user,
146                trust_level: DeviceTrustLevel::Unknown,
147                fingerprint_hash: fingerprint,
148                first_seen_at: now,
149                last_seen_at: now,
150                revoked_at: None,
151                bindings: Vec::new(),
152            };
153            self.store.save(&device).await?;
154
155            // Emit `DeviceFirstSeen` after the row commit so a
156            // sink failure can never fail the device creation. The
157            // sink's own failure handling logs and swallows, so the
158            // outer caller never observes audit-pipeline blips.
159            let event = AuthEventBuilder::success(AuthEventType::DeviceFirstSeen)
160                .maybe_attributed_to(user.as_ref(), Some(&tenant))
161                .with_device(id)
162                .build();
163            self.event_sink.emit(event).await;
164
165            Ok(id)
166        }
167    }
168
169    /// Promote a device's trust level after a successful
170    /// authentication ceremony.
171    ///
172    /// State machine:
173    ///
174    /// | Current | After `promote_on_authn` |
175    /// |---------|--------------------------|
176    /// | `Unknown` | `Seen` (recorded with `record_sighting(now)`) |
177    /// | `Seen` | `Seen` (no-op; `last_seen_at` bumped) |
178    /// | `Trusted` | `Trusted` (no-op; `last_seen_at` bumped) |
179    /// | `Revoked` | `Revoked` (no-op; `last_seen_at` **not** bumped) |
180    ///
181    /// **Never re-elevates a `Revoked` device.** Revocation is a
182    /// terminal state until an admin / user explicitly resurrects the
183    /// device via [`DeviceStore::set_trust_level`]; passing through
184    /// `promote_on_authn` after a successful login on a revoked
185    /// device must not silently undo the revocation. (The application
186    /// should reject the login earlier in such cases; this is
187    /// defence-in-depth.)
188    ///
189    /// **Never demotes a `Trusted` device.** A Trusted device that
190    /// authenticates again stays Trusted. This is the standard "user
191    /// elevated device once via explicit consent; subsequent logins
192    /// don't downgrade trust" semantic.
193    ///
194    /// Returns the trust level the device is in after the call.
195    /// `Ok(None)` if the `device_id` doesn't resolve: defensive,
196    /// callers shouldn't see this if they pass an id from
197    /// [`Self::ensure_device`].
198    pub fn promote_on_authn(
199        &self,
200        tenant: &TenantId,
201        device_id: &DeviceId,
202        now: DateTime<Utc>,
203    ) -> impl Future<Output = Result<Option<DeviceTrustLevel>, S::Error>> + Send {
204        let tenant = *tenant;
205        let device_id = *device_id;
206        async move {
207            let device = match self.store.load(&tenant, &device_id).await? {
208                Some(d) => d,
209                None => return Ok(None),
210            };
211
212            match device.trust_level {
213                DeviceTrustLevel::Unknown => {
214                    self.store
215                        .set_trust_level(&tenant, &device_id, DeviceTrustLevel::Seen, now)
216                        .await?;
217                    self.store.record_sighting(&tenant, &device_id, now).await?;
218                    Ok(Some(DeviceTrustLevel::Seen))
219                }
220                DeviceTrustLevel::Seen | DeviceTrustLevel::Trusted => {
221                    self.store.record_sighting(&tenant, &device_id, now).await?;
222                    Ok(Some(device.trust_level))
223                }
224                DeviceTrustLevel::Revoked => {
225                    // Terminal; no last_seen bump, no trust change.
226                    Ok(Some(DeviceTrustLevel::Revoked))
227                }
228            }
229        }
230    }
231
232    /// Convenience composition for the AuthnService glue pattern:
233    /// fire [`Self::promote_on_authn`] iff the [`FactorOutcome`]
234    /// indicates the user just completed authentication. No-op on
235    /// `FactorRequired` / `InvalidCredential` / `Locked`.
236    ///
237    /// Usage:
238    ///
239    /// ```text
240    /// let outcome = authn.complete_factor_step(...).await?;
241    /// let _ = device_lifecycle
242    ///     .promote_if_authenticated(&outcome, &tenant, &device_id, now)
243    ///     .await?;
244    /// ```
245    ///
246    /// Returns the same `Option<DeviceTrustLevel>` as
247    /// [`Self::promote_on_authn`] when the outcome was
248    /// `Authenticated`; `Ok(None)` otherwise (including the absent-
249    /// device-id case, mirroring `promote_on_authn` semantics).
250    pub fn promote_if_authenticated(
251        &self,
252        outcome: &FactorOutcome,
253        tenant: &TenantId,
254        device_id: &DeviceId,
255        now: DateTime<Utc>,
256    ) -> impl Future<Output = Result<Option<DeviceTrustLevel>, S::Error>> + Send {
257        let is_authenticated = matches!(outcome, FactorOutcome::Authenticated);
258        let tenant = *tenant;
259        let device_id = *device_id;
260        async move {
261            if is_authenticated {
262                self.promote_on_authn(&tenant, &device_id, now).await
263            } else {
264                Ok(None)
265            }
266        }
267    }
268
269    // ── WebAuthn binding ────────────────────────────────────────────
270
271    /// Record a `DeviceBinding::WebAuthn` on a device after a successful
272    /// FIDO2 registration ceremony.
273    ///
274    /// If the device already carries a `WebAuthn` binding for the same
275    /// `credential_id`, this is a no-op (idempotent). Otherwise the new
276    /// binding is appended and a [`AuthEventType::DeviceBindingAdded`]
277    /// audit event is emitted.
278    ///
279    /// Returns `Ok(true)` when a new binding was added, `Ok(false)` when
280    /// deduplicated, `Ok(None)`-shaped `Err` when the device doesn't exist.
281    #[cfg(feature = "fido2")]
282    pub fn bind_webauthn_credential(
283        &self,
284        tenant: &TenantId,
285        device_id: &DeviceId,
286        credential_id: String,
287        attestation_class: crate::device::types::AttestationClass,
288        now: DateTime<Utc>,
289    ) -> impl Future<Output = Result<bool, S::Error>> + Send {
290        let tenant = *tenant;
291        let device_id = *device_id;
292        let event_sink = self.event_sink.clone();
293        async move {
294            let mut device = match self.store.load(&tenant, &device_id).await? {
295                Some(d) => d,
296                None => return Ok(false),
297            };
298
299            // Dedup: don't add a second binding for the same credential.
300            let already_bound = device.bindings.iter().any(|b| {
301                matches!(
302                    b,
303                    crate::device::types::DeviceBinding::WebAuthn {
304                        credential_id: cid, ..
305                    } if cid == &credential_id
306                )
307            });
308            if already_bound {
309                return Ok(false);
310            }
311
312            device
313                .bindings
314                .push(crate::device::types::DeviceBinding::WebAuthn {
315                    credential_id: credential_id.clone(),
316                    attestation_class,
317                    bound_at: now,
318                    last_used_at: now,
319                });
320            self.store.save(&device).await?;
321
322            // Emit `DeviceBindingAdded` after the save so the
323            // binding is persisted even if the sink fails.
324            let event = AuthEventBuilder::success(AuthEventType::DeviceBindingAdded)
325                .maybe_attributed_to(device.user_id.as_ref(), Some(&tenant))
326                .with_device(device_id)
327                .build();
328            event_sink.emit(event).await;
329
330            Ok(true)
331        }
332    }
333
334    /// Update the `last_used_at` timestamp on an existing
335    /// `DeviceBinding::WebAuthn` after a successful FIDO2 assertion.
336    ///
337    /// No-op if the device doesn't exist or has no `WebAuthn` binding
338    /// matching `credential_id`.
339    #[cfg(feature = "fido2")]
340    pub fn record_webauthn_usage(
341        &self,
342        tenant: &TenantId,
343        device_id: &DeviceId,
344        credential_id: &str,
345        now: DateTime<Utc>,
346    ) -> impl Future<Output = Result<(), S::Error>> + Send {
347        let tenant = *tenant;
348        let device_id = *device_id;
349        let credential_id = credential_id.to_owned();
350        async move {
351            let mut device = match self.store.load(&tenant, &device_id).await? {
352                Some(d) => d,
353                None => return Ok(()),
354            };
355
356            let mut touched = false;
357            for binding in &mut device.bindings {
358                if let crate::device::types::DeviceBinding::WebAuthn {
359                    credential_id: cid,
360                    last_used_at,
361                    ..
362                } = binding
363                {
364                    if cid == &credential_id {
365                        *last_used_at = now;
366                        touched = true;
367                        break;
368                    }
369                }
370            }
371
372            if touched {
373                self.store.save(&device).await?;
374            }
375            Ok(())
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests;