epics-bridge-rs 0.18.2

EPICS protocol bridges: Record↔PVA (QSRV), CA gateway, pvalink, PVA gateway
//! Access security adapter for the gateway.
//!
//! Wraps an EPICS access security configuration (`.access` / ACF file)
//! and provides per-channel read/write permission checks.
//!
//! ## Format
//!
//! ```text
//! UAG(engineers) { jones, smith }
//! HAG(controlroom) { console1, console2 }
//!
//! ASG(DEFAULT) {
//!   RULE(1, READ)
//!   RULE(1, WRITE)
//! }
//!
//! ASG(BeamGroup) {
//!   RULE(1, READ)
//!   RULE(1, WRITE) { UAG(engineers), HAG(controlroom) }
//! }
//! ```
//!
//! Each PV is associated with an ASG via the `.pvlist` `ALLOW` / `ALIAS`
//! directives (the third token after `ALLOW`/`ALIAS` is the ASG name).
//! When a downstream client attempts a put or read, the gateway checks
//! the ASG rules against the client's user/host credentials.
//!
//! ## Status
//!
//! Per-rule enforcement is live: every PUT goes through
//! [`super::upstream::build_write_hook`], which calls [`Self::can_write`]
//! with the ASG from `.pvlist`, the rule's ASL, and the (user, host)
//! pair the CA server attaches to the WriteContext. Rejected puts
//! return `ECA_NORDACCESS` to the client and are recorded in the
//! putlog with outcome=DENIED.

use std::path::Path;

use epics_base_rs::server::access_security::{AccessLevel, AccessSecurityConfig, parse_acf};

use crate::error::{BridgeError, BridgeResult};

/// Access security configuration for the gateway.
pub struct AccessConfig {
    /// Underlying parsed ACF, or None if no file was loaded.
    config: Option<AccessSecurityConfig>,
    /// If true, all operations are allowed regardless of rules.
    /// Used as the default when no `.access` file is provided.
    allow_all: bool,
}

impl AccessConfig {
    /// Construct an "allow all" config with no underlying rules.
    pub fn allow_all() -> Self {
        Self {
            config: None,
            allow_all: true,
        }
    }

    /// Load an `.access` file from disk.
    pub fn from_file(path: &Path) -> BridgeResult<Self> {
        let content = std::fs::read_to_string(path)?;
        Self::from_string(&content)
    }

    /// Parse `.access` content from a string.
    pub fn from_string(content: &str) -> BridgeResult<Self> {
        let config = parse_acf(content)
            .map_err(|e| BridgeError::GroupConfigError(format!("ACF parse: {e}")))?;
        Ok(Self {
            config: Some(config),
            allow_all: false,
        })
    }

    /// Whether reading the given (asg, asl, user, host) tuple is allowed.
    ///
    /// The ASL is now passed to the underlying ACF check (C-G6 fix);
    /// rules with a level below `asl` are skipped, matching epics-base
    /// `asLibRoutines.c::asCompute`. Negative or zero ASL is treated
    /// as level 0 (most-restrictive).
    pub fn can_read(&self, asg: &str, asl: i32, user: &str, host: &str) -> bool {
        if self.allow_all {
            return true;
        }
        let asl = asl.max(0).min(u8::MAX as i32) as u8;
        match &self.config {
            Some(cfg) => matches!(
                cfg.check_access_asl(asg, host, user, asl),
                AccessLevel::Read | AccessLevel::ReadWrite
            ),
            None => true,
        }
    }

    /// Whether writing the given tuple is allowed.
    pub fn can_write(&self, asg: &str, asl: i32, user: &str, host: &str) -> bool {
        if self.allow_all {
            return true;
        }
        let asl = asl.max(0).min(u8::MAX as i32) as u8;
        match &self.config {
            Some(cfg) => matches!(
                cfg.check_access_asl(asg, host, user, asl),
                AccessLevel::ReadWrite
            ),
            None => true,
        }
    }

    /// Whether the underlying ACF was successfully loaded.
    pub fn has_rules(&self) -> bool {
        self.config.is_some()
    }
}

impl Default for AccessConfig {
    fn default() -> Self {
        Self::allow_all()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn allow_all_default() {
        let acc = AccessConfig::allow_all();
        assert!(!acc.has_rules());
        assert!(acc.can_read("BeamGroup", 1, "anyone", "anywhere"));
        assert!(acc.can_write("BeamGroup", 1, "anyone", "anywhere"));
    }

    #[test]
    fn allow_all_default_via_default_trait() {
        let acc = AccessConfig::default();
        assert!(acc.can_read("X", 0, "u", "h"));
    }

    #[test]
    fn from_string_with_minimal_acf() {
        // Minimal ACF: a single ASG with READ/WRITE rules
        let content = r#"
            ASG(DEFAULT) {
                RULE(1, READ)
                RULE(1, WRITE)
            }
        "#;
        // Just verify parsing doesn't blow up; the ACF parser may have
        // its own quirks but allow-mode fallback should still hold
        let acc = AccessConfig::from_string(content);
        // ACF parser may succeed or fail depending on supported syntax;
        // both outcomes are acceptable for this skeleton.
        let _ = acc;
    }
}