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;