use std::path::Path;
use apcore::{match_pattern, ACLRule, ErrorCode, ModuleError, ACL};
use apcore_toolkit::ScannedModule;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct AclConfig {
rules: Vec<ACLRule>,
default_effect: String,
}
const READONLY_RULE_DESCRIPTION: &str = "Auto-allow readonly CLI commands";
const DESTRUCTIVE_RULE_DESCRIPTION: &str = "Block destructive CLI commands by default";
const OPEN_WORLD_RULE_DESCRIPTION: &str = "Block open-world CLI commands by default";
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::GeneralInvalidInput,
format!("Failed to load ACL '{}': {e}", config_path.display()),
)
.with_retryable(false)
.with_ai_guidance(
"The ACL file is malformed. Check the rule that the message names, then \
re-run. A rule's `callers`/`targets` must each hold at least one non-empty \
pattern -- `[]` matches nothing and protects nothing.",
)
})?;
let default_effect = Self::read_default_effect(config_path);
Ok(Self {
acl,
default_effect,
})
}
pub fn generate_default(modules: &[ScannedModule]) -> Self {
let readonly_ids = Self::readonly_ids(modules);
let destructive_ids = Self::destructive_ids(modules);
let open_world_ids = Self::open_world_ids(modules);
let mut rules = Vec::new();
if !readonly_ids.is_empty() {
rules.push(Self::readonly_rule(readonly_ids));
}
if !destructive_ids.is_empty() {
rules.push(Self::destructive_rule(destructive_ids));
}
if !open_world_ids.is_empty() {
rules.push(Self::open_world_rule(open_world_ids));
}
let acl = ACL::new(rules, "deny", None);
Self {
acl,
default_effect: "deny".to_string(),
}
}
#[allow(clippy::result_large_err)] pub fn merge_default(
existing_path: &Path,
modules: &[ScannedModule],
) -> Result<Self, ModuleError> {
let existing = Self::from_config(existing_path)?;
let batch_ids: std::collections::HashSet<&str> =
modules.iter().map(|m| m.module_id.as_str()).collect();
let mut fresh_readonly = Self::readonly_ids(modules);
let mut fresh_destructive = Self::destructive_ids(modules);
let mut fresh_open_world = Self::open_world_ids(modules);
let mut rules = Vec::new();
for mut rule in existing.acl.rules().to_vec() {
match rule.description.as_deref() {
Some(READONLY_RULE_DESCRIPTION) => {
rule.targets.retain(|id| !batch_ids.contains(id.as_str()));
rule.targets.append(&mut fresh_readonly);
if rule.targets.is_empty() {
continue;
}
}
Some(DESTRUCTIVE_RULE_DESCRIPTION) => {
rule.targets.retain(|id| !batch_ids.contains(id.as_str()));
rule.targets.append(&mut fresh_destructive);
if rule.targets.is_empty() {
continue;
}
}
Some(OPEN_WORLD_RULE_DESCRIPTION) => {
rule.targets.retain(|id| !batch_ids.contains(id.as_str()));
rule.targets.append(&mut fresh_open_world);
if rule.targets.is_empty() {
continue;
}
}
_ => {}
}
rules.push(rule);
}
if !fresh_readonly.is_empty() {
rules.push(Self::readonly_rule(fresh_readonly));
}
if !fresh_destructive.is_empty() {
rules.push(Self::destructive_rule(fresh_destructive));
}
if !fresh_open_world.is_empty() {
rules.push(Self::open_world_rule(fresh_open_world));
}
let default_effect = existing.default_effect.clone();
let acl = ACL::new(rules, &default_effect, None);
Ok(Self {
acl,
default_effect,
})
}
fn readonly_ids(modules: &[ScannedModule]) -> Vec<String> {
modules
.iter()
.filter(|m| {
m.annotations
.as_ref()
.is_some_and(|a| a.readonly && !a.open_world)
})
.map(|m| m.module_id.clone())
.collect()
}
fn open_world_ids(modules: &[ScannedModule]) -> Vec<String> {
modules
.iter()
.filter(|m| {
m.annotations
.as_ref()
.is_some_and(|a| a.open_world && !a.destructive)
})
.map(|m| m.module_id.clone())
.collect()
}
fn open_world_rule(targets: Vec<String>) -> ACLRule {
let mut rule = ACLRule::new(vec!["*".to_string()], targets, "deny");
rule.description = Some(OPEN_WORLD_RULE_DESCRIPTION.to_string());
rule
}
fn readonly_rule(targets: Vec<String>) -> ACLRule {
let mut rule = ACLRule::new(vec!["*".to_string()], targets, "allow");
rule.description = Some(READONLY_RULE_DESCRIPTION.to_string());
rule
}
fn destructive_ids(modules: &[ScannedModule]) -> Vec<String> {
modules
.iter()
.filter(|m| m.annotations.as_ref().is_some_and(|a| a.destructive))
.map(|m| m.module_id.clone())
.collect()
}
fn destructive_rule(targets: Vec<String>) -> ACLRule {
let mut rule = ACLRule::new(vec!["*".to_string()], targets, "deny");
rule.description = Some(DESTRUCTIVE_RULE_DESCRIPTION.to_string());
rule
}
#[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
}
pub fn default_effect(&self) -> &str {
&self.default_effect
}
pub fn explain(&self, target_id: &str) -> AclDecision {
const GENERIC_CALLER: &str = apcore::EXTERNAL_CALLER;
let rule_matches = |patterns: &[String], value: &str| {
target_patterns(patterns)
.iter()
.any(|p| p.negated != match_pattern(p.pattern, value))
};
for (idx, rule) in self.acl.rules().iter().enumerate() {
if rule_matches(&rule.callers, GENERIC_CALLER) && rule_matches(&rule.targets, target_id)
{
return AclDecision {
effect: rule.effect.clone(),
default_effect: self.default_effect.clone(),
matched_rule_index: Some(idx),
matched_rule_description: rule.description.clone(),
matched_rule_has_conditions: rule.conditions.is_some(),
};
}
}
AclDecision {
effect: self.default_effect.clone(),
default_effect: self.default_effect.clone(),
matched_rule_index: None,
matched_rule_description: None,
matched_rule_has_conditions: false,
}
}
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())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AclDecision {
pub effect: String,
pub default_effect: String,
pub matched_rule_index: Option<usize>,
pub matched_rule_description: Option<String>,
pub matched_rule_has_conditions: bool,
}
const OR_SENTINEL: &str = "$or";
const NOT_SENTINEL: &str = "$not";
const MAX_SUGGESTIONS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnmatchedTarget {
pub rule_index: usize,
pub effect: String,
pub pattern: String,
pub negated: bool,
pub suggestions: Vec<String>,
}
impl UnmatchedTarget {
pub fn is_near_miss(&self) -> bool {
!self.suggestions.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AclValidationReport {
pub unmatched_targets: Vec<UnmatchedTarget>,
}
impl AclValidationReport {
pub fn is_fatal(&self) -> bool {
self.unmatched_targets
.iter()
.any(UnmatchedTarget::is_near_miss)
}
pub fn emit_warnings(&self) {
for target in self.unmatched_targets.iter().filter(|t| !t.is_near_miss()) {
if target.negated {
tracing::warn!(
rule_index = target.rule_index,
effect = %target.effect,
pattern = %target.pattern,
"ACL `$not` operand matches no registered module, so apcore matches \
this rule against EVERY module rather than none. Under \
first-match-wins an `allow` here nullifies every rule below it. \
Harmless if the operand anticipates a module this server does not \
serve (a glob, or an ACL shared with a differently filtered \
server); a typo otherwise."
);
} else {
tracing::warn!(
rule_index = target.rule_index,
effect = %target.effect,
pattern = %target.pattern,
"ACL target matches no registered module — this rule currently \
protects nothing. Harmless if the pattern anticipates a module \
this server does not serve (a glob, or an ACL shared with a \
differently filtered server); a typo otherwise."
);
}
}
}
pub fn fatal_error(&self, acl_path: &Path) -> Option<ModuleError> {
if !self.is_fatal() {
return None;
}
let lines: Vec<String> = self
.unmatched_targets
.iter()
.filter(|t| t.is_near_miss())
.map(describe_near_miss)
.collect();
Some(
ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"ACL '{}' contains {} rule(s) that protect nothing:\n{}\n\
Refusing to start: an operator who asked for access control must not \
silently get less of it than they wrote.",
acl_path.display(),
lines.len(),
lines.join("\n")
),
)
.with_retryable(false),
)
}
}
fn describe_near_miss(target: &UnmatchedTarget) -> String {
if target.negated {
return format!(
" - rule {} ({}): `$not` operand '{}' matches no registered module, so this rule \
applies to every module instead of excluding one; did you mean {}?",
target.rule_index,
target.effect,
target.pattern,
target.suggestions.join(" or ")
);
}
format!(
" - rule {} ({}): target '{}' matches no registered module; did you mean {}?",
target.rule_index,
target.effect,
target.pattern,
target.suggestions.join(" or ")
)
}
pub fn validate_acl_rules(rules: &[ACLRule], registered_ids: &[String]) -> AclValidationReport {
let mut report = AclValidationReport::default();
for (rule_index, rule) in rules.iter().enumerate() {
if registered_ids.is_empty() {
continue;
}
collect_unmatched_targets(rule_index, rule, registered_ids, &mut report);
}
report
}
fn collect_unmatched_targets(
rule_index: usize,
rule: &ACLRule,
registered_ids: &[String],
report: &mut AclValidationReport,
) {
for target in target_patterns(&rule.targets) {
if registered_ids
.iter()
.any(|id| match_pattern(target.pattern, id))
{
continue;
}
report.unmatched_targets.push(UnmatchedTarget {
rule_index,
effect: rule.effect.clone(),
pattern: target.pattern.to_string(),
negated: target.negated,
suggestions: suggest_similar(target.pattern, registered_ids),
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TargetPattern<'a> {
pattern: &'a str,
negated: bool,
}
fn target_patterns(targets: &[String]) -> Vec<TargetPattern<'_>> {
fn positive(pattern: &str) -> TargetPattern<'_> {
TargetPattern {
pattern,
negated: false,
}
}
match targets.first().map(String::as_str) {
Some(OR_SENTINEL) => targets[1..]
.iter()
.map(String::as_str)
.map(positive)
.collect(),
Some(NOT_SENTINEL) => targets
.get(1)
.map(|pattern| TargetPattern {
pattern: pattern.as_str(),
negated: true,
})
.into_iter()
.collect(),
_ => targets.iter().map(String::as_str).map(positive).collect(),
}
}
fn suggest_similar(pattern: &str, registered_ids: &[String]) -> Vec<String> {
let needle = normalize_id(pattern);
if needle.is_empty() || needle.contains('*') {
return vec![];
}
let suffix = format!(".{needle}");
let mut hits: Vec<String> = registered_ids
.iter()
.filter(|id| {
let normalized = normalize_id(id);
normalized == needle || normalized.ends_with(&suffix)
})
.cloned()
.collect();
hits.truncate(MAX_SUGGESTIONS);
hits
}
fn normalize_id(id: &str) -> String {
id.trim()
.to_ascii_lowercase()
.chars()
.map(|c| {
if c == '-' || c == '_' || c == '/' || c.is_whitespace() {
'.'
} else {
c
}
})
.collect()
}
#[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,
open_world: false,
..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::new(
vec!["*".to_string()],
vec!["cli.git.clean".to_string()],
"allow",
));
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"
);
}
fn make_module_with_open_world(
id: &str,
readonly: bool,
destructive: bool,
open_world: bool,
) -> ScannedModule {
let mut module = make_module_with_annotations(id, readonly, destructive);
if let Some(a) = module.annotations.as_mut() {
a.open_world = open_world;
}
module
}
#[test]
fn test_acl_generate_default_open_world_rule_denies_on_its_own() {
let modules = vec![make_module_with_open_world("cli.curl", false, false, true)];
let mgr = AclManager::generate_default(&modules);
let acl = ACL::new(mgr.acl.rules().to_vec(), "allow", None);
assert!(
!acl.check(None, "cli.curl", None),
"an open-world command must be denied by its own rule, not merely by \
a coincidental default_effect"
);
}
#[test]
fn test_acl_open_world_is_not_auto_allowed_even_when_readonly() {
let modules = vec![make_module_with_open_world("cli.curl", true, false, true)];
let mgr = AclManager::generate_default(&modules);
let allow_rule = mgr
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(READONLY_RULE_DESCRIPTION));
assert!(
allow_rule.is_none(),
"a readonly command that reaches the network must not land in the \
auto-allow rule"
);
assert!(
!mgr.acl.check(None, "cli.curl", None),
"and it must actually be denied"
);
}
#[test]
fn test_acl_destructive_open_world_is_listed_in_one_rule_only() {
let modules = vec![make_module_with_open_world(
"cli.git.push",
false,
true,
true,
)];
let mgr = AclManager::generate_default(&modules);
let listings: Vec<_> = mgr
.acl
.rules()
.iter()
.filter(|r| r.targets.iter().any(|t| t == "cli.git.push"))
.map(|r| r.description.clone().unwrap_or_default())
.collect();
assert_eq!(
listings,
vec![DESTRUCTIVE_RULE_DESCRIPTION.to_string()],
"destructive already denies it; the open-world rule must not repeat it"
);
}
#[test]
fn test_acl_merge_default_updates_the_open_world_rule() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("acl.yaml");
let first = vec![make_module_with_open_world("cli.curl", false, false, true)];
AclManager::generate_default(&first)
.write_config(&path)
.unwrap();
let second = vec![make_module_with_open_world("cli.wget", false, false, true)];
let merged = AclManager::merge_default(&path, &second).unwrap();
let rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(OPEN_WORLD_RULE_DESCRIPTION))
.expect("the open-world rule must survive a merge");
assert!(rule.targets.contains(&"cli.curl".to_string()));
assert!(rule.targets.contains(&"cli.wget".to_string()));
}
#[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_merge_default_preserves_targets_from_an_earlier_scan() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl.yaml");
let first_batch = vec![make_module_with_annotations("cli.ls", true, false)];
AclManager::generate_default(&first_batch)
.write_config(&path)
.unwrap();
let second_batch = vec![make_module_with_annotations("cli.echo", false, false)];
let merged = AclManager::merge_default(&path, &second_batch).unwrap();
let readonly_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(READONLY_RULE_DESCRIPTION))
.expect("the readonly-allow rule from the first scan must survive");
assert!(
readonly_rule.targets.contains(&"cli.ls".to_string()),
"{:?}",
readonly_rule.targets
);
}
#[test]
fn test_acl_merge_default_moves_a_module_between_rules_when_its_annotations_change() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl.yaml");
let first_batch = vec![make_module_with_annotations("cli.tool", true, false)];
AclManager::generate_default(&first_batch)
.write_config(&path)
.unwrap();
let second_batch = vec![make_module_with_annotations("cli.tool", false, true)];
let merged = AclManager::merge_default(&path, &second_batch).unwrap();
let readonly_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(READONLY_RULE_DESCRIPTION));
if let Some(rule) = readonly_rule {
assert!(
!rule.targets.contains(&"cli.tool".to_string()),
"{:?}",
rule.targets
);
}
let destructive_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(DESTRUCTIVE_RULE_DESCRIPTION))
.expect("cli.tool must now be in the destructive-deny rule");
assert!(destructive_rule.targets.contains(&"cli.tool".to_string()));
}
#[test]
fn test_acl_merge_default_keeps_hand_authored_rules_untouched() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl.yaml");
std::fs::write(
&path,
"rules:\n - callers: [\"*\"]\n targets: [\"cli.special\"]\n effect: deny\n description: Hand-authored exception\ndefault_effect: allow\n",
)
.unwrap();
let batch = vec![make_module_with_annotations("cli.ls", true, false)];
let merged = AclManager::merge_default(&path, &batch).unwrap();
assert_eq!(
merged.default_effect, "allow",
"existing default_effect must be preserved"
);
let hand_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some("Hand-authored exception"))
.expect("hand-authored rule must survive a merge");
assert_eq!(hand_rule.targets, vec!["cli.special".to_string()]);
}
#[test]
fn test_acl_merge_default_falls_back_to_generate_default_when_no_file_exists() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("does-not-exist.yaml");
let batch = vec![make_module_with_annotations("cli.ls", true, false)];
let result = AclManager::merge_default(&path, &batch);
assert!(
result.is_err(),
"merging against a missing file should error, not silently start from empty"
);
}
#[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");
}
fn rule(targets: &[&str], effect: &str) -> ACLRule {
ACLRule::new(
vec!["*".to_string()],
targets.iter().map(|t| (*t).to_string()).collect(),
effect,
)
}
fn registered(ids: &[&str]) -> Vec<String> {
ids.iter().map(|id| (*id).to_string()).collect()
}
#[test]
fn test_validate_acl_rules_accepts_exact_registered_target() {
let report = validate_acl_rules(
&[rule(&["cli.cp"], "deny")],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report, AclValidationReport::default());
assert!(!report.is_fatal());
}
#[test]
fn test_apcore_refuses_an_inert_pattern_list_at_every_door() {
for (label, callers, targets) in [
("empty targets", vec!["*".to_string()], vec![]),
("empty callers", vec![], vec!["cli.cp".to_string()]),
(
"bare $or",
vec!["*".to_string()],
vec![OR_SENTINEL.to_string()],
),
(
"bare $not",
vec!["*".to_string()],
vec![NOT_SENTINEL.to_string()],
),
] {
let mut r = ACLRule::new(vec!["*".to_string()], vec!["cli.cp".to_string()], "deny");
r.callers = callers;
r.targets = targets;
assert!(
ACL::try_new(vec![r.clone()], "deny", None).is_err(),
"{label}: ACL::try_new must refuse an inert pattern list"
);
assert!(
std::panic::catch_unwind(move || ACL::new(vec![r], "deny", None)).is_err(),
"{label}: ACL::new must panic rather than build an inert rule"
);
}
}
#[test]
fn test_validate_acl_rules_flags_hyphenated_id_as_near_miss() {
let report = validate_acl_rules(
&[rule(&["cli.git.cat-file"], "deny")],
®istered(&["cli.git.cat_file"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert_eq!(
report.unmatched_targets[0].suggestions,
vec!["cli.git.cat_file".to_string()]
);
assert!(report.unmatched_targets[0].is_near_miss());
assert!(report.is_fatal());
}
#[test]
fn test_validate_acl_rules_flags_bare_command_names_as_near_miss() {
for (pattern, expected) in [
("cp", "cli.cp"),
("ls", "cli.ls"),
("git log", "cli.git.log"),
] {
let report = validate_acl_rules(
&[rule(&[pattern], "deny")],
®istered(&["cli.cp", "cli.ls", "cli.git.log"]),
);
assert_eq!(
report.unmatched_targets[0].suggestions,
vec![expected.to_string()],
"'{pattern}' should point at '{expected}'"
);
assert!(report.is_fatal(), "'{pattern}' should refuse to start");
}
}
#[test]
fn test_validate_acl_rules_warns_but_does_not_refuse_on_unknown_target() {
let report = validate_acl_rules(
&[rule(&["cli.kubectl.apply"], "deny")],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert!(report.unmatched_targets[0].suggestions.is_empty());
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_accepts_working_globs() {
let report = validate_acl_rules(
&[
rule(&["cli.c*"], "deny"),
rule(&["*.log"], "allow"),
rule(&["cli.*.status"], "allow"),
rule(&["*"], "deny"),
],
®istered(&["cli.cp", "cli.git.log", "cli.git.status"]),
);
assert_eq!(report, AclValidationReport::default());
}
#[test]
fn test_validate_acl_rules_warns_on_glob_matching_nothing() {
let report = validate_acl_rules(&[rule(&["cli.z*"], "deny")], ®istered(&["cli.cp"]));
assert_eq!(report.unmatched_targets.len(), 1);
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_understands_compound_targets() {
let report = validate_acl_rules(
&[
rule(&["$or", "cli.cp", "cli.ls"], "deny"),
rule(&["$not", "cli.cp", "never.consulted"], "allow"),
],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report, AclValidationReport::default());
}
#[test]
fn test_validate_acl_rules_flags_unmatched_operand_inside_compound() {
let report = validate_acl_rules(
&[rule(&["$or", "cli.cp", "cli.git.cat-file"], "deny")],
®istered(&["cli.cp", "cli.git.cat_file"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert_eq!(report.unmatched_targets[0].pattern, "cli.git.cat-file");
assert!(report.is_fatal());
}
#[test]
fn test_validate_acl_rules_reports_a_not_operand_as_covering_everything() {
let report = validate_acl_rules(
&[rule(&["$not", "cli.kubectl.apply"], "allow")],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
let finding = &report.unmatched_targets[0];
assert!(finding.negated, "the $not polarity must be carried through");
assert_eq!(finding.pattern, "cli.kubectl.apply");
assert!(!finding.is_near_miss());
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_refuses_a_near_miss_not_operand() {
let report = validate_acl_rules(
&[rule(&["$not", "cli.git.cat-file"], "allow")],
®istered(&["cli.git.cat_file", "cli.cp"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert!(report.unmatched_targets[0].negated);
assert!(report.is_fatal());
let message = report
.fatal_error(Path::new("/etc/apexe/acl.yaml"))
.expect("a near-miss $not operand must refuse to start")
.message;
assert!(message.contains("$not"), "{message}");
assert!(
message.contains("applies to every module"),
"the diagnostic must not claim the rule protects nothing: {message}"
);
assert!(message.contains("cli.git.cat_file"), "{message}");
}
#[test]
fn test_validate_acl_rules_skips_target_check_on_empty_registry() {
let report = validate_acl_rules(&[rule(&["cli.cp"], "deny")], &[]);
assert_eq!(report, AclValidationReport::default());
}
#[test]
fn test_acl_validation_report_fatal_error_names_the_findings() {
let report = validate_acl_rules(
&[rule(&["cli.git.cat-file"], "deny")],
®istered(&["cli.git.cat_file"]),
);
let err = report
.fatal_error(Path::new("/etc/apexe/acl.yaml"))
.expect("report should be fatal");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(err.message.contains("/etc/apexe/acl.yaml"));
assert!(err.message.contains("cli.git.cat_file"));
assert!(err.message.contains("cli.git.cat-file"));
}
#[test]
fn test_acl_validation_report_fatal_error_none_when_clean() {
let report = validate_acl_rules(&[rule(&["cli.cp"], "deny")], ®istered(&["cli.cp"]));
assert!(report.fatal_error(Path::new("/tmp/acl.yaml")).is_none());
}
#[test]
fn test_normalize_id_folds_separators_and_case() {
assert_eq!(normalize_id("cli.git.cat-file"), "cli.git.cat.file");
assert_eq!(normalize_id("CLI_GIT_LOG"), "cli.git.log");
assert_eq!(normalize_id(" git log "), "git.log");
}
#[test]
fn test_suggest_similar_does_not_guess_across_unrelated_modules() {
assert!(suggest_similar("clip", ®istered(&["cli.cp"])).is_empty());
assert!(suggest_similar("cli.c*", ®istered(&["cli.cp"])).is_empty());
}
#[tokio::test]
async 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(apcore::EXTERNAL_CALLER), "cli.rm", None);
assert!(!allowed);
let mut content = String::new();
for _ in 0..200 {
content = std::fs::read_to_string(&audit_path).unwrap_or_default();
if !content.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
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");
}
}
#[test]
fn test_explain_reports_the_generated_readonly_allow_rule() {
let modules = vec![make_module_with_annotations("cli.git.status", true, false)];
let mgr = AclManager::generate_default(&modules);
let decision = mgr.explain("cli.git.status");
assert_eq!(decision.effect, "allow");
assert_eq!(decision.matched_rule_index, Some(0));
assert_eq!(
decision.matched_rule_description.as_deref(),
Some(READONLY_RULE_DESCRIPTION)
);
assert!(!decision.matched_rule_has_conditions);
}
#[test]
fn test_explain_reports_the_generated_destructive_deny_rule() {
let modules = vec![make_module_with_annotations("cli.rm", false, true)];
let mgr = AclManager::generate_default(&modules);
let decision = mgr.explain("cli.rm");
assert_eq!(decision.effect, "deny");
assert_eq!(
decision.matched_rule_description.as_deref(),
Some(DESTRUCTIVE_RULE_DESCRIPTION)
);
}
#[test]
fn test_explain_falls_back_to_default_effect_when_no_rule_matches() {
let modules = vec![make_module_with_annotations("cli.echo", false, false)];
let mgr = AclManager::generate_default(&modules);
let decision = mgr.explain("cli.echo");
assert_eq!(decision.effect, "deny");
assert_eq!(decision.default_effect, "deny");
assert_eq!(decision.matched_rule_index, None);
assert_eq!(decision.matched_rule_description, None);
}
#[test]
fn test_explain_agrees_with_acl_check_for_an_external_caller() {
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.rm", false, true),
make_module_with_annotations("cli.echo", false, false),
];
let mgr = AclManager::generate_default(&modules);
for id in ["cli.git.status", "cli.rm", "cli.echo"] {
let decision = mgr.explain(id);
let allowed = mgr.acl.check(Some(apcore::EXTERNAL_CALLER), id, None);
assert_eq!(
decision.effect == "allow",
allowed,
"explain({id}) disagreed with ACL::check"
);
}
}
#[test]
fn test_explain_flags_a_matched_rule_that_carries_conditions() {
let mut rule = ACLRule::new(
vec!["*".to_string()],
vec!["cli.deploy".to_string()],
"allow",
);
rule.description = Some("Ops-only deploy".to_string());
rule.conditions = Some(json!({"roles": ["ops"]}));
let acl = ACL::new(vec![rule], "deny", None);
let mgr = AclManager {
acl,
default_effect: "deny".to_string(),
};
let decision = mgr.explain("cli.deploy");
assert!(decision.matched_rule_has_conditions);
}
#[test]
fn test_default_effect_accessor() {
let mgr = AclManager::generate_default(&[]);
assert_eq!(mgr.default_effect(), "deny");
}
}