crates/nornir-identity-session/src/session.rs
//! **Session revocation floors** (moved from Holger's `passkey.rs`, A34 M-5).
//!
//! Floors under a session's `auth_time`, never a second epoch: a verifier built
//! with a fixed epoch needs no change to the token. `all_before` is the hammer
//! (revoke every session); `subjects` is one person's logout (every device they
//! hold), and the revocation a credential's removal implies.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// The floors, as a product persists them.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Revocations {
/// Every session minted at or before this second is void (0: none).
#[serde(default)]
pub all_before: i64,
/// Per subject: every session of theirs minted at or before this second is void.
#[serde(default)]
pub subjects: BTreeMap<String, i64>,
}
impl Revocations {
/// Is a session minted at `auth_time` for `subject` still allowed?
///
/// STRICTLY above a floor: a floor is a whole second, and a session minted
/// in the same second as a logout may have been minted a moment BEFORE it,
/// the one that must not survive. The cost is the other direction: a login
/// in the very second of a revocation is refused once and works a second
/// later.
pub fn admits(&self, subject: &str, auth_time: i64) -> bool {
(self.all_before == 0 || auth_time > self.all_before)
&& self.subjects.get(subject).is_none_or(|floor| auth_time > *floor)
}
/// **Log a person out of every device**: a floor under their sessions at
/// `now`. A floor never moves back.
pub fn revoke_subject(&mut self, subject: &str, now: i64) {
let floor = self.subjects.entry(subject.to_owned()).or_insert(now);
*floor = (*floor).max(now);
}
/// **Log everyone out**: a floor under every session at `now`.
pub fn revoke_all(&mut self, now: i64) {
self.all_before = self.all_before.max(now);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_floor_voids_what_was_minted_up_to_it_and_nothing_after() {
let mut r = Revocations::default();
assert!(r.admits("kim", 1), "no floor: everything is admitted");
r.revoke_subject("kim", 100);
assert!(!r.admits("kim", 99));
assert!(!r.admits("kim", 100), "the same second may be before the logout");
assert!(r.admits("kim", 101));
assert!(r.admits("bo", 50), "another person is untouched");
r.revoke_subject("kim", 10);
assert!(!r.admits("kim", 100), "a floor never moves back");
r.revoke_all(200);
assert!(!r.admits("bo", 200) && r.admits("bo", 201));
r.revoke_all(150);
assert_eq!(r.all_before, 200);
}
#[test]
fn the_floors_read_back_as_written_and_an_old_file_reads_as_none() {
let mut r = Revocations::default();
r.revoke_subject("kim", 7);
r.revoke_all(3);
let json = serde_json::to_string(&r).unwrap();
assert_eq!(serde_json::from_str::<Revocations>(&json).unwrap(), r);
assert_eq!(serde_json::from_str::<Revocations>("{}").unwrap(), Revocations::default());
}
}