async_snmp/agent/
vacm.rs

1//! View-based Access Control Model (RFC 3415).
2//!
3//! VACM controls access to MIB objects based on who is making the request
4//! and what they are trying to access. It implements fine-grained access control
5//! through a three-table architecture.
6//!
7//! # Overview
8//!
9//! VACM (View-based Access Control Model) is the standard access control mechanism
10//! for SNMPv3, though it can also be used with SNMPv1/v2c. It answers the question:
11//! "Can this user perform this operation on this OID?"
12//!
13//! # Architecture
14//!
15//! VACM controls access through three tables:
16//!
17//! 1. **Security-to-Group Table**: Maps (securityModel, securityName) to groupName.
18//!    This groups users/communities with similar access rights.
19//!
20//! 2. **Access Table**: Maps (groupName, contextPrefix, securityModel, securityLevel)
21//!    to view names for read, write, and notify operations.
22//!
23//! 3. **View Tree Family Table**: Defines views as collections of OID subtrees,
24//!    with optional inclusion/exclusion and wildcard masks.
25//!
26//! # Basic Example
27//!
28//! Configure read-only access for "public" community:
29//!
30//! ```rust
31//! use async_snmp::agent::{Agent, SecurityModel, VacmBuilder};
32//! use async_snmp::oid;
33//!
34//! # fn example() {
35//! let vacm = VacmBuilder::new()
36//!     // Map "public" community to "readonly_group"
37//!     .group("public", SecurityModel::V2c, "readonly_group")
38//!     // Grant read access to full_view
39//!     .access("readonly_group", |a| a.read_view("full_view"))
40//!     // Define what OIDs are in full_view
41//!     .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
42//!     .build();
43//! # }
44//! ```
45//!
46//! # Read/Write Access Example
47//!
48//! Configure different access levels for different users:
49//!
50//! ```rust
51//! use async_snmp::agent::{Agent, SecurityModel, VacmBuilder};
52//! use async_snmp::message::SecurityLevel;
53//! use async_snmp::oid;
54//!
55//! # fn example() {
56//! let vacm = VacmBuilder::new()
57//!     // Read-only community
58//!     .group("public", SecurityModel::V2c, "readers")
59//!     // Read-write community
60//!     .group("private", SecurityModel::V2c, "writers")
61//!     // SNMPv3 admin user
62//!     .group("admin", SecurityModel::Usm, "admins")
63//!
64//!     // Readers can only read
65//!     .access("readers", |a| a
66//!         .read_view("system_view"))
67//!
68//!     // Writers can read everything and write to ifAdminStatus
69//!     .access("writers", |a| a
70//!         .read_view("full_view")
71//!         .write_view("if_admin_view"))
72//!
73//!     // Admins require encryption and can read/write everything
74//!     .access("admins", |a| a
75//!         .security_model(SecurityModel::Usm)
76//!         .security_level(SecurityLevel::AuthPriv)
77//!         .read_view("full_view")
78//!         .write_view("full_view"))
79//!
80//!     // Define views
81//!     .view("system_view", |v| v
82//!         .include(oid!(1, 3, 6, 1, 2, 1, 1)))  // system MIB only
83//!     .view("full_view", |v| v
84//!         .include(oid!(1, 3, 6, 1)))           // everything
85//!     .view("if_admin_view", |v| v
86//!         .include(oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 7)))  // ifAdminStatus
87//!     .build();
88//! # }
89//! ```
90//!
91//! # View Exclusions
92//!
93//! Views can exclude specific subtrees from a broader include:
94//!
95//! ```rust
96//! use async_snmp::agent::View;
97//! use async_snmp::oid;
98//!
99//! // Include all of system MIB except sysServices
100//! let view = View::new()
101//!     .include(oid!(1, 3, 6, 1, 2, 1, 1))        // system MIB
102//!     .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7));    // except sysServices
103//!
104//! assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));   // sysDescr.0 - allowed
105//! assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)));  // sysServices.0 - blocked
106//! ```
107//!
108//! # Wildcard Masks
109//!
110//! Masks allow matching OIDs with wildcards at specific positions:
111//!
112//! ```rust
113//! use async_snmp::agent::ViewSubtree;
114//! use async_snmp::oid;
115//!
116//! // Match ifDescr for any interface index (ifDescr.*)
117//! // OID: 1.3.6.1.2.1.2.2.1.2 (10 arcs, indices 0-9)
118//! // Mask: 0xFF 0xC0 = 11111111 11000000 (arcs 0-9 must match, 10+ wildcard)
119//! let subtree = ViewSubtree {
120//!     oid: oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2),  // ifDescr
121//!     mask: vec![0xFF, 0xC0],
122//!     included: true,
123//! };
124//!
125//! // Matches any interface index
126//! assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));    // ifDescr.1
127//! assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 100)));  // ifDescr.100
128//!
129//! // Does not match different columns
130//! assert!(!subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3, 1)));   // ifType.1
131//! ```
132//!
133//! # Integration with Agent
134//!
135//! Use [`AgentBuilder::vacm()`](super::AgentBuilder::vacm) to configure VACM:
136//!
137//! ```rust,no_run
138//! use async_snmp::agent::{Agent, SecurityModel};
139//! use async_snmp::oid;
140//!
141//! # async fn example() -> Result<(), async_snmp::Error> {
142//! let agent = Agent::builder()
143//!     .bind("0.0.0.0:161")
144//!     .community(b"public")
145//!     .community(b"private")
146//!     .vacm(|v| v
147//!         .group("public", SecurityModel::V2c, "readonly")
148//!         .group("private", SecurityModel::V2c, "readwrite")
149//!         .access("readonly", |a| a.read_view("all"))
150//!         .access("readwrite", |a| a.read_view("all").write_view("all"))
151//!         .view("all", |v| v.include(oid!(1, 3, 6, 1))))
152//!     .build()
153//!     .await?;
154//! # Ok(())
155//! # }
156//! ```
157//!
158//! # Access Denied Behavior
159//!
160//! When VACM denies access:
161//! - **SNMPv1**: Returns `noSuchName` error
162//! - **SNMPv2c/v3 GET**: Returns `noAccess` error or `NoSuchObject` per RFC 3416
163//! - **SNMPv2c/v3 SET**: Returns `noAccess` error
164
165use std::collections::HashMap;
166
167use bytes::Bytes;
168
169use crate::message::SecurityLevel;
170use crate::oid::Oid;
171
172/// Security model identifiers (RFC 3411).
173///
174/// Used to specify which SNMP version/security mechanism a mapping applies to.
175///
176/// # Example
177///
178/// ```rust
179/// use async_snmp::agent::{SecurityModel, VacmBuilder};
180///
181/// let vacm = VacmBuilder::new()
182///     // Map communities to groups by security model
183///     .group("public", SecurityModel::V2c, "readonly")
184///     .group("admin", SecurityModel::Usm, "admin_group")
185///     // Any model can match as a fallback
186///     .group("universal", SecurityModel::Any, "universal_group")
187///     .build();
188/// ```
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum SecurityModel {
191    /// Wildcard for VACM matching (matches any model).
192    ///
193    /// Use this when the same mapping should apply regardless of SNMP version.
194    Any = 0,
195    /// SNMPv1.
196    V1 = 1,
197    /// SNMPv2c.
198    V2c = 2,
199    /// SNMPv3 User-based Security Model.
200    Usm = 3,
201}
202
203/// Context matching mode for access entries.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub(crate) enum ContextMatch {
206    /// Exact context name match.
207    #[default]
208    Exact,
209    /// Context name prefix match.
210    Prefix,
211}
212
213/// A view is a collection of OID subtrees defining accessible objects.
214///
215/// Views are used by VACM to determine which OIDs a user can access.
216/// Each view consists of included and/or excluded subtrees.
217///
218/// # Example
219///
220/// ```rust
221/// use async_snmp::agent::View;
222/// use async_snmp::oid;
223///
224/// // Create a view that includes the system MIB but excludes sysContact
225/// let view = View::new()
226///     .include(oid!(1, 3, 6, 1, 2, 1, 1))        // system MIB
227///     .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 4));    // sysContact
228///
229/// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));   // sysDescr.0
230/// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 4, 0)));  // sysContact.0
231/// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 2)));        // interfaces MIB
232/// ```
233#[derive(Debug, Clone, Default)]
234pub struct View {
235    subtrees: Vec<ViewSubtree>,
236}
237
238impl View {
239    /// Create a new empty view.
240    ///
241    /// An empty view contains no OIDs. Add subtrees with [`include()`](View::include)
242    /// or [`exclude()`](View::exclude).
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Add an included subtree to the view.
248    ///
249    /// All OIDs starting with `oid` will be included in the view,
250    /// unless excluded by a later [`exclude()`](View::exclude) call.
251    ///
252    /// # Example
253    ///
254    /// ```rust
255    /// use async_snmp::agent::View;
256    /// use async_snmp::oid;
257    ///
258    /// let view = View::new()
259    ///     .include(oid!(1, 3, 6, 1, 2, 1))  // MIB-2
260    ///     .include(oid!(1, 3, 6, 1, 4, 1)); // enterprises
261    ///
262    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
263    /// assert!(view.contains(&oid!(1, 3, 6, 1, 4, 1, 99999, 1)));
264    /// ```
265    pub fn include(mut self, oid: Oid) -> Self {
266        self.subtrees.push(ViewSubtree {
267            oid,
268            mask: Vec::new(),
269            included: true,
270        });
271        self
272    }
273
274    /// Add an included subtree with a wildcard mask.
275    ///
276    /// The mask allows wildcards at specific OID arc positions.
277    /// See [`ViewSubtree::mask`] for mask format details.
278    ///
279    /// # Example
280    ///
281    /// ```rust
282    /// use async_snmp::agent::View;
283    /// use async_snmp::oid;
284    ///
285    /// // Include ifDescr for any interface (mask makes arc 10 a wildcard)
286    /// let view = View::new()
287    ///     .include_masked(
288    ///         oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2),  // ifDescr
289    ///         vec![0xFF, 0xC0]  // First 10 arcs must match
290    ///     );
291    ///
292    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));   // ifDescr.1
293    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 100))); // ifDescr.100
294    /// ```
295    pub fn include_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
296        self.subtrees.push(ViewSubtree {
297            oid,
298            mask,
299            included: true,
300        });
301        self
302    }
303
304    /// Add an excluded subtree to the view.
305    ///
306    /// OIDs starting with `oid` will be excluded, even if they match
307    /// an included subtree. Exclusions take precedence.
308    ///
309    /// # Example
310    ///
311    /// ```rust
312    /// use async_snmp::agent::View;
313    /// use async_snmp::oid;
314    ///
315    /// let view = View::new()
316    ///     .include(oid!(1, 3, 6, 1, 2, 1, 1))     // system MIB
317    ///     .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 6)); // except sysLocation
318    ///
319    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));  // sysDescr
320    /// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 6, 0))); // sysLocation
321    /// ```
322    pub fn exclude(mut self, oid: Oid) -> Self {
323        self.subtrees.push(ViewSubtree {
324            oid,
325            mask: Vec::new(),
326            included: false,
327        });
328        self
329    }
330
331    /// Add an excluded subtree with a wildcard mask.
332    ///
333    /// See [`include_masked()`](View::include_masked) for mask usage.
334    pub fn exclude_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
335        self.subtrees.push(ViewSubtree {
336            oid,
337            mask,
338            included: false,
339        });
340        self
341    }
342
343    /// Check if an OID is in this view.
344    ///
345    /// Per RFC 3415 Section 5, an OID is in the view if:
346    /// - At least one included subtree matches, AND
347    /// - No excluded subtree matches
348    ///
349    /// # Example
350    ///
351    /// ```rust
352    /// use async_snmp::agent::View;
353    /// use async_snmp::oid;
354    ///
355    /// let view = View::new()
356    ///     .include(oid!(1, 3, 6, 1, 2, 1))
357    ///     .exclude(oid!(1, 3, 6, 1, 2, 1, 25));  // host resources
358    ///
359    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
360    /// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 25, 1, 0)));
361    /// assert!(!view.contains(&oid!(1, 3, 6, 1, 4, 1)));  // not included
362    /// ```
363    pub fn contains(&self, oid: &Oid) -> bool {
364        let mut dominated_by_include = false;
365        let mut dominated_by_exclude = false;
366
367        for subtree in &self.subtrees {
368            if subtree.matches(oid) {
369                if subtree.included {
370                    dominated_by_include = true;
371                } else {
372                    dominated_by_exclude = true;
373                }
374            }
375        }
376
377        // Included and not excluded
378        dominated_by_include && !dominated_by_exclude
379    }
380}
381
382/// A subtree in a view with optional mask.
383#[derive(Debug, Clone)]
384pub struct ViewSubtree {
385    /// Base OID of subtree.
386    pub oid: Oid,
387    /// Bit mask for wildcard matching (empty = exact match).
388    ///
389    /// Each bit position corresponds to an arc in the OID:
390    /// - Bit 7 (MSB) of byte 0 = arc 0
391    /// - Bit 6 of byte 0 = arc 1
392    /// - etc.
393    ///
394    /// A bit value of 1 means the arc must match exactly.
395    /// A bit value of 0 means any value is accepted (wildcard).
396    pub mask: Vec<u8>,
397    /// Include (true) or exclude (false) this subtree.
398    pub included: bool,
399}
400
401impl ViewSubtree {
402    /// Check if an OID matches this subtree (with mask).
403    pub fn matches(&self, oid: &Oid) -> bool {
404        let subtree_arcs = self.oid.arcs();
405        let oid_arcs = oid.arcs();
406
407        // OID must be at least as long as subtree
408        if oid_arcs.len() < subtree_arcs.len() {
409            return false;
410        }
411
412        // Check each arc against mask
413        for (i, &subtree_arc) in subtree_arcs.iter().enumerate() {
414            let mask_bit = if i / 8 < self.mask.len() {
415                (self.mask[i / 8] >> (7 - (i % 8))) & 1
416            } else {
417                1 // Default: exact match required
418            };
419
420            if mask_bit == 1 && oid_arcs[i] != subtree_arc {
421                return false;
422            }
423            // mask_bit == 0: wildcard, any value matches
424        }
425
426        true
427    }
428}
429
430/// Access table entry.
431#[derive(Debug, Clone)]
432pub struct VacmAccessEntry {
433    /// Group name this entry applies to.
434    pub group_name: Bytes,
435    /// Context prefix for matching.
436    pub context_prefix: Bytes,
437    /// Security model (or Any for wildcard).
438    pub security_model: SecurityModel,
439    /// Minimum security level required.
440    pub security_level: SecurityLevel,
441    /// Context matching mode.
442    pub(crate) context_match: ContextMatch,
443    /// View name for read access.
444    pub read_view: Bytes,
445    /// View name for write access.
446    pub write_view: Bytes,
447    /// View name for notify access (traps/informs).
448    pub notify_view: Bytes,
449}
450
451/// Builder for access entries.
452///
453/// Configure what views a group can access for different operations.
454/// Typically used via [`VacmBuilder::access()`].
455///
456/// # Example
457///
458/// ```rust
459/// use async_snmp::agent::{SecurityModel, VacmBuilder};
460/// use async_snmp::message::SecurityLevel;
461/// use async_snmp::oid;
462///
463/// let vacm = VacmBuilder::new()
464///     .group("admin", SecurityModel::Usm, "admin_group")
465///     .access("admin_group", |a| a
466///         .security_model(SecurityModel::Usm)
467///         .security_level(SecurityLevel::AuthPriv)
468///         .read_view("full_view")
469///         .write_view("config_view")
470///         .notify_view("trap_view"))
471///     .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
472///     .view("config_view", |v| v.include(oid!(1, 3, 6, 1, 4, 1)))
473///     .view("trap_view", |v| v.include(oid!(1, 3, 6, 1)))
474///     .build();
475/// ```
476pub struct AccessEntryBuilder {
477    group_name: Bytes,
478    context_prefix: Bytes,
479    security_model: SecurityModel,
480    security_level: SecurityLevel,
481    context_match: ContextMatch,
482    read_view: Bytes,
483    write_view: Bytes,
484    notify_view: Bytes,
485}
486
487impl AccessEntryBuilder {
488    /// Create a new access entry builder for a group.
489    pub fn new(group_name: impl Into<Bytes>) -> Self {
490        Self {
491            group_name: group_name.into(),
492            context_prefix: Bytes::new(),
493            security_model: SecurityModel::Any,
494            security_level: SecurityLevel::NoAuthNoPriv,
495            context_match: ContextMatch::Exact,
496            read_view: Bytes::new(),
497            write_view: Bytes::new(),
498            notify_view: Bytes::new(),
499        }
500    }
501
502    /// Set the context prefix for matching.
503    ///
504    /// Context is an SNMPv3 concept that allows partitioning MIB views.
505    /// Most deployments use an empty context (the default).
506    pub fn context_prefix(mut self, prefix: impl Into<Bytes>) -> Self {
507        self.context_prefix = prefix.into();
508        self
509    }
510
511    /// Set the security model this entry applies to.
512    ///
513    /// Default is [`SecurityModel::Any`] which matches all models.
514    pub fn security_model(mut self, model: SecurityModel) -> Self {
515        self.security_model = model;
516        self
517    }
518
519    /// Set the minimum security level required.
520    ///
521    /// Requests with lower security levels will be denied access.
522    /// Default is [`SecurityLevel::NoAuthNoPriv`].
523    ///
524    /// # Example
525    ///
526    /// ```rust
527    /// use async_snmp::agent::{SecurityModel, VacmBuilder};
528    /// use async_snmp::message::SecurityLevel;
529    /// use async_snmp::oid;
530    ///
531    /// let vacm = VacmBuilder::new()
532    ///     .group("admin", SecurityModel::Usm, "secure_group")
533    ///     .access("secure_group", |a| a
534    ///         // Require authentication and encryption
535    ///         .security_level(SecurityLevel::AuthPriv)
536    ///         .read_view("full_view"))
537    ///     .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
538    ///     .build();
539    /// ```
540    pub fn security_level(mut self, level: SecurityLevel) -> Self {
541        self.security_level = level;
542        self
543    }
544
545    /// Set context matching to prefix mode.
546    ///
547    /// When enabled, the context prefix is matched against the start of
548    /// the request context name rather than requiring an exact match.
549    /// The default is exact matching.
550    pub fn context_match_prefix(mut self) -> Self {
551        self.context_match = ContextMatch::Prefix;
552        self
553    }
554
555    /// Set the read view name.
556    ///
557    /// The view must be defined with [`VacmBuilder::view()`].
558    /// If not set, read operations are denied.
559    pub fn read_view(mut self, view: impl Into<Bytes>) -> Self {
560        self.read_view = view.into();
561        self
562    }
563
564    /// Set the write view name.
565    ///
566    /// The view must be defined with [`VacmBuilder::view()`].
567    /// If not set, write (SET) operations are denied.
568    pub fn write_view(mut self, view: impl Into<Bytes>) -> Self {
569        self.write_view = view.into();
570        self
571    }
572
573    /// Set the notify view name.
574    ///
575    /// Used for trap/inform generation (not access control).
576    /// The view must be defined with [`VacmBuilder::view()`].
577    pub fn notify_view(mut self, view: impl Into<Bytes>) -> Self {
578        self.notify_view = view.into();
579        self
580    }
581
582    /// Build the access entry.
583    pub fn build(self) -> VacmAccessEntry {
584        VacmAccessEntry {
585            group_name: self.group_name,
586            context_prefix: self.context_prefix,
587            security_model: self.security_model,
588            security_level: self.security_level,
589            context_match: self.context_match,
590            read_view: self.read_view,
591            write_view: self.write_view,
592            notify_view: self.notify_view,
593        }
594    }
595}
596
597/// VACM configuration.
598#[derive(Debug, Clone, Default)]
599pub struct VacmConfig {
600    /// (securityModel, securityName) → groupName
601    security_to_group: HashMap<(SecurityModel, Bytes), Bytes>,
602    /// Access table entries.
603    access_entries: Vec<VacmAccessEntry>,
604    /// viewName → View
605    views: HashMap<Bytes, View>,
606}
607
608impl VacmConfig {
609    /// Create a new empty VACM configuration.
610    pub fn new() -> Self {
611        Self::default()
612    }
613
614    /// Map a security name to a group for a specific security model.
615    pub fn add_group(
616        &mut self,
617        security_name: impl Into<Bytes>,
618        security_model: SecurityModel,
619        group_name: impl Into<Bytes>,
620    ) {
621        self.security_to_group
622            .insert((security_model, security_name.into()), group_name.into());
623    }
624
625    /// Add an access entry.
626    pub fn add_access(&mut self, entry: VacmAccessEntry) {
627        self.access_entries.push(entry);
628    }
629
630    /// Add a view.
631    pub fn add_view(&mut self, name: impl Into<Bytes>, view: View) {
632        self.views.insert(name.into(), view);
633    }
634
635    /// Resolve group name for a request.
636    pub fn get_group(&self, model: SecurityModel, name: &[u8]) -> Option<&Bytes> {
637        let name_bytes = Bytes::copy_from_slice(name);
638        // Try exact match first
639        self.security_to_group
640            .get(&(model, name_bytes.clone()))
641            // Fall back to Any security model
642            .or_else(|| {
643                self.security_to_group
644                    .get(&(SecurityModel::Any, name_bytes))
645            })
646    }
647
648    /// Get access entry for context.
649    ///
650    /// Returns the best matching entry per RFC 3415 Section 4:
651    /// - Prefer specific security model over Any
652    /// - Prefer longer context prefix
653    pub fn get_access(
654        &self,
655        group: &[u8],
656        context: &[u8],
657        model: SecurityModel,
658        level: SecurityLevel,
659    ) -> Option<&VacmAccessEntry> {
660        // Find best matching entry
661        self.access_entries
662            .iter()
663            .filter(|e| {
664                e.group_name.as_ref() == group
665                    && self.context_matches(&e.context_prefix, context, e.context_match)
666                    && (e.security_model == model || e.security_model == SecurityModel::Any)
667                    && level >= e.security_level
668            })
669            .max_by_key(|e| {
670                // Prefer specific matches
671                let model_score = if e.security_model == model { 2 } else { 1 };
672                let context_score = e.context_prefix.len();
673                (model_score, context_score)
674            })
675    }
676
677    /// Check if context matches the prefix.
678    fn context_matches(&self, prefix: &[u8], context: &[u8], mode: ContextMatch) -> bool {
679        match mode {
680            ContextMatch::Exact => prefix == context,
681            ContextMatch::Prefix => context.starts_with(prefix),
682        }
683    }
684
685    /// Check if OID access is permitted.
686    pub fn check_access(&self, view_name: Option<&Bytes>, oid: &Oid) -> bool {
687        let Some(view_name) = view_name else {
688            return false;
689        };
690
691        if view_name.is_empty() {
692            return false;
693        }
694
695        let Some(view) = self.views.get(view_name) else {
696            return false;
697        };
698
699        view.contains(oid)
700    }
701}
702
703/// Builder for VACM configuration.
704///
705/// Use this to configure access control for your SNMP agent. The typical
706/// workflow is:
707///
708/// 1. Map security names (communities/usernames) to groups with [`group()`](VacmBuilder::group)
709/// 2. Define access rules for groups with [`access()`](VacmBuilder::access)
710/// 3. Define views (OID collections) with [`view()`](VacmBuilder::view)
711/// 4. Build with [`build()`](VacmBuilder::build)
712///
713/// # Example
714///
715/// ```rust
716/// use async_snmp::agent::{SecurityModel, VacmBuilder};
717/// use async_snmp::message::SecurityLevel;
718/// use async_snmp::oid;
719///
720/// let vacm = VacmBuilder::new()
721///     // Step 1: Map security names to groups
722///     .group("public", SecurityModel::V2c, "readers")
723///     .group("admin", SecurityModel::Usm, "admins")
724///
725///     // Step 2: Define access for each group
726///     .access("readers", |a| a
727///         .read_view("system_view"))
728///     .access("admins", |a| a
729///         .security_level(SecurityLevel::AuthPriv)
730///         .read_view("full_view")
731///         .write_view("full_view"))
732///
733///     // Step 3: Define views
734///     .view("system_view", |v| v
735///         .include(oid!(1, 3, 6, 1, 2, 1, 1)))
736///     .view("full_view", |v| v
737///         .include(oid!(1, 3, 6, 1)))
738///
739///     // Step 4: Build
740///     .build();
741/// ```
742pub struct VacmBuilder {
743    config: VacmConfig,
744}
745
746impl VacmBuilder {
747    /// Create a new VACM builder.
748    pub fn new() -> Self {
749        Self {
750            config: VacmConfig::new(),
751        }
752    }
753
754    /// Map a security name to a group.
755    ///
756    /// The security name is:
757    /// - For SNMPv1/v2c: the community string
758    /// - For SNMPv3: the USM username
759    ///
760    /// Multiple security names can map to the same group.
761    ///
762    /// # Example
763    ///
764    /// ```rust
765    /// use async_snmp::agent::{SecurityModel, VacmBuilder};
766    ///
767    /// let vacm = VacmBuilder::new()
768    ///     // Multiple communities in same group
769    ///     .group("public", SecurityModel::V2c, "readonly")
770    ///     .group("monitor", SecurityModel::V2c, "readonly")
771    ///     // Different users in different groups
772    ///     .group("admin", SecurityModel::Usm, "admin_group")
773    ///     .build();
774    /// ```
775    pub fn group(
776        mut self,
777        security_name: impl Into<Bytes>,
778        security_model: SecurityModel,
779        group_name: impl Into<Bytes>,
780    ) -> Self {
781        self.config
782            .add_group(security_name, security_model, group_name);
783        self
784    }
785
786    /// Add an access entry using a builder function.
787    ///
788    /// Access entries define what views a group can use for read, write,
789    /// and notify operations. Use the closure to configure the entry.
790    ///
791    /// # Example
792    ///
793    /// ```rust
794    /// use async_snmp::agent::{SecurityModel, VacmBuilder};
795    /// use async_snmp::message::SecurityLevel;
796    /// use async_snmp::oid;
797    ///
798    /// let vacm = VacmBuilder::new()
799    ///     .group("public", SecurityModel::V2c, "readers")
800    ///     .access("readers", |a| a
801    ///         .security_model(SecurityModel::V2c)
802    ///         .security_level(SecurityLevel::NoAuthNoPriv)
803    ///         .read_view("system_view")
804    ///         // No write_view = read-only
805    ///     )
806    ///     .view("system_view", |v| v.include(oid!(1, 3, 6, 1, 2, 1, 1)))
807    ///     .build();
808    /// ```
809    pub fn access<F>(mut self, group_name: impl Into<Bytes>, configure: F) -> Self
810    where
811        F: FnOnce(AccessEntryBuilder) -> AccessEntryBuilder,
812    {
813        let builder = AccessEntryBuilder::new(group_name);
814        let entry = configure(builder).build();
815        self.config.add_access(entry);
816        self
817    }
818
819    /// Add a view using a builder function.
820    ///
821    /// Views define collections of OID subtrees. Use the closure to add
822    /// included and excluded subtrees.
823    ///
824    /// # Example
825    ///
826    /// ```rust
827    /// use async_snmp::agent::VacmBuilder;
828    /// use async_snmp::oid;
829    ///
830    /// let vacm = VacmBuilder::new()
831    ///     .view("system_only", |v| v
832    ///         .include(oid!(1, 3, 6, 1, 2, 1, 1)))  // system MIB
833    ///     .view("all_except_private", |v| v
834    ///         .include(oid!(1, 3, 6, 1))
835    ///         .exclude(oid!(1, 3, 6, 1, 4, 1, 99999)))  // exclude our enterprise
836    ///     .build();
837    /// ```
838    pub fn view<F>(mut self, name: impl Into<Bytes>, configure: F) -> Self
839    where
840        F: FnOnce(View) -> View,
841    {
842        let view = configure(View::new());
843        self.config.add_view(name, view);
844        self
845    }
846
847    /// Build the VACM configuration.
848    pub fn build(self) -> VacmConfig {
849        self.config
850    }
851}
852
853impl Default for VacmBuilder {
854    fn default() -> Self {
855        Self::new()
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use crate::oid;
863
864    #[test]
865    fn test_view_contains_simple() {
866        let view = View::new().include(oid!(1, 3, 6, 1, 2, 1)); // system MIB
867
868        // OID within the subtree
869        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
870        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 1, 1)));
871
872        // OID exactly at subtree
873        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1)));
874
875        // OID outside the subtree
876        assert!(!view.contains(&oid!(1, 3, 6, 1, 4, 1)));
877        assert!(!view.contains(&oid!(1, 3, 6, 1, 2)));
878    }
879
880    #[test]
881    fn test_view_exclude() {
882        let view = View::new()
883            .include(oid!(1, 3, 6, 1, 2, 1)) // system MIB
884            .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7)); // sysServices
885
886        // Included OIDs
887        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
888        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));
889
890        // Excluded OID
891        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7)));
892        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)));
893    }
894
895    #[test]
896    fn test_view_subtree_mask() {
897        // Create a view that matches ifDescr.* (any interface index)
898        // The subtree OID is ifDescr (1.3.6.1.2.1.2.2.1.2) with 10 arcs (indices 0-9)
899        // We want arcs 0-9 to match exactly, and arc 10+ to be wildcard
900        // Mask: 0xFF = 11111111 (arcs 0-7 must match)
901        //       0xC0 = 11000000 (arcs 8-9 must match, 10-15 wildcard)
902        let subtree = ViewSubtree {
903            oid: oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2), // ifDescr
904            mask: vec![0xFF, 0xC0],                  // 11111111 11000000 - arcs 0-9 must match
905            included: true,
906        };
907
908        // Should match with any interface index in position 10
909        assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));
910        assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 999)));
911
912        // Should not match if arc 9 differs (the "2" in ifDescr)
913        assert!(!subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3, 1)));
914    }
915
916    #[test]
917    fn test_vacm_group_lookup() {
918        let mut config = VacmConfig::new();
919        config.add_group("public", SecurityModel::V2c, "readonly_group");
920        config.add_group("admin", SecurityModel::Usm, "admin_group");
921
922        assert_eq!(
923            config.get_group(SecurityModel::V2c, b"public"),
924            Some(&Bytes::from_static(b"readonly_group"))
925        );
926        assert_eq!(
927            config.get_group(SecurityModel::Usm, b"admin"),
928            Some(&Bytes::from_static(b"admin_group"))
929        );
930        assert_eq!(config.get_group(SecurityModel::V1, b"public"), None);
931    }
932
933    #[test]
934    fn test_vacm_group_any_model() {
935        let mut config = VacmConfig::new();
936        config.add_group("universal", SecurityModel::Any, "universal_group");
937
938        // Should match any security model
939        assert_eq!(
940            config.get_group(SecurityModel::V1, b"universal"),
941            Some(&Bytes::from_static(b"universal_group"))
942        );
943        assert_eq!(
944            config.get_group(SecurityModel::V2c, b"universal"),
945            Some(&Bytes::from_static(b"universal_group"))
946        );
947    }
948
949    #[test]
950    fn test_vacm_access_lookup() {
951        let mut config = VacmConfig::new();
952        config.add_access(VacmAccessEntry {
953            group_name: Bytes::from_static(b"readonly_group"),
954            context_prefix: Bytes::new(),
955            security_model: SecurityModel::Any,
956            security_level: SecurityLevel::NoAuthNoPriv,
957            context_match: ContextMatch::Exact,
958            read_view: Bytes::from_static(b"full_view"),
959            write_view: Bytes::new(),
960            notify_view: Bytes::new(),
961        });
962
963        let access = config.get_access(
964            b"readonly_group",
965            b"",
966            SecurityModel::V2c,
967            SecurityLevel::NoAuthNoPriv,
968        );
969        assert!(access.is_some());
970        assert_eq!(access.unwrap().read_view, Bytes::from_static(b"full_view"));
971    }
972
973    #[test]
974    fn test_vacm_access_security_level() {
975        let mut config = VacmConfig::new();
976        config.add_access(VacmAccessEntry {
977            group_name: Bytes::from_static(b"admin_group"),
978            context_prefix: Bytes::new(),
979            security_model: SecurityModel::Usm,
980            security_level: SecurityLevel::AuthPriv, // Require encryption
981            context_match: ContextMatch::Exact,
982            read_view: Bytes::from_static(b"full_view"),
983            write_view: Bytes::from_static(b"full_view"),
984            notify_view: Bytes::new(),
985        });
986
987        // Should not match with lower security level
988        let access = config.get_access(
989            b"admin_group",
990            b"",
991            SecurityModel::Usm,
992            SecurityLevel::AuthNoPriv,
993        );
994        assert!(access.is_none());
995
996        // Should match with required level
997        let access = config.get_access(
998            b"admin_group",
999            b"",
1000            SecurityModel::Usm,
1001            SecurityLevel::AuthPriv,
1002        );
1003        assert!(access.is_some());
1004    }
1005
1006    #[test]
1007    fn test_vacm_check_access() {
1008        let mut config = VacmConfig::new();
1009        config.add_view("full_view", View::new().include(oid!(1, 3, 6, 1)));
1010
1011        assert!(config.check_access(
1012            Some(&Bytes::from_static(b"full_view")),
1013            &oid!(1, 3, 6, 1, 2, 1, 1, 0),
1014        ));
1015
1016        // Empty view name = no access
1017        assert!(!config.check_access(Some(&Bytes::new()), &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
1018
1019        // None = no access
1020        assert!(!config.check_access(None, &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
1021
1022        // Unknown view = no access
1023        assert!(!config.check_access(
1024            Some(&Bytes::from_static(b"unknown_view")),
1025            &oid!(1, 3, 6, 1, 2, 1, 1, 0),
1026        ));
1027    }
1028
1029    #[test]
1030    fn test_vacm_builder() {
1031        let config = VacmBuilder::new()
1032            .group("public", SecurityModel::V2c, "readonly_group")
1033            .group("admin", SecurityModel::Usm, "admin_group")
1034            .access("readonly_group", |a| {
1035                a.context_prefix("")
1036                    .security_model(SecurityModel::Any)
1037                    .security_level(SecurityLevel::NoAuthNoPriv)
1038                    .read_view("full_view")
1039            })
1040            .access("admin_group", |a| {
1041                a.security_model(SecurityModel::Usm)
1042                    .security_level(SecurityLevel::AuthPriv)
1043                    .read_view("full_view")
1044                    .write_view("full_view")
1045            })
1046            .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
1047            .build();
1048
1049        assert!(config.get_group(SecurityModel::V2c, b"public").is_some());
1050        assert!(config.get_group(SecurityModel::Usm, b"admin").is_some());
1051    }
1052}