#![allow(clippy::doc_markdown)]
use core::fmt;
use std::net::IpAddr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DeviceState {
Unknown,
Registered,
Compliant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum RiskLevel {
None,
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AuthnStrength {
pub mfa: bool,
pub passkey: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct AccessRequest<'a> {
pub user_id: &'a str,
pub group_ids: &'a [String],
pub client_id: &'a str,
pub ip: Option<IpAddr>,
pub device: DeviceState,
pub risk: RiskLevel,
pub authn: AuthnStrength,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Selector {
All,
Ids(Vec<String>),
}
impl Selector {
fn matches_any(&self, ids: &[&str]) -> bool {
match self {
Self::All => true,
Self::Ids(list) => list.iter().any(|l| ids.contains(&l.as_str())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IpCidr {
base: IpAddr,
prefix: u8,
}
impl IpCidr {
pub fn parse(s: &str) -> Result<Self, CidrParseError> {
let (addr_str, prefix) = match s.split_once('/') {
Some((a, p)) => (a, Some(p)),
None => (s, None),
};
let base: IpAddr = addr_str.parse().map_err(|_| CidrParseError::Address)?;
let max = if base.is_ipv4() { 32 } else { 128 };
let prefix = match prefix {
Some(p) => p.parse::<u8>().map_err(|_| CidrParseError::Prefix)?,
None => max,
};
if prefix > max {
return Err(CidrParseError::Prefix);
}
Ok(Self { base, prefix })
}
#[must_use]
pub fn contains(&self, ip: IpAddr) -> bool {
match (self.base, ip) {
(IpAddr::V4(base), IpAddr::V4(ip)) => masked(&base.octets(), &ip.octets(), self.prefix),
(IpAddr::V6(base), IpAddr::V6(ip)) => masked(&base.octets(), &ip.octets(), self.prefix),
_ => false,
}
}
}
fn masked(base: &[u8], ip: &[u8], prefix: u8) -> bool {
let full = usize::from(prefix / 8);
if base[..full] != ip[..full] {
return false;
}
let rem = prefix % 8;
if rem == 0 {
return true;
}
let mask = 0xffu8 << (8 - rem);
(base[full] & mask) == (ip[full] & mask)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CidrParseError {
Address,
Prefix,
}
impl fmt::Display for CidrParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Address => f.write_str("invalid CIDR address"),
Self::Prefix => f.write_str("invalid CIDR prefix length"),
}
}
}
impl std::error::Error for CidrParseError {}
#[derive(Debug, Clone)]
pub struct Conditions {
pub include_users: Selector,
pub exclude_users: Vec<String>,
pub include_groups: Selector,
pub exclude_groups: Vec<String>,
pub include_apps: Selector,
pub exclude_apps: Vec<String>,
pub include_networks: Vec<IpCidr>,
pub exclude_networks: Vec<IpCidr>,
pub device_in: Vec<DeviceState>,
pub min_risk: Option<RiskLevel>,
}
impl Default for Conditions {
fn default() -> Self {
Self {
include_users: Selector::All,
exclude_users: Vec::new(),
include_groups: Selector::All,
exclude_groups: Vec::new(),
include_apps: Selector::All,
exclude_apps: Vec::new(),
include_networks: Vec::new(),
exclude_networks: Vec::new(),
device_in: Vec::new(),
min_risk: None,
}
}
}
impl Conditions {
#[must_use]
pub fn matches(&self, req: &AccessRequest<'_>) -> bool {
if !self.include_users.matches_any(&[req.user_id]) {
return false;
}
if self.exclude_users.iter().any(|u| u == req.user_id) {
return false;
}
let groups: Vec<&str> = req.group_ids.iter().map(String::as_str).collect();
if !self.include_groups.matches_any(&groups) {
return false;
}
if self
.exclude_groups
.iter()
.any(|g| groups.contains(&g.as_str()))
{
return false;
}
if !self.include_apps.matches_any(&[req.client_id]) {
return false;
}
if self.exclude_apps.iter().any(|a| a == req.client_id) {
return false;
}
if let Some(ip) = req.ip {
if self.exclude_networks.iter().any(|c| c.contains(ip)) {
return false;
}
}
if !self.include_networks.is_empty() {
let Some(ip) = req.ip else { return false };
if !self.include_networks.iter().any(|c| c.contains(ip)) {
return false;
}
}
if !self.device_in.is_empty() && !self.device_in.contains(&req.device) {
return false;
}
if let Some(min) = self.min_risk {
if req.risk < min {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GrantControl {
Mfa,
Passkey,
CompliantDevice,
}
impl GrantControl {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Mfa => "mfa",
Self::Passkey => "passkey",
Self::CompliantDevice => "compliant_device",
}
}
fn satisfied_by(self, req: &AccessRequest<'_>) -> bool {
match self {
Self::Mfa => req.authn.mfa,
Self::Passkey => req.authn.passkey,
Self::CompliantDevice => req.device == DeviceState::Compliant,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
Block,
Grant(Vec<GrantControl>),
}
#[derive(Debug, Clone)]
pub struct Policy {
pub id: String,
pub enabled: bool,
pub conditions: Conditions,
pub effect: Effect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Allow,
Block {
policy_id: String,
},
StepUp(Vec<GrantControl>),
}
#[must_use]
pub fn evaluate(policies: &[Policy], req: &AccessRequest<'_>) -> Verdict {
let mut required: Vec<GrantControl> = Vec::new();
for p in policies {
if !p.enabled || !p.conditions.matches(req) {
continue;
}
match &p.effect {
Effect::Block => {
return Verdict::Block {
policy_id: p.id.clone(),
};
}
Effect::Grant(controls) => {
for &c in controls {
if !required.contains(&c) {
required.push(c);
}
}
}
}
}
let unmet: Vec<GrantControl> = required
.into_iter()
.filter(|c| !c.satisfied_by(req))
.collect();
if unmet.is_empty() {
Verdict::Allow
} else {
Verdict::StepUp(unmet)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req<'a>(groups: &'a [String], client: &'a str) -> AccessRequest<'a> {
AccessRequest {
user_id: "u1",
group_ids: groups,
client_id: client,
ip: None,
device: DeviceState::Unknown,
risk: RiskLevel::None,
authn: AuthnStrength::default(),
}
}
fn grant(id: &str, conditions: Conditions, controls: Vec<GrantControl>) -> Policy {
Policy {
id: id.to_string(),
enabled: true,
conditions,
effect: Effect::Grant(controls),
}
}
#[test]
fn no_policies_allows() {
let g = vec![];
assert_eq!(evaluate(&[], &req(&g, "app")), Verdict::Allow);
}
#[test]
fn block_wins_over_grant() {
let g = vec![];
let policies = vec![
grant("g", Conditions::default(), vec![GrantControl::Mfa]),
Policy {
id: "block".into(),
enabled: true,
conditions: Conditions::default(),
effect: Effect::Block,
},
];
assert_eq!(
evaluate(&policies, &req(&g, "app")),
Verdict::Block {
policy_id: "block".into()
}
);
}
#[test]
fn unmet_control_steps_up() {
let g = vec![];
let p = vec![grant(
"g",
Conditions::default(),
vec![GrantControl::Mfa, GrantControl::Passkey],
)];
assert_eq!(
evaluate(&p, &req(&g, "app")),
Verdict::StepUp(vec![GrantControl::Mfa, GrantControl::Passkey])
);
}
#[test]
fn satisfied_control_allows() {
let g = vec![];
let mut r = req(&g, "app");
r.authn.mfa = true;
let p = vec![grant("g", Conditions::default(), vec![GrantControl::Mfa])];
assert_eq!(evaluate(&p, &r), Verdict::Allow);
}
#[test]
fn disabled_policy_ignored() {
let g = vec![];
let p = vec![Policy {
id: "b".into(),
enabled: false,
conditions: Conditions::default(),
effect: Effect::Block,
}];
assert_eq!(evaluate(&p, &req(&g, "app")), Verdict::Allow);
}
#[test]
fn group_scoped_policy_matches_only_members() {
let admins = vec!["g-admin".to_string()];
let others = vec!["g-other".to_string()];
let c = Conditions {
include_groups: Selector::Ids(vec!["g-admin".into()]),
..Conditions::default()
};
let p = vec![grant("g", c, vec![GrantControl::Mfa])];
assert_eq!(
evaluate(&p, &req(&admins, "app")),
Verdict::StepUp(vec![GrantControl::Mfa])
);
assert_eq!(evaluate(&p, &req(&others, "app")), Verdict::Allow);
}
#[test]
fn exclude_user_exempts() {
let g = vec![];
let c = Conditions {
exclude_users: vec!["u1".into()],
..Conditions::default()
};
let p = vec![Policy {
id: "b".into(),
enabled: true,
conditions: c,
effect: Effect::Block,
}];
assert_eq!(evaluate(&p, &req(&g, "app")), Verdict::Allow);
}
#[test]
fn cidr_v4_contains() {
let c = IpCidr::parse("10.0.0.0/8").unwrap();
assert!(c.contains("10.1.2.3".parse().unwrap()));
assert!(!c.contains("11.0.0.1".parse().unwrap()));
}
#[test]
fn cidr_v6_and_host_route() {
let c = IpCidr::parse("2001:db8::/32").unwrap();
assert!(c.contains("2001:db8::1".parse().unwrap()));
assert!(!c.contains("2001:dead::1".parse().unwrap()));
let host = IpCidr::parse("192.168.1.5").unwrap();
assert!(host.contains("192.168.1.5".parse().unwrap()));
assert!(!host.contains("192.168.1.6".parse().unwrap()));
assert!(!c.contains("10.0.0.1".parse().unwrap())); }
#[test]
fn network_include_requires_ip_in_block() {
let g = vec![];
let c = Conditions {
include_networks: vec![IpCidr::parse("203.0.113.0/24").unwrap()],
..Conditions::default()
};
let p = vec![Policy {
id: "b".into(),
enabled: true,
conditions: c,
effect: Effect::Block,
}];
let mut inside = req(&g, "app");
inside.ip = Some("203.0.113.9".parse().unwrap());
assert!(matches!(evaluate(&p, &inside), Verdict::Block { .. }));
let mut outside = req(&g, "app");
outside.ip = Some("198.51.100.1".parse().unwrap());
assert_eq!(evaluate(&p, &outside), Verdict::Allow);
assert_eq!(evaluate(&p, &req(&g, "app")), Verdict::Allow);
}
#[test]
fn risk_floor_gates() {
let g = vec![];
let c = Conditions {
min_risk: Some(RiskLevel::Medium),
..Conditions::default()
};
let p = vec![grant("g", c, vec![GrantControl::Mfa])];
let mut low = req(&g, "app");
low.risk = RiskLevel::Low;
assert_eq!(evaluate(&p, &low), Verdict::Allow);
let mut high = req(&g, "app");
high.risk = RiskLevel::High;
assert_eq!(
evaluate(&p, &high),
Verdict::StepUp(vec![GrantControl::Mfa])
);
}
#[test]
fn compliant_device_control() {
let g = vec![];
let p = vec![grant(
"g",
Conditions::default(),
vec![GrantControl::CompliantDevice],
)];
let mut unknown = req(&g, "app");
unknown.device = DeviceState::Registered;
assert_eq!(
evaluate(&p, &unknown),
Verdict::StepUp(vec![GrantControl::CompliantDevice])
);
let mut ok = req(&g, "app");
ok.device = DeviceState::Compliant;
assert_eq!(evaluate(&p, &ok), Verdict::Allow);
}
}