road-runner-common 0.9.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Policy Decision Point: evaluates a permission check against the in-memory
//! snapshot. Pure and lock-free on the hot path (an `ArcSwap` load + hashmap
//! lookups). Deny-by-default; explicit `DENY` beats `ALLOW`; break-glass
//! `EMERGENCY_DENY` short-circuits; binding `expires_at` is re-checked locally so
//! JIT elevations lapse between snapshots.

use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;

use arc_swap::ArcSwapOption;

use super::model::{grant_matches, CompiledSnapshot, Effect, SnapshotPayload};

/// Why a check was denied (for audit/debugging; never leaked to end users verbatim).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyReason {
    /// The principal has no active binding at all (not an admin).
    NoBinding,
    /// An active break-glass EMERGENCY_DENY binding is in effect.
    EmergencyDeny,
    /// A matching grant explicitly denies the permission.
    ExplicitDeny,
    /// The principal is bound but no grant matches the permission.
    NoGrant,
}

impl DenyReason {
    pub fn as_str(self) -> &'static str {
        match self {
            DenyReason::NoBinding => "no_binding",
            DenyReason::EmergencyDeny => "emergency_deny",
            DenyReason::ExplicitDeny => "explicit_deny",
            DenyReason::NoGrant => "no_grant",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
    Allow,
    Deny { reason: DenyReason },
    /// Allowed, but the permission requires a higher session ACR than presented.
    NeedsStepUp { min_acr: String },
    /// Allowed, but the operation must go through M-of-N quorum, not direct execution.
    NeedsQuorum,
}

impl Decision {
    pub fn is_allow(&self) -> bool {
        matches!(self, Decision::Allow)
    }

    /// Short, stable label for audit records.
    pub fn label(&self) -> &'static str {
        match self {
            Decision::Allow => "allow",
            Decision::Deny { .. } => "deny",
            Decision::NeedsStepUp { .. } => "step_up_required",
            Decision::NeedsQuorum => "quorum_required",
        }
    }
}

/// The Policy Decision Point. Holds the current compiled snapshot behind an
/// `ArcSwap`, hot-swapped by the snapshot consumer.
pub struct Pdp {
    snapshot: ArcSwapOption<CompiledSnapshot>,
    last_updated_ms: AtomicI64,
}

impl Default for Pdp {
    fn default() -> Self {
        Self::new()
    }
}

impl Pdp {
    pub fn new() -> Self {
        Self {
            snapshot: ArcSwapOption::empty(),
            last_updated_ms: AtomicI64::new(0),
        }
    }

    /// Atomically swap in a new snapshot (compiling from the verified payload).
    pub fn install(&self, payload: SnapshotPayload) {
        let compiled = Arc::new(CompiledSnapshot::from_payload(payload));
        self.snapshot.store(Some(compiled));
        self.last_updated_ms.store(now_millis(), Ordering::Relaxed);
    }

    /// True once a snapshot has been loaded. Admin-plane readiness gates on this.
    pub fn ready(&self) -> bool {
        self.snapshot.load().is_some()
    }

    pub fn version(&self) -> Option<u64> {
        self.snapshot.load().as_ref().map(|s| s.version)
    }

    /// Unix millis of the last successful snapshot install (0 = never). For a
    /// staleness metric/alert.
    pub fn last_updated_ms(&self) -> i64 {
        self.last_updated_ms.load(Ordering::Relaxed)
    }

    /// Coarse gate for the gateway: does this principal hold ≥1 active role binding
    /// and no active EMERGENCY_DENY? `None` snapshot → `false` (fail-closed).
    pub fn has_any_binding(&self, sub: &str) -> bool {
        let Some(snap) = self.snapshot.load_full() else {
            return false;
        };
        let now = now_secs();
        let mut has_role = false;
        for b in snap.bindings_for(sub) {
            if !b.active(now) {
                continue;
            }
            if b.is_emergency_deny {
                return false;
            }
            if b.role_id.is_some() {
                has_role = true;
            }
        }
        has_role
    }

    /// Evaluate a permission check. Returns `None`-snapshot as a signal via
    /// [`Pdp::ready`]; callers should check `ready()` first (PEP maps it to 503).
    /// When no snapshot is loaded, this denies (`NoBinding`) as a safety net.
    pub fn check(&self, sub: &str, acr: Option<&str>, permission: &str) -> Decision {
        let Some(snap) = self.snapshot.load_full() else {
            return Decision::Deny { reason: DenyReason::NoBinding };
        };
        evaluate(&snap, sub, acr, permission, now_secs())
    }
}

fn evaluate(
    snap: &CompiledSnapshot,
    sub: &str,
    acr: Option<&str>,
    permission: &str,
    now: i64,
) -> Decision {
    let mut has_active_role = false;
    let mut saw_allow = false;

    for b in snap.bindings_for(sub) {
        if !b.active(now) {
            continue;
        }
        // Break-glass wins over everything.
        if b.is_emergency_deny {
            return Decision::Deny { reason: DenyReason::EmergencyDeny };
        }
        let Some(role_id) = b.role_id.as_deref() else {
            continue;
        };
        has_active_role = true;
        for g in snap.grants_for_role(role_id) {
            if grant_matches(&g.permission, permission) {
                match g.effect {
                    // DENY wins regardless of any ALLOW anywhere.
                    Effect::Deny => return Decision::Deny { reason: DenyReason::ExplicitDeny },
                    Effect::Allow => saw_allow = true,
                }
            }
        }
    }

    if !has_active_role {
        return Decision::Deny { reason: DenyReason::NoBinding };
    }
    if !saw_allow {
        return Decision::Deny { reason: DenyReason::NoGrant };
    }

    // Allowed. Apply per-permission step-up / quorum requirements.
    if let Some(meta) = snap.perm_meta(permission) {
        if let Some(min) = &meta.min_acr {
            if acr != Some(min.as_str()) {
                return Decision::NeedsStepUp { min_acr: min.clone() };
            }
        }
        if meta.requires_quorum {
            return Decision::NeedsQuorum;
        }
    }
    Decision::Allow
}

fn now_secs() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

fn now_millis() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}