use std::path::{Path, PathBuf};
use apcore::acl::{AuditEntry, ACL};
use apcore::errors::{ErrorCode, ModuleError};
use crate::config::ConfigResolver;
use crate::security::AuditLogger;
pub const ACL_ROOT_ENV: &str = "APCORE_ACL_ROOT";
pub const DEFAULT_ACL_ROOT: &str = "./acl";
pub const GLOBAL_ACL_FILENAME: &str = "global_acl.yaml";
pub const ACL_ROOT_KEY: &str = "acl.root";
pub const ACL_AUDIT_ENABLED_KEY: &str = "acl.audit.enabled";
pub const ACL_AUDIT_ENABLED_ENV: &str = "APCORE_ACL_AUDIT_ENABLED";
pub const ACL_AUDIT_INCLUDE_DENIED_KEY: &str = "acl.audit.include_denied";
pub const ACL_AUDIT_INCLUDE_DENIED_ENV: &str = "APCORE_ACL_AUDIT_INCLUDE_DENIED";
pub fn resolve_acl_root(config: &ConfigResolver, cli_flag: Option<&str>) -> Option<String> {
if let Some(value) = non_empty(cli_flag) {
return Some(value);
}
if let Ok(raw) = std::env::var(ACL_ROOT_ENV) {
if let Some(value) = non_empty(Some(&raw)) {
return Some(value);
}
}
if let Some(value) = config
.config_file
.as_ref()
.and_then(|file| file.get(ACL_ROOT_KEY))
.and_then(|raw| non_empty(Some(raw)))
{
return Some(value);
}
let default_root = config
.defaults
.get(ACL_ROOT_KEY)
.copied()
.unwrap_or(DEFAULT_ACL_ROOT);
if Path::new(default_root).exists() {
Some(default_root.to_string())
} else {
None
}
}
fn non_empty(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub fn resolve_acl_file(root: &str) -> Option<PathBuf> {
let path = Path::new(root);
if !path.exists() {
return None;
}
if path.is_dir() {
let candidate = path.join(GLOBAL_ACL_FILENAME);
return candidate.is_file().then_some(candidate);
}
Some(path.to_path_buf())
}
pub fn load_cli_acl(root: &str) -> Result<Option<ACL>, ModuleError> {
let Some(file) = resolve_acl_file(root) else {
return Ok(None);
};
let path = file.to_string_lossy().to_string();
ACL::load(&path).map(Some)
}
fn resolve_audit_flag(config: &ConfigResolver, key: &str, env_var: &str) -> bool {
match config.resolve(key, None, Some(env_var)) {
Some(raw) => parse_config_bool(&raw, key, true),
None => true,
}
}
fn parse_config_bool(raw: &str, key: &str, default: bool) -> bool {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => {
tracing::warn!(
"Unrecognised boolean value {raw:?} for config key '{key}'; \
using the default ({default}). Accepted: true/1/yes/on, false/0/no/off."
);
default
}
}
}
pub fn acl_audit_enabled(config: &ConfigResolver) -> bool {
resolve_audit_flag(config, ACL_AUDIT_ENABLED_KEY, ACL_AUDIT_ENABLED_ENV)
}
pub fn acl_audit_include_denied(config: &ConfigResolver) -> bool {
resolve_audit_flag(
config,
ACL_AUDIT_INCLUDE_DENIED_KEY,
ACL_AUDIT_INCLUDE_DENIED_ENV,
)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AclAuditRecord {
pub timestamp: String,
pub caller_id: String,
pub target_id: String,
pub decision: String,
pub reason: String,
pub matched_rule: Option<String>,
pub matched_rule_index: Option<usize>,
pub identity_type: Option<String>,
pub roles: Vec<String>,
pub call_depth: Option<usize>,
pub trace_id: Option<String>,
pub handler_error: Option<String>,
pub approval_required: bool,
}
pub const ACL_AUDIT_FIELDS: [&str; 13] = [
"timestamp",
"caller_id",
"target_id",
"decision",
"reason",
"matched_rule",
"matched_rule_index",
"identity_type",
"roles",
"call_depth",
"trace_id",
"handler_error",
"approval_required",
];
pub fn acl_audit_record(entry: &AuditEntry) -> AclAuditRecord {
AclAuditRecord {
timestamp: entry.timestamp.clone(),
caller_id: entry.caller_id.clone(),
target_id: entry.target_id.clone(),
decision: entry.decision.clone(),
reason: entry.reason.clone(),
matched_rule: entry.matched_rule.clone(),
matched_rule_index: entry.matched_rule_index,
identity_type: entry.identity_type.clone(),
roles: entry.roles.clone(),
call_depth: entry.call_depth,
trace_id: entry.trace_id.clone(),
handler_error: entry.handler_error.clone(),
approval_required: entry.approval_required,
}
}
pub fn install_acl_audit_logger(acl: &mut ACL, logger: AuditLogger, include_denied: bool) {
acl.set_audit_logger(move |entry: &AuditEntry| {
if !include_denied && entry.decision == "deny" {
return;
}
logger.log_acl_decision(&acl_audit_record(entry));
});
}
pub fn load_cli_acl_with_audit(
root: &str,
config: &ConfigResolver,
logger: Option<AuditLogger>,
) -> Result<Option<ACL>, ModuleError> {
let Some(mut acl) = load_cli_acl(root)? else {
return Ok(None);
};
if let Some(logger) = logger {
if acl_audit_enabled(config) {
install_acl_audit_logger(&mut acl, logger, acl_audit_include_denied(config));
}
}
Ok(Some(acl))
}
pub fn describe_load_error(root: &str, err: &ModuleError) -> String {
let path = resolve_acl_file(root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| root.to_string());
if err.code == ErrorCode::ConfigNotFound {
format!("ACL file not found: {path}")
} else {
format!("Invalid ACL configuration in {path}: {}", err.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard {
previous: Option<String>,
previous_cwd: Option<PathBuf>,
}
impl EnvGuard {
fn new() -> Self {
let previous = std::env::var(ACL_ROOT_ENV).ok();
unsafe {
std::env::remove_var(ACL_ROOT_ENV);
}
Self {
previous,
previous_cwd: std::env::current_dir().ok(),
}
}
fn set(&self, value: &str) {
unsafe {
std::env::set_var(ACL_ROOT_ENV, value);
}
}
fn chdir(&self, dir: &Path) {
std::env::set_current_dir(dir).expect("chdir");
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.take() {
Some(v) => std::env::set_var(ACL_ROOT_ENV, v),
None => std::env::remove_var(ACL_ROOT_ENV),
}
}
if let Some(cwd) = self.previous_cwd.take() {
let _ = std::env::set_current_dir(cwd);
}
}
}
fn resolver_with_file(entries: &[(&str, &str)]) -> ConfigResolver {
let mut r = ConfigResolver::new(None, None);
let mut map: HashMap<String, String> = HashMap::new();
for (k, v) in entries {
map.insert((*k).to_string(), (*v).to_string());
}
r.config_file = Some(map);
r
}
const MINIMAL_ACL: &str =
"default_effect: deny\nrules:\n - callers: ['*']\n targets: ['*']\n effect: allow\n";
#[test]
fn tier1_cli_flag_wins_over_everything() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = EnvGuard::new();
guard.set("./from-env");
let resolver = resolver_with_file(&[("acl.root", "./from-yaml")]);
assert_eq!(
resolve_acl_root(&resolver, Some("./custom.yaml")),
Some("./custom.yaml".to_string())
);
}
#[test]
fn tier2_env_wins_over_yaml() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = EnvGuard::new();
guard.set("./other");
let resolver = resolver_with_file(&[("acl.root", "./from-yaml")]);
assert_eq!(
resolve_acl_root(&resolver, None),
Some("./other".to_string())
);
}
#[test]
fn tier3_yaml_used_when_no_flag_or_env() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = EnvGuard::new();
let resolver = resolver_with_file(&[("acl.root", "./from-yaml")]);
assert_eq!(
resolve_acl_root(&resolver, None),
Some("./from-yaml".to_string())
);
}
#[test]
fn tier4_default_is_none_when_absent() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = EnvGuard::new();
let dir = tempfile::tempdir().expect("tempdir");
guard.chdir(dir.path());
let resolver = ConfigResolver::new(None, None);
assert_eq!(resolve_acl_root(&resolver, None), None);
}
#[test]
fn tier4_default_is_reported_when_present() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = EnvGuard::new();
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("acl")).expect("mkdir acl");
guard.chdir(dir.path());
let resolver = ConfigResolver::new(None, None);
assert_eq!(
resolve_acl_root(&resolver, None),
Some(DEFAULT_ACL_ROOT.to_string())
);
}
#[test]
fn empty_values_fall_through_to_the_next_tier() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = EnvGuard::new();
guard.set(" ");
let resolver = resolver_with_file(&[("acl.root", "./from-yaml")]);
assert_eq!(
resolve_acl_root(&resolver, Some("")),
Some("./from-yaml".to_string())
);
}
#[test]
fn missing_root_attaches_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope");
assert_eq!(resolve_acl_file(missing.to_str().unwrap()), None);
assert!(load_cli_acl(missing.to_str().unwrap())
.expect("missing root is not an error")
.is_none());
}
#[test]
fn directory_without_global_acl_attaches_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let acl_dir = dir.path().join("acl");
std::fs::create_dir(&acl_dir).expect("mkdir");
assert_eq!(resolve_acl_file(acl_dir.to_str().unwrap()), None);
assert!(load_cli_acl(acl_dir.to_str().unwrap())
.expect("no conventional file is not an error")
.is_none());
}
#[test]
fn directory_with_global_acl_is_loaded() {
let dir = tempfile::tempdir().expect("tempdir");
let acl_dir = dir.path().join("acl");
std::fs::create_dir(&acl_dir).expect("mkdir");
std::fs::write(acl_dir.join(GLOBAL_ACL_FILENAME), MINIMAL_ACL).expect("write");
let acl = load_cli_acl(acl_dir.to_str().unwrap())
.expect("well-formed ACL loads")
.expect("an ACL is attached");
assert_eq!(acl.default_effect(), "deny");
assert_eq!(acl.rules().len(), 1);
}
#[test]
fn file_root_is_loaded_directly() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("custom.yaml");
std::fs::write(&file, MINIMAL_ACL).expect("write");
let acl = load_cli_acl(file.to_str().unwrap())
.expect("well-formed ACL loads")
.expect("an ACL is attached");
assert_eq!(acl.rules().len(), 1);
}
#[test]
fn structurally_invalid_acl_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("bad.yaml");
std::fs::write(
&file,
"default_effect: deny\nrules:\n - callers: ['*']\n targets: ['*']\n effect: permit\n",
)
.expect("write");
let err = load_cli_acl(file.to_str().unwrap()).expect_err("effect enum is closed");
assert_eq!(err.code, ErrorCode::ACLRuleError);
let msg = describe_load_error(file.to_str().unwrap(), &err);
assert!(
msg.starts_with("Invalid ACL configuration in "),
"unexpected message: {msg}"
);
}
#[test]
fn describe_load_error_names_a_missing_file() {
let err = ModuleError::new(ErrorCode::ConfigNotFound, "gone".to_string());
assert_eq!(
describe_load_error("/nope/acl.yaml", &err),
"ACL file not found: /nope/acl.yaml"
);
}
struct AuditEnvGuard {
previous: Vec<(&'static str, Option<String>)>,
}
impl AuditEnvGuard {
fn new() -> Self {
let vars = [ACL_AUDIT_ENABLED_ENV, ACL_AUDIT_INCLUDE_DENIED_ENV];
let previous = vars
.iter()
.map(|name| {
let prior = std::env::var(name).ok();
unsafe {
std::env::remove_var(name);
}
(*name, prior)
})
.collect();
Self { previous }
}
fn set(&self, name: &str, value: &str) {
unsafe {
std::env::set_var(name, value);
}
}
}
impl Drop for AuditEnvGuard {
fn drop(&mut self) {
for (name, prior) in self.previous.drain(..) {
unsafe {
match prior {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
}
#[test]
fn audit_flags_default_to_true() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = AuditEnvGuard::new();
let resolver = ConfigResolver::new(None, None);
assert!(acl_audit_enabled(&resolver));
assert!(acl_audit_include_denied(&resolver));
}
#[test]
fn audit_flags_read_the_environment() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = AuditEnvGuard::new();
guard.set(ACL_AUDIT_ENABLED_ENV, "false");
guard.set(ACL_AUDIT_INCLUDE_DENIED_ENV, "0");
let resolver = ConfigResolver::new(None, None);
assert!(!acl_audit_enabled(&resolver));
assert!(!acl_audit_include_denied(&resolver));
}
#[test]
fn audit_flags_read_apcore_yaml() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = AuditEnvGuard::new();
let resolver = resolver_with_file(&[
(ACL_AUDIT_ENABLED_KEY, "false"),
(ACL_AUDIT_INCLUDE_DENIED_KEY, "false"),
]);
assert!(!acl_audit_enabled(&resolver));
assert!(!acl_audit_include_denied(&resolver));
}
#[test]
fn audit_flag_env_beats_apcore_yaml() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = AuditEnvGuard::new();
guard.set(ACL_AUDIT_ENABLED_ENV, "true");
let resolver = resolver_with_file(&[(ACL_AUDIT_ENABLED_KEY, "false")]);
assert!(acl_audit_enabled(&resolver));
}
#[test]
fn an_unparseable_audit_flag_keeps_the_default() {
let _g = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let guard = AuditEnvGuard::new();
guard.set(ACL_AUDIT_ENABLED_ENV, "maybe");
let resolver = ConfigResolver::new(None, None);
assert!(acl_audit_enabled(&resolver));
}
#[test]
fn parse_config_bool_accepts_the_section_4_8_spelling_table() {
for raw in ["true", "TRUE", " 1 ", "yes", "On"] {
assert!(
parse_config_bool(raw, "acl.audit.enabled", false),
"should parse true: {raw:?}"
);
}
for raw in ["false", "FALSE", " 0 ", "no", "Off"] {
assert!(
!parse_config_bool(raw, "acl.audit.enabled", true),
"should parse false: {raw:?}"
);
}
}
#[test]
fn zero_switches_a_flag_off_even_though_rust_cannot_parse_it_as_bool() {
assert!("0".parse::<bool>().is_err(), "premise of this test");
assert!(!parse_config_bool("0", "acl.audit.enabled", true));
assert!(parse_config_bool("1", "acl.audit.enabled", false));
}
#[test]
fn the_audit_record_serialises_the_13_fields_in_declaration_order() {
let line = serde_json::to_string(&acl_audit_record(&AuditEntry::default()))
.expect("the record serialises");
assert_eq!(
ordered_keys(&line),
ACL_AUDIT_FIELDS,
"13 fields, apcore's AuditEntry declaration order, snake_case, no extras"
);
assert!(!ordered_keys(&line).contains(&"user".to_string()));
}
#[test]
fn the_audit_record_writes_absent_optionals_as_null() {
let record = acl_audit_record(&AuditEntry::default());
let value = serde_json::to_value(&record).expect("serialises");
for key in [
"matched_rule",
"matched_rule_index",
"identity_type",
"call_depth",
"trace_id",
"handler_error",
] {
assert!(value[key].is_null(), "{key} must be null, not missing");
}
assert_eq!(value["approval_required"], json!(false));
assert_eq!(value["roles"], json!([]));
}
#[test]
fn the_audit_record_copies_values_verbatim() {
let mut entry = AuditEntry::default();
entry.timestamp = "2026-09-06T00:00:00+00:00".to_string();
entry.caller_id = "@external".to_string();
entry.target_id = "system.control.disable".to_string();
entry.decision = "deny".to_string();
entry.reason = "rule_match".to_string();
entry.matched_rule = Some("no external control".to_string());
entry.matched_rule_index = Some(0);
entry.identity_type = Some("user".to_string());
entry.roles = vec!["admin".to_string()];
entry.call_depth = Some(2);
entry.trace_id = Some("trace-1".to_string());
entry.handler_error = Some("boom".to_string());
entry.approval_required = true;
let value = serde_json::to_value(acl_audit_record(&entry)).expect("serialises");
assert_eq!(value["timestamp"], "2026-09-06T00:00:00+00:00");
assert_eq!(value["caller_id"], "@external");
assert_eq!(value["target_id"], "system.control.disable");
assert_eq!(value["decision"], "deny");
assert_eq!(value["reason"], "rule_match");
assert_eq!(value["matched_rule"], "no external control");
assert_eq!(value["matched_rule_index"], 0);
assert_eq!(value["identity_type"], "user");
assert_eq!(value["roles"], json!(["admin"]));
assert_eq!(value["call_depth"], 2);
assert_eq!(value["trace_id"], "trace-1");
assert_eq!(value["handler_error"], "boom");
assert_eq!(value["approval_required"], json!(true));
}
fn ordered_keys(line: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut pending: Option<String> = None;
let mut depth: i32 = 0;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'"' => {
let mut s = String::new();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
chars.next();
}
'"' => break,
_ => s.push(ch),
}
}
pending = Some(s);
}
':' if depth == 1 => {
if let Some(key) = pending.take() {
keys.push(key);
}
}
'{' | '[' => {
depth += 1;
pending = None;
}
'}' | ']' => {
depth -= 1;
pending = None;
}
_ => {}
}
}
keys
}
#[test]
fn ordered_keys_reads_order_off_the_raw_text() {
let line = r#"{"b":"12:00","a":{"z":1},"c":["x:y"],"d":null}"#;
assert_eq!(ordered_keys(line), vec!["b", "a", "c", "d"]);
}
}