Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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"));
    }
}