Skip to main content

ma_core/acl/
mod.rs

1//! Capability-based access control for ma identities.
2//!
3//! An [`AclMap`] maps principal strings to [`CapabilityEntry`] values.
4//! Deny always wins over allow; a wildcard deny closes access to everyone.
5//!
6//! # Capabilities
7//!
8//! Capabilities are plain strings. Built-in system capabilities:
9//!
10//! | Capability | Meaning |
11//! |------------|---------|
12//! | `"rpc"`    | Send RPC messages via `/ma/rpc/0.0.1` |
13//! | `"ipfs"`   | Publish DID documents via `/ma/ipfs/0.0.1` |
14//! | `"read"`   | Read entities, config, and namespace contents |
15//! | `"create"` | Create new namespaces or entities |
16//! | `"update"` | Update existing namespaces or entities |
17//! | `"delete"` | Delete namespaces or entities |
18//! | `"*"`      | Wildcard — grants **all** capabilities at this level |
19//!
20//! Entity and namespace ACLs may also use arbitrary capability strings that
21//! correspond to verb names or sub-namespace names.
22//!
23//! # Key forms in an [`AclMap`]
24//!
25//! Keys are **principal strings** — exactly one of:
26//!
27//! | Form | Meaning |
28//! |------|---------|
29//! | `"*"` | Wildcard — matches any caller |
30//! | `"did:ma:<identity>"` | Bare DID (no fragment) |
31//! | `"#<local>"` | Local entity identifier |
32//! | `"+<handle>.<path>"` | Named group of principals (unlimited depth) |
33//!
34//! # YAML format
35//!
36//! ```yaml
37//! acl:
38//!   "*": [rpc, create]          # everyone: RPC + create
39//!   "did:ma:alice": ["*"]        # alice: all capabilities
40//!   "did:ma:bob": [rpc, read]   # bob: restricted
41//!   "did:ma:eve":               # null / absent → explicit deny
42//!   "+carlotta.friends": [rpc]         # group: all members get rpc
43//!   "+alice.project4.admins": ["*"]  # deep path: project4 admins get all caps
44//!   "+alice.enemies":                # group: all members denied
45//! ```
46//!
47//! # Example
48//!
49//! ```rust
50//! # use ma_core::{AclMap, CapabilityEntry, check_cap, CAP_RPC};
51//! let mut acl = AclMap::new();
52//! acl.insert("*".to_string(), CapabilityEntry::from_caps(["rpc"]));
53//! acl.insert("did:ma:Qmevil".to_string(), CapabilityEntry::Deny);
54//! assert!(check_cap(&acl, "did:ma:Qmgood", CAP_RPC).is_ok());
55//! assert!(check_cap(&acl, "did:ma:Qmevil", CAP_RPC).is_err());
56//! ```
57
58use std::collections::{BTreeSet, HashMap};
59
60use serde::{Deserialize, Deserializer, Serialize, Serializer};
61
62#[cfg(feature = "acl")]
63use crate::{Error, Result};
64
65// ── Capability constants ───────────────────────────────────────────────────────
66
67/// Deliver messages to an endpoint's inbox (`/ma/inbox/0.0.1`).
68pub const CAP_INBOX: &str = "inbox";
69/// Send RPC messages via `/ma/rpc/0.0.1`.
70pub const CAP_RPC: &str = "rpc";
71/// Publish DID documents via `/ma/ipfs/0.0.1`.
72pub const CAP_IPFS: &str = "ipfs";
73/// Access the structured CRUD service via `/ma/crud/0.0.1`.
74pub const CAP_CRUD: &str = "crud";
75/// Read entities, config, and namespace contents.
76pub const CAP_READ: &str = "read";
77/// Create new namespaces or entities.
78pub const CAP_CREATE: &str = "create";
79/// Update existing namespaces or entities.
80pub const CAP_UPDATE: &str = "update";
81/// Delete namespaces or entities.
82pub const CAP_DELETE: &str = "delete";
83/// Read or modify ACL documents.
84///
85/// Separates ACL-administration rights from general CRUD access.
86/// A principal with `acl` capability can read and update ACL documents
87/// without necessarily having access to the resources those ACLs protect.
88pub const CAP_ACL: &str = "acl";
89
90// ── Group-principal prefix ─────────────────────────────────────────────────────
91
92/// Sigil that marks a group principal in an [`AclMap`] key.
93///
94/// A group key has the form `+<handle>.<path>` where `<handle>` is the owning
95/// identity's handle and `<path>` is a dot-separated path of arbitrary depth
96/// into that handle's group tree (e.g. `+alice.project4.admins`).
97pub const GROUP_PREFIX: &str = "+";
98
99/// Local-entity wildcard principal.
100///
101/// The bare `"#"` key in an [`AclMap`] matches **any** caller whose principal
102/// starts with `"#"` — i.e. any entity running on the same runtime instance.
103///
104/// It sits between the specific `"#<name>"` form and the global `"*"` wildcard
105/// in lookup priority:
106///
107/// ```text
108/// // specific entity  > local wildcard  > global wildcard
109/// //   "#fortune"         "#"                 "*"
110/// ```
111///
112/// The runtime pre-normalises intra-runtime callers from their full DID-URL form
113/// (`did:ma:<our_did>#fortune`) to the bare fragment form (`#fortune`) before
114/// the ACL lookup, so that these keys resolve correctly.
115///
116/// ## YAML example
117///
118/// ```yaml
119/// acl:
120///   "#": [handle_cast]        # any local entity on this runtime may call
121///   "did:ma:alice": ["*"]     # remote alice: all caps
122///   "*":                      # everyone else: deny
123/// ```
124pub const LOCAL_ENTITY_WILDCARD: &str = "#";
125
126// ── CapabilityEntry ────────────────────────────────────────────────────────────
127
128/// Capability set for a principal in an [`AclMap`].
129///
130/// Serialises as:
131/// - `null` → [`Deny`](CapabilityEntry::Deny)
132/// - YAML sequence → [`Allow`](CapabilityEntry::Allow)
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum CapabilityEntry {
135    /// Explicit deny. Wins over any wildcard allow for the same principal.
136    Deny,
137    /// Allow the listed capabilities. `["*"]` grants all capabilities.
138    Allow(BTreeSet<String>),
139}
140
141impl CapabilityEntry {
142    /// Construct an `Allow` entry from an iterator of capability name strings.
143    pub fn from_caps<I, S>(caps: I) -> Self
144    where
145        I: IntoIterator<Item = S>,
146        S: Into<String>,
147    {
148        Self::Allow(caps.into_iter().map(Into::into).collect())
149    }
150
151    /// Return `true` if this entry grants `cap`.
152    /// `"*"` in the capability set grants any capability.
153    pub fn has(&self, cap: &str) -> bool {
154        match self {
155            Self::Deny => false,
156            Self::Allow(caps) => caps.contains(cap) || caps.contains("*"),
157        }
158    }
159
160    /// Return `true` if this is an explicit deny.
161    pub fn is_deny(&self) -> bool {
162        matches!(self, Self::Deny)
163    }
164}
165
166impl Serialize for CapabilityEntry {
167    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
168        match self {
169            Self::Deny => serializer.serialize_none(),
170            Self::Allow(caps) => {
171                use serde::ser::SerializeSeq;
172                let mut seq = serializer.serialize_seq(Some(caps.len()))?;
173                for cap in caps {
174                    seq.serialize_element(cap)?;
175                }
176                seq.end()
177            }
178        }
179    }
180}
181
182impl<'de> Deserialize<'de> for CapabilityEntry {
183    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
184        #[derive(Deserialize)]
185        #[serde(untagged)]
186        enum Raw {
187            #[allow(dead_code)]
188            Str(String),
189            Seq(Vec<String>),
190        }
191        let opt: Option<Raw> = Option::deserialize(deserializer)?;
192        match opt {
193            None => Ok(Self::Deny),
194            Some(Raw::Seq(v)) if v.is_empty() => Ok(Self::Deny),
195            Some(Raw::Seq(v)) => Ok(Self::Allow(v.into_iter().collect())),
196            Some(Raw::Str(_)) => Err(serde::de::Error::custom(
197                "invalid ACL entry: use a YAML sequence for Allow or null for Deny",
198            )),
199        }
200    }
201}
202
203// ── AclMap ─────────────────────────────────────────────────────────────────────
204
205/// Capability-based access control map.
206///
207/// Keys are principal strings — exactly one of:
208/// - `"*"` — wildcard
209/// - `"did:ma:<identity>"` — bare DID, no fragment
210/// - `"#<local>"` — local entity identifier
211/// - `"+<handle>.<path>"` — named group of principals (unlimited depth)
212///
213/// DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys;
214/// use [`is_valid_acl_key`] to validate before inserting.
215pub type AclMap = HashMap<String, CapabilityEntry>;
216
217// ── check_cap ──────────────────────────────────────────────────────────────────
218
219/// Check whether `caller` has capability `cap` in `acl`.
220///
221/// 1. Normalise `caller` (strip fragment from DID-URLs).
222/// 2. Look up the normalised caller directly — if a principal entry, apply and stop.
223/// 3. Fall back to the `"*"` wildcard principal entry.
224/// 4. Explicit deny → `Err`; capability absent → `Err`; no entry → `Err`.
225///
226/// Group principals (`+<handle>.<path>`) are **not** resolved here;
227/// they are expanded by the runtime's async `check_full`.
228///
229/// A `"*"` item inside an `Allow` set grants **all** capabilities.
230#[cfg(feature = "acl")]
231pub fn check_cap(acl: &AclMap, caller: &str, cap: &str) -> Result<()> {
232    let normalized = normalize_principal(caller);
233
234    // 1. Direct principal match (highest priority).
235    if let Some(direct) = acl.get(normalized) {
236        return match direct {
237            CapabilityEntry::Deny => Err(Error::Acl(format!("operation denied for {caller}"))),
238            CapabilityEntry::Allow(caps) if caps.contains(cap) || caps.contains("*") => Ok(()),
239            CapabilityEntry::Allow(_) => Err(Error::Acl(format!(
240                "capability '{cap}' denied for {caller}"
241            ))),
242        };
243    }
244
245    // 2. Local-entity wildcard "#" — matches any `#`-prefixed caller.
246    if normalized.starts_with('#') {
247        if let Some(local_wild) = acl.get(LOCAL_ENTITY_WILDCARD) {
248            return match local_wild {
249                CapabilityEntry::Deny => Err(Error::Acl(format!("operation denied for {caller}"))),
250                CapabilityEntry::Allow(caps) if caps.contains(cap) || caps.contains("*") => Ok(()),
251                CapabilityEntry::Allow(_) => Err(Error::Acl(format!(
252                    "capability '{cap}' denied for {caller}"
253                ))),
254            };
255        }
256    }
257
258    // 3. Global wildcard "*" (lowest priority).
259    match acl.get("*") {
260        None => Err(Error::Acl(format!("no ACL entry for {caller}"))),
261        Some(CapabilityEntry::Deny) => Err(Error::Acl(format!("operation denied for {caller}"))),
262        Some(CapabilityEntry::Allow(caps)) if caps.contains(cap) || caps.contains("*") => Ok(()),
263        Some(CapabilityEntry::Allow(_)) => Err(Error::Acl(format!(
264            "capability '{cap}' denied for {caller}"
265        ))),
266    }
267}
268
269/// Return `true` if `key` is a valid [`AclMap`] key.
270///
271/// Valid keys: `"*"`, `"did:ma:<id>"`, `"#<local>"`, `"+<handle>.<path>"`
272/// (where `<path>` is dot-separated with unlimited depth),
273/// and arbitrary capability/verb name strings (non-empty).
274pub fn is_valid_acl_key(key: &str) -> bool {
275    !key.is_empty()
276}
277
278/// Return `true` if `key` is a principal key (identifies *who*).
279///
280/// Valid principal key forms:
281/// - `"*"` — global wildcard
282/// - `"did:ma:<id>"` — bare DID (no fragment)
283/// - `"#"` — local-entity wildcard (any entity on this runtime)
284/// - `"#<name>"` — specific local entity by fragment name
285/// - `"+<handle>.<path>"` — group principal
286pub fn is_principal_key(key: &str) -> bool {
287    key == "*"
288        || (key.starts_with("did:") && !key.contains('#'))
289        || key.starts_with('#')   // covers both "#" (local wildcard) and "#name" (specific)
290        || is_valid_group_key(key)
291}
292
293/// Return `true` if `key` is a valid group principal.
294///
295/// Form: `+<handle>.<path>` where `<handle>` is non-empty and `<path>` is a
296/// non-empty dot-separated string of arbitrary depth
297/// (e.g. `+alice.admins`, `+alice.project4.admins`).
298fn is_valid_group_key(key: &str) -> bool {
299    if let Some(rest) = key.strip_prefix(GROUP_PREFIX) {
300        if let Some(dot) = rest.find('.') {
301            let handle = &rest[..dot];
302            let path = &rest[dot + 1..];
303            return !handle.is_empty() && !path.is_empty();
304        }
305    }
306    false
307}
308
309/// Validate all keys in an [`AclMap`], returning a descriptive error for the
310/// first invalid key found.
311///
312/// Call this immediately after loading an ACL from YAML or any external source.
313#[cfg(feature = "acl")]
314pub fn validate_acl_map(acl: &AclMap) -> Result<()> {
315    for key in acl.keys() {
316        if !is_valid_acl_key(key) {
317            return Err(Error::Acl(format!(
318                "invalid ACL key {key:?}: key must be non-empty"
319            )));
320        }
321    }
322    Ok(())
323}
324
325/// Normalise a caller identity for [`AclMap`] lookup.
326///
327/// - `did:ma:foo#bar` → `did:ma:foo` (strips fragment from **remote** DID-URLs)
328/// - `#local` → `#local` (already-normalised local entity, passed through)
329/// - `#` → `#` (local-entity wildcard, passed through)
330/// - `*` → `*` (global wildcard, passed through)
331///
332/// **Note:** Callers from the same runtime arrive as `did:ma:<our_did>#fragment`.
333/// The runtime is responsible for converting those to `#fragment` *before*
334/// calling this function (or [`check_cap`]) so that `"#<name>"` and `"#"`
335/// ACL keys resolve correctly.  This function only strips fragments from
336/// foreign DID-URLs; it does not know which DID belongs to the local runtime.
337pub fn normalize_principal(did: &str) -> &str {
338    if did.starts_with("did:") {
339        if let Some(pos) = did.find('#') {
340            return &did[..pos];
341        }
342    }
343    did
344}
345
346// ── Tests ──────────────────────────────────────────────────────────────────────
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn allow(caps: &[&str]) -> CapabilityEntry {
353        CapabilityEntry::from_caps(caps.iter().copied())
354    }
355
356    fn m(entries: &[(&str, CapabilityEntry)]) -> AclMap {
357        entries
358            .iter()
359            .map(|(k, v)| (k.to_string(), v.clone()))
360            .collect()
361    }
362
363    #[test]
364    fn wildcard_rpc_allows_rpc() {
365        let acl = m(&[("*", allow(&[CAP_RPC]))]);
366        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
367    }
368
369    #[test]
370    fn wildcard_rpc_denies_ipfs() {
371        let acl = m(&[("*", allow(&[CAP_RPC]))]);
372        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_err());
373    }
374
375    #[test]
376    fn explicit_deny_wins_over_wildcard_allow() {
377        let acl = m(&[
378            ("*", allow(&[CAP_RPC, CAP_IPFS])),
379            ("did:ma:bandit", CapabilityEntry::Deny),
380        ]);
381        assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
382    }
383
384    #[test]
385    fn exact_match_restricts_below_wildcard() {
386        let acl = m(&[
387            ("*", allow(&[CAP_RPC, CAP_IPFS])),
388            ("did:ma:bob", allow(&[CAP_RPC])),
389        ]);
390        assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
391        assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
392    }
393
394    #[test]
395    fn did_url_caller_is_normalized() {
396        let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS]))]);
397        assert!(check_cap(&acl, "did:ma:alice#sign", CAP_RPC).is_ok());
398    }
399
400    #[test]
401    fn no_entry_default_deny() {
402        assert!(check_cap(&AclMap::new(), "did:ma:anyone", CAP_RPC).is_err());
403    }
404
405    #[test]
406    fn wildcard_deny_blocks_all() {
407        let acl = m(&[("*", CapabilityEntry::Deny)]);
408        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_err());
409    }
410
411    #[test]
412    fn local_entity_key_allowed() {
413        let acl = m(&[("#agent", allow(&[CAP_RPC]))]);
414        assert!(check_cap(&acl, "#agent", CAP_RPC).is_ok());
415        assert!(check_cap(&acl, "#other", CAP_RPC).is_err());
416    }
417
418    #[test]
419    fn arbitrary_capability_works() {
420        let acl = m(&[("did:ma:alice", allow(&["emote", "reply"]))]);
421        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
422        assert!(check_cap(&acl, "did:ma:alice", "reply").is_ok());
423        assert!(check_cap(&acl, "did:ma:alice", "admin").is_err());
424    }
425
426    #[test]
427    fn wildcard_cap_grants_all_capabilities() {
428        let acl = m(&[("did:ma:alice", allow(&["*"]))]);
429        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
430        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_ok());
431        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
432        assert!(check_cap(&acl, "did:ma:alice", "admin").is_ok());
433    }
434
435    #[test]
436    fn owner_capability_is_just_a_string() {
437        let acl = m(&[("did:ma:alice", allow(&["owner"]))]);
438        assert!(check_cap(&acl, "did:ma:alice", "owner").is_ok());
439        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
440    }
441
442    #[test]
443    fn normalize_strips_fragment() {
444        assert_eq!(normalize_principal("did:ma:foo#bar"), "did:ma:foo");
445        assert_eq!(normalize_principal("did:ma:foo"), "did:ma:foo");
446        assert_eq!(normalize_principal("#local"), "#local");
447        assert_eq!(normalize_principal("#"), "#");
448        assert_eq!(normalize_principal("*"), "*");
449    }
450
451    // ── Local-entity wildcard ("#") tests ─────────────────────────────────────
452
453    #[test]
454    fn local_wildcard_allows_any_hash_prefixed_caller() {
455        // "#" matches any #-prefixed caller when there is no specific entry.
456        let acl = m(&[("#", allow(&[CAP_RPC]))]);
457        assert!(check_cap(&acl, "#fortune", CAP_RPC).is_ok());
458        assert!(check_cap(&acl, "#scheduler", CAP_RPC).is_ok());
459        assert!(check_cap(&acl, "#any_entity", CAP_RPC).is_ok());
460    }
461
462    #[test]
463    fn local_wildcard_does_not_match_remote_callers() {
464        // Remote DIDs do not start with '#', so "#" should not grant them.
465        let acl = m(&[("#", allow(&[CAP_RPC]))]);
466        assert!(check_cap(&acl, "did:ma:remote", CAP_RPC).is_err());
467    }
468
469    #[test]
470    fn specific_local_entity_wins_over_local_wildcard() {
471        // "#fortune" is more specific: it restricts below the "#" wildcard.
472        let acl = m(&[
473            ("#", allow(&[CAP_RPC, CAP_IPFS])),
474            ("#fortune", allow(&[CAP_RPC])), // only rpc, not ipfs
475        ]);
476        assert!(check_cap(&acl, "#fortune", CAP_RPC).is_ok());
477        assert!(check_cap(&acl, "#fortune", CAP_IPFS).is_err());
478        // Other local entities still get rpc+ipfs from the wildcard.
479        assert!(check_cap(&acl, "#other", CAP_IPFS).is_ok());
480    }
481
482    #[test]
483    fn local_wildcard_deny_blocks_all_local_entities() {
484        let acl = m(&[
485            ("#", CapabilityEntry::Deny),
486            ("*", allow(&[CAP_RPC])), // global wildcard would allow, but # deny wins
487        ]);
488        assert!(check_cap(&acl, "#fortune", CAP_RPC).is_err());
489        assert!(check_cap(&acl, "#any", CAP_RPC).is_err());
490        // Remote callers are unaffected by the "#" deny.
491        assert!(check_cap(&acl, "did:ma:remote", CAP_RPC).is_ok());
492    }
493
494    #[test]
495    fn specific_local_entity_allow_overrides_local_wildcard_deny() {
496        // "#fortune" explicit allow wins over "#" deny for that entity.
497        let acl = m(&[
498            ("#", CapabilityEntry::Deny),
499            ("#fortune", allow(&[CAP_RPC])),
500        ]);
501        assert!(check_cap(&acl, "#fortune", CAP_RPC).is_ok());
502        assert!(check_cap(&acl, "#other", CAP_RPC).is_err());
503    }
504
505    #[test]
506    fn global_wildcard_not_triggered_for_hash_caller_when_local_wildcard_present() {
507        // When "#" deny is set, "*" allow must NOT override it for local callers.
508        let acl = m(&[("#", CapabilityEntry::Deny), ("*", allow(&[CAP_RPC]))]);
509        // Local entity is denied by "#" — must not fall through to "*".
510        assert!(check_cap(&acl, "#fortune", CAP_RPC).is_err());
511    }
512
513    #[test]
514    fn local_wildcard_is_key_form_valid() {
515        assert!(is_principal_key("#"));
516        assert!(is_principal_key("#fortune"));
517        assert!(is_principal_key("*"));
518        assert!(is_principal_key("did:ma:alice"));
519    }
520
521    #[test]
522    fn explicit_deny_without_wildcard() {
523        // A bare Deny entry with no wildcard still denies.
524        let acl = m(&[("did:ma:bandit", CapabilityEntry::Deny)]);
525        assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
526        // Others get default deny too (no wildcard).
527        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
528    }
529
530    #[test]
531    fn multiple_caps_in_single_entry() {
532        let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS, CAP_READ]))]);
533        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
534        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_ok());
535        assert!(check_cap(&acl, "did:ma:alice", CAP_READ).is_ok());
536        assert!(check_cap(&acl, "did:ma:alice", CAP_CREATE).is_err());
537        assert!(check_cap(&acl, "did:ma:alice", CAP_DELETE).is_err());
538    }
539
540    #[test]
541    fn direct_entry_restricts_even_when_wildcard_is_broader() {
542        // Wildcard gives everyone rpc+ipfs, but bob only gets rpc.
543        // Direct entry wins and caps don't accumulate from wildcard.
544        let acl = m(&[
545            ("*", allow(&[CAP_RPC, CAP_IPFS])),
546            ("did:ma:bob", allow(&[CAP_RPC])),
547        ]);
548        assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
549        assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
550        // Alice (no direct entry) still gets both from wildcard.
551        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
552        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_ok());
553    }
554
555    #[test]
556    fn group_principal_allowed() {
557        // "+group" keys are not resolved by check_cap; they pass through.
558        // Resolution happens in the runtime's async check_full.
559        let acl = m(&[("*", allow(&[CAP_RPC]))]);
560        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_ok());
561    }
562
563    #[test]
564    fn valid_acl_keys() {
565        assert!(is_valid_acl_key("*"));
566        assert!(is_valid_acl_key("did:ma:Qmfoo"));
567        assert!(is_valid_acl_key("#agent"));
568        assert!(is_valid_acl_key("+alice.venner"));
569        assert!(is_valid_acl_key("+runtime.admins"));
570        assert!(is_valid_acl_key("fortune"));
571        assert!(is_valid_acl_key("admin"));
572        assert!(is_valid_acl_key("emote"));
573        assert!(!is_valid_acl_key(""));
574    }
575
576    #[cfg(feature = "acl")]
577    #[test]
578    fn capability_serde_roundtrip() {
579        let acl: AclMap = [
580            (
581                "*".to_string(),
582                CapabilityEntry::from_caps(["rpc", "create"]),
583            ),
584            ("did:ma:bandit".to_string(), CapabilityEntry::Deny),
585        ]
586        .into_iter()
587        .collect();
588        let yaml = serde_yaml::to_string(&acl).unwrap();
589        let roundtrip: AclMap = serde_yaml::from_str(&yaml).unwrap();
590        assert_eq!(acl, roundtrip);
591    }
592
593    #[cfg(feature = "acl")]
594    #[test]
595    fn yaml_null_deserializes_to_deny() {
596        let yaml = "'did:ma:x': ~\n'*':\n- rpc\n- create\n";
597        let acl: AclMap = serde_yaml::from_str(yaml).unwrap();
598        assert_eq!(acl.get("did:ma:x"), Some(&CapabilityEntry::Deny));
599        assert_eq!(
600            acl.get("*"),
601            Some(&CapabilityEntry::from_caps(["rpc", "create"]))
602        );
603    }
604}