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}