Skip to main content

styrene_rbac/
policy.rs

1//! RBAC policy evaluation — roster-based identity → role → capability checks.
2//!
3//! Pure evaluation logic with no I/O. Policy is constructed from a roster
4//! (identity hash → role + grants) and a default role for unknown identities.
5
6use crate::capability::{capabilities_for_role, is_valid_capability};
7use crate::role::Role;
8
9#[cfg(feature = "config")]
10use serde::{Deserialize, Serialize};
11
12/// Minimum length for blocked prefixes (4 bytes = 8 hex chars).
13/// Shorter prefixes would block unacceptably large portions of the identity space.
14pub const MIN_BLOCKED_PREFIX_LEN: usize = 8;
15
16/// A single identity's role assignment with optional explicit grants.
17#[derive(Debug, Clone)]
18#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
19pub struct RosterEntry {
20    /// 32-char hex identity hash (lowercase).
21    #[cfg_attr(feature = "config", serde(alias = "identity"))]
22    pub identity_hash: String,
23    /// Assigned role tier.
24    pub role: Role,
25    /// Human-readable label (optional).
26    #[cfg_attr(feature = "config", serde(default, skip_serializing_if = "String::is_empty"))]
27    pub label: String,
28    /// Explicit capability grants beyond what the role provides.
29    /// Used for orthogonal capabilities like `vpn.handshake`.
30    /// Private — only settable via `with_grants()` which validates, or
31    /// via `add_entry()` which filters to known capabilities.
32    #[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    /// Read-only access to the grants list.
52    pub fn grants(&self) -> &[String] {
53        &self.grants
54    }
55
56    /// Check whether this entry holds a specific capability (via role or grant).
57    pub fn has_capability(&self, cap: &str) -> bool {
58        // Only honor grants that are known capabilities (defense in depth).
59        capabilities_for_role(self.role).contains(&cap)
60            || (is_valid_capability(cap) && self.grants.iter().any(|g| g == cap))
61    }
62}
63
64/// Validate that a string is a valid hex identity hash (32 hex chars).
65fn is_valid_identity_hash(hash: &str) -> bool {
66    hash.len() == 32 && hash.bytes().all(|b| b.is_ascii_hexdigit())
67}
68
69/// Validate that a string is a valid blocked prefix (>= MIN_BLOCKED_PREFIX_LEN hex chars).
70fn 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/// Central authorization policy. Resolves identities → roles → capabilities.
75#[derive(Debug, Clone)]
76#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
77pub struct RbacPolicy {
78    /// Role assigned to identities not in the roster.
79    #[cfg_attr(feature = "config", serde(default = "default_role"))]
80    pub default_role: Role,
81
82    /// Explicit identity → role mappings.
83    #[cfg_attr(feature = "config", serde(default))]
84    roster: Vec<RosterEntry>,
85
86    /// Blocked identity hash prefixes. Any identity whose hash starts
87    /// with one of these prefixes is treated as `Role::Blocked`.
88    #[cfg_attr(feature = "config", serde(default))]
89    blocked: Vec<String>,
90
91    /// Hub public keys trusted to sign roster entries.
92    #[cfg_attr(feature = "config", serde(default))]
93    trusted_hubs: Vec<crate::signed::TrustedHub>,
94
95    /// Hub-signed roster entries. Verified against `trusted_hubs` during
96    /// `normalize()`. Valid entries supplement the static roster — the
97    /// static roster takes precedence on conflict.
98    #[cfg_attr(feature = "config", serde(default))]
99    hub_entries: Vec<crate::signed::SignedRosterEntry>,
100}
101
102#[allow(dead_code)] // Referenced by serde(default) when config feature enabled
103fn 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    /// Normalize and validate a deserialized policy, reporting every issue.
131    ///
132    /// Call this after deserializing from config. Returns a list of warnings
133    /// for every entry that was dropped, filtered, or normalized. Callers
134    /// should log these warnings so operators can fix their config.
135    ///
136    /// For a silent version (testing only), use `normalize_quiet()`.
137    pub fn normalize(&mut self) -> Vec<crate::PolicyWarning> {
138        use crate::PolicyWarning;
139
140        let mut warnings = Vec::new();
141
142        // Normalize and validate roster entries.
143        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        // Report and drop invalid identity hashes, then deduplicate (last wins).
166        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        // Normalize and validate blocked prefixes.
190        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    /// Normalize without collecting warnings (for testing convenience).
212    pub fn normalize_quiet(&mut self) {
213        let _ = self.normalize();
214    }
215
216    // ── Roster management ─────────────────────────────────────
217
218    /// Add or replace a roster entry. Identity hashes are normalized to lowercase.
219    ///
220    /// Returns `false` if the identity hash is not a valid 32-char hex string.
221    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        // Filter grants to known capabilities.
229        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    /// Remove a roster entry by identity hash. Returns true if found.
242    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    /// Add a blocked identity hash prefix (normalized to lowercase).
250    ///
251    /// Returns `false` if the prefix is shorter than `MIN_BLOCKED_PREFIX_LEN`
252    /// (8 hex chars / 4 bytes) or contains non-hex characters.
253    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    /// Remove a blocked prefix. Returns true if found.
265    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    /// Get a roster entry by identity hash.
273    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    /// Iterate over all roster entries.
279    pub fn entries(&self) -> &[RosterEntry] {
280        &self.roster
281    }
282
283    /// Iterate over blocked prefixes.
284    ///
285    /// Crate-internal only — exposing blocked prefixes to untrusted callers
286    /// enables evasion (choosing hashes outside blocked ranges).
287    #[allow(dead_code)] // Used in tests and future enforcement points
288    pub(crate) fn blocked_prefixes(&self) -> &[String] {
289        &self.blocked
290    }
291
292    /// Number of blocked prefixes (safe to expose).
293    pub fn blocked_count(&self) -> usize {
294        self.blocked.len()
295    }
296
297    // ── Hub entries ──────────────────────────────────────────
298
299    /// Add a trusted hub whose signed roster entries will be accepted.
300    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    /// Read-only access to trusted hubs.
307    pub fn trusted_hubs(&self) -> &[crate::signed::TrustedHub] {
308        &self.trusted_hubs
309    }
310
311    /// Add a hub-signed roster entry. The entry is stored as-is — callers
312    /// should verify the signature and check trusted_hubs before calling this.
313    pub fn add_hub_entry(&mut self, entry: crate::signed::SignedRosterEntry) {
314        // Deduplicate by identity hash (last wins)
315        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    /// Read-only access to hub-signed entries.
321    pub fn hub_entries(&self) -> &[crate::signed::SignedRosterEntry] {
322        &self.hub_entries
323    }
324
325    /// Remove all hub-signed entries (used before re-verifying at startup).
326    pub fn clear_hub_entries(&mut self) {
327        self.hub_entries.clear();
328    }
329
330    // ── Policy evaluation ─────────────────────────────────────
331
332    /// Resolve the effective role for an identity.
333    ///
334    /// Check order: blocked → static roster → hub entries → default role.
335    pub fn resolve_role(&self, identity_hash: &str) -> Role {
336        let normalized = identity_hash.to_ascii_lowercase();
337
338        // 1. Blocked prefix check
339        if self.blocked.iter().any(|prefix| normalized.starts_with(prefix.as_str())) {
340            return Role::Blocked;
341        }
342
343        // 2. Explicit (static) roster — takes precedence over hub entries
344        if let Some(entry) = self.roster.iter().find(|e| e.identity_hash == normalized) {
345            return entry.role;
346        }
347
348        // 3. Hub-signed entries (verified during normalize)
349        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        // 4. Default
358        self.default_role
359    }
360
361    /// Check whether an identity holds a specific capability.
362    ///
363    /// Capabilities come from three sources (in order):
364    /// 1. Static roster entry (role caps + explicit grants).
365    /// 2. Hub-signed entry (role caps + explicit grants).
366    /// 3. Default role's capability set.
367    pub fn has_capability(&self, identity_hash: &str, cap: &str) -> bool {
368        let normalized = identity_hash.to_ascii_lowercase();
369
370        // Blocked identities have no capabilities.
371        if self.blocked.iter().any(|prefix| normalized.starts_with(prefix.as_str())) {
372            return false;
373        }
374
375        // Check static roster entry (role caps + explicit grants).
376        if let Some(entry) = self.roster.iter().find(|e| e.identity_hash == normalized) {
377            return entry.has_capability(cap);
378        }
379
380        // Check hub-signed entry (role caps + explicit grants).
381        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        // Fall back to default role's capability set.
390        capabilities_for_role(self.default_role).contains(&cap)
391    }
392
393    /// Whether the default role grants a given capability.
394    /// Used to decide between ALLOW_ALL vs ALLOW_LIST in RNS handlers.
395    pub fn default_role_grants(&self, cap: &str) -> bool {
396        capabilities_for_role(self.default_role).contains(&cap)
397    }
398
399    /// Get the list of identity hashes that hold a given capability
400    /// (only explicitly rostered identities, not those covered by default).
401    ///
402    /// Blocked identities are excluded even if they have a roster entry.
403    ///
404    /// Crate-internal — exposing the full admin list enables targeted attacks.
405    /// External callers should use `has_capability()` for point checks.
406    #[allow(dead_code)] // Used in tests and future enforcement points
407    pub(crate) fn allow_list(&self, cap: &str) -> Vec<String> {
408        self.roster
409            .iter()
410            .filter(|e| {
411                // Exclude blocked identities — blocked overrides roster.
412                !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    // ── Role resolution ───────────────────────────────────────
445
446    #[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        // Block a prefix that matches Alice's hash.
470        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    // ── Capability checks ─────────────────────────────────────
482
483    #[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        // Bob has explicit VPN grant.
506        assert!(
507            policy.has_capability("eeee5555ffff6666aaaa7777bbbb8888", Capability::VPN_HANDSHAKE)
508        );
509        // Alice (admin) does NOT have VPN — it's orthogonal.
510        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        // Default is Peer — has chat.send but not rpc.exec.
525        assert!(policy.has_capability("0000000011111111aaaa2222bbbb3333", Capability::CHAT_SEND));
526        assert!(!policy.has_capability("0000000011111111aaaa2222bbbb3333", Capability::RPC_EXEC));
527    }
528
529    // ── Aether capabilities ───────────────────────────────────
530
531    #[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    // ── Roster management ─────────────────────────────────────
555
556    #[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        // Even if a grant string somehow got into the entry, has_capability
604        // won't honor it if it's not a known capability.
605        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    // ── Identity hash validation ──────────────────────────────
615
616    #[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")); // 8 chars = minimum
634    }
635
636    // ── Allow list ────────────────────────────────────────────
637
638    #[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        // Block Alice's prefix — she should disappear from allow lists.
649        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    // ── Config deserialization ─────────────────────────────────
662
663    #[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        // Alice normalized to lowercase, short entry dropped.
719        assert_eq!(policy.entries().len(), 2);
720        assert_eq!(policy.entries()[0].identity_hash, "aaaa1111bbbb2222cccc3333dddd4444");
721        // Invalid grant filtered, valid one kept.
722        assert_eq!(policy.entries()[1].grants(), &["vpn.handshake"]);
723        // Short prefix "ab" dropped, "DEADBEEF" normalized, "aabbccdd" kept.
724        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        // Verify warnings cover every issue.
729        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}