use crate::authn::ids::{DeviceId, TenantId};
use crate::device::store::DeviceStore;
use crate::device::types::DeviceTrustLevel;
use chrono::{DateTime, Utc};
pub async fn cascade_revoke_devices<D: DeviceStore>(
device_store: &D,
targets: &[(TenantId, DeviceId)],
now: DateTime<Utc>,
) -> u64 {
let mut revoked = 0u64;
for (tenant_id, device_id) in targets {
match device_store
.set_trust_level(tenant_id, device_id, DeviceTrustLevel::Revoked, now)
.await
{
Ok(()) => revoked += 1,
Err(e) => {
tracing::warn!(
error = %e,
%tenant_id,
%device_id,
"cascade_revoke_devices: per-target revoke failed; continuing"
);
}
}
}
revoked
}
pub async fn cascade_revoke_by_refresh_family<D: DeviceStore>(
device_store: &D,
tenant_id: &TenantId,
family_id: &str,
now: DateTime<Utc>,
) -> Result<u64, D::Error> {
let devices = device_store
.find_by_refresh_family(tenant_id, family_id)
.await?;
let targets: Vec<(TenantId, DeviceId)> = devices.iter().map(|d| (d.tenant_id, d.id)).collect();
Ok(cascade_revoke_devices(device_store, &targets, now).await)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::store::MemoryDeviceStore;
use crate::device::types::{Device, DeviceBinding, FingerprintHash};
fn t(s: &str) -> TenantId {
axess_identity::testing::tenant(s)
}
fn d(id: &str, fp: u8, tenant: &TenantId) -> Device {
let now = Utc::now();
Device {
id: axess_identity::testing::device(id),
tenant_id: *tenant,
user_id: Some(axess_identity::testing::user("u")),
trust_level: DeviceTrustLevel::Trusted,
fingerprint_hash: FingerprintHash::from_bytes([fp; 32]),
first_seen_at: now,
last_seen_at: now,
revoked_at: None,
bindings: Vec::<DeviceBinding>::new(),
}
}
#[tokio::test]
async fn cascade_revokes_all_targets_and_returns_count() {
let store = MemoryDeviceStore::new();
let tenant = t("tenant-1");
let dev_a = d("dev-a", 0xaa, &tenant);
let dev_b = d("dev-b", 0xbb, &tenant);
store.save(&dev_a).await.unwrap();
store.save(&dev_b).await.unwrap();
let targets = vec![(tenant, dev_a.id), (tenant, dev_b.id)];
let now = Utc::now();
let count = cascade_revoke_devices(&store, &targets, now).await;
assert_eq!(count, 2);
let after_a = store.load(&tenant, &dev_a.id).await.unwrap().unwrap();
let after_b = store.load(&tenant, &dev_b.id).await.unwrap().unwrap();
assert_eq!(after_a.trust_level, DeviceTrustLevel::Revoked);
assert_eq!(after_b.trust_level, DeviceTrustLevel::Revoked);
assert_eq!(after_a.revoked_at, Some(now));
assert_eq!(after_b.revoked_at, Some(now));
}
#[tokio::test]
async fn cascade_skips_unknown_devices_without_aborting() {
let store = MemoryDeviceStore::new();
let tenant = t("tenant-1");
let dev_a = d("dev-a", 0xaa, &tenant);
store.save(&dev_a).await.unwrap();
let targets = vec![
(tenant, dev_a.id),
(tenant, axess_identity::testing::device("dev-missing")),
];
let count = cascade_revoke_devices(&store, &targets, Utc::now()).await;
assert_eq!(count, 1, "only dev-a was actually present in the store");
let after_a = store.load(&tenant, &dev_a.id).await.unwrap().unwrap();
assert_eq!(after_a.trust_level, DeviceTrustLevel::Revoked);
}
#[tokio::test]
async fn cascade_handles_empty_target_list() {
let store = MemoryDeviceStore::new();
let count = cascade_revoke_devices::<MemoryDeviceStore>(&store, &[], Utc::now()).await;
assert_eq!(count, 0);
}
}