nacm_validator/
lib.rs

1//! # NACM Validator - Network Access Control Model with Tail-f ACM Extensions
2//!
3//! This library implements NACM (RFC 8341) access control validation in Rust with comprehensive
4//! support for Tail-f ACM (Access Control Model) extensions and multiple configuration files. 
5//! It provides functionality to:
6//!
7//! ## Core NACM Features (RFC 8341)
8//! - Parse real-world NACM XML configurations
9//! - Validate access requests against defined rules
10//! - Handle user groups and rule precedence
11//! - Support various operations (CRUD + exec) and path matching
12//!
13//! ## Multiple Configuration Files Support (v0.2.0+)
14//! - **Configuration Merging**: Combine multiple XML files using YANG merge semantics
15//! - **YANG Compliance**: Proper merge rules (last-wins for globals, additive for groups/rules)
16//! - **Rule Precedence**: Automatic precedence adjustment across files
17//! - **Error Resilience**: Handle invalid files gracefully during merging
18//! - **Modular Configuration**: Support team-based and environment-specific configurations
19//!
20//! ## Tail-f ACM Extensions
21//! - **Command Rules**: Context-aware command access control (CLI, WebUI, NETCONF)
22//! - **Enhanced Logging**: Granular logging control with `log-if-*` attributes
23//! - **ValidationResult**: Returns both access decision and logging indication
24//! - **Group ID Mapping**: External authentication system integration via GID
25//! - **Context Awareness**: Different access policies for different user interfaces
26//!
27//! ## Quick Start
28//!
29//! ### Standard NACM Data Access Validation
30//! 
31//! ```rust
32//! use nacm_validator::{NacmConfig, AccessRequest, Operation, RequestContext};
33//!
34//! // Load configuration from XML
35//! let xml_content = r#"<?xml version="1.0" encoding="UTF-8"?>
36//! <config xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
37//!   <nacm>
38//!     <enable-nacm>true</enable-nacm>
39//!     <read-default>permit</read-default>
40//!     <write-default>deny</write-default>
41//!     <exec-default>permit</exec-default>
42//!     <groups>
43//!       <group>
44//!         <name>admin</name>
45//!         <user-name>alice</user-name>
46//!       </group>
47//!     </groups>
48//!     <rule-list>
49//!       <name>admin-acl</name>
50//!       <group>admin</group>
51//!       <rule>
52//!         <name>permit-all</name>
53//!         <action>permit</action>
54//!       </rule>
55//!     </rule-list>
56//!   </nacm>
57//! </config>"#;
58//! let config = NacmConfig::from_xml(&xml_content)?;
59//!
60//! // Create a data access request
61//! let context = RequestContext::NETCONF;
62//! let request = AccessRequest {
63//!     user: "alice",
64//!     module_name: Some("ietf-interfaces"),
65//!     rpc_name: None,
66//!     operation: Operation::Read,
67//!     path: Some("/interfaces"),
68//!     context: Some(&context),
69//!     command: None,
70//! };
71//!
72//! // Validate the request - returns ValidationResult with access decision and logging info
73//! let result = config.validate(&request);
74//! println!("Access {}: {}", 
75//!          if result.effect == nacm_validator::RuleEffect::Permit { "PERMIT" } else { "DENY" },
76//!          if result.should_log { "[LOGGED]" } else { "" });
77//! # Ok::<(), Box<dyn std::error::Error>>(())
78//! ```
79//!
80//! ### Tail-f ACM Command Access Validation
81//!
82//! ```rust
83//! use nacm_validator::{NacmConfig, AccessRequest, Operation, RequestContext};
84//!
85//! // Load configuration with Tail-f ACM command rules
86//! let xml_content = r#"<?xml version="1.0" encoding="UTF-8"?>
87//! <config xmlns="http://tail-f.com/ns/config/1.0">
88//!     <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
89//!         <enable-nacm>true</enable-nacm>
90//!         <read-default>deny</read-default>
91//!         <write-default>deny</write-default>
92//!         <exec-default>deny</exec-default>
93//!         <cmd-read-default xmlns="http://tail-f.com/yang/acm">permit</cmd-read-default>
94//!         <cmd-exec-default xmlns="http://tail-f.com/yang/acm">deny</cmd-exec-default>
95//!         <groups>
96//!             <group>
97//!                 <name>admin</name>
98//!                 <user-name>alice</user-name>
99//!             </group>
100//!         </groups>
101//!         <rule-list>
102//!             <name>admin-rules</name>
103//!             <group>admin</group>
104//!             <cmdrule xmlns="http://tail-f.com/yang/acm">
105//!                 <name>cli-show</name>
106//!                 <context>cli</context>
107//!                 <command>show *</command>
108//!                 <action>permit</action>
109//!             </cmdrule>
110//!         </rule-list>
111//!     </nacm>
112//! </config>"#;
113//! let config = NacmConfig::from_xml(&xml_content)?;
114//!
115//! // Create a command access request
116//! let context = RequestContext::CLI;
117//! let request = AccessRequest {
118//!     user: "alice",
119//!     module_name: None,
120//!     rpc_name: None,
121//!     operation: Operation::Read,
122//!     path: None,
123//!     context: Some(&context),
124//!     command: Some("show status"),
125//! };
126//!
127//! // Validate command access using Tail-f ACM command rules
128//! let result = config.validate(&request);
129//! match result.effect {
130//!     nacm_validator::RuleEffect::Permit => {
131//!         println!("Command access PERMITTED{}", 
132//!                 if result.should_log { " [LOGGED]" } else { "" });
133//!     },
134//!     nacm_validator::RuleEffect::Deny => {
135//!         println!("Command access DENIED{}", 
136//!                 if result.should_log { " [LOGGED]" } else { "" });
137//!     }
138//! }
139//! # Ok::<(), Box<dyn std::error::Error>>(())
140//! ```
141//!
142//! ### Multiple Configuration Files with YANG Merge Semantics (v0.2.0+)
143//!
144//! ```rust
145//! use nacm_validator::{NacmConfig, AccessRequest, Operation, RequestContext};
146//!
147//! // First configuration file - base settings and admin group
148//! let base_config = r#"<?xml version="1.0" encoding="UTF-8"?>
149//! <config xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
150//!   <nacm>
151//!     <enable-nacm>true</enable-nacm>
152//!     <read-default>deny</read-default>
153//!     <write-default>deny</write-default>
154//!     <exec-default>deny</exec-default>
155//!     <groups>
156//!       <group>
157//!         <name>admin</name>
158//!         <user-name>alice</user-name>
159//!       </group>
160//!     </groups>
161//!     <rule-list>
162//!       <name>admin-rules</name>
163//!       <group>admin</group>
164//!       <rule>
165//!         <name>admin-access</name>
166//!         <action>permit</action>
167//!       </rule>
168//!     </rule-list>
169//!   </nacm>
170//! </config>"#;
171//!
172//! // Second configuration file - operators group and rules  
173//! let ops_config = r#"<?xml version="1.0" encoding="UTF-8"?>
174//! <config xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
175//!   <nacm>
176//!     <enable-nacm>true</enable-nacm>
177//!     <read-default>permit</read-default>  <!-- Override base setting -->
178//!     <write-default>deny</write-default>
179//!     <exec-default>deny</exec-default>
180//!     <groups>
181//!       <group>
182//!         <name>admin</name>
183//!         <user-name>bob</user-name>  <!-- Add to existing admin group -->
184//!       </group>
185//!       <group>
186//!         <name>operators</name>      <!-- New group -->
187//!         <user-name>charlie</user-name>
188//!       </group>
189//!     </groups>
190//!     <rule-list>
191//!       <name>operator-rules</name>   <!-- New rule list -->
192//!       <group>operators</group>
193//!       <rule>
194//!         <name>read-only</name>
195//!         <access-operations>read</access-operations>
196//!         <action>permit</action>
197//!       </rule>
198//!     </rule-list>
199//!   </nacm>
200//! </config>"#;
201//!
202//! // Parse individual configurations
203//! let config1 = NacmConfig::from_xml(&base_config)?;
204//! let config2 = NacmConfig::from_xml(&ops_config)?;
205//!
206//! // Merge configurations using YANG merge semantics
207//! let merged_config = NacmConfig::merge(vec![
208//!     (config1, 0),  // File index 0 - lower precedence
209//!     (config2, 1),  // File index 1 - higher precedence
210//! ])?;
211//!
212//! // Result demonstrates YANG merge semantics:
213//! // - Global settings: read-default is "permit" (last-wins from config2)
214//! // - Groups merged additively: admin now has ["alice", "bob"], operators has ["charlie"] 
215//! // - Rule lists combined: admin-rules (precedence 0) and operator-rules (precedence 10000)
216//! // - Rules maintain proper precedence across files
217//!
218//! assert_eq!(merged_config.read_default, nacm_validator::RuleEffect::Permit); // Last-wins
219//! assert_eq!(merged_config.groups.len(), 2); // admin + operators
220//! assert_eq!(merged_config.groups["admin"].users.len(), 2); // alice + bob merged
221//! assert_eq!(merged_config.rule_lists.len(), 2); // admin-rules + operator-rules
222//!
223//! // Validate access with merged configuration
224//! let request = AccessRequest {
225//!     user: "charlie",
226//!     module_name: None,
227//!     rpc_name: None,
228//!     operation: Operation::Read,
229//!     path: None,
230//!     context: Some(&RequestContext::NETCONF),
231//!     command: None,
232//! };
233//!
234//! let result = merged_config.validate(&request);
235//! // Charlie (operator) gets read access via operator-rules from second config
236//! assert_eq!(result.effect, nacm_validator::RuleEffect::Permit);
237//! # Ok::<(), Box<dyn std::error::Error>>(())
238//! ```
239
240use serde::{Deserialize, Serialize};
241use std::collections::{HashMap, HashSet};
242
243/// NACM Rule effect (permit or deny)
244/// 
245/// This enum represents the final decision for an access request.
246/// In NACM, every rule must have an action that either permits or denies access.
247/// 
248/// # Examples
249/// 
250/// ```
251/// use nacm_validator::RuleEffect;
252/// 
253/// let permit = RuleEffect::Permit;
254/// let deny = RuleEffect::Deny;
255/// 
256/// // Rules with permit effects allow access
257/// assert_eq!(permit == RuleEffect::Permit, true);
258/// ```
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")] // Serializes as "permit"/"deny" in JSON/XML
261pub enum RuleEffect {
262    /// Allow the requested access
263    Permit,
264    /// Deny the requested access
265    Deny,
266}
267
268/// Access control validation result with logging indication
269/// 
270/// This structure contains both the access control decision and whether
271/// the decision should be logged according to the configured logging rules.
272/// This supports the Tail-f ACM extensions that provide fine-grained control
273/// over access control logging.
274/// 
275/// # Examples
276/// 
277/// ```
278/// use nacm_validator::{ValidationResult, RuleEffect};
279/// 
280/// let result = ValidationResult {
281///     effect: RuleEffect::Permit,
282///     should_log: true,
283/// };
284/// 
285/// if result.should_log {
286///     println!("Access {}: should be logged", 
287///              if result.effect == RuleEffect::Permit { "permitted" } else { "denied" });
288/// }
289/// ```
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub struct ValidationResult {
292    /// The access control decision
293    pub effect: RuleEffect,
294    /// Whether this decision should be logged
295    pub should_log: bool,
296}
297
298/// Implementation of `FromStr` trait for `RuleEffect`
299/// 
300/// This allows parsing rule effects from strings (used when parsing XML).
301/// Case-insensitive parsing: "PERMIT", "permit", "Permit" all work.
302impl std::str::FromStr for RuleEffect {
303    type Err = String;
304    
305    fn from_str(s: &str) -> Result<Self, Self::Err> {
306        match s.trim().to_lowercase().as_str() {
307            "permit" => Ok(RuleEffect::Permit),
308            "deny" => Ok(RuleEffect::Deny),
309            _ => Err(format!("Unknown rule effect: {}", s)),
310        }
311    }
312}
313
314/// NACM operations enumeration
315/// 
316/// Represents the different types of operations that can be performed
317/// in a NETCONF/RESTCONF system. Maps to standard CRUD operations plus exec.
318/// 
319/// # Examples
320/// 
321/// ```
322/// use nacm_validator::Operation;
323/// 
324/// let read_op = Operation::Read;
325/// let write_op = Operation::Update;
326/// 
327/// // Operations can be compared
328/// assert_ne!(read_op, write_op);
329/// ```
330#[derive(Debug, Clone, PartialEq, Eq, Hash)]
331pub enum Operation {
332    /// Reading/retrieving data (GET operations)
333    Read,
334    /// Creating new data (POST operations)
335    Create,
336    /// Modifying existing data (PUT/PATCH operations)
337    Update,
338    /// Removing data (DELETE operations)
339    Delete,
340    /// Executing RPCs or actions (RPC operations)
341    Exec,
342}
343
344/// Request context enumeration
345/// 
346/// Represents the different management interfaces or contexts from which
347/// an access request originates. This is part of the Tail-f ACM extensions
348/// that enable context-specific access control rules.
349/// 
350/// # Examples
351/// 
352/// ```
353/// use nacm_validator::RequestContext;
354/// 
355/// let cli_context = RequestContext::CLI;
356/// let netconf_context = RequestContext::NETCONF;
357/// ```
358#[derive(Debug, Clone, PartialEq, Eq, Hash)]
359pub enum RequestContext {
360    /// NETCONF protocol access
361    NETCONF,
362    /// Command-line interface access
363    CLI,
364    /// Web-based user interface access
365    WebUI,
366    /// Other/custom interface
367    Other(String),
368}
369
370impl RequestContext {
371    /// Check if this context matches a pattern
372    /// 
373    /// Supports wildcard matching where "*" matches any context.
374    /// 
375    /// # Arguments
376    /// 
377    /// * `pattern` - The pattern to match against (e.g., "cli", "*", "webui")
378    /// 
379    /// # Returns
380    /// 
381    /// * `true` if the context matches the pattern
382    /// * `false` otherwise
383    pub fn matches(&self, pattern: &str) -> bool {
384        if pattern == "*" {
385            return true;
386        }
387        
388        match self {
389            RequestContext::NETCONF => pattern.eq_ignore_ascii_case("netconf"),
390            RequestContext::CLI => pattern.eq_ignore_ascii_case("cli"),
391            RequestContext::WebUI => pattern.eq_ignore_ascii_case("webui"),
392            RequestContext::Other(name) => pattern.eq_ignore_ascii_case(name),
393        }
394    }
395}
396
397/// Implementation of `FromStr` trait for `Operation`
398/// 
399/// Enables parsing operations from strings, used in CLI and XML parsing.
400/// Case-insensitive: "READ", "read", "Read" all parse to `Operation::Read`.
401impl std::str::FromStr for Operation {
402    type Err = String;
403    
404    fn from_str(s: &str) -> Result<Self, Self::Err> {
405        match s.trim().to_lowercase().as_str() {
406            "read" => Ok(Operation::Read),
407            "create" => Ok(Operation::Create),
408            "update" => Ok(Operation::Update),
409            "delete" => Ok(Operation::Delete),
410            "exec" => Ok(Operation::Exec),
411            _ => Err(format!("Unknown operation: {}", s)),
412        }
413    }
414}
415
416/// NACM Rule structure (extended to match XML format)
417/// 
418/// Represents a single NACM access control rule. Each rule defines:
419/// - What it applies to (module, RPC, path)
420/// - Which operations it covers
421/// - Whether it permits or denies access
422/// - Its precedence order (lower numbers = higher priority)
423/// 
424/// # Fields
425/// 
426/// * `name` - Human-readable identifier for the rule
427/// * `module_name` - YANG module this rule applies to (None = any module)
428/// * `rpc_name` - Specific RPC name (None = any RPC, "*" = wildcard)
429/// * `path` - XPath or data path (None = any path, "/" = root)
430/// * `access_operations` - Set of operations this rule covers
431/// * `effect` - Whether to permit or deny matching requests
432/// * `order` - Rule precedence (lower = higher priority)
433/// * `context` - Request context this rule applies to (Tail-f extension)
434/// * `log_if_permit` - Log when this rule permits access (Tail-f extension)
435/// * `log_if_deny` - Log when this rule denies access (Tail-f extension)
436/// 
437/// # Examples
438/// 
439/// ```
440/// use nacm_validator::{NacmRule, RuleEffect, Operation};
441/// use std::collections::HashSet;
442/// 
443/// let mut ops = HashSet::new();
444/// ops.insert(Operation::Read);
445/// 
446/// let rule = NacmRule {
447///     name: "allow-read-interfaces".to_string(),
448///     module_name: Some("ietf-interfaces".to_string()),
449///     rpc_name: None,
450///     path: Some("/interfaces".to_string()),
451///     access_operations: ops,
452///     effect: RuleEffect::Permit,
453///     order: 10,
454///     context: None,
455///     log_if_permit: false,
456///     log_if_deny: false,
457/// };
458/// ```
459#[derive(Debug, Clone)]
460pub struct NacmRule {
461    /// Unique name for this rule
462    pub name: String,
463    /// YANG module name this rule applies to (None = any module)
464    pub module_name: Option<String>,
465    /// RPC name this rule applies to (None = any RPC)
466    pub rpc_name: Option<String>,
467    /// XPath or data path (None = any path)
468    pub path: Option<String>,
469    /// Set of operations covered by this rule
470    pub access_operations: HashSet<Operation>,
471    /// Whether this rule permits or denies access
472    pub effect: RuleEffect,
473    /// Rule precedence - lower numbers have higher priority
474    pub order: u32,
475    /// Request context pattern this rule applies to (Tail-f extension)
476    pub context: Option<String>,
477    /// Log when this rule permits access (Tail-f extension)
478    pub log_if_permit: bool,
479    /// Log when this rule denies access (Tail-f extension)
480    pub log_if_deny: bool,
481}
482
483/// NACM Command Rule structure (Tail-f ACM extension)
484/// 
485/// Represents a command-based access control rule for CLI and Web UI operations.
486/// Command rules complement standard NACM data access rules by controlling
487/// access to management commands that don't map to NETCONF operations.
488/// 
489/// # Fields
490/// 
491/// * `name` - Human-readable identifier for the command rule
492/// * `context` - Management interface pattern (e.g., "cli", "webui", "*")
493/// * `command` - Command pattern to match (supports wildcards)
494/// * `access_operations` - Set of command operations (read, exec)
495/// * `effect` - Whether to permit or deny matching command requests
496/// * `order` - Rule precedence within the rule list
497/// * `log_if_permit` - Log when this rule permits access
498/// * `log_if_deny` - Log when this rule denies access
499/// * `comment` - Optional description of the rule
500/// 
501/// # Examples
502/// 
503/// ```
504/// use nacm_validator::{NacmCommandRule, RuleEffect, Operation};
505/// use std::collections::HashSet;
506/// 
507/// let mut ops = HashSet::new();
508/// ops.insert(Operation::Read);
509/// ops.insert(Operation::Exec);
510/// 
511/// let cmd_rule = NacmCommandRule {
512///     name: "cli-show-status".to_string(),
513///     context: Some("cli".to_string()),
514///     command: Some("show status".to_string()),
515///     access_operations: ops,
516///     effect: RuleEffect::Permit,
517///     order: 10,
518///     log_if_permit: true,
519///     log_if_deny: false,
520///     comment: Some("Allow operators to view system status".to_string()),
521/// };
522/// ```
523#[derive(Debug, Clone)]
524pub struct NacmCommandRule {
525    /// Unique name for this command rule
526    pub name: String,
527    /// Management interface pattern (e.g., "cli", "webui", "*")
528    pub context: Option<String>,
529    /// Command pattern to match (supports wildcards)
530    pub command: Option<String>,
531    /// Set of command operations covered by this rule
532    pub access_operations: HashSet<Operation>,
533    /// Whether this rule permits or denies access
534    pub effect: RuleEffect,
535    /// Rule precedence within the rule list
536    pub order: u32,
537    /// Log when this rule permits access
538    pub log_if_permit: bool,
539    /// Log when this rule denies access
540    pub log_if_deny: bool,
541    /// Optional description of the rule
542    pub comment: Option<String>,
543}
544
545/// NACM Rule List with associated groups
546/// 
547/// A rule list is a named collection of rules that applies to specific user groups.
548/// Rule lists are processed in order, and within each list, rules are ordered by priority.
549/// 
550/// # Fields
551/// 
552/// * `name` - Identifier for this rule list
553/// * `groups` - User groups this rule list applies to
554/// * `rules` - Ordered list of access control rules
555/// * `command_rules` - Ordered list of command access control rules (Tail-f extension)
556/// 
557/// # Examples
558/// 
559/// ```
560/// use nacm_validator::{NacmRuleList, NacmRule, NacmCommandRule, RuleEffect, Operation};
561/// use std::collections::HashSet;
562/// 
563/// let rule_list = NacmRuleList {
564///     name: "admin-rules".to_string(),
565///     groups: vec!["admin".to_string()],
566///     rules: vec![], // Would contain actual rules
567///     command_rules: vec![], // Would contain command rules
568/// };
569/// ```
570#[derive(Debug, Clone)]
571pub struct NacmRuleList {
572    /// Name of this rule list
573    pub name: String,
574    /// User groups this rule list applies to
575    pub groups: Vec<String>,
576    /// Ordered list of rules in this list
577    pub rules: Vec<NacmRule>,
578    /// Ordered list of command rules in this list (Tail-f extension)
579    pub command_rules: Vec<NacmCommandRule>,
580}
581
582/// NACM Group definition
583/// 
584/// Represents a named group of users. Groups are used to organize users
585/// and apply rule lists to multiple users at once.
586/// 
587/// # Fields
588/// 
589/// * `name` - Group identifier (e.g., "admin", "operators")
590/// * `users` - List of usernames belonging to this group
591/// * `gid` - Optional numerical group ID for OS integration (Tail-f extension)
592/// 
593/// # Examples
594/// 
595/// ```
596/// use nacm_validator::NacmGroup;
597/// 
598/// let admin_group = NacmGroup {
599///     name: "admin".to_string(),
600///     users: vec!["alice".to_string(), "bob".to_string()],
601///     gid: Some(1000),
602/// };
603/// ```
604#[derive(Debug, Clone)]
605pub struct NacmGroup {
606    /// Name of the group
607    pub name: String,
608    /// List of usernames in this group
609    pub users: Vec<String>,
610    /// Optional numerical group ID for OS integration (Tail-f extension)
611    pub gid: Option<i32>,
612}
613
614/// Full NACM configuration
615/// 
616/// The main configuration object that contains all NACM settings:
617/// - Global enable/disable flag
618/// - Default policies for different operation types
619/// - User groups and their members
620/// - Rule lists with access control rules
621/// 
622/// # Fields
623/// 
624/// * `enable_nacm` - Global NACM enable flag
625/// * `read_default` - Default policy for read operations
626/// * `write_default` - Default policy for write operations (create/update/delete)
627/// * `exec_default` - Default policy for exec operations (RPC calls)
628/// * `cmd_read_default` - Default policy for command read operations (Tail-f extension)
629/// * `cmd_exec_default` - Default policy for command exec operations (Tail-f extension)
630/// * `log_if_default_permit` - Log when default policies permit access (Tail-f extension)
631/// * `log_if_default_deny` - Log when default policies deny access (Tail-f extension)
632/// * `groups` - Map of group names to group definitions
633/// * `rule_lists` - List of rule lists, processed in order
634/// 
635/// # Examples
636/// 
637/// ```
638/// use nacm_validator::{NacmConfig, RuleEffect};
639/// use std::collections::HashMap;
640/// 
641/// let config = NacmConfig {
642///     enable_nacm: true,
643///     read_default: RuleEffect::Deny,
644///     write_default: RuleEffect::Deny,
645///     exec_default: RuleEffect::Deny,
646///     cmd_read_default: RuleEffect::Permit,
647///     cmd_exec_default: RuleEffect::Permit,
648///     log_if_default_permit: false,
649///     log_if_default_deny: false,
650///     groups: HashMap::new(),
651///     rule_lists: vec![],
652/// };
653/// ```
654#[derive(Debug, Clone)]
655pub struct NacmConfig {
656    /// Global NACM enable flag - if false, all access is permitted
657    pub enable_nacm: bool,
658    /// Default policy for read operations when no rules match
659    pub read_default: RuleEffect,
660    /// Default policy for write operations (create/update/delete) when no rules match
661    pub write_default: RuleEffect,
662    /// Default policy for exec operations (RPCs) when no rules match
663    pub exec_default: RuleEffect,
664    /// Default policy for command read operations when no command rules match (Tail-f extension)
665    pub cmd_read_default: RuleEffect,
666    /// Default policy for command exec operations when no command rules match (Tail-f extension)
667    pub cmd_exec_default: RuleEffect,
668    /// Log when default policies permit access (Tail-f extension)
669    pub log_if_default_permit: bool,
670    /// Log when default policies deny access (Tail-f extension)
671    pub log_if_default_deny: bool,
672    /// Map of group name to group definition
673    pub groups: HashMap<String, NacmGroup>,
674    /// Ordered list of rule lists
675    pub rule_lists: Vec<NacmRuleList>,
676}
677
678/// Represents an access request for validation
679/// 
680/// This structure contains all the information needed to validate
681/// an access request against NACM rules. Uses borrowed string slices
682/// for efficiency (avoids copying strings).
683/// 
684/// # Lifetimes
685/// 
686/// The `'a` lifetime parameter ensures that this struct doesn't outlive
687/// the data it references. This is Rust's way of preventing dangling pointers.
688/// 
689/// # Fields
690/// 
691/// * `user` - Username making the request
692/// * `module_name` - YANG module being accessed (if applicable)
693/// * `rpc_name` - RPC being called (if applicable)
694/// * `operation` - Type of operation being performed
695/// * `path` - Data path being accessed (if applicable)
696/// * `context` - Request context (NETCONF, CLI, WebUI, etc.) - Tail-f extension
697/// * `command` - Command being executed (for command rules) - Tail-f extension
698/// 
699/// # Examples
700/// 
701/// ```
702/// use nacm_validator::{AccessRequest, Operation, RequestContext};
703/// 
704/// let request = AccessRequest {
705///     user: "alice",
706///     module_name: Some("ietf-interfaces"),
707///     rpc_name: None,
708///     operation: Operation::Read,
709///     path: Some("/interfaces/interface[name='eth0']"),
710///     context: Some(&RequestContext::NETCONF),
711///     command: None,
712/// };
713/// ```
714pub struct AccessRequest<'a> {
715    /// Username making the access request
716    pub user: &'a str,
717    /// YANG module name being accessed (None if not module-specific)
718    pub module_name: Option<&'a str>,
719    /// RPC name being called (None if not an RPC call)
720    pub rpc_name: Option<&'a str>,
721    /// Type of operation being performed
722    pub operation: Operation,
723    /// XPath or data path being accessed (None if not path-specific)
724    pub path: Option<&'a str>,
725    /// Request context (NETCONF, CLI, WebUI, etc.) - Tail-f extension
726    pub context: Option<&'a RequestContext>,
727    /// Command being executed (for command rules) - Tail-f extension
728    pub command: Option<&'a str>,
729}
730
731// ============================================================================
732// XML Parsing Structures
733// ============================================================================
734//
735// The following structures are used internally for parsing XML configuration
736// files. They mirror the XML schema and use serde attributes to handle
737// the conversion from XML elements to Rust structs.
738//
739// These are separate from the main API structs to:
740// 1. Handle XML-specific naming (kebab-case vs snake_case)
741// 2. Deal with XML structure differences (nested elements, attributes)
742// 3. Keep the public API clean and independent of XML format
743//
744// The #[derive(Deserialize)] enables automatic XML parsing via serde-xml-rs.
745// The #[serde(rename = "...")] attributes map XML element names to Rust fields.
746// ============================================================================
747
748/// Root XML configuration element
749/// 
750/// Maps to the top-level `<config>` element in NACM XML files.
751/// Contains the main `<nacm>` configuration block.
752#[derive(Debug, Deserialize)]
753struct XmlConfig {
754    /// The main NACM configuration block
755    #[serde(rename = "nacm")]
756    pub nacm: XmlNacm,
757}
758
759/// Main NACM configuration element from XML
760/// 
761/// Maps to the `<nacm>` element and contains all NACM settings:
762/// global flags, default policies, groups, and rule lists.
763#[derive(Debug, Deserialize)]
764struct XmlNacm {
765    /// Global NACM enable flag (XML: <enable-nacm>)
766    #[serde(rename = "enable-nacm")]
767    pub enable_nacm: bool,
768    /// Default policy for read operations (XML: <read-default>)
769    #[serde(rename = "read-default")]
770    pub read_default: String,
771    /// Default policy for write operations (XML: <write-default>)
772    #[serde(rename = "write-default")]
773    pub write_default: String,
774    /// Default policy for exec operations (XML: <exec-default>)
775    #[serde(rename = "exec-default")]
776    pub exec_default: String,
777    /// Default policy for command read operations (XML: <cmd-read-default>) - Tail-f extension
778    #[serde(rename = "cmd-read-default", default = "default_permit")]
779    pub cmd_read_default: String,
780    /// Default policy for command exec operations (XML: <cmd-exec-default>) - Tail-f extension
781    #[serde(rename = "cmd-exec-default", default = "default_permit")]
782    pub cmd_exec_default: String,
783    /// Log when default policies permit access (XML: <log-if-default-permit/>) - Tail-f extension
784    #[serde(rename = "log-if-default-permit", default)]
785    pub log_if_default_permit: Option<()>,
786    /// Log when default policies deny access (XML: <log-if-default-deny/>) - Tail-f extension
787    #[serde(rename = "log-if-default-deny", default)]
788    pub log_if_default_deny: Option<()>,
789    /// Container for all groups (XML: <groups>)
790    pub groups: XmlGroups,
791    /// List of rule lists (XML: <rule-list> elements)
792    #[serde(rename = "rule-list")]
793    pub rule_lists: Vec<XmlRuleList>,
794}
795
796/// Default function for cmd-read-default and cmd-exec-default
797fn default_permit() -> String {
798    "permit".to_string()
799}
800
801/// Container for group definitions from XML
802/// 
803/// Maps to the `<groups>` element which contains multiple `<group>` elements.
804#[derive(Debug, Deserialize)]
805struct XmlGroups {
806    /// List of individual group definitions
807    pub group: Vec<XmlGroup>,
808}
809
810/// Individual group definition from XML
811/// 
812/// Maps to a `<group>` element containing group name and user list.
813#[derive(Debug, Deserialize)]
814struct XmlGroup {
815    /// Group name (XML: <name>)
816    pub name: String,
817    /// List of usernames in this group (XML: <user-name> elements)
818    /// The `default` attribute provides an empty vector if no users are specified
819    #[serde(rename = "user-name", default)]
820    pub user_names: Vec<String>,
821    /// Optional numerical group ID (XML: <gid>) - Tail-f extension
822    #[serde(default)]
823    pub gid: Option<i32>,
824}
825
826/// Rule list definition from XML
827/// 
828/// Maps to a `<rule-list>` element containing the rule list metadata
829/// and the actual access control rules.
830#[derive(Debug, Deserialize)]
831struct XmlRuleList {
832    /// Rule list name (XML: <name>)
833    pub name: String,
834    /// Group this rule list applies to (XML: <group>)
835    pub group: String,
836    /// List of rules in this rule list (XML: <rule> elements)
837    /// The `default` attribute provides an empty vector if no rules are specified
838    #[serde(default)]
839    pub rule: Vec<XmlRule>,
840    /// List of command rules in this rule list (XML: <cmdrule> elements) - Tail-f extension
841    #[serde(default)]
842    pub cmdrule: Vec<XmlCommandRule>,
843}
844
845/// Individual access control rule from XML
846/// 
847/// Maps to a `<rule>` element with all its sub-elements.
848/// Optional fields use `Option<T>` to handle missing XML elements.
849#[derive(Debug, Deserialize)]
850struct XmlRule {
851    /// Rule name (XML: <name>)
852    pub name: String,
853    /// YANG module name this rule applies to (XML: <module-name>)
854    #[serde(rename = "module-name")]
855    pub module_name: Option<String>,
856    /// RPC name this rule applies to (XML: <rpc-name>)
857    #[serde(rename = "rpc-name")]
858    pub rpc_name: Option<String>,
859    /// XPath or data path (XML: <path>)
860    pub path: Option<String>,
861    /// Space-separated list of operations (XML: <access-operations>)
862    #[serde(rename = "access-operations")]
863    pub access_operations: Option<String>,
864    /// Rule effect: "permit" or "deny" (XML: <action>)
865    pub action: String,
866    /// Request context pattern (XML: <context>) - Tail-f extension
867    #[serde(default)]
868    pub context: Option<String>,
869    /// Log when this rule permits access (XML: <log-if-permit/>) - Tail-f extension
870    #[serde(rename = "log-if-permit", default)]
871    pub log_if_permit: Option<()>,
872    /// Log when this rule denies access (XML: <log-if-deny/>) - Tail-f extension
873    #[serde(rename = "log-if-deny", default)]
874    pub log_if_deny: Option<()>,
875}
876
877/// Individual command access control rule from XML (Tail-f extension)
878/// 
879/// Maps to a `<cmdrule>` element with all its sub-elements.
880/// Optional fields use `Option<T>` to handle missing XML elements.
881#[derive(Debug, Deserialize)]
882struct XmlCommandRule {
883    /// Command rule name (XML: <name>)
884    pub name: String,
885    /// Management interface pattern (XML: <context>)
886    #[serde(default)]
887    pub context: Option<String>,
888    /// Command pattern to match (XML: <command>)
889    #[serde(default)]
890    pub command: Option<String>,
891    /// Space-separated list of command operations (XML: <access-operations>)
892    #[serde(rename = "access-operations")]
893    pub access_operations: Option<String>,
894    /// Rule effect: "permit" or "deny" (XML: <action>)
895    pub action: String,
896    /// Log when this rule permits access (XML: <log-if-permit/>)
897    #[serde(rename = "log-if-permit", default)]
898    pub log_if_permit: Option<()>,
899    /// Log when this rule denies access (XML: <log-if-deny/>)
900    #[serde(rename = "log-if-deny", default)]
901    pub log_if_deny: Option<()>,
902    /// Optional description (XML: <comment>)
903    #[serde(default)]
904    pub comment: Option<String>,
905}
906
907impl NacmConfig {
908    /// Merge multiple NACM configurations into a single configuration
909    /// 
910    /// Implements YANG merge semantics where:
911    /// - Leaf values (global settings) use last-wins strategy
912    /// - List elements (groups, rules) are merged additively
913    /// - Rule precedence is adjusted based on source file ordering
914    /// 
915    /// # Arguments
916    /// 
917    /// * `configs` - Vector of (config, file_index) tuples where file_index determines precedence
918    /// 
919    /// # Returns
920    /// 
921    /// * `Ok(NacmConfig)` - Successfully merged configuration
922    /// * `Err(Box<dyn Error>)` - Merge operation failed
923    /// 
924    /// # Examples
925    /// 
926    /// ```rust
927    /// use nacm_validator::NacmConfig;
928    /// 
929    /// let xml1 = r#"<config xmlns="http://tail-f.com/ns/config/1.0">
930    ///   <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
931    ///     <enable-nacm>true</enable-nacm>
932    ///     <read-default>deny</read-default>
933    ///     <write-default>deny</write-default>
934    ///     <exec-default>deny</exec-default>
935    ///     <groups><group><name>admin</name><user-name>alice</user-name></group></groups>
936    ///     <rule-list><name>admin-rules</name><group>admin</group></rule-list>
937    ///   </nacm>
938    /// </config>"#;
939    /// let xml2 = r#"<config xmlns="http://tail-f.com/ns/config/1.0">
940    ///   <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
941    ///     <enable-nacm>true</enable-nacm>
942    ///     <read-default>permit</read-default>
943    ///     <write-default>deny</write-default>
944    ///     <exec-default>deny</exec-default>
945    ///     <groups><group><name>ops</name><user-name>bob</user-name></group></groups>
946    ///     <rule-list><name>ops-rules</name><group>ops</group></rule-list>
947    ///   </nacm>
948    /// </config>"#;
949    /// 
950    /// let config1 = NacmConfig::from_xml(xml1).unwrap();
951    /// let config2 = NacmConfig::from_xml(xml2).unwrap();
952    /// 
953    /// let merged = NacmConfig::merge(vec![
954    ///     (config1, 0),
955    ///     (config2, 1),
956    /// ]).unwrap();
957    /// assert_eq!(merged.groups.len(), 2); // Both groups merged
958    /// ```
959    pub fn merge(configs: Vec<(NacmConfig, usize)>) -> Result<Self, Box<dyn std::error::Error>> {
960        if configs.is_empty() {
961            return Err("Cannot merge empty configuration list".into());
962        }
963        
964        if configs.len() == 1 {
965            return Ok(configs.into_iter().next().unwrap().0);
966        }
967        
968        // Start with default configuration
969        let mut merged = Self::default();
970        
971        // Merge each configuration in order
972        for (config, file_index) in configs {
973            merged = merged.merge_single_config(config, file_index)?;
974        }
975        
976        Ok(merged)
977    }
978    
979    /// Create a default NACM configuration
980    /// 
981    /// Returns a configuration with reasonable defaults:
982    /// - NACM enabled
983    /// - All operations denied by default
984    /// - No logging by default
985    /// - Empty groups and rule lists
986    pub fn default() -> Self {
987        Self {
988            enable_nacm: true,
989            read_default: RuleEffect::Deny,
990            write_default: RuleEffect::Deny,
991            exec_default: RuleEffect::Deny,
992            cmd_read_default: RuleEffect::Permit,
993            cmd_exec_default: RuleEffect::Permit,
994            log_if_default_permit: false,
995            log_if_default_deny: false,
996            groups: HashMap::new(),
997            rule_lists: Vec::new(),
998        }
999    }
1000    
1001    /// Merge a single configuration into this one
1002    /// 
1003    /// # Arguments
1004    /// 
1005    /// * `other` - Configuration to merge into this one
1006    /// * `file_index` - Index used for rule precedence adjustment
1007    /// 
1008    /// # Returns
1009    /// 
1010    /// * `Result<NacmConfig, Box<dyn Error>>` - Merged configuration or error
1011    fn merge_single_config(mut self, other: NacmConfig, file_index: usize) -> Result<Self, Box<dyn std::error::Error>> {
1012        // Merge leaf values (last-wins strategy)
1013        self.enable_nacm = other.enable_nacm;
1014        self.read_default = other.read_default;
1015        self.write_default = other.write_default;
1016        self.exec_default = other.exec_default;
1017        self.cmd_read_default = other.cmd_read_default;
1018        self.cmd_exec_default = other.cmd_exec_default;
1019        self.log_if_default_permit = other.log_if_default_permit;
1020        self.log_if_default_deny = other.log_if_default_deny;
1021        
1022        // Merge groups (additive)
1023        for (group_name, group) in other.groups {
1024            if self.groups.contains_key(&group_name) {
1025                // Group exists - merge users additively
1026                self.merge_group(&group_name, group)?;
1027            } else {
1028                // New group - add directly
1029                self.groups.insert(group_name, group);
1030            }
1031        }
1032        
1033        // Merge rule lists (additive with precedence adjustment)
1034        for mut rule_list in other.rule_lists {
1035            // Adjust rule precedence based on file order
1036            self.adjust_rule_orders(&mut rule_list, file_index);
1037            
1038            // Check if rule list with same name exists
1039            if let Some(existing_idx) = self.rule_lists.iter().position(|rl| rl.name == rule_list.name) {
1040                // Merge with existing rule list
1041                self.merge_rule_list(existing_idx, rule_list)?;
1042            } else {
1043                // New rule list - add directly
1044                self.rule_lists.push(rule_list);
1045            }
1046        }
1047        
1048        Ok(self)
1049    }
1050    
1051    /// Merge a group with an existing group
1052    /// 
1053    /// # Arguments
1054    /// 
1055    /// * `group_name` - Name of the group to merge
1056    /// * `new_group` - Group data to merge in
1057    /// 
1058    /// # Returns
1059    /// 
1060    /// * `Result<(), Box<dyn Error>>` - Success or error
1061    fn merge_group(&mut self, group_name: &str, new_group: NacmGroup) -> Result<(), Box<dyn std::error::Error>> {
1062        if let Some(existing_group) = self.groups.get_mut(group_name) {
1063            // Merge users additively (avoid duplicates)
1064            for user in new_group.users {
1065                if !existing_group.users.contains(&user) {
1066                    existing_group.users.push(user);
1067                }
1068            }
1069            
1070            // Update GID if provided (last-wins)
1071            if new_group.gid.is_some() {
1072                existing_group.gid = new_group.gid;
1073            }
1074        }
1075        Ok(())
1076    }
1077    
1078    /// Adjust rule orders based on file index
1079    /// 
1080    /// Rules from files loaded later get higher precedence numbers,
1081    /// ensuring that rules from earlier files take precedence.
1082    /// 
1083    /// # Arguments
1084    /// 
1085    /// * `rule_list` - Rule list to adjust
1086    /// * `file_index` - File index for precedence calculation
1087    fn adjust_rule_orders(&self, rule_list: &mut NacmRuleList, file_index: usize) {
1088        // Adjust regular rules
1089        for rule in &mut rule_list.rules {
1090            rule.order = (file_index as u32) * 10000 + rule.order;
1091        }
1092        
1093        // Adjust command rules
1094        for cmd_rule in &mut rule_list.command_rules {
1095            cmd_rule.order = (file_index as u32) * 10000 + cmd_rule.order;
1096        }
1097    }
1098    
1099    /// Merge a rule list with an existing rule list
1100    /// 
1101    /// # Arguments
1102    /// 
1103    /// * `existing_idx` - Index of existing rule list
1104    /// * `new_rule_list` - New rule list to merge in
1105    /// 
1106    /// # Returns
1107    /// 
1108    /// * `Result<(), Box<dyn Error>>` - Success or error
1109    fn merge_rule_list(&mut self, existing_idx: usize, new_rule_list: NacmRuleList) -> Result<(), Box<dyn std::error::Error>> {
1110        let existing = &mut self.rule_lists[existing_idx];
1111        
1112        // Merge groups additively
1113        for group in new_rule_list.groups {
1114            if !existing.groups.contains(&group) {
1115                existing.groups.push(group);
1116            }
1117        }
1118        
1119        // Merge rules additively
1120        existing.rules.extend(new_rule_list.rules);
1121        
1122        // Merge command rules additively
1123        existing.command_rules.extend(new_rule_list.command_rules);
1124        
1125        Ok(())
1126    }
1127
1128    /// Parse NACM configuration from XML string
1129    /// 
1130    /// This function takes an XML string containing NACM configuration
1131    /// and parses it into a `NacmConfig` struct. It handles the conversion
1132    /// from the XML schema to our internal representation.
1133    /// 
1134    /// # Arguments
1135    /// 
1136    /// * `xml_content` - String slice containing the XML configuration
1137    /// 
1138    /// # Returns
1139    /// 
1140    /// * `Ok(NacmConfig)` - Successfully parsed configuration
1141    /// * `Err(Box<dyn Error>)` - Parsing failed (malformed XML, unknown values, etc.)
1142    /// 
1143    /// # Examples
1144    /// 
1145    /// ```rust
1146    /// use nacm_validator::NacmConfig;
1147    /// 
1148    /// let xml = r#"
1149    /// <config xmlns="http://tail-f.com/ns/config/1.0">
1150    ///   <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
1151    ///     <enable-nacm>true</enable-nacm>
1152    ///     <read-default>deny</read-default>
1153    ///     <write-default>deny</write-default>
1154    ///     <exec-default>deny</exec-default>
1155    ///     <groups>
1156    ///       <group>
1157    ///         <name>admin</name>
1158    ///         <user-name>alice</user-name>
1159    ///       </group>
1160    ///     </groups>
1161    ///     <rule-list>
1162    ///       <name>admin-rules</name>
1163    ///       <group>admin</group>
1164    ///     </rule-list>
1165    ///   </nacm>
1166    /// </config>
1167    /// "#;
1168    /// 
1169    /// let config = NacmConfig::from_xml(xml).unwrap();
1170    /// assert_eq!(config.enable_nacm, true);
1171    /// ```
1172    pub fn from_xml(xml_content: &str) -> Result<Self, Box<dyn std::error::Error>> {
1173        // Step 1: Parse XML into intermediate structures
1174        // serde_xml_rs automatically deserializes the XML based on our struct definitions
1175        let xml_config: XmlConfig = serde_xml_rs::from_str(xml_content)?;
1176        
1177        // Step 2: Convert XML groups to our internal representation
1178        // Transform from XML format to HashMap for efficient lookups
1179        let mut groups = HashMap::new();
1180        for xml_group in xml_config.nacm.groups.group {
1181            // Create internal group representation and add to HashMap for O(1) lookup
1182            groups.insert(xml_group.name.clone(), NacmGroup {
1183                name: xml_group.name,
1184                users: xml_group.user_names,
1185                gid: xml_group.gid, // Tail-f extension
1186            });
1187        }
1188        
1189        // Step 3: Convert XML rule lists to our internal representation
1190        // Process each rule list and assign ordering for rule precedence
1191        let mut rule_lists = Vec::new();
1192        for (order_base, xml_rule_list) in xml_config.nacm.rule_lists.iter().enumerate() {
1193            let mut rules = Vec::new();
1194            
1195            // Process each rule within this rule list
1196            for (rule_order, xml_rule) in xml_rule_list.rule.iter().enumerate() {
1197                // Step 3a: Parse access operations from string format
1198                // Handle both wildcard ("*") and space-separated operation lists
1199                let mut access_operations = HashSet::new();
1200                if let Some(ops_str) = &xml_rule.access_operations {
1201                    if ops_str.trim() == "*" {
1202                        // Wildcard means all operations
1203                        access_operations.insert(Operation::Read);
1204                        access_operations.insert(Operation::Create);
1205                        access_operations.insert(Operation::Update);
1206                        access_operations.insert(Operation::Delete);
1207                        access_operations.insert(Operation::Exec);
1208                    } else {
1209                        // Parse space-separated operation names like "read write"
1210                        for op in ops_str.split_whitespace() {
1211                            if let Ok(operation) = op.parse::<Operation>() {
1212                                access_operations.insert(operation);
1213                            }
1214                            // Note: Invalid operations are silently ignored
1215                        }
1216                    }
1217                }
1218                
1219                // Step 3b: Parse the rule effect (permit/deny)
1220                let effect = xml_rule.action.parse::<RuleEffect>()?;
1221                
1222                // Step 3c: Create internal rule representation
1223                // Calculate rule order: rule list order * 1000 + rule position
1224                // This ensures rules in earlier rule lists have higher priority
1225                rules.push(NacmRule {
1226                    name: xml_rule.name.clone(),
1227                    module_name: xml_rule.module_name.clone(),
1228                    rpc_name: xml_rule.rpc_name.clone(),
1229                    path: xml_rule.path.clone(),
1230                    access_operations,
1231                    effect,
1232                    // Calculate rule priority: list_position * 1000 + rule_position
1233                    // This ensures proper ordering across multiple rule lists
1234                    order: (order_base * 1000 + rule_order) as u32,
1235                    context: xml_rule.context.clone(), // Tail-f extension
1236                    log_if_permit: xml_rule.log_if_permit.is_some(), // Tail-f extension
1237                    log_if_deny: xml_rule.log_if_deny.is_some(), // Tail-f extension
1238                });
1239            }
1240            
1241            // Process command rules within this rule list (Tail-f extension)
1242            let mut command_rules = Vec::new();
1243            for (cmd_rule_order, xml_cmd_rule) in xml_rule_list.cmdrule.iter().enumerate() {
1244                // Parse command access operations
1245                let mut cmd_access_operations = HashSet::new();
1246                if let Some(ops_str) = &xml_cmd_rule.access_operations {
1247                    if ops_str.trim() == "*" {
1248                        // For command rules, wildcard typically means read and exec
1249                        cmd_access_operations.insert(Operation::Read);
1250                        cmd_access_operations.insert(Operation::Exec);
1251                    } else {
1252                        // Parse space-separated operation names like "read exec"
1253                        for op in ops_str.split_whitespace() {
1254                            if let Ok(operation) = op.parse::<Operation>() {
1255                                cmd_access_operations.insert(operation);
1256                            }
1257                        }
1258                    }
1259                } else {
1260                    // Default to all command operations if not specified
1261                    cmd_access_operations.insert(Operation::Read);
1262                    cmd_access_operations.insert(Operation::Exec);
1263                }
1264                
1265                // Parse command rule effect
1266                let cmd_effect = xml_cmd_rule.action.parse::<RuleEffect>()?;
1267                
1268                // Create internal command rule representation
1269                command_rules.push(NacmCommandRule {
1270                    name: xml_cmd_rule.name.clone(),
1271                    context: xml_cmd_rule.context.clone(),
1272                    command: xml_cmd_rule.command.clone(),
1273                    access_operations: cmd_access_operations,
1274                    effect: cmd_effect,
1275                    order: (order_base * 1000 + cmd_rule_order) as u32,
1276                    log_if_permit: xml_cmd_rule.log_if_permit.is_some(),
1277                    log_if_deny: xml_cmd_rule.log_if_deny.is_some(),
1278                    comment: xml_cmd_rule.comment.clone(),
1279                });
1280            }
1281            
1282            // Step 3d: Create the rule list with its associated group
1283            // Note: XML format has single group per rule list, our internal format supports multiple
1284            rule_lists.push(NacmRuleList {
1285                name: xml_rule_list.name.clone(),
1286                groups: vec![xml_rule_list.group.clone()],  // Wrap single group in Vec
1287                rules,
1288                command_rules, // Tail-f extension
1289            });
1290        }
1291        
1292        // Step 4: Create the final configuration object
1293        // Parse default policies from strings and assemble everything
1294        Ok(NacmConfig {
1295            enable_nacm: xml_config.nacm.enable_nacm,
1296            // Parse default policy strings ("permit"/"deny") to enum values
1297            read_default: xml_config.nacm.read_default.parse()?,
1298            write_default: xml_config.nacm.write_default.parse()?,
1299            exec_default: xml_config.nacm.exec_default.parse()?,
1300            // Parse Tail-f command default policies
1301            cmd_read_default: xml_config.nacm.cmd_read_default.parse()?,
1302            cmd_exec_default: xml_config.nacm.cmd_exec_default.parse()?,
1303            // Parse Tail-f logging settings (empty elements become true if present)
1304            log_if_default_permit: xml_config.nacm.log_if_default_permit.is_some(),
1305            log_if_default_deny: xml_config.nacm.log_if_default_deny.is_some(),
1306            groups,
1307            rule_lists,
1308        })
1309    }
1310    
1311    /// Validate an access request against the NACM configuration
1312    /// 
1313    /// This is the main validation function that determines whether an access
1314    /// request should be permitted or denied based on the NACM rules, including
1315    /// command rules from the Tail-f ACM extensions.
1316    /// 
1317    /// # Algorithm
1318    /// 
1319    /// 1. If NACM is disabled globally, permit all access
1320    /// 2. Find all groups the user belongs to
1321    /// 3. If this is a command request, check command rules first
1322    /// 4. Otherwise, check standard NACM data access rules
1323    /// 5. Sort rules by precedence (order field)
1324    /// 6. Return the effect and logging info of the first matching rule
1325    /// 7. If no rules match, apply the appropriate default policy
1326    /// 
1327    /// # Arguments
1328    /// 
1329    /// * `req` - The access request to validate
1330    /// 
1331    /// # Returns
1332    /// 
1333    /// * `ValidationResult` - Contains the access decision and logging flag
1334    /// 
1335    /// # Examples
1336    /// 
1337    /// ```rust
1338    /// use nacm_validator::{NacmConfig, AccessRequest, Operation, RequestContext, ValidationResult, RuleEffect};
1339    /// 
1340    /// # let config = NacmConfig {
1341    /// #     enable_nacm: true,
1342    /// #     read_default: RuleEffect::Deny,
1343    /// #     write_default: RuleEffect::Deny,
1344    /// #     exec_default: RuleEffect::Deny,
1345    /// #     cmd_read_default: RuleEffect::Permit,
1346    /// #     cmd_exec_default: RuleEffect::Permit,
1347    /// #     log_if_default_permit: false,
1348    /// #     log_if_default_deny: false,
1349    /// #     groups: std::collections::HashMap::new(),
1350    /// #     rule_lists: vec![],
1351    /// # };
1352    /// let request = AccessRequest {
1353    ///     user: "alice",
1354    ///     module_name: Some("ietf-interfaces"),
1355    ///     rpc_name: None,
1356    ///     operation: Operation::Read,
1357    ///     path: Some("/interfaces"),
1358    ///     context: Some(&RequestContext::NETCONF),
1359    ///     command: None,
1360    /// };
1361    /// 
1362    /// let result = config.validate(&request);
1363    /// // Result contains both the access decision and logging flag
1364    /// ```
1365    pub fn validate(&self, req: &AccessRequest) -> ValidationResult {
1366        // Step 1: If NACM is disabled, permit all access without logging
1367        if !self.enable_nacm {
1368            return ValidationResult {
1369                effect: RuleEffect::Permit,
1370                should_log: false,
1371            };
1372        }
1373        
1374        // Step 2: Find all groups this user belongs to
1375        // Uses functional programming style with iterator chains
1376        let user_groups: Vec<&str> = self.groups
1377            .iter()                    // Iterator over (group_name, group) pairs
1378            .filter_map(|(group_name, group)| {  // Transform and filter in one step
1379                if group.users.contains(&req.user.to_string()) {
1380                    Some(group_name.as_str())  // Include this group name
1381                } else {
1382                    None                       // Skip this group
1383                }
1384            })
1385            .collect();                // Collect into a Vec
1386        
1387        // Step 3: Check if this is a command request
1388        if req.command.is_some() {
1389            return self.validate_command_request(req, &user_groups);
1390        }
1391        
1392        // Step 4: Standard NACM data access validation
1393        self.validate_data_request(req, &user_groups)
1394    }
1395    
1396    /// Validate a command access request (Tail-f ACM extension)
1397    /// 
1398    /// This helper function specifically handles command rule validation
1399    /// for CLI, WebUI, and other command-based access requests.
1400    /// 
1401    /// # Arguments
1402    /// 
1403    /// * `req` - The access request containing command information
1404    /// * `user_groups` - List of groups the user belongs to
1405    /// 
1406    /// # Returns
1407    /// 
1408    /// * `ValidationResult` - Contains the access decision and logging flag
1409    fn validate_command_request(&self, req: &AccessRequest, user_groups: &[&str]) -> ValidationResult {
1410        let mut matching_cmd_rules = Vec::new();
1411        
1412        // Collect all matching command rules from applicable rule lists
1413        for rule_list in &self.rule_lists {
1414            // Check if this rule list applies to any of the user's groups
1415            let applies = rule_list.groups.iter().any(|group| {
1416                group == "*" || user_groups.contains(&group.as_str())
1417            });
1418            
1419            if applies {
1420                // Check each command rule in this rule list
1421                for cmd_rule in &rule_list.command_rules {
1422                    if self.command_rule_matches(cmd_rule, req) {
1423                        matching_cmd_rules.push(cmd_rule);
1424                    }
1425                }
1426            }
1427        }
1428        
1429        // Sort command rules by precedence (lower order = higher priority)
1430        matching_cmd_rules.sort_by_key(|r| r.order);
1431        
1432        // Return the effect of the first matching command rule
1433        if let Some(cmd_rule) = matching_cmd_rules.first() {
1434            let should_log = match cmd_rule.effect {
1435                RuleEffect::Permit => cmd_rule.log_if_permit,
1436                RuleEffect::Deny => cmd_rule.log_if_deny,
1437            };
1438            
1439            ValidationResult {
1440                effect: cmd_rule.effect,
1441                should_log,
1442            }
1443        } else {
1444            // No command rules matched - apply command default policy
1445            let default_effect = match req.operation {
1446                Operation::Read => self.cmd_read_default,
1447                _ => self.cmd_exec_default, // All other operations default to exec policy
1448            };
1449            
1450            let should_log = match default_effect {
1451                RuleEffect::Permit => self.log_if_default_permit,
1452                RuleEffect::Deny => self.log_if_default_deny,
1453            };
1454            
1455            ValidationResult {
1456                effect: default_effect,
1457                should_log,
1458            }
1459        }
1460    }
1461    
1462    /// Validate a data access request (standard NACM)
1463    /// 
1464    /// This helper function handles standard NACM data access rule validation
1465    /// for NETCONF and similar protocol-based requests.
1466    /// 
1467    /// # Arguments
1468    /// 
1469    /// * `req` - The access request containing data access information
1470    /// * `user_groups` - List of groups the user belongs to
1471    /// 
1472    /// # Returns
1473    /// 
1474    /// * `ValidationResult` - Contains the access decision and logging flag
1475    fn validate_data_request(&self, req: &AccessRequest, user_groups: &[&str]) -> ValidationResult {
1476        let mut matching_rules = Vec::new();
1477        
1478        // Collect all matching rules from applicable rule lists
1479        for rule_list in &self.rule_lists {
1480            // Check if this rule list applies to any of the user's groups
1481            let applies = rule_list.groups.iter().any(|group| {
1482                group == "*" || user_groups.contains(&group.as_str())
1483            });
1484            
1485            if applies {
1486                // Check each rule in this rule list
1487                for rule in &rule_list.rules {
1488                    if self.rule_matches(rule, req) {
1489                        matching_rules.push(rule);
1490                    }
1491                }
1492            }
1493        }
1494        
1495        // Sort rules by precedence (lower order = higher priority)
1496        matching_rules.sort_by_key(|r| r.order);
1497        
1498        // Return the effect of the first matching rule
1499        if let Some(rule) = matching_rules.first() {
1500            let should_log = match rule.effect {
1501                RuleEffect::Permit => rule.log_if_permit,
1502                RuleEffect::Deny => rule.log_if_deny,
1503            };
1504            
1505            ValidationResult {
1506                effect: rule.effect,
1507                should_log,
1508            }
1509        } else {
1510            // No rules matched - apply default policy based on operation type
1511            let default_effect = match req.operation {
1512                Operation::Read => self.read_default,
1513                // Group write operations together (create/update/delete)
1514                Operation::Create | Operation::Update | Operation::Delete => self.write_default,
1515                Operation::Exec => self.exec_default,
1516            };
1517            
1518            let should_log = match default_effect {
1519                RuleEffect::Permit => self.log_if_default_permit,
1520                RuleEffect::Deny => self.log_if_default_deny,
1521            };
1522            
1523            ValidationResult {
1524                effect: default_effect,
1525                should_log,
1526            }
1527        }
1528    }
1529    
1530    /// Check if a command rule matches an access request (Tail-f ACM extension)
1531    /// 
1532    /// This private helper function determines whether a specific command rule
1533    /// applies to a given access request. A command rule matches if ALL of its
1534    /// conditions are satisfied (AND logic).
1535    /// 
1536    /// # Matching Logic
1537    /// 
1538    /// * **Operations**: Rule must cover the requested operation
1539    /// * **Context**: Rule's context must match the request context (or be wildcard)
1540    /// * **Command**: Rule's command pattern must match the requested command
1541    /// 
1542    /// # Arguments
1543    /// 
1544    /// * `cmd_rule` - The command rule to check
1545    /// * `req` - The access request to match against
1546    /// 
1547    /// # Returns
1548    /// 
1549    /// * `true` if the command rule matches the request
1550    /// * `false` if any condition fails
1551    fn command_rule_matches(&self, cmd_rule: &NacmCommandRule, req: &AccessRequest) -> bool {
1552        // Check 1: Operations - Rule must cover the requested operation
1553        if !cmd_rule.access_operations.is_empty() && !cmd_rule.access_operations.contains(&req.operation) {
1554            return false;
1555        }
1556        
1557        // Check 2: Context matching
1558        if let Some(rule_context) = &cmd_rule.context {
1559            if let Some(req_context) = req.context {
1560                if !req_context.matches(rule_context) {
1561                    return false;
1562                }
1563            } else if rule_context != "*" {
1564                // Rule specifies context but request has none
1565                return false;
1566            }
1567        }
1568        
1569        // Check 3: Command matching
1570        if let Some(rule_command) = &cmd_rule.command {
1571            if let Some(req_command) = req.command {
1572                if !self.command_matches(rule_command, req_command) {
1573                    return false;
1574                }
1575            } else if rule_command != "*" {
1576                // Rule specifies command but request has none
1577                return false;
1578            }
1579        }
1580        
1581        true
1582    }
1583    
1584    /// Check if a command pattern matches a requested command
1585    /// 
1586    /// Implements command matching logic supporting:
1587    /// - Exact string matching
1588    /// - Wildcard matching with '*'
1589    /// - Prefix matching for command hierarchies
1590    /// 
1591    /// # Arguments
1592    /// 
1593    /// * `pattern` - The command pattern from the rule
1594    /// * `command` - The requested command
1595    /// 
1596    /// # Returns
1597    /// 
1598    /// * `true` if the pattern matches the command
1599    /// * `false` otherwise
1600    fn command_matches(&self, pattern: &str, command: &str) -> bool {
1601        if pattern == "*" {
1602            return true; // Wildcard matches everything
1603        }
1604        
1605        if pattern == command {
1606            return true; // Exact match
1607        }
1608        
1609        // Check for wildcard suffix (e.g., "show *")
1610        if let Some(stripped) = pattern.strip_suffix('*') {
1611            let prefix = &stripped.trim();
1612            return command.starts_with(prefix);
1613        }
1614        
1615        false
1616    }
1617    
1618    
1619    /// Check if a rule matches an access request
1620    /// 
1621    /// This private helper function determines whether a specific rule
1622    /// applies to a given access request. A rule matches if ALL of its
1623    /// conditions are satisfied (AND logic).
1624    /// 
1625    /// # Matching Logic
1626    /// 
1627    /// * **Operations**: Rule must cover the requested operation
1628    /// * **Module**: Rule's module must match (or be unspecified)
1629    /// * **RPC**: Rule's RPC must match (or be wildcard/unspecified)
1630    /// * **Path**: Rule's path must match (with wildcard support)
1631    /// 
1632    /// # Arguments
1633    /// 
1634    /// * `rule` - The rule to check
1635    /// * `req` - The access request to match against
1636    /// 
1637    /// # Returns
1638    /// 
1639    /// * `true` if the rule matches the request
1640    /// * `false` if any condition fails
1641    fn rule_matches(&self, rule: &NacmRule, req: &AccessRequest) -> bool {
1642        // Check 1: Operations - Rule must cover the requested operation
1643        // If rule specifies operations, the request operation must be included
1644        if !rule.access_operations.is_empty() && !rule.access_operations.contains(&req.operation) {
1645            return false;  // Rule doesn't cover this operation
1646        }
1647        
1648        // Check 2: Context matching (Tail-f extension)
1649        if let Some(rule_context) = &rule.context {
1650            if let Some(req_context) = req.context {
1651                if !req_context.matches(rule_context) {
1652                    return false;  // Context doesn't match
1653                }
1654            } else if rule_context != "*" {
1655                // Rule specifies context but request has none
1656                return false;
1657            }
1658        }
1659        
1660        // Check 3: Module name matching
1661        // If rule specifies a module, request must be for the same module
1662        if let Some(rule_module) = &rule.module_name {
1663            if let Some(req_module) = req.module_name {
1664                if rule_module != req_module {
1665                    return false;  // Different modules
1666                }
1667            } else {
1668                return false;  // Rule requires module, but request has none
1669            }
1670        }
1671        
1672        // Check 4: RPC name matching
1673        // Special handling for wildcard ("*") RPCs
1674        if let Some(rule_rpc) = &rule.rpc_name {
1675            if rule_rpc == "*" {
1676                // Wildcard matches any RPC (or no RPC)
1677            } else if let Some(req_rpc) = req.rpc_name {
1678                if rule_rpc != req_rpc {
1679                    return false;  // Different RPC names
1680                }
1681            } else {
1682                return false;  // Rule requires specific RPC, but request has none
1683            }
1684        }
1685        
1686        // Check 5: Path matching (simplified XPath-style matching)
1687        // Supports exact matches and simple wildcard patterns
1688        if let Some(rule_path) = &rule.path {
1689            if rule_path == "/" {
1690                // Root path matches everything (universal path rule)
1691            } else if let Some(req_path) = req.path {
1692                if rule_path.ends_with("/*") {
1693                    // Wildcard path: "/interfaces/*" matches "/interfaces/interface[1]"
1694                    let prefix = &rule_path[..rule_path.len() - 2];
1695                    if !req_path.starts_with(prefix) {
1696                        return false;  // Path doesn't match prefix
1697                    }
1698                } else if rule_path != req_path {
1699                    return false;  // Exact path mismatch
1700                }
1701            } else {
1702                return false;  // Rule requires path, but request has none
1703            }
1704        }
1705        
1706        // All checks passed - rule matches this request
1707        true
1708    }
1709}
1710
1711// --- Example usage and tests ---
1712
1713#[cfg(test)]
1714mod tests {
1715    use super::*;
1716
1717    #[test]
1718    fn test_xml_parsing() {
1719        let xml = r#"
1720        <config xmlns="http://tail-f.com/ns/config/1.0">
1721            <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
1722                <enable-nacm>true</enable-nacm>
1723                <read-default>deny</read-default>
1724                <write-default>deny</write-default>
1725                <exec-default>deny</exec-default>
1726                <groups>
1727                    <group>
1728                        <name>admin</name>
1729                        <user-name>admin</user-name>
1730                    </group>
1731                </groups>
1732                <rule-list>
1733                    <name>admin</name>
1734                    <group>admin</group>
1735                    <rule>
1736                        <name>any-rpc</name>
1737                        <rpc-name>*</rpc-name>
1738                        <access-operations>exec</access-operations>
1739                        <action>permit</action>
1740                    </rule>
1741                </rule-list>
1742            </nacm>
1743        </config>"#;
1744        
1745        let config = NacmConfig::from_xml(xml).unwrap();
1746        assert!(config.enable_nacm);
1747        assert_eq!(config.read_default, RuleEffect::Deny);
1748        assert_eq!(config.groups.len(), 1);
1749        assert_eq!(config.rule_lists.len(), 1);
1750    }
1751
1752    #[test]
1753    fn test_nacm_validation_admin() {
1754        let xml = r#"
1755        <config xmlns="http://tail-f.com/ns/config/1.0">
1756            <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
1757                <enable-nacm>true</enable-nacm>
1758                <read-default>deny</read-default>
1759                <write-default>deny</write-default>
1760                <exec-default>deny</exec-default>
1761                <groups>
1762                    <group>
1763                        <name>admin</name>
1764                        <user-name>admin</user-name>
1765                    </group>
1766                </groups>
1767                <rule-list>
1768                    <name>admin</name>
1769                    <group>admin</group>
1770                    <rule>
1771                        <name>any-rpc</name>
1772                        <rpc-name>*</rpc-name>
1773                        <access-operations>exec</access-operations>
1774                        <action>permit</action>
1775                    </rule>
1776                </rule-list>
1777            </nacm>
1778        </config>"#;
1779        
1780        let config = NacmConfig::from_xml(xml).unwrap();
1781        
1782        let req = AccessRequest {
1783            user: "admin",
1784            module_name: None,
1785            rpc_name: Some("edit-config"),
1786            operation: Operation::Exec,
1787            path: None,
1788            context: Some(&RequestContext::NETCONF),
1789            command: None,
1790        };
1791        
1792        let result = config.validate(&req);
1793        assert_eq!(result.effect, RuleEffect::Permit);
1794    }
1795
1796    #[test]
1797    fn test_real_nacm_xml() {
1798        use std::path::Path;
1799        
1800        let xml_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1801            .join("examples")
1802            .join("data")
1803            .join("aaa_ncm_init.xml");
1804            
1805        let xml = std::fs::read_to_string(&xml_path)
1806            .unwrap_or_else(|_| panic!("Failed to read XML file at {:?}", xml_path));
1807        
1808        let config = NacmConfig::from_xml(&xml).expect("Failed to parse XML");
1809        
1810        // Test admin user - should be able to execute any RPC
1811        let admin_req = AccessRequest {
1812            user: "admin",
1813            module_name: None,
1814            rpc_name: Some("edit-config"),
1815            operation: Operation::Exec,
1816            path: None,
1817            context: Some(&RequestContext::NETCONF),
1818            command: None,
1819        };
1820        let admin_result = config.validate(&admin_req);
1821        assert_eq!(admin_result.effect, RuleEffect::Permit);
1822        
1823        // Test oper user - should be denied edit-config
1824        let oper_req = AccessRequest {
1825            user: "oper",
1826            module_name: None,
1827            rpc_name: Some("edit-config"),
1828            operation: Operation::Exec,
1829            path: None,
1830            context: Some(&RequestContext::NETCONF),
1831            command: None,
1832        };
1833        let oper_result = config.validate(&oper_req);
1834        assert_eq!(oper_result.effect, RuleEffect::Deny);
1835        
1836        // Test oper user - should be denied writing to nacm module
1837        let nacm_write_req = AccessRequest {
1838            user: "oper",
1839            module_name: Some("ietf-netconf-acm"),
1840            rpc_name: None,
1841            operation: Operation::Update,
1842            path: Some("/"),
1843            context: Some(&RequestContext::NETCONF),
1844            command: None,
1845        };
1846        let nacm_write_result = config.validate(&nacm_write_req);
1847        assert_eq!(nacm_write_result.effect, RuleEffect::Deny);
1848        
1849        // Test any user with example module - should be permitted for /misc/*
1850        let example_req = AccessRequest {
1851            user: "Guest",
1852            module_name: Some("example"),
1853            rpc_name: None,
1854            operation: Operation::Read,
1855            path: Some("/misc/foo"),
1856            context: Some(&RequestContext::NETCONF),
1857            command: None,
1858        };
1859        let example_result = config.validate(&example_req);
1860        assert_eq!(example_result.effect, RuleEffect::Permit);
1861        
1862        // Test configuration loaded properly
1863        assert!(config.enable_nacm);
1864        assert_eq!(config.read_default, RuleEffect::Deny);
1865        assert_eq!(config.write_default, RuleEffect::Deny);
1866        assert_eq!(config.exec_default, RuleEffect::Deny);
1867        
1868        // Check groups
1869        assert_eq!(config.groups.len(), 2);
1870        assert!(config.groups.contains_key("admin"));
1871        assert!(config.groups.contains_key("oper"));
1872        
1873        let admin_group = &config.groups["admin"];
1874        assert_eq!(admin_group.users, vec!["admin", "private"]);
1875        
1876        let oper_group = &config.groups["oper"];
1877        assert_eq!(oper_group.users, vec!["oper", "public"]);
1878        
1879        // Check rule lists
1880        assert_eq!(config.rule_lists.len(), 3);
1881        
1882        println!("Successfully parsed {} groups and {} rule lists", 
1883                 config.groups.len(), config.rule_lists.len());
1884    }
1885    
1886    #[test]
1887    fn test_tailf_command_rules() {
1888        let xml = r#"
1889        <config xmlns="http://tail-f.com/ns/config/1.0">
1890            <nacm xmlns="urn:ietf:params:xml:ns:yang:ietf-netconf-acm">
1891                <enable-nacm>true</enable-nacm>
1892                <read-default>deny</read-default>
1893                <write-default>deny</write-default>
1894                <exec-default>deny</exec-default>
1895                <cmd-read-default xmlns="http://tail-f.com/yang/acm">deny</cmd-read-default>
1896                <cmd-exec-default xmlns="http://tail-f.com/yang/acm">deny</cmd-exec-default>
1897                <log-if-default-permit xmlns="http://tail-f.com/yang/acm"/>
1898                <log-if-default-deny xmlns="http://tail-f.com/yang/acm"/>
1899                <groups>
1900                    <group>
1901                        <name>operators</name>
1902                        <user-name>oper</user-name>
1903                        <gid xmlns="http://tail-f.com/yang/acm">1000</gid>
1904                    </group>
1905                </groups>
1906                <rule-list>
1907                    <name>operators</name>
1908                    <group>operators</group>
1909                    <cmdrule xmlns="http://tail-f.com/yang/acm">
1910                        <name>cli-show-status</name>
1911                        <context>cli</context>
1912                        <command>show status</command>
1913                        <access-operations>read exec</access-operations>
1914                        <action>permit</action>
1915                        <log-if-permit/>
1916                    </cmdrule>
1917                    <cmdrule xmlns="http://tail-f.com/yang/acm">
1918                        <name>cli-help</name>
1919                        <context>cli</context>
1920                        <command>help</command>
1921                        <action>permit</action>
1922                    </cmdrule>
1923                    <cmdrule xmlns="http://tail-f.com/yang/acm">
1924                        <name>deny-reboot</name>
1925                        <context>*</context>
1926                        <command>reboot</command>
1927                        <action>deny</action>
1928                        <log-if-deny/>
1929                    </cmdrule>
1930                </rule-list>
1931            </nacm>
1932        </config>"#;
1933        
1934        let config = NacmConfig::from_xml(xml).unwrap();
1935        
1936        // Test Tail-f extensions parsed correctly
1937        assert_eq!(config.cmd_read_default, RuleEffect::Deny);
1938        assert_eq!(config.cmd_exec_default, RuleEffect::Deny);
1939        assert!(config.log_if_default_permit);
1940        assert!(config.log_if_default_deny);
1941        
1942        // Test group GID
1943        let oper_group = &config.groups["operators"];
1944        assert_eq!(oper_group.gid, Some(1000));
1945        
1946        // Test command rule parsed correctly
1947        let rule_list = &config.rule_lists[0];
1948        assert_eq!(rule_list.command_rules.len(), 3);
1949        
1950        let show_status_rule = &rule_list.command_rules[0];
1951        assert_eq!(show_status_rule.name, "cli-show-status");
1952        assert_eq!(show_status_rule.context.as_deref(), Some("cli"));
1953        assert_eq!(show_status_rule.command.as_deref(), Some("show status"));
1954        assert_eq!(show_status_rule.effect, RuleEffect::Permit);
1955        assert!(show_status_rule.log_if_permit);
1956        assert!(!show_status_rule.log_if_deny);
1957        
1958        // Test CLI command validation - should permit
1959        let cli_show_req = AccessRequest {
1960            user: "oper",
1961            module_name: None,
1962            rpc_name: None,
1963            operation: Operation::Read,
1964            path: None,
1965            context: Some(&RequestContext::CLI),
1966            command: Some("show status"),
1967        };
1968        let show_result = config.validate(&cli_show_req);
1969        assert_eq!(show_result.effect, RuleEffect::Permit);
1970        assert!(show_result.should_log); // Should log because rule has log-if-permit
1971        
1972        // Test CLI help command - should permit but not log
1973        let cli_help_req = AccessRequest {
1974            user: "oper",
1975            module_name: None,
1976            rpc_name: None,
1977            operation: Operation::Exec,
1978            path: None,
1979            context: Some(&RequestContext::CLI),
1980            command: Some("help"),
1981        };
1982        let help_result = config.validate(&cli_help_req);
1983        assert_eq!(help_result.effect, RuleEffect::Permit);
1984        assert!(!help_result.should_log); // No logging flags set
1985        
1986        // Test reboot command from any context - should deny and log
1987        let reboot_req = AccessRequest {
1988            user: "oper",
1989            module_name: None,
1990            rpc_name: None,
1991            operation: Operation::Exec,
1992            path: None,
1993            context: Some(&RequestContext::WebUI),
1994            command: Some("reboot"),
1995        };
1996        let reboot_result = config.validate(&reboot_req);
1997        assert_eq!(reboot_result.effect, RuleEffect::Deny);
1998        assert!(reboot_result.should_log); // Should log because rule has log-if-deny
1999        
2000        // Test command that doesn't match any rule - should use default and log
2001        let unknown_cmd_req = AccessRequest {
2002            user: "oper",
2003            module_name: None,
2004            rpc_name: None,
2005            operation: Operation::Exec,
2006            path: None,
2007            context: Some(&RequestContext::CLI),
2008            command: Some("unknown-command"),
2009        };
2010        let unknown_result = config.validate(&unknown_cmd_req);
2011        assert_eq!(unknown_result.effect, RuleEffect::Deny); // cmd-exec-default is deny
2012        assert!(unknown_result.should_log); // log-if-default-deny is true
2013    }
2014}