crates/nornir-identity-directory/src/scim/bearer.rs
//! **A SCIM bearer's own rules** (A34-27): a bearer may carry an expiry and a
//! read-only scope, a bearer nobody used for a long time is flagged, and the
//! refusals of unknown bearers are counted per client, so a flood of them
//! neither fills the audit log nor goes on unanswered.
//!
//! The product stores the bearer (its hash, never the secret) with these
//! fields; this module only decides.
use std::collections::HashMap;
use std::sync::Mutex;
use super::{error_answer, Answer};
/// What a bearer may do.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Scope {
/// Users and Groups, read and write (what an IdP's provisioning needs).
#[default]
ReadWrite,
/// GET only: an audit or a sync check.
Read,
}
impl Scope {
pub fn as_str(self) -> &'static str {
match self {
Scope::ReadWrite => "read-write",
Scope::Read => "read",
}
}
/// **The scope a stored or asked name is**: exactly what [`Scope::as_str`]
/// writes, `read` or `read-write`. Anything else (a near miss such as
/// "read-only" or "READ", an empty or a damaged stored value) is refused,
/// never read-write (RA5-6: the scope fails closed).
pub fn parse(raw: &str) -> Result<Scope, String> {
match raw {
"read" => Ok(Scope::Read),
"read-write" => Ok(Scope::ReadWrite),
other => Err(format!(
"{other:?} is no SCIM bearer scope: it is \"read\" or \"read-write\""
)),
}
}
}
/// A bearer as the check sees it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Bearer {
/// `0` never expires.
pub expires_at_unix_ms: i64,
pub scope: Scope,
}
/// The longest expiry an admin may set: one year.
pub const MAX_LIFETIME_MS: i64 = 366 * 24 * 3600 * 1000;
/// A bearer unused this long is flagged on the admin's list.
pub const UNUSED_ALERT_MS: i64 = 30 * 24 * 3600 * 1000;
/// The expiry a create asks for: `days` from `now`, `0` for none. Refused
/// past [`MAX_LIFETIME_MS`].
pub fn expiry_of(days: u32, now_unix_ms: i64) -> Result<i64, String> {
if days == 0 {
return Ok(0);
}
let ms = i64::from(days) * 24 * 3600 * 1000;
if ms > MAX_LIFETIME_MS {
return Err(format!(
"a SCIM bearer lives at most {} days",
MAX_LIFETIME_MS / (24 * 3600 * 1000)
));
}
Ok(now_unix_ms + ms)
}
impl Bearer {
/// **Whether this bearer is still good** at `now`: an expired one is the
/// 401 an unknown one gets, with its own sentence.
pub fn check(&self, now_unix_ms: i64) -> Result<(), Answer> {
if self.expires_at_unix_ms > 0 && now_unix_ms >= self.expires_at_unix_ms {
return Err(error_answer(
401,
None,
format!(
"the bearer expired at {}: the account admin creates a new one",
super::rfc3339(self.expires_at_unix_ms.div_euclid(1000))
),
));
}
Ok(())
}
}
/// Whether a bearer is worth a warning on the admin's list: unused (or never
/// used) for [`UNUSED_ALERT_MS`], or expiring within a week.
pub fn needs_attention(
created_unix_ms: i64,
last_used_unix_ms: i64,
expires_at_unix_ms: i64,
now_unix_ms: i64,
) -> Option<&'static str> {
const WEEK: i64 = 7 * 24 * 3600 * 1000;
if expires_at_unix_ms > 0 && now_unix_ms >= expires_at_unix_ms {
return Some("expired");
}
if expires_at_unix_ms > 0 && expires_at_unix_ms - now_unix_ms < WEEK {
return Some("expires within a week");
}
let since = if last_used_unix_ms > 0 {
last_used_unix_ms
} else {
created_unix_ms
};
(now_unix_ms - since >= UNUSED_ALERT_MS).then_some(if last_used_unix_ms > 0 {
"unused for 30 days"
} else {
"never used in 30 days"
})
}
/// The key a client is counted under: its address, an IPv6 one by its /64
/// (one host holds a whole /64, so per address would never bind). The one partition rule,
/// `nornir_auth_webauthn::client_key::ClientKey` (RA5-34); an empty address is `unknown`.
pub fn client_key(addr: &str) -> String {
let raw = addr.trim();
if raw.is_empty() {
return "unknown".to_owned();
}
let key = nornir_auth_webauthn::client_key::ClientKey::of_peer(raw);
key.as_str().chars().take(64).collect()
}
/// What to do with one refused bearer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
/// Answer 401 and write the audit row.
Audit,
/// Answer 401, write no row (this client's rows are already written
/// this minute; one summary row says how many were held back).
Quiet,
/// Answer 429: this client is refused too often.
Throttle,
}
/// Refusals per client per minute that each get an audit row.
pub const AUDITED_PER_MINUTE: u32 = 5;
/// Refusals per client per minute before the client is answered 429.
pub const REFUSED_PER_MINUTE: u32 = 30;
/// The most clients counted at once (past it the oldest minute is dropped).
const MAX_CLIENTS: usize = 20_000;
/// **The flood guard for refused bearers**, one per door.
#[derive(Default)]
pub struct RefusalGuard {
counts: Mutex<HashMap<String, (i64, u32)>>,
}
impl RefusalGuard {
pub fn new() -> Self {
Self::default()
}
/// Count one refusal of `client` (an address, see [`client_key`]) at
/// `now`, and say what to do with it.
pub fn refused(&self, client: &str, now_unix_ms: i64) -> Refusal {
let minute = now_unix_ms.div_euclid(60_000);
let key = client_key(client);
let mut counts = self
.counts
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if counts.len() >= MAX_CLIENTS && !counts.contains_key(&key) {
counts.retain(|_, (m, _)| *m == minute);
if counts.len() >= MAX_CLIENTS {
return Refusal::Throttle;
}
}
let entry = counts.entry(key).or_insert((minute, 0));
if entry.0 != minute {
*entry = (minute, 0);
}
entry.1 = entry.1.saturating_add(1);
match entry.1 {
n if n > REFUSED_PER_MINUTE => Refusal::Throttle,
n if n > AUDITED_PER_MINUTE => Refusal::Quiet,
_ => Refusal::Audit,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: i64 = 24 * 3600 * 1000;
/// ★ **A34-27: a bearer can expire**, and an expired one is a 401 with
/// its own sentence.
#[test]
fn an_expired_bearer_is_refused() {
let now = 1_800_000_000_000;
let b = Bearer {
expires_at_unix_ms: expiry_of(30, now).unwrap(),
scope: Scope::ReadWrite,
};
assert!(b.check(now).is_ok());
let err = b.check(now + 30 * DAY).unwrap_err();
assert_eq!(err.status, 401);
assert!(
err.body["detail"].as_str().unwrap().contains("expired"),
"{err:?}"
);
assert!(Bearer::default().check(i64::MAX).is_ok(), "0 never expires");
assert!(expiry_of(400, now).is_err());
assert_eq!(expiry_of(0, now), Ok(0));
}
/// ★ RA5-6: **a bearer's scope fails closed.** Only the two names the
/// crate writes are a scope; a near miss ("read-only", "ro", "READ"), an
/// empty or a damaged stored value is refused, never read-write.
#[test]
fn a_scope_is_a_known_name_or_refused() {
assert_eq!(Scope::parse("read"), Ok(Scope::Read));
assert_eq!(Scope::parse("read-write"), Ok(Scope::ReadWrite));
for raw in [
"read-only",
"readonly",
"ro",
"READ",
" read",
"",
"rw",
"read-write\0",
"write",
] {
let refused = Scope::parse(raw).unwrap_err();
assert!(
refused.contains("is no SCIM bearer scope"),
"{raw:?} is refused, not a scope: {refused}"
);
}
for scope in [Scope::Read, Scope::ReadWrite] {
assert_eq!(
Scope::parse(scope.as_str()),
Ok(scope),
"what the crate writes, it reads back"
);
}
}
/// ★ **A34-27: refusals are counted per client** — the first few are
/// audited, then held back, then answered 429; an IPv6 client is counted
/// by its /64, and a new minute starts over.
#[test]
fn refused_bearers_are_rate_limited_per_client() {
let guard = RefusalGuard::new();
let now = 1_800_000_000_000;
let verdicts: Vec<Refusal> = (0..40)
.map(|i| guard.refused(&format!("[2001:db8:1:2::{i:x}]:443"), now))
.collect();
assert!(verdicts[..AUDITED_PER_MINUTE as usize]
.iter()
.all(|v| *v == Refusal::Audit));
assert_eq!(verdicts[AUDITED_PER_MINUTE as usize], Refusal::Quiet);
assert_eq!(verdicts[REFUSED_PER_MINUTE as usize], Refusal::Throttle);
assert_eq!(
guard.refused("192.0.2.7:5000", now),
Refusal::Audit,
"another client"
);
assert_eq!(
guard.refused("2001:db8:1:2::99", now + 60_000),
Refusal::Audit,
"a new minute"
);
}
#[test]
fn client_keys() {
assert_eq!(client_key("192.0.2.1:443"), "192.0.2.1");
assert_eq!(client_key("[2001:db8::1]:443"), "2001:db8:0:0::/64");
assert_eq!(client_key("2001:db8::1"), "2001:db8:0:0::/64");
assert_eq!(client_key("::ffff:192.0.2.1"), "192.0.2.1");
assert_eq!(client_key(""), "unknown");
}
#[test]
fn stale_and_expiring_bearers_are_flagged() {
let now = 100 * DAY;
assert_eq!(needs_attention(now - DAY, now - DAY, 0, now), None);
assert_eq!(
needs_attention(now - 40 * DAY, 0, 0, now),
Some("never used in 30 days")
);
assert_eq!(
needs_attention(0, now - 31 * DAY, 0, now),
Some("unused for 30 days")
);
assert_eq!(
needs_attention(now, now, now + DAY, now),
Some("expires within a week")
);
assert_eq!(needs_attention(now, now, now - 1, now), Some("expired"));
}
}