async_snmp/agent/
vacm.rs

1//! View-based Access Control Model (RFC 3415).
2//!
3//! VACM controls access through three tables:
4//! 1. Security-to-Group: Maps (securityModel, securityName) → groupName
5//! 2. Access: Maps (groupName, contextPrefix, securityModel, securityLevel) → views
6//! 3. View Tree Family: Defines views as OID subtree collections
7
8use std::collections::HashMap;
9
10use bytes::Bytes;
11
12use crate::message::SecurityLevel;
13use crate::oid::Oid;
14
15/// Security model identifiers (RFC 3411).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum SecurityModel {
18    /// Wildcard for VACM matching (matches any model).
19    Any = 0,
20    /// SNMPv1.
21    V1 = 1,
22    /// SNMPv2c.
23    V2c = 2,
24    /// SNMPv3 User-based Security Model.
25    Usm = 3,
26}
27
28/// Context matching mode for access entries.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub(crate) enum ContextMatch {
31    /// Exact context name match.
32    #[default]
33    Exact,
34    /// Context name prefix match.
35    Prefix,
36}
37
38/// A view is a collection of OID subtrees.
39#[derive(Debug, Clone, Default)]
40pub struct View {
41    subtrees: Vec<ViewSubtree>,
42}
43
44impl View {
45    /// Create a new empty view.
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Add an included subtree to the view.
51    pub fn include(mut self, oid: Oid) -> Self {
52        self.subtrees.push(ViewSubtree {
53            oid,
54            mask: Vec::new(),
55            included: true,
56        });
57        self
58    }
59
60    /// Add an included subtree with a mask to the view.
61    pub fn include_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
62        self.subtrees.push(ViewSubtree {
63            oid,
64            mask,
65            included: true,
66        });
67        self
68    }
69
70    /// Add an excluded subtree to the view.
71    pub fn exclude(mut self, oid: Oid) -> Self {
72        self.subtrees.push(ViewSubtree {
73            oid,
74            mask: Vec::new(),
75            included: false,
76        });
77        self
78    }
79
80    /// Add an excluded subtree with a mask to the view.
81    pub fn exclude_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
82        self.subtrees.push(ViewSubtree {
83            oid,
84            mask,
85            included: false,
86        });
87        self
88    }
89
90    /// Check if an OID is in this view.
91    ///
92    /// Per RFC 3415 Section 5, an OID is in the view if:
93    /// - At least one included subtree matches, AND
94    /// - No excluded subtree matches
95    pub fn contains(&self, oid: &Oid) -> bool {
96        let mut dominated_by_include = false;
97        let mut dominated_by_exclude = false;
98
99        for subtree in &self.subtrees {
100            if subtree.matches(oid) {
101                if subtree.included {
102                    dominated_by_include = true;
103                } else {
104                    dominated_by_exclude = true;
105                }
106            }
107        }
108
109        // Included and not excluded
110        dominated_by_include && !dominated_by_exclude
111    }
112}
113
114/// A subtree in a view with optional mask.
115#[derive(Debug, Clone)]
116pub struct ViewSubtree {
117    /// Base OID of subtree.
118    pub oid: Oid,
119    /// Bit mask for wildcard matching (empty = exact match).
120    ///
121    /// Each bit position corresponds to an arc in the OID:
122    /// - Bit 8 of byte 0 = arc 0
123    /// - Bit 7 of byte 0 = arc 1
124    /// - etc.
125    ///
126    /// A bit value of 1 means the arc must match exactly.
127    /// A bit value of 0 means any value is accepted (wildcard).
128    pub mask: Vec<u8>,
129    /// Include (true) or exclude (false) this subtree.
130    pub included: bool,
131}
132
133impl ViewSubtree {
134    /// Check if an OID matches this subtree (with mask).
135    pub fn matches(&self, oid: &Oid) -> bool {
136        let subtree_arcs = self.oid.arcs();
137        let oid_arcs = oid.arcs();
138
139        // OID must be at least as long as subtree
140        if oid_arcs.len() < subtree_arcs.len() {
141            return false;
142        }
143
144        // Check each arc against mask
145        for (i, &subtree_arc) in subtree_arcs.iter().enumerate() {
146            let mask_bit = if i / 8 < self.mask.len() {
147                (self.mask[i / 8] >> (7 - (i % 8))) & 1
148            } else {
149                1 // Default: exact match required
150            };
151
152            if mask_bit == 1 && oid_arcs[i] != subtree_arc {
153                return false;
154            }
155            // mask_bit == 0: wildcard, any value matches
156        }
157
158        true
159    }
160}
161
162/// Access table entry.
163#[derive(Debug, Clone)]
164pub struct VacmAccessEntry {
165    /// Group name this entry applies to.
166    pub group_name: Bytes,
167    /// Context prefix for matching.
168    pub context_prefix: Bytes,
169    /// Security model (or Any for wildcard).
170    pub security_model: SecurityModel,
171    /// Minimum security level required.
172    pub security_level: SecurityLevel,
173    /// Context matching mode.
174    pub(crate) context_match: ContextMatch,
175    /// View name for read access.
176    pub read_view: Bytes,
177    /// View name for write access.
178    pub write_view: Bytes,
179    /// View name for notify access (traps/informs).
180    pub notify_view: Bytes,
181}
182
183/// Builder for access entries.
184pub struct AccessEntryBuilder {
185    group_name: Bytes,
186    context_prefix: Bytes,
187    security_model: SecurityModel,
188    security_level: SecurityLevel,
189    context_match: ContextMatch,
190    read_view: Bytes,
191    write_view: Bytes,
192    notify_view: Bytes,
193}
194
195impl AccessEntryBuilder {
196    /// Create a new access entry builder for a group.
197    pub fn new(group_name: impl Into<Bytes>) -> Self {
198        Self {
199            group_name: group_name.into(),
200            context_prefix: Bytes::new(),
201            security_model: SecurityModel::Any,
202            security_level: SecurityLevel::NoAuthNoPriv,
203            context_match: ContextMatch::Exact,
204            read_view: Bytes::new(),
205            write_view: Bytes::new(),
206            notify_view: Bytes::new(),
207        }
208    }
209
210    /// Set the context prefix for matching.
211    pub fn context_prefix(mut self, prefix: impl Into<Bytes>) -> Self {
212        self.context_prefix = prefix.into();
213        self
214    }
215
216    /// Set the security model.
217    pub fn security_model(mut self, model: SecurityModel) -> Self {
218        self.security_model = model;
219        self
220    }
221
222    /// Set the minimum security level required.
223    pub fn security_level(mut self, level: SecurityLevel) -> Self {
224        self.security_level = level;
225        self
226    }
227
228    /// Set context matching to prefix mode.
229    ///
230    /// When enabled, the context prefix is matched against the start of
231    /// the request context name rather than requiring an exact match.
232    /// The default is exact matching.
233    pub fn context_match_prefix(mut self) -> Self {
234        self.context_match = ContextMatch::Prefix;
235        self
236    }
237
238    /// Set the read view name.
239    pub fn read_view(mut self, view: impl Into<Bytes>) -> Self {
240        self.read_view = view.into();
241        self
242    }
243
244    /// Set the write view name.
245    pub fn write_view(mut self, view: impl Into<Bytes>) -> Self {
246        self.write_view = view.into();
247        self
248    }
249
250    /// Set the notify view name.
251    pub fn notify_view(mut self, view: impl Into<Bytes>) -> Self {
252        self.notify_view = view.into();
253        self
254    }
255
256    /// Build the access entry.
257    pub fn build(self) -> VacmAccessEntry {
258        VacmAccessEntry {
259            group_name: self.group_name,
260            context_prefix: self.context_prefix,
261            security_model: self.security_model,
262            security_level: self.security_level,
263            context_match: self.context_match,
264            read_view: self.read_view,
265            write_view: self.write_view,
266            notify_view: self.notify_view,
267        }
268    }
269}
270
271/// VACM configuration.
272#[derive(Debug, Clone, Default)]
273pub struct VacmConfig {
274    /// (securityModel, securityName) → groupName
275    security_to_group: HashMap<(SecurityModel, Bytes), Bytes>,
276    /// Access table entries.
277    access_entries: Vec<VacmAccessEntry>,
278    /// viewName → View
279    views: HashMap<Bytes, View>,
280}
281
282impl VacmConfig {
283    /// Create a new empty VACM configuration.
284    pub fn new() -> Self {
285        Self::default()
286    }
287
288    /// Map a security name to a group for a specific security model.
289    pub fn add_group(
290        &mut self,
291        security_name: impl Into<Bytes>,
292        security_model: SecurityModel,
293        group_name: impl Into<Bytes>,
294    ) {
295        self.security_to_group
296            .insert((security_model, security_name.into()), group_name.into());
297    }
298
299    /// Add an access entry.
300    pub fn add_access(&mut self, entry: VacmAccessEntry) {
301        self.access_entries.push(entry);
302    }
303
304    /// Add a view.
305    pub fn add_view(&mut self, name: impl Into<Bytes>, view: View) {
306        self.views.insert(name.into(), view);
307    }
308
309    /// Resolve group name for a request.
310    pub fn get_group(&self, model: SecurityModel, name: &[u8]) -> Option<&Bytes> {
311        let name_bytes = Bytes::copy_from_slice(name);
312        // Try exact match first
313        self.security_to_group
314            .get(&(model, name_bytes.clone()))
315            // Fall back to Any security model
316            .or_else(|| {
317                self.security_to_group
318                    .get(&(SecurityModel::Any, name_bytes))
319            })
320    }
321
322    /// Get access entry for context.
323    ///
324    /// Returns the best matching entry per RFC 3415 Section 4:
325    /// - Prefer specific security model over Any
326    /// - Prefer longer context prefix
327    pub fn get_access(
328        &self,
329        group: &[u8],
330        context: &[u8],
331        model: SecurityModel,
332        level: SecurityLevel,
333    ) -> Option<&VacmAccessEntry> {
334        // Find best matching entry
335        self.access_entries
336            .iter()
337            .filter(|e| {
338                e.group_name.as_ref() == group
339                    && self.context_matches(&e.context_prefix, context, e.context_match)
340                    && (e.security_model == model || e.security_model == SecurityModel::Any)
341                    && level >= e.security_level
342            })
343            .max_by_key(|e| {
344                // Prefer specific matches
345                let model_score = if e.security_model == model { 2 } else { 1 };
346                let context_score = e.context_prefix.len();
347                (model_score, context_score)
348            })
349    }
350
351    /// Check if context matches the prefix.
352    fn context_matches(&self, prefix: &[u8], context: &[u8], mode: ContextMatch) -> bool {
353        match mode {
354            ContextMatch::Exact => prefix == context,
355            ContextMatch::Prefix => context.starts_with(prefix),
356        }
357    }
358
359    /// Check if OID access is permitted.
360    pub fn check_access(&self, view_name: Option<&Bytes>, oid: &Oid) -> bool {
361        let Some(view_name) = view_name else {
362            return false;
363        };
364
365        if view_name.is_empty() {
366            return false;
367        }
368
369        let Some(view) = self.views.get(view_name) else {
370            return false;
371        };
372
373        view.contains(oid)
374    }
375}
376
377/// Builder for VACM configuration.
378pub struct VacmBuilder {
379    config: VacmConfig,
380}
381
382impl VacmBuilder {
383    /// Create a new VACM builder.
384    pub fn new() -> Self {
385        Self {
386            config: VacmConfig::new(),
387        }
388    }
389
390    /// Map a security name to a group.
391    pub fn group(
392        mut self,
393        security_name: impl Into<Bytes>,
394        security_model: SecurityModel,
395        group_name: impl Into<Bytes>,
396    ) -> Self {
397        self.config
398            .add_group(security_name, security_model, group_name);
399        self
400    }
401
402    /// Add an access entry using a builder function.
403    pub fn access<F>(mut self, group_name: impl Into<Bytes>, configure: F) -> Self
404    where
405        F: FnOnce(AccessEntryBuilder) -> AccessEntryBuilder,
406    {
407        let builder = AccessEntryBuilder::new(group_name);
408        let entry = configure(builder).build();
409        self.config.add_access(entry);
410        self
411    }
412
413    /// Add a view using a builder function.
414    pub fn view<F>(mut self, name: impl Into<Bytes>, configure: F) -> Self
415    where
416        F: FnOnce(View) -> View,
417    {
418        let view = configure(View::new());
419        self.config.add_view(name, view);
420        self
421    }
422
423    /// Build the VACM configuration.
424    pub fn build(self) -> VacmConfig {
425        self.config
426    }
427}
428
429impl Default for VacmBuilder {
430    fn default() -> Self {
431        Self::new()
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::oid;
439
440    #[test]
441    fn test_view_contains_simple() {
442        let view = View::new().include(oid!(1, 3, 6, 1, 2, 1)); // system MIB
443
444        // OID within the subtree
445        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
446        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 1, 1)));
447
448        // OID exactly at subtree
449        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1)));
450
451        // OID outside the subtree
452        assert!(!view.contains(&oid!(1, 3, 6, 1, 4, 1)));
453        assert!(!view.contains(&oid!(1, 3, 6, 1, 2)));
454    }
455
456    #[test]
457    fn test_view_exclude() {
458        let view = View::new()
459            .include(oid!(1, 3, 6, 1, 2, 1)) // system MIB
460            .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7)); // sysServices
461
462        // Included OIDs
463        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
464        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));
465
466        // Excluded OID
467        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7)));
468        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)));
469    }
470
471    #[test]
472    fn test_view_subtree_mask() {
473        // Create a view that matches ifDescr.* (any interface index)
474        // The subtree OID is ifDescr (1.3.6.1.2.1.2.2.1.2) with 10 arcs (indices 0-9)
475        // We want arcs 0-9 to match exactly, and arc 10+ to be wildcard
476        // Mask: 0xFF = 11111111 (arcs 0-7 must match)
477        //       0xC0 = 11000000 (arcs 8-9 must match, 10-15 wildcard)
478        let subtree = ViewSubtree {
479            oid: oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2), // ifDescr
480            mask: vec![0xFF, 0xC0],                  // 11111111 11000000 - arcs 0-9 must match
481            included: true,
482        };
483
484        // Should match with any interface index in position 10
485        assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));
486        assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 999)));
487
488        // Should not match if arc 9 differs (the "2" in ifDescr)
489        assert!(!subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3, 1)));
490    }
491
492    #[test]
493    fn test_vacm_group_lookup() {
494        let mut config = VacmConfig::new();
495        config.add_group("public", SecurityModel::V2c, "readonly_group");
496        config.add_group("admin", SecurityModel::Usm, "admin_group");
497
498        assert_eq!(
499            config.get_group(SecurityModel::V2c, b"public"),
500            Some(&Bytes::from_static(b"readonly_group"))
501        );
502        assert_eq!(
503            config.get_group(SecurityModel::Usm, b"admin"),
504            Some(&Bytes::from_static(b"admin_group"))
505        );
506        assert_eq!(config.get_group(SecurityModel::V1, b"public"), None);
507    }
508
509    #[test]
510    fn test_vacm_group_any_model() {
511        let mut config = VacmConfig::new();
512        config.add_group("universal", SecurityModel::Any, "universal_group");
513
514        // Should match any security model
515        assert_eq!(
516            config.get_group(SecurityModel::V1, b"universal"),
517            Some(&Bytes::from_static(b"universal_group"))
518        );
519        assert_eq!(
520            config.get_group(SecurityModel::V2c, b"universal"),
521            Some(&Bytes::from_static(b"universal_group"))
522        );
523    }
524
525    #[test]
526    fn test_vacm_access_lookup() {
527        let mut config = VacmConfig::new();
528        config.add_access(VacmAccessEntry {
529            group_name: Bytes::from_static(b"readonly_group"),
530            context_prefix: Bytes::new(),
531            security_model: SecurityModel::Any,
532            security_level: SecurityLevel::NoAuthNoPriv,
533            context_match: ContextMatch::Exact,
534            read_view: Bytes::from_static(b"full_view"),
535            write_view: Bytes::new(),
536            notify_view: Bytes::new(),
537        });
538
539        let access = config.get_access(
540            b"readonly_group",
541            b"",
542            SecurityModel::V2c,
543            SecurityLevel::NoAuthNoPriv,
544        );
545        assert!(access.is_some());
546        assert_eq!(access.unwrap().read_view, Bytes::from_static(b"full_view"));
547    }
548
549    #[test]
550    fn test_vacm_access_security_level() {
551        let mut config = VacmConfig::new();
552        config.add_access(VacmAccessEntry {
553            group_name: Bytes::from_static(b"admin_group"),
554            context_prefix: Bytes::new(),
555            security_model: SecurityModel::Usm,
556            security_level: SecurityLevel::AuthPriv, // Require encryption
557            context_match: ContextMatch::Exact,
558            read_view: Bytes::from_static(b"full_view"),
559            write_view: Bytes::from_static(b"full_view"),
560            notify_view: Bytes::new(),
561        });
562
563        // Should not match with lower security level
564        let access = config.get_access(
565            b"admin_group",
566            b"",
567            SecurityModel::Usm,
568            SecurityLevel::AuthNoPriv,
569        );
570        assert!(access.is_none());
571
572        // Should match with required level
573        let access = config.get_access(
574            b"admin_group",
575            b"",
576            SecurityModel::Usm,
577            SecurityLevel::AuthPriv,
578        );
579        assert!(access.is_some());
580    }
581
582    #[test]
583    fn test_vacm_check_access() {
584        let mut config = VacmConfig::new();
585        config.add_view("full_view", View::new().include(oid!(1, 3, 6, 1)));
586
587        assert!(config.check_access(
588            Some(&Bytes::from_static(b"full_view")),
589            &oid!(1, 3, 6, 1, 2, 1, 1, 0),
590        ));
591
592        // Empty view name = no access
593        assert!(!config.check_access(Some(&Bytes::new()), &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
594
595        // None = no access
596        assert!(!config.check_access(None, &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
597
598        // Unknown view = no access
599        assert!(!config.check_access(
600            Some(&Bytes::from_static(b"unknown_view")),
601            &oid!(1, 3, 6, 1, 2, 1, 1, 0),
602        ));
603    }
604
605    #[test]
606    fn test_vacm_builder() {
607        let config = VacmBuilder::new()
608            .group("public", SecurityModel::V2c, "readonly_group")
609            .group("admin", SecurityModel::Usm, "admin_group")
610            .access("readonly_group", |a| {
611                a.context_prefix("")
612                    .security_model(SecurityModel::Any)
613                    .security_level(SecurityLevel::NoAuthNoPriv)
614                    .read_view("full_view")
615            })
616            .access("admin_group", |a| {
617                a.security_model(SecurityModel::Usm)
618                    .security_level(SecurityLevel::AuthPriv)
619                    .read_view("full_view")
620                    .write_view("full_view")
621            })
622            .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
623            .build();
624
625        assert!(config.get_group(SecurityModel::V2c, b"public").is_some());
626        assert!(config.get_group(SecurityModel::Usm, b"admin").is_some());
627    }
628}