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