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// ── CapabilityEntry ────────────────────────────────────────────────────────────
100
101/// Capability set for a principal in an [`AclMap`].
102///
103/// Serialises as:
104/// - `null` → [`Deny`](CapabilityEntry::Deny)
105/// - YAML sequence → [`Allow`](CapabilityEntry::Allow)
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum CapabilityEntry {
108    /// Explicit deny. Wins over any wildcard allow for the same principal.
109    Deny,
110    /// Allow the listed capabilities. `["*"]` grants all capabilities.
111    Allow(BTreeSet<String>),
112}
113
114impl CapabilityEntry {
115    /// Construct an `Allow` entry from an iterator of capability name strings.
116    pub fn from_caps<I, S>(caps: I) -> Self
117    where
118        I: IntoIterator<Item = S>,
119        S: Into<String>,
120    {
121        Self::Allow(caps.into_iter().map(Into::into).collect())
122    }
123
124    /// Return `true` if this entry grants `cap`.
125    /// `"*"` in the capability set grants any capability.
126    pub fn has(&self, cap: &str) -> bool {
127        match self {
128            Self::Deny => false,
129            Self::Allow(caps) => caps.contains(cap) || caps.contains("*"),
130        }
131    }
132
133    /// Return `true` if this is an explicit deny.
134    pub fn is_deny(&self) -> bool {
135        matches!(self, Self::Deny)
136    }
137}
138
139impl Serialize for CapabilityEntry {
140    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
141        match self {
142            Self::Deny => serializer.serialize_none(),
143            Self::Allow(caps) => {
144                use serde::ser::SerializeSeq;
145                let mut seq = serializer.serialize_seq(Some(caps.len()))?;
146                for cap in caps {
147                    seq.serialize_element(cap)?;
148                }
149                seq.end()
150            }
151        }
152    }
153}
154
155impl<'de> Deserialize<'de> for CapabilityEntry {
156    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
157        #[derive(Deserialize)]
158        #[serde(untagged)]
159        enum Raw {
160            #[allow(dead_code)]
161            Str(String),
162            Seq(Vec<String>),
163        }
164        let opt: Option<Raw> = Option::deserialize(deserializer)?;
165        match opt {
166            None => Ok(Self::Deny),
167            Some(Raw::Seq(v)) if v.is_empty() => Ok(Self::Deny),
168            Some(Raw::Seq(v)) => Ok(Self::Allow(v.into_iter().collect())),
169            Some(Raw::Str(_)) => Err(serde::de::Error::custom(
170                "invalid ACL entry: use a YAML sequence for Allow or null for Deny",
171            )),
172        }
173    }
174}
175
176// ── AclMap ─────────────────────────────────────────────────────────────────────
177
178/// Capability-based access control map.
179///
180/// Keys are principal strings — exactly one of:
181/// - `"*"` — wildcard
182/// - `"did:ma:<identity>"` — bare DID, no fragment
183/// - `"#<local>"` — local entity identifier
184/// - `"+<handle>.<path>"` — named group of principals (unlimited depth)
185///
186/// DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys;
187/// use [`is_valid_acl_key`] to validate before inserting.
188pub type AclMap = HashMap<String, CapabilityEntry>;
189
190// ── check_cap ──────────────────────────────────────────────────────────────────
191
192/// Check whether `caller` has capability `cap` in `acl`.
193///
194/// 1. Normalise `caller` (strip fragment from DID-URLs).
195/// 2. Look up the normalised caller directly — if a principal entry, apply and stop.
196/// 3. Fall back to the `"*"` wildcard principal entry.
197/// 4. Explicit deny → `Err`; capability absent → `Err`; no entry → `Err`.
198///
199/// Group principals (`+<handle>.<path>`) are **not** resolved here;
200/// they are expanded by the runtime's async `check_full`.
201///
202/// A `"*"` item inside an `Allow` set grants **all** capabilities.
203#[cfg(feature = "acl")]
204pub fn check_cap(acl: &AclMap, caller: &str, cap: &str) -> Result<()> {
205    let normalized = normalize_principal(caller);
206    if let Some(direct) = acl.get(normalized) {
207        match direct {
208            CapabilityEntry::Deny => {
209                return Err(Error::Acl(format!("operation denied for {caller}")));
210            }
211            CapabilityEntry::Allow(caps) if caps.contains(cap) || caps.contains("*") => {
212                return Ok(());
213            }
214            CapabilityEntry::Allow(_) => {
215                return Err(Error::Acl(format!(
216                    "capability '{cap}' denied for {caller}"
217                )));
218            }
219        }
220    }
221
222    match acl.get("*") {
223        None => Err(Error::Acl(format!("no ACL entry for {caller}"))),
224        Some(CapabilityEntry::Deny) => Err(Error::Acl(format!("operation denied for {caller}"))),
225        Some(CapabilityEntry::Allow(caps)) if caps.contains(cap) || caps.contains("*") => Ok(()),
226        Some(CapabilityEntry::Allow(_)) => Err(Error::Acl(format!(
227            "capability '{cap}' denied for {caller}"
228        ))),
229    }
230}
231
232/// Return `true` if `key` is a valid [`AclMap`] key.
233///
234/// Valid keys: `"*"`, `"did:ma:<id>"`, `"#<local>"`, `"+<handle>.<path>"`
235/// (where `<path>` is dot-separated with unlimited depth),
236/// and arbitrary capability/verb name strings (non-empty).
237pub fn is_valid_acl_key(key: &str) -> bool {
238    !key.is_empty()
239}
240
241/// Return `true` if `key` is a principal key (identifies *who*).
242pub fn is_principal_key(key: &str) -> bool {
243    key == "*"
244        || (key.starts_with("did:") && !key.contains('#'))
245        || (key.starts_with('#') && key.len() > 1)
246        || is_valid_group_key(key)
247}
248
249/// Return `true` if `key` is a valid group principal.
250///
251/// Form: `+<handle>.<path>` where `<handle>` is non-empty and `<path>` is a
252/// non-empty dot-separated string of arbitrary depth
253/// (e.g. `+alice.admins`, `+alice.project4.admins`).
254fn is_valid_group_key(key: &str) -> bool {
255    if let Some(rest) = key.strip_prefix(GROUP_PREFIX) {
256        if let Some(dot) = rest.find('.') {
257            let handle = &rest[..dot];
258            let path = &rest[dot + 1..];
259            return !handle.is_empty() && !path.is_empty();
260        }
261    }
262    false
263}
264
265/// Validate all keys in an [`AclMap`], returning a descriptive error for the
266/// first invalid key found.
267///
268/// Call this immediately after loading an ACL from YAML or any external source.
269#[cfg(feature = "acl")]
270pub fn validate_acl_map(acl: &AclMap) -> Result<()> {
271    for key in acl.keys() {
272        if !is_valid_acl_key(key) {
273            return Err(Error::Acl(format!(
274                "invalid ACL key {key:?}: key must be non-empty"
275            )));
276        }
277    }
278    Ok(())
279}
280
281/// Normalise a caller identity for [`AclMap`] lookup.
282///
283/// - `did:ma:foo#bar` → `did:ma:foo` (strips fragment from DID-URLs)
284/// - `#local` → `#local` (local entity, passed through)
285/// - `*` → `*` (wildcard, passed through)
286pub fn normalize_principal(did: &str) -> &str {
287    if did.starts_with("did:") {
288        if let Some(pos) = did.find('#') {
289            return &did[..pos];
290        }
291    }
292    did
293}
294
295// ── Tests ──────────────────────────────────────────────────────────────────────
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn allow(caps: &[&str]) -> CapabilityEntry {
302        CapabilityEntry::from_caps(caps.iter().copied())
303    }
304
305    fn m(entries: &[(&str, CapabilityEntry)]) -> AclMap {
306        entries
307            .iter()
308            .map(|(k, v)| (k.to_string(), v.clone()))
309            .collect()
310    }
311
312    #[test]
313    fn wildcard_rpc_allows_rpc() {
314        let acl = m(&[("*", allow(&[CAP_RPC]))]);
315        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
316    }
317
318    #[test]
319    fn wildcard_rpc_denies_ipfs() {
320        let acl = m(&[("*", allow(&[CAP_RPC]))]);
321        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_err());
322    }
323
324    #[test]
325    fn explicit_deny_wins_over_wildcard_allow() {
326        let acl = m(&[
327            ("*", allow(&[CAP_RPC, CAP_IPFS])),
328            ("did:ma:bandit", CapabilityEntry::Deny),
329        ]);
330        assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
331    }
332
333    #[test]
334    fn exact_match_restricts_below_wildcard() {
335        let acl = m(&[
336            ("*", allow(&[CAP_RPC, CAP_IPFS])),
337            ("did:ma:bob", allow(&[CAP_RPC])),
338        ]);
339        assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
340        assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
341    }
342
343    #[test]
344    fn did_url_caller_is_normalized() {
345        let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS]))]);
346        assert!(check_cap(&acl, "did:ma:alice#sign", CAP_RPC).is_ok());
347    }
348
349    #[test]
350    fn no_entry_default_deny() {
351        assert!(check_cap(&AclMap::new(), "did:ma:anyone", CAP_RPC).is_err());
352    }
353
354    #[test]
355    fn wildcard_deny_blocks_all() {
356        let acl = m(&[("*", CapabilityEntry::Deny)]);
357        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_err());
358    }
359
360    #[test]
361    fn local_entity_key_allowed() {
362        let acl = m(&[("#agent", allow(&[CAP_RPC]))]);
363        assert!(check_cap(&acl, "#agent", CAP_RPC).is_ok());
364        assert!(check_cap(&acl, "#other", CAP_RPC).is_err());
365    }
366
367    #[test]
368    fn arbitrary_capability_works() {
369        let acl = m(&[("did:ma:alice", allow(&["emote", "reply"]))]);
370        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
371        assert!(check_cap(&acl, "did:ma:alice", "reply").is_ok());
372        assert!(check_cap(&acl, "did:ma:alice", "admin").is_err());
373    }
374
375    #[test]
376    fn wildcard_cap_grants_all_capabilities() {
377        let acl = m(&[("did:ma:alice", allow(&["*"]))]);
378        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
379        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_ok());
380        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
381        assert!(check_cap(&acl, "did:ma:alice", "admin").is_ok());
382    }
383
384    #[test]
385    fn owner_capability_is_just_a_string() {
386        let acl = m(&[("did:ma:alice", allow(&["owner"]))]);
387        assert!(check_cap(&acl, "did:ma:alice", "owner").is_ok());
388        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
389    }
390
391    #[test]
392    fn normalize_strips_fragment() {
393        assert_eq!(normalize_principal("did:ma:foo#bar"), "did:ma:foo");
394        assert_eq!(normalize_principal("did:ma:foo"), "did:ma:foo");
395        assert_eq!(normalize_principal("#local"), "#local");
396        assert_eq!(normalize_principal("*"), "*");
397    }
398
399    #[test]
400    fn explicit_deny_without_wildcard() {
401        // A bare Deny entry with no wildcard still denies.
402        let acl = m(&[("did:ma:bandit", CapabilityEntry::Deny)]);
403        assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
404        // Others get default deny too (no wildcard).
405        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
406    }
407
408    #[test]
409    fn multiple_caps_in_single_entry() {
410        let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS, CAP_READ]))]);
411        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
412        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_ok());
413        assert!(check_cap(&acl, "did:ma:alice", CAP_READ).is_ok());
414        assert!(check_cap(&acl, "did:ma:alice", CAP_CREATE).is_err());
415        assert!(check_cap(&acl, "did:ma:alice", CAP_DELETE).is_err());
416    }
417
418    #[test]
419    fn direct_entry_restricts_even_when_wildcard_is_broader() {
420        // Wildcard gives everyone rpc+ipfs, but bob only gets rpc.
421        // Direct entry wins and caps don't accumulate from wildcard.
422        let acl = m(&[
423            ("*", allow(&[CAP_RPC, CAP_IPFS])),
424            ("did:ma:bob", allow(&[CAP_RPC])),
425        ]);
426        assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
427        assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
428        // Alice (no direct entry) still gets both from wildcard.
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    }
432
433    #[test]
434    fn group_principal_allowed() {
435        // "+group" keys are not resolved by check_cap; they pass through.
436        // Resolution happens in the runtime's async check_full.
437        let acl = m(&[("*", allow(&[CAP_RPC]))]);
438        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_ok());
439    }
440
441    #[test]
442    fn valid_acl_keys() {
443        assert!(is_valid_acl_key("*"));
444        assert!(is_valid_acl_key("did:ma:Qmfoo"));
445        assert!(is_valid_acl_key("#agent"));
446        assert!(is_valid_acl_key("+alice.venner"));
447        assert!(is_valid_acl_key("+runtime.admins"));
448        assert!(is_valid_acl_key("fortune"));
449        assert!(is_valid_acl_key("admin"));
450        assert!(is_valid_acl_key("emote"));
451        assert!(!is_valid_acl_key(""));
452    }
453
454    #[cfg(feature = "acl")]
455    #[test]
456    fn capability_serde_roundtrip() {
457        let acl: AclMap = [
458            (
459                "*".to_string(),
460                CapabilityEntry::from_caps(["rpc", "create"]),
461            ),
462            ("did:ma:bandit".to_string(), CapabilityEntry::Deny),
463        ]
464        .into_iter()
465        .collect();
466        let yaml = serde_yaml::to_string(&acl).unwrap();
467        let roundtrip: AclMap = serde_yaml::from_str(&yaml).unwrap();
468        assert_eq!(acl, roundtrip);
469    }
470
471    #[cfg(feature = "acl")]
472    #[test]
473    fn yaml_null_deserializes_to_deny() {
474        let yaml = "'did:ma:x': ~\n'*':\n- rpc\n- create\n";
475        let acl: AclMap = serde_yaml::from_str(yaml).unwrap();
476        assert_eq!(acl.get("did:ma:x"), Some(&CapabilityEntry::Deny));
477        assert_eq!(
478            acl.get("*"),
479            Some(&CapabilityEntry::from_caps(["rpc", "create"]))
480        );
481    }
482}