use std::path::Path;
use apcore::{ACLRule, ErrorCode, ModuleError, ACL};
use apcore_toolkit::ScannedModule;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct AclConfig {
rules: Vec<ACLRule>,
default_effect: String,
}
pub struct AclManager {
acl: ACL,
default_effect: String,
}
impl AclManager {
#[allow(clippy::result_large_err)] pub fn from_config(config_path: &Path) -> Result<Self, ModuleError> {
let acl = ACL::load(&config_path.to_string_lossy()).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to load ACL: {e}"),
)
})?;
let default_effect = Self::read_default_effect(config_path);
Ok(Self {
acl,
default_effect,
})
}
pub fn generate_default(modules: &[ScannedModule]) -> Self {
let mut rules = Vec::new();
let readonly_ids: Vec<String> = modules
.iter()
.filter(|m| m.annotations.as_ref().is_some_and(|a| a.readonly))
.map(|m| m.module_id.clone())
.collect();
if !readonly_ids.is_empty() {
rules.push(ACLRule {
callers: vec!["*".to_string()],
targets: readonly_ids,
effect: "allow".to_string(),
description: Some("Auto-allow readonly CLI commands".to_string()),
conditions: None,
});
}
let destructive_ids: Vec<String> = modules
.iter()
.filter(|m| m.annotations.as_ref().is_some_and(|a| a.destructive))
.map(|m| m.module_id.clone())
.collect();
if !destructive_ids.is_empty() {
rules.push(ACLRule {
callers: vec!["*".to_string()],
targets: destructive_ids,
effect: "deny".to_string(),
description: Some("Block destructive CLI commands by default".to_string()),
conditions: None,
});
}
let acl = ACL::new(rules, "deny", None);
Self {
acl,
default_effect: "deny".to_string(),
}
}
#[allow(clippy::result_large_err)] pub fn write_config(&self, path: &Path) -> Result<(), ModuleError> {
let config = AclConfig {
rules: self.acl.rules().to_vec(),
default_effect: self.default_effect.clone(),
};
let yaml = serde_yaml::to_string(&config).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to serialize ACL: {e}"),
)
})?;
std::fs::write(path, yaml).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to write ACL file: {e}"),
)
})?;
Ok(())
}
pub fn into_inner(self) -> ACL {
self.acl
}
fn read_default_effect(path: &Path) -> String {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_yaml::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("default_effect")?.as_str().map(String::from))
.unwrap_or_else(|| "deny".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_module_with_annotations(id: &str, readonly: bool, destructive: bool) -> ScannedModule {
let mut module = ScannedModule::new(
id.to_string(),
format!("Test {id}"),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string()],
format!("exec:///usr/bin/test {id}"),
);
module.annotations = Some(apcore::module::ModuleAnnotations {
readonly,
destructive,
requires_approval: destructive,
..Default::default()
});
module
}
#[test]
fn test_acl_generate_default_readonly() {
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.git.log", true, false),
];
let mgr = AclManager::generate_default(&modules);
let rules = mgr.acl.rules();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].effect, "allow");
assert_eq!(rules[0].targets.len(), 2);
}
#[test]
fn test_acl_generate_default_destructive() {
let modules = vec![make_module_with_annotations("cli.git.clean", false, true)];
let mgr = AclManager::generate_default(&modules);
let rules = mgr.acl.rules();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].effect, "deny");
assert!(rules[0].conditions.is_none());
}
#[test]
fn test_acl_generate_default_destructive_rule_denies_on_its_own() {
let modules = vec![make_module_with_annotations("cli.git.clean", false, true)];
let mgr = AclManager::generate_default(&modules);
let mut rules = mgr.acl.rules().to_vec();
rules.push(ACLRule {
callers: vec!["*".to_string()],
targets: vec!["cli.git.clean".to_string()],
effect: "allow".to_string(),
description: None,
conditions: None,
});
let acl = ACL::new(rules, "allow", None);
assert!(
!acl.check(None, "cli.git.clean", None),
"destructive command must be denied by its own ACL rule, not merely \
by a coincidental default_effect"
);
}
#[test]
fn test_acl_generate_default_mixed() {
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.git.clean", false, true),
];
let mgr = AclManager::generate_default(&modules);
let rules = mgr.acl.rules();
assert_eq!(rules.len(), 2);
}
#[test]
fn test_acl_generate_default_empty() {
let mgr = AclManager::generate_default(&[]);
let rules = mgr.acl.rules();
assert!(rules.is_empty());
assert_eq!(mgr.default_effect, "deny");
}
#[test]
fn test_acl_write_and_load() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl_manager.yaml");
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.git.clean", false, true),
];
let mgr = AclManager::generate_default(&modules);
mgr.write_config(&path).unwrap();
let loaded = AclManager::from_config(&path).unwrap();
assert_eq!(loaded.acl.rules().len(), 2);
assert_eq!(loaded.default_effect, "deny");
}
#[test]
fn test_acl_decision_recorded_via_audit_logger() {
use std::sync::Arc;
let tmp = tempfile::TempDir::new().unwrap();
let audit_path = tmp.path().join("audit.jsonl");
let audit = Arc::new(crate::governance::AuditManager::new(&audit_path));
let modules = vec![make_module_with_annotations("cli.rm", false, true)];
let mut acl = AclManager::generate_default(&modules).into_inner();
{
let audit = audit.clone();
acl.set_audit_logger(move |entry| audit.log_acl_decision(entry));
}
let allowed = acl.check(Some("@external"), "cli.rm", None);
assert!(!allowed);
let content = std::fs::read_to_string(&audit_path).unwrap();
assert!(
content.contains("\"decision\""),
"ACL decision not recorded: {content}"
);
assert!(content.contains("cli.rm"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&audit_path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "audit log should be owner-only");
}
}
}