axess-core 0.10.0

Core implementation for the axess library. Session state machine, multi-factor authentication engine, Cedar Policy evaluation, and pluggable storage backends. Use the `axess` facade crate unless you need direct access to internals.
//! Interleavings between a cached read and a write, built
//! deterministically with a store that runs a hook inside the window.

use super::*;
use crate::device::store::MemoryDeviceStore;
use crate::device::types::{Device, FingerprintHash};
use axess_clock::testing::MockClock;
use chrono::TimeZone;
use std::pin::Pin;
use std::sync::Mutex;

type Hook = Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>;

/// Wraps a memory store; each hook fires once, right before the write
/// reaches storage (`before_write`) or right after a load has read its
/// row but before it returns (`after_load`).
#[derive(Clone, Default)]
struct HookedStore {
    inner: MemoryDeviceStore,
    before_write: Arc<Mutex<Option<Hook>>>,
    after_load: Arc<Mutex<Option<Hook>>>,
}

async fn fire(slot: &Mutex<Option<Hook>>) {
    let hook = slot.lock().unwrap().take();
    if let Some(h) = hook {
        h().await;
    }
}

impl DeviceStore for HookedStore {
    type Error = <MemoryDeviceStore as DeviceStore>::Error;

    async fn load(&self, t: &TenantId, id: &DeviceId) -> Result<Option<Device>, Self::Error> {
        let row = self.inner.load(t, id).await?;
        fire(&self.after_load).await;
        Ok(row)
    }
    fn find_by_fingerprint(
        &self,
        t: &TenantId,
        h: &FingerprintHash,
    ) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send {
        self.inner.find_by_fingerprint(t, h)
    }
    fn find_for_user(
        &self,
        t: &TenantId,
        u: &UserId,
        limit: usize,
    ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
        self.inner.find_for_user(t, u, limit)
    }
    fn find_by_refresh_family(
        &self,
        t: &TenantId,
        f: &str,
    ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
        self.inner.find_by_refresh_family(t, f)
    }
    async fn save(&self, d: &Device) -> Result<(), Self::Error> {
        fire(&self.before_write).await;
        self.inner.save(d).await
    }
    fn record_sighting(
        &self,
        t: &TenantId,
        id: &DeviceId,
        now: DateTime<Utc>,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
        self.inner.record_sighting(t, id, now)
    }
    async fn set_trust_level(
        &self,
        t: &TenantId,
        id: &DeviceId,
        level: DeviceTrustLevel,
        now: DateTime<Utc>,
    ) -> Result<(), Self::Error> {
        fire(&self.before_write).await;
        self.inner.set_trust_level(t, id, level, now).await
    }
    async fn delete(&self, t: &TenantId, id: &DeviceId) -> Result<(), Self::Error> {
        fire(&self.before_write).await;
        self.inner.delete(t, id).await
    }
    fn sweep(
        &self,
        t: &TenantId,
        now: DateTime<Utc>,
    ) -> impl Future<Output = Result<SweepCounts, Self::Error>> + Send {
        self.inner.sweep(t, now)
    }
}

fn now() -> DateTime<Utc> {
    Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
}

fn device(level: DeviceTrustLevel) -> Device {
    Device {
        id: crate::authn::ids::testing::device("device-1"),
        tenant_id: crate::authn::ids::testing::tenant("tenant-1"),
        user_id: Some(crate::authn::ids::testing::user("user-1")),
        trust_level: level,
        fingerprint_hash: FingerprintHash::from_bytes([1u8; 32]),
        first_seen_at: now(),
        last_seen_at: now(),
        revoked_at: None,
        bindings: Vec::new(),
    }
}

/// Cached store over a `HookedStore` holding one Trusted device.
async fn setup() -> (CachedDeviceStore<HookedStore>, HookedStore, Device) {
    let hooked = HookedStore::default();
    let d = device(DeviceTrustLevel::Trusted);
    hooked.inner.save(&d).await.unwrap();
    let cached =
        CachedDeviceStore::new(hooked.clone()).with_clock(Arc::new(MockClock::at(now())) as _);
    (cached, hooked, d)
}

/// Hook: a concurrent reader loads the device through the cache.
fn reader(cached: &CachedDeviceStore<HookedStore>, d: &Device) -> Hook {
    let (c, t, id) = (cached.clone(), d.tenant_id, d.id);
    Box::new(move || {
        Box::pin(async move {
            drop(c.load(&t, &id).await.unwrap());
        })
    })
}

