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