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}