#[tokio::test]
async fn set_trust_level_revocation_is_not_undone_by_a_load_during_the_write() {
    let (cached, hooked, d) = setup().await;
    *hooked.before_write.lock().unwrap() = Some(reader(&cached, &d));
    cached
        .set_trust_level(&d.tenant_id, &d.id, DeviceTrustLevel::Revoked, now())
        .await
        .unwrap();
    let seen = cached.load(&d.tenant_id, &d.id).await.unwrap().unwrap();
    assert_eq!(seen.trust_level, DeviceTrustLevel::Revoked);
}

#[tokio::test]
async fn save_is_not_undone_by_a_load_during_the_write() {
    let (cached, hooked, d) = setup().await;
    *hooked.before_write.lock().unwrap() = Some(reader(&cached, &d));
    let mut revoked = d.clone();
    revoked.trust_level = DeviceTrustLevel::Revoked;
    cached.save(&revoked).await.unwrap();
    let seen = cached.load(&d.tenant_id, &d.id).await.unwrap().unwrap();
    assert_eq!(seen.trust_level, DeviceTrustLevel::Revoked);
}

#[tokio::test]
async fn delete_is_not_undone_by_a_load_during_the_write() {
    let (cached, hooked, d) = setup().await;
    *hooked.before_write.lock().unwrap() = Some(reader(&cached, &d));
    cached.delete(&d.tenant_id, &d.id).await.unwrap();
    assert!(cached.load(&d.tenant_id, &d.id).await.unwrap().is_none());
}

/// The load reads the Trusted row, then a full revocation lands before the
/// load returns. The stale row must not be promoted into the cache.
#[tokio::test]
async fn load_in_flight_across_a_revocation_does_not_cache_the_old_row() {
    let (cached, hooked, d) = setup().await;
    let (c, t, id) = (cached.clone(), d.tenant_id, d.id);
    *hooked.after_load.lock().unwrap() = Some(Box::new(move || {
        Box::pin(async move {
            c.set_trust_level(&t, &id, DeviceTrustLevel::Revoked, now())
                .await
                .unwrap();
        })
    }));
    drop(cached.load(&d.tenant_id, &d.id).await.unwrap());
    let seen = cached.load(&d.tenant_id, &d.id).await.unwrap().unwrap();
    assert_eq!(seen.trust_level, DeviceTrustLevel::Revoked);
}

#[tokio::test]
async fn sweep_drops_cached_rows_of_the_tenant() {
    let hooked = HookedStore {
        inner: MemoryDeviceStore::new().with_sweep_config(
            crate::device::store::SweepConfig::builder()
                .trusted_idle(chrono::Duration::days(1))
                .build(),
        ),
        ..HookedStore::default()
    };
    let d = device(DeviceTrustLevel::Trusted);
    hooked.inner.save(&d).await.unwrap();
    let cached = CachedDeviceStore::new(hooked).with_clock(Arc::new(MockClock::at(now())) as _);
    drop(cached.load(&d.tenant_id, &d.id).await.unwrap());

    let later = now() + chrono::Duration::days(2);
    let counts = cached.sweep(&d.tenant_id, later).await.unwrap();
    assert_eq!(counts.trusted_to_seen, 1);
    let seen = cached.load(&d.tenant_id, &d.id).await.unwrap().unwrap();
    assert_eq!(seen.trust_level, DeviceTrustLevel::Seen);
}

/// `with_clock` after `with_ttl` must keep the TTL: an entry outlives a
/// shorter window and is gone after the configured one.
#[tokio::test]
async fn builders_keep_each_others_settings() {
    let clock = Arc::new(MockClock::at(now()));
    let hooked = HookedStore::default();
    let d = device(DeviceTrustLevel::Trusted);
    hooked.inner.save(&d).await.unwrap();
    let cached = CachedDeviceStore::new(hooked)
        .with_ttl(Duration::from_secs(5))
        .with_capacity(7)
        .with_clock(clock.clone() as _);
    drop(cached.load(&d.tenant_id, &d.id).await.unwrap());
    clock.advance_secs(3);
    drop(cached.load(&d.tenant_id, &d.id).await.unwrap());
    assert_eq!(cached.stats().hits, 1, "entry lives within the 5 s TTL");
    clock.advance_secs(3);
    drop(cached.load(&d.tenant_id, &d.id).await.unwrap());
    assert_eq!(cached.stats().hits, 1, "entry expires after the 5 s TTL");
}