1use crate::capability::{capabilities_for_role, is_valid_capability};
7use crate::role::Role;
8
9#[cfg(feature = "config")]
10use serde::{Deserialize, Serialize};
11
12pub const MIN_BLOCKED_PREFIX_LEN: usize = 8;
15
16#[derive(Debug, Clone)]
18#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
19pub struct RosterEntry {
20 #[cfg_attr(feature = "config", serde(alias = "identity"))]
22 pub identity_hash: String,
23 pub role: Role,
25 #[cfg_attr(feature = "config", serde(default, skip_serializing_if = "String::is_empty"))]
27 pub label: String,
28 #[cfg_attr(feature = "config", serde(default, skip_serializing_if = "Vec::is_empty"))]
33 grants: Vec<String>,
34}
35
36impl RosterEntry {
37 pub fn new(identity_hash: impl Into<String>, role: Role) -> Self {
38 Self { identity_hash: identity_hash.into(), role, label: String::new(), grants: Vec::new() }
39 }
40
41 pub fn with_label(mut self, label: impl Into<String>) -> Self {
42 self.label = label.into();
43 self
44 }
45
46 pub fn with_grants(mut self, grants: Vec<String>) -> Self {
47 self.grants = grants.into_iter().filter(|g| is_valid_capability(g)).collect();
48 self
49 }
50
51 pub fn grants(&self) -> &[String] {
53 &self.grants
54 }
55
56 pub fn has_capability(&self, cap: &str) -> bool {
58 capabilities_for_role(self.role).contains(&cap)
60 || (is_valid_capability(cap) && self.grants.iter().any(|g| g == cap))
61 }
62}
63
64fn is_valid_identity_hash(hash: &str) -> bool {
66 hash.len() == 32 && hash.bytes().all(|b| b.is_ascii_hexdigit())
67}
68
69fn is_valid_blocked_prefix(prefix: &str) -> bool {
71 prefix.len() >= MIN_BLOCKED_PREFIX_LEN && prefix.bytes().all(|b| b.is_ascii_hexdigit())
72}
73
74#[derive(Debug, Clone)]
76#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
77pub struct RbacPolicy {
78 #[cfg_attr(feature = "config", serde(default = "default_role"))]
80 pub default_role: Role,
81
82 #[cfg_attr(feature = "config", serde(default))]
84 roster: Vec<RosterEntry>,
85
86 #[cfg_attr(feature = "config", serde(default))]
89 blocked: Vec<String>,
90
91 #[cfg_attr(feature = "config", serde(default))]
93 trusted_hubs: Vec<crate::signed::TrustedHub>,
94
95 #[cfg_attr(feature = "config", serde(default))]
99 hub_entries: Vec<crate::signed::SignedRosterEntry>,
100}
101
102#[allow(dead_code)] fn default_role() -> Role {
104 Role::Peer
105}
106
107impl Default for RbacPolicy {
108 fn default() -> Self {
109 Self {
110 default_role: Role::Peer,
111 roster: Vec::new(),
112 blocked: Vec::new(),
113 trusted_hubs: Vec::new(),
114 hub_entries: Vec::new(),
115 }
116 }
117}
118
119impl RbacPolicy {
120 pub fn new(default_role: Role) -> Self {
121 Self {
122 default_role,
123 roster: Vec::new(),
124 blocked: Vec::new(),
125 trusted_hubs: Vec::new(),
126 hub_entries: Vec::new(),
127 }
128 }
129
130 pub fn normalize(&mut self) -> Vec<crate::PolicyWarning> {
138 use crate::PolicyWarning;
139
140 let mut warnings = Vec::new();
141
142 for entry in &mut self.roster {
144 let original = entry.identity_hash.clone();
145 entry.identity_hash = entry.identity_hash.to_ascii_lowercase();
146 if original != entry.identity_hash {
147 warnings.push(PolicyWarning::NormalizedIdentityHash {
148 original,
149 normalized: entry.identity_hash.clone(),
150 });
151 }
152
153 let grants_before: Vec<String> = entry.grants.clone();
154 entry.grants.retain(|g| is_valid_capability(g));
155 for g in &grants_before {
156 if !entry.grants.contains(g) {
157 warnings.push(PolicyWarning::UnknownGrant {
158 identity_hash: entry.identity_hash.clone(),
159 grant: g.clone(),
160 });
161 }
162 }
163 }
164
165 let roster_before = std::mem::take(&mut self.roster);
167 for entry in roster_before {
168 if !is_valid_identity_hash(&entry.identity_hash) {
169 warnings.push(PolicyWarning::InvalidIdentityHash {
170 identity_hash: entry.identity_hash.clone(),
171 label: entry.label.clone(),
172 });
173 continue;
174 }
175 if let Some(existing) =
176 self.roster.iter_mut().find(|e| e.identity_hash == entry.identity_hash)
177 {
178 warnings.push(PolicyWarning::DuplicateRosterEntry {
179 identity_hash: entry.identity_hash.clone(),
180 kept_role: entry.role.as_str().to_string(),
181 dropped_role: existing.role.as_str().to_string(),
182 });
183 *existing = entry;
184 } else {
185 self.roster.push(entry);
186 }
187 }
188
189 let blocked_before = std::mem::take(&mut self.blocked);
191 for prefix in blocked_before {
192 let normalized = prefix.to_ascii_lowercase();
193 if normalized != prefix {
194 warnings.push(PolicyWarning::NormalizedBlockedPrefix {
195 original: prefix.clone(),
196 normalized: normalized.clone(),
197 });
198 }
199 if is_valid_blocked_prefix(&normalized) {
200 if !self.blocked.contains(&normalized) {
201 self.blocked.push(normalized);
202 }
203 } else {
204 warnings.push(PolicyWarning::InvalidBlockedPrefix { prefix: prefix.clone() });
205 }
206 }
207
208 warnings
209 }
210
211 pub fn normalize_quiet(&mut self) {
213 let _ = self.normalize();
214 }
215
216 pub fn add_entry(&mut self, mut entry: RosterEntry) -> bool {
222 entry.identity_hash = entry.identity_hash.to_ascii_lowercase();
223
224 if !is_valid_identity_hash(&entry.identity_hash) {
225 return false;
226 }
227
228 entry.grants.retain(|g| is_valid_capability(g));
230
231 if let Some(existing) =
232 self.roster.iter_mut().find(|e| e.identity_hash == entry.identity_hash)
233 {
234 *existing = entry;
235 } else {
236 self.roster.push(entry);
237 }
238 true
239 }
240
241 pub fn remove_entry(&mut self, identity_hash: &str) -> bool {
243 let normalized = identity_hash.to_ascii_lowercase();
244 let len_before = self.roster.len();
245 self.roster.retain(|e| e.identity_hash != normalized);
246 self.roster.len() < len_before
247 }
248
249 pub fn block(&mut self, prefix: impl Into<String>) -> bool {
254 let p = prefix.into().to_ascii_lowercase();
255 if !is_valid_blocked_prefix(&p) {
256 return false;
257 }
258 if !self.blocked.contains(&p) {
259 self.blocked.push(p);
260 }
261 true
262 }
263
264 pub fn unblock(&mut self, prefix: &str) -> bool {
266 let normalized = prefix.to_ascii_lowercase();
267 let len_before = self.blocked.len();
268 self.blocked.retain(|p| *p != normalized);
269 self.blocked.len() < len_before
270 }
271
272 pub fn get_entry(&self, identity_hash: &str) -> Option<&RosterEntry> {
274 let normalized = identity_hash.to_ascii_lowercase();
275 self.roster.iter().find(|e| e.identity_hash == normalized)
276 }
277
278 pub fn entries(&self) -> &[RosterEntry] {
280 &self.roster
281 }
282
283 #[allow(dead_code)] pub(crate) fn blocked_prefixes(&self) -> &[String] {
289 &self.blocked
290 }
291
292 pub fn blocked_count(&self) -> usize {
294 self.blocked.len()
295 }
296
297 pub fn add_trusted_hub(&mut self, hub: crate::signed::TrustedHub) {
301 if !self.trusted_hubs.iter().any(|h| h.hub_hash == hub.hub_hash) {
302 self.trusted_hubs.push(hub);
303 }
304 }
305
306 pub fn trusted_hubs(&self) -> &[crate::signed::TrustedHub] {
308 &self.trusted_hubs
309 }
310
311 pub fn add_hub_entry(&mut self, entry: crate::signed::SignedRosterEntry) {
314 let normalized = entry.entry.identity_hash.to_ascii_lowercase();
316 self.hub_entries.retain(|e| e.entry.identity_hash.to_ascii_lowercase() != normalized);
317 self.hub_entries.push(entry);
318 }
319
320 pub fn hub_entries(&self) -> &[crate::signed::SignedRosterEntry] {
322 &self.hub_entries
323 }
324
325 pub fn clear_hub_entries(&mut self) {
327 self.hub_entries.clear();
328 }
329
330 pub fn resolve_role(&self, identity_hash: &str) -> Role {
336 let normalized = identity_hash.to_ascii_lowercase();
337
338 if self.blocked.iter().any(|prefix| normalized.starts_with(prefix.as_str())) {
340 return Role::Blocked;
341 }
342
343 if let Some(entry) = self.roster.iter().find(|e| e.identity_hash == normalized) {
345 return entry.role;
346 }
347
348 if let Some(hub_entry) = self
350 .hub_entries
351 .iter()
352 .find(|e| e.entry.identity_hash.to_ascii_lowercase() == normalized)
353 {
354 return hub_entry.entry.role;
355 }
356
357 self.default_role
359 }
360
361 pub fn has_capability(&self, identity_hash: &str, cap: &str) -> bool {
368 let normalized = identity_hash.to_ascii_lowercase();
369
370 if self.blocked.iter().any(|prefix| normalized.starts_with(prefix.as_str())) {
372 return false;
373 }
374
375 if let Some(entry) = self.roster.iter().find(|e| e.identity_hash == normalized) {
377 return entry.has_capability(cap);
378 }
379
380 if let Some(hub_entry) = self
382 .hub_entries
383 .iter()
384 .find(|e| e.entry.identity_hash.to_ascii_lowercase() == normalized)
385 {
386 return hub_entry.entry.has_capability(cap);
387 }
388
389 capabilities_for_role(self.default_role).contains(&cap)
391 }
392
393 pub fn default_role_grants(&self, cap: &str) -> bool {
396 capabilities_for_role(self.default_role).contains(&cap)
397 }
398
399 #[allow(dead_code)] pub(crate) fn allow_list(&self, cap: &str) -> Vec<String> {
408 self.roster
409 .iter()
410 .filter(|e| {
411 !self.blocked.iter().any(|prefix| e.identity_hash.starts_with(prefix.as_str()))
413 && e.has_capability(cap)
414 })
415 .map(|e| e.identity_hash.clone())
416 .collect()
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::Capability;
424
425 fn test_policy() -> RbacPolicy {
426 let mut policy = RbacPolicy::new(Role::Peer);
427 assert!(policy.add_entry(
428 RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Admin).with_label("Alice"),
429 ));
430 assert!(policy.add_entry(
431 RosterEntry::new("eeee5555ffff6666aaaa7777bbbb8888", Role::Operator)
432 .with_label("Bob")
433 .with_grants(vec![Capability::VPN_HANDSHAKE.to_string()]),
434 ));
435 assert!(policy.add_entry(
436 RosterEntry::new("1111222233334444555566667777aaaa", Role::Monitor)
437 .with_label("Charlie"),
438 ));
439 assert!(policy.block("deadbeef"));
440 assert!(policy.block("ca3e9813"));
441 policy
442 }
443
444 #[test]
447 fn resolve_rostered_identity() {
448 let policy = test_policy();
449 assert_eq!(policy.resolve_role("aaaa1111bbbb2222cccc3333dddd4444"), Role::Admin);
450 assert_eq!(policy.resolve_role("eeee5555ffff6666aaaa7777bbbb8888"), Role::Operator);
451 }
452
453 #[test]
454 fn resolve_unknown_gets_default() {
455 let policy = test_policy();
456 assert_eq!(policy.resolve_role("0000000000000000ffffffffffffffff"), Role::Peer);
457 }
458
459 #[test]
460 fn resolve_blocked_prefix() {
461 let policy = test_policy();
462 assert_eq!(policy.resolve_role("deadbeef11112222333344445555aaaa"), Role::Blocked);
463 assert_eq!(policy.resolve_role("ca3e981300000000aaaa00001111ffff"), Role::Blocked);
464 }
465
466 #[test]
467 fn blocked_overrides_roster() {
468 let mut policy = test_policy();
469 assert!(policy.block("aaaa1111"));
471 assert_eq!(policy.resolve_role("aaaa1111bbbb2222cccc3333dddd4444"), Role::Blocked);
472 }
473
474 #[test]
475 fn case_insensitive() {
476 let policy = test_policy();
477 assert_eq!(policy.resolve_role("AAAA1111BBBB2222CCCC3333DDDD4444"), Role::Admin);
478 assert_eq!(policy.resolve_role("DEADBEEF11112222333344445555aaaa"), Role::Blocked);
479 }
480
481 #[test]
484 fn admin_has_exec() {
485 let policy = test_policy();
486 assert!(policy.has_capability("aaaa1111bbbb2222cccc3333dddd4444", Capability::RPC_EXEC));
487 }
488
489 #[test]
490 fn operator_no_exec() {
491 let policy = test_policy();
492 assert!(!policy.has_capability("eeee5555ffff6666aaaa7777bbbb8888", Capability::RPC_EXEC));
493 }
494
495 #[test]
496 fn operator_has_config_update() {
497 let policy = test_policy();
498 assert!(policy
499 .has_capability("eeee5555ffff6666aaaa7777bbbb8888", Capability::RPC_CONFIG_UPDATE));
500 }
501
502 #[test]
503 fn orthogonal_grant() {
504 let policy = test_policy();
505 assert!(
507 policy.has_capability("eeee5555ffff6666aaaa7777bbbb8888", Capability::VPN_HANDSHAKE)
508 );
509 assert!(
511 !policy.has_capability("aaaa1111bbbb2222cccc3333dddd4444", Capability::VPN_HANDSHAKE)
512 );
513 }
514
515 #[test]
516 fn blocked_has_no_capabilities() {
517 let policy = test_policy();
518 assert!(!policy.has_capability("deadbeef11112222333344445555aaaa", Capability::CHAT_SEND));
519 }
520
521 #[test]
522 fn unknown_identity_gets_default_caps() {
523 let policy = test_policy();
524 assert!(policy.has_capability("0000000011111111aaaa2222bbbb3333", Capability::CHAT_SEND));
526 assert!(!policy.has_capability("0000000011111111aaaa2222bbbb3333", Capability::RPC_EXEC));
527 }
528
529 #[test]
532 fn peer_can_query_and_report() {
533 let policy = test_policy();
534 let unknown = "0000000011111111aaaa2222bbbb3333";
535 assert!(policy.has_capability(unknown, Capability::AETHER_QUERY));
536 assert!(policy.has_capability(unknown, Capability::AETHER_REPORT));
537 }
538
539 #[test]
540 fn peer_cannot_delegate() {
541 let policy = test_policy();
542 let unknown = "0000000011111111aaaa2222bbbb3333";
543 assert!(!policy.has_capability(unknown, Capability::AETHER_DELEGATE));
544 }
545
546 #[test]
547 fn operator_can_delegate() {
548 let policy = test_policy();
549 assert!(
550 policy.has_capability("eeee5555ffff6666aaaa7777bbbb8888", Capability::AETHER_DELEGATE)
551 );
552 }
553
554 #[test]
557 fn add_replaces_existing() {
558 let mut policy = test_policy();
559 assert!(policy.add_entry(
560 RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer)
561 .with_label("Alice demoted"),
562 ));
563 assert_eq!(policy.resolve_role("aaaa1111bbbb2222cccc3333dddd4444"), Role::Peer);
564 }
565
566 #[test]
567 fn remove_entry() {
568 let mut policy = test_policy();
569 assert!(policy.remove_entry("aaaa1111bbbb2222cccc3333dddd4444"));
570 assert_eq!(policy.resolve_role("aaaa1111bbbb2222cccc3333dddd4444"), Role::Peer);
571 assert!(!policy.remove_entry("nonexistent"));
572 }
573
574 #[test]
575 fn unblock() {
576 let mut policy = test_policy();
577 assert!(policy.unblock("deadbeef"));
578 assert_eq!(policy.resolve_role("deadbeef11112222333344445555aaaa"), Role::Peer);
579 }
580
581 #[test]
582 fn invalid_grants_filtered_at_construction() {
583 let entry = RosterEntry::new("aaaa0000bbbb1111cccc2222dddd3333", Role::Peer)
584 .with_grants(vec!["fake.cap".to_string(), Capability::VPN_HANDSHAKE.to_string()]);
585 assert_eq!(entry.grants().len(), 1);
586 assert_eq!(entry.grants()[0], Capability::VPN_HANDSHAKE);
587 }
588
589 #[test]
590 fn invalid_grants_filtered_at_add() {
591 let mut policy = RbacPolicy::default();
592 assert!(policy.add_entry(
593 RosterEntry::new("aaaa0000bbbb1111cccc2222dddd3333", Role::Peer)
594 .with_grants(vec!["fake.cap".to_string(), Capability::VPN_HANDSHAKE.to_string(),]),
595 ));
596 let entry = policy.get_entry("aaaa0000bbbb1111cccc2222dddd3333").expect("entry exists");
597 assert_eq!(entry.grants().len(), 1);
598 assert_eq!(entry.grants()[0], Capability::VPN_HANDSHAKE);
599 }
600
601 #[test]
602 fn has_capability_rejects_unknown_grants() {
603 let entry = RosterEntry {
606 identity_hash: "aaaa0000bbbb1111cccc2222dddd3333".into(),
607 role: Role::Peer,
608 label: String::new(),
609 grants: vec!["smuggled.capability".into()],
610 };
611 assert!(!entry.has_capability("smuggled.capability"));
612 }
613
614 #[test]
617 fn reject_short_identity_hash() {
618 let mut policy = RbacPolicy::default();
619 assert!(!policy.add_entry(RosterEntry::new("aaaa", Role::Peer)));
620 }
621
622 #[test]
623 fn reject_non_hex_identity_hash() {
624 let mut policy = RbacPolicy::default();
625 assert!(!policy.add_entry(RosterEntry::new("zzzz1111bbbb2222cccc3333dddd4444", Role::Peer)));
626 }
627
628 #[test]
629 fn reject_short_blocked_prefix() {
630 let mut policy = RbacPolicy::default();
631 assert!(!policy.block("aa"));
632 assert!(!policy.block("aabb"));
633 assert!(policy.block("aabbccdd")); }
635
636 #[test]
639 fn allow_list_for_exec() {
640 let policy = test_policy();
641 let list = policy.allow_list(Capability::RPC_EXEC);
642 assert_eq!(list, vec!["aaaa1111bbbb2222cccc3333dddd4444"]);
643 }
644
645 #[test]
646 fn allow_list_excludes_blocked() {
647 let mut policy = test_policy();
648 assert!(policy.block("aaaa1111"));
650 let list = policy.allow_list(Capability::RPC_EXEC);
651 assert!(list.is_empty(), "blocked identity should not appear in allow list");
652 }
653
654 #[test]
655 fn default_role_grants_chat() {
656 let policy = test_policy();
657 assert!(policy.default_role_grants(Capability::CHAT_SEND));
658 assert!(!policy.default_role_grants(Capability::RPC_EXEC));
659 }
660
661 #[test]
664 #[cfg(feature = "config")]
665 fn deserialize_from_json() {
666 let json = serde_json::json!({
667 "default_role": "peer",
668 "roster": [
669 {
670 "identity": "aaaa1111bbbb2222cccc3333dddd4444",
671 "role": "admin",
672 "label": "Alice",
673 "grants": ["vpn.handshake"]
674 }
675 ],
676 "blocked": ["deadbeef"]
677 });
678
679 let mut policy: RbacPolicy = serde_json::from_value(json).expect("should parse");
680 let warnings = policy.normalize();
681 assert!(warnings.is_empty(), "clean config should produce no warnings");
682 assert_eq!(policy.default_role, Role::Peer);
683 assert_eq!(policy.entries().len(), 1);
684 assert_eq!(policy.entries()[0].role, Role::Admin);
685 assert_eq!(policy.blocked_prefixes(), &["deadbeef"]);
686 }
687
688 #[test]
689 #[cfg(feature = "config")]
690 fn normalize_reports_all_issues() {
691 use crate::PolicyWarning;
692
693 let json = serde_json::json!({
694 "default_role": "peer",
695 "roster": [
696 {
697 "identity": "AAAA1111BBBB2222CCCC3333DDDD4444",
698 "role": "admin",
699 "label": "Alice (uppercase)"
700 },
701 {
702 "identity": "short",
703 "role": "peer",
704 "label": "Invalid (too short)"
705 },
706 {
707 "identity": "bbbb2222cccc3333dddd4444eeee5555",
708 "role": "peer",
709 "grants": ["fake.grant", "vpn.handshake"]
710 }
711 ],
712 "blocked": ["DEADBEEF", "ab", "aabbccdd"]
713 });
714
715 let mut policy: RbacPolicy = serde_json::from_value(json).expect("should parse");
716 let warnings = policy.normalize();
717
718 assert_eq!(policy.entries().len(), 2);
720 assert_eq!(policy.entries()[0].identity_hash, "aaaa1111bbbb2222cccc3333dddd4444");
721 assert_eq!(policy.entries()[1].grants(), &["vpn.handshake"]);
723 assert_eq!(policy.blocked_prefixes().len(), 2);
725 assert!(policy.blocked_prefixes().contains(&"deadbeef".to_string()));
726 assert!(policy.blocked_prefixes().contains(&"aabbccdd".to_string()));
727
728 assert!(
730 warnings.iter().any(|w| matches!(w,
731 PolicyWarning::NormalizedIdentityHash { original, .. }
732 if original == "AAAA1111BBBB2222CCCC3333DDDD4444"
733 )),
734 "should warn about normalized Alice hash"
735 );
736 assert!(
737 warnings.iter().any(|w| matches!(w,
738 PolicyWarning::InvalidIdentityHash { identity_hash, .. }
739 if identity_hash == "short"
740 )),
741 "should warn about invalid 'short' hash"
742 );
743 assert!(
744 warnings.iter().any(|w| matches!(w,
745 PolicyWarning::UnknownGrant { grant, .. }
746 if grant == "fake.grant"
747 )),
748 "should warn about unknown grant"
749 );
750 assert!(
751 warnings.iter().any(|w| matches!(w,
752 PolicyWarning::InvalidBlockedPrefix { prefix }
753 if prefix == "ab"
754 )),
755 "should warn about short blocked prefix"
756 );
757 assert!(
758 warnings.iter().any(|w| matches!(w,
759 PolicyWarning::NormalizedBlockedPrefix { original, .. }
760 if original == "DEADBEEF"
761 )),
762 "should warn about normalized blocked prefix"
763 );
764 }
765}