liminal-server 0.27.1

Standalone server for the liminal messaging bus
Documentation
//! ACCESS-011 R2: advisory decisions.
//!
//! Under `[auth.lys] decisions = "advise"` the three grant decisions (a hot
//! act on a subject, a live ask, a site act) let an act through where Lys
//! holds no grant for it, saying the missing grant by name at WARN once per
//! act per connection and counting every one. Nothing else moves: an
//! unverifiable pass is refused exactly as under enforce.

use std::collections::HashSet;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};

use super::{AccessAction, AccessResource, DeliberateDecision, GrantReceipt};
use crate::config::types::Decisions;

/// The grant word an advised act carries in its receipt.
pub const ADVISED_GRANT: &str = "advised";

/// Distinct (kind, id, action) keys one connection is warned about. Past
/// this the act is still advised and counted, and no longer logged, so a
/// busy worker is never a log flood.
const WARNED_BOUND: usize = 256;

static ADVISED_TOTAL: AtomicU64 = AtomicU64::new(0);

/// Every act advised through since the process started: the figure behind
/// `liminal_advised_total`.
#[must_use]
pub fn advised_total() -> u64 {
    ADVISED_TOTAL.load(Ordering::Relaxed)
}

/// What one connection has already been warned about. It lives on the
/// admitted pass, so it goes with the connection: no timer, no sweep.
#[derive(Debug, Default)]
pub struct Warned {
    keys: Mutex<HashSet<(String, String, String)>>,
}

impl Warned {
    /// Whether this is the first time the connection is advised on this act.
    fn first(&self, kind: &str, id: &str, action: &str) -> bool {
        let Ok(mut keys) = self.keys.lock() else {
            return false;
        };
        if keys.len() >= WARNED_BOUND {
            return false;
        }
        keys.insert((kind.to_owned(), id.to_owned(), action.to_owned()))
    }
}

/// One act Lys refused or held, by the names the WARN carries.
#[derive(Debug, Clone, Copy)]
pub struct Act<'a> {
    /// The pass's holder.
    pub holder: &'a str,
    /// The resource kind, qualified (`liminal.subject`).
    pub kind: &'a str,
    /// The resource id.
    pub id: &'a str,
    /// The action asked.
    pub action: &'a str,
    /// The refusal's needed grant, or the cause by name.
    pub lacking: &'a str,
}

/// Decides a refused or held act under `decisions`.
///
/// `None` under enforce, so the caller refuses as before; the `advised`
/// receipt under advise, logged once per (kind, id, action) on `warned` and
/// counted every time.
#[must_use]
pub fn advised(decisions: Decisions, warned: &Warned, act: Act<'_>) -> Option<GrantReceipt> {
    if decisions != Decisions::Advise {
        return None;
    }
    ADVISED_TOTAL.fetch_add(1, Ordering::Relaxed);
    crate::metrics::advised();
    if warned.first(act.kind, act.id, act.action) {
        let Act {
            holder,
            kind,
            id,
            action,
            lacking,
        } = act;
        tracing::warn!(
            holder,
            kind,
            id,
            action,
            lacking,
            "advised: {holder} {action} on {kind}:{id}, grant lacking: {lacking}"
        );
    }
    Some(GrantReceipt {
        grant: ADVISED_GRANT.to_owned(),
    })
}

/// [`advised`] for a typed resource and action.
#[must_use]
pub fn advised_access(
    decisions: Decisions,
    warned: &Warned,
    holder: &str,
    resource: &AccessResource,
    action: AccessAction,
    lacking: &str,
) -> Option<GrantReceipt> {
    advised(
        decisions,
        warned,
        Act {
            holder,
            kind: resource.kind.qualified(),
            id: &resource.id,
            action: action.name(),
            lacking,
        },
    )
}

/// [`advised_access`] as a live decision: an advised act is outright.
#[must_use]
pub fn advised_deliberate(
    decisions: Decisions,
    warned: &Warned,
    holder: &str,
    resource: &AccessResource,
    action: AccessAction,
    lacking: &str,
) -> Option<DeliberateDecision> {
    advised_access(decisions, warned, holder, resource, action, lacking)
        .map(DeliberateDecision::Outright)
}