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//! An `AclMap` supports two kinds of entries:
26//!
27//! **Principal entries** — key identifies *who*:
28//!
29//! | Form | Meaning |
30//! |------|---------|
31//! | `"*"` | Wildcard — matches any caller |
32//! | `"did:ma:<identity>"` | Bare DID (no fragment) |
33//! | `"#<local>"` | Local entity identifier |
34//! | `"group:<handle>.<name>"` | Named group of principals |
35//!
36//! **Capability-grant entries** — key identifies *what*, value lists *who*:
37//!
38//! Plain words (e.g. `"fortune"`, `"admin"`) as keys map a capability name
39//! to a comma-separated list of group/DID references.
40//! These are resolved by the runtime's async ACL checker; [`check_cap`] skips them.
41//!
42//! # YAML format
43//!
44//! ```yaml
45//! acl:
46//!   "*": [rpc, create]                     # everyone: RPC + create
47//!   "did:ma:alice": ["*"]                   # alice: all capabilities
48//!   "did:ma:bob": [rpc, read]              # bob: restricted
49//!   "did:ma:eve":                          # null / absent → explicit deny
50//!   fortune: "group:carlotta.friends,did:ma:dave"  # cap-grant entry
51//! ```
52//!
53//! # Example
54//!
55//! ```rust
56//! # use ma_core::{AclMap, CapabilityEntry, check_cap, CAP_RPC};
57//! let mut acl = AclMap::new();
58//! acl.insert("*".to_string(), CapabilityEntry::from_caps(["rpc"]));
59//! acl.insert("did:ma:Qmevil".to_string(), CapabilityEntry::Deny);
60//! assert!(check_cap(&acl, "did:ma:Qmgood", CAP_RPC).is_ok());
61//! assert!(check_cap(&acl, "did:ma:Qmevil", CAP_RPC).is_err());
62//! ```
63
64use std::collections::{BTreeSet, HashMap};
65
66use serde::{Deserialize, Deserializer, Serialize, Serializer};
67
68#[cfg(feature = "acl")]
69use crate::{Error, Result};
70
71// ── Capability constants ───────────────────────────────────────────────────────
72
73/// Deliver messages to an endpoint's inbox (`/ma/inbox/0.0.1`).
74pub const CAP_INBOX: &str = "inbox";
75/// Send RPC messages via `/ma/rpc/0.0.1`.
76pub const CAP_RPC: &str = "rpc";
77/// Publish DID documents via `/ma/ipfs/0.0.1`.
78pub const CAP_IPFS: &str = "ipfs";
79/// Read entities, config, and namespace contents.
80pub const CAP_READ: &str = "read";
81/// Create new namespaces or entities.
82pub const CAP_CREATE: &str = "create";
83/// Update existing namespaces or entities.
84pub const CAP_UPDATE: &str = "update";
85/// Delete namespaces or entities.
86pub const CAP_DELETE: &str = "delete";
87
88// ── CapabilityEntry ────────────────────────────────────────────────────────────
89
90/// Capability set for a principal in an [`AclMap`], or a grantee list for a
91/// capability-grant entry.
92///
93/// Serialises as:
94/// - `null` → [`Deny`](CapabilityEntry::Deny)
95/// - YAML sequence → [`Allow`](CapabilityEntry::Allow)
96/// - comma-separated string → [`Grant`](CapabilityEntry::Grant)
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum CapabilityEntry {
99    /// Explicit deny. Wins over any wildcard allow for the same principal.
100    Deny,
101    /// Allow the listed capabilities. `["*"]` grants all capabilities.
102    Allow(BTreeSet<String>),
103    /// Capability-grant entry: the listed group/DID refs may use this capability.
104    /// Stored as a comma-separated string in YAML.
105    /// Resolved lazily by the runtime's async ACL checker; [`check_cap`] skips these.
106    Grant(Vec<String>),
107}
108
109impl CapabilityEntry {
110    /// Construct an `Allow` entry from an iterator of capability name strings.
111    pub fn from_caps<I, S>(caps: I) -> Self
112    where
113        I: IntoIterator<Item = S>,
114        S: Into<String>,
115    {
116        Self::Allow(caps.into_iter().map(Into::into).collect())
117    }
118
119    /// Return `true` if this entry grants `cap`.
120    /// `"*"` in the capability set grants any capability.
121    pub fn has(&self, cap: &str) -> bool {
122        match self {
123            Self::Deny | Self::Grant(_) => false,
124            Self::Allow(caps) => caps.contains(cap) || caps.contains("*"),
125        }
126    }
127
128    /// Return `true` if this is an explicit deny.
129    pub fn is_deny(&self) -> bool {
130        matches!(self, Self::Deny)
131    }
132
133    /// Return the grantee refs if this is a [`Grant`](Self::Grant) entry.
134    pub fn grantees(&self) -> Option<&[String]> {
135        if let Self::Grant(refs) = self {
136            Some(refs)
137        } else {
138            None
139        }
140    }
141}
142
143impl Serialize for CapabilityEntry {
144    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
145        match self {
146            Self::Deny => serializer.serialize_none(),
147            Self::Allow(caps) => {
148                use serde::ser::SerializeSeq;
149                let mut seq = serializer.serialize_seq(Some(caps.len()))?;
150                for cap in caps {
151                    seq.serialize_element(cap)?;
152                }
153                seq.end()
154            }
155            Self::Grant(refs) => serializer.serialize_str(&refs.join(",")),
156        }
157    }
158}
159
160impl<'de> Deserialize<'de> for CapabilityEntry {
161    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
162        #[derive(Deserialize)]
163        #[serde(untagged)]
164        enum Raw {
165            Str(String),
166            Seq(Vec<String>),
167        }
168        let opt: Option<Raw> = Option::deserialize(deserializer)?;
169        match opt {
170            None => Ok(Self::Deny),
171            Some(Raw::Seq(v)) if v.is_empty() => Ok(Self::Deny),
172            Some(Raw::Seq(v)) => Ok(Self::Allow(v.into_iter().collect())),
173            Some(Raw::Str(s)) => {
174                let refs: Vec<String> = s
175                    .split(',')
176                    .map(|r| r.trim().to_string())
177                    .filter(|r| !r.is_empty())
178                    .collect();
179                if refs.is_empty() {
180                    Ok(Self::Deny)
181                } else {
182                    Ok(Self::Grant(refs))
183                }
184            }
185        }
186    }
187}
188
189// ── AclMap ─────────────────────────────────────────────────────────────────────
190
191/// Capability-based access control map.
192///
193/// Keys are principal strings — exactly one of:
194/// - `"*"` — wildcard
195/// - `"did:ma:<identity>"` — bare DID, no fragment
196/// - `"#<local>"` — local entity identifier
197/// - `"group:<handle>.<name>"` — named group of principals
198///
199/// DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys;
200/// use [`is_valid_acl_key`] to validate before inserting.
201pub type AclMap = HashMap<String, CapabilityEntry>;
202
203// ── check_cap ──────────────────────────────────────────────────────────────────
204
205/// Check whether `caller` has capability `cap` in `acl`.
206///
207/// 1. Normalise `caller` (strip fragment from DID-URLs).
208/// 2. Look up the normalised caller directly — if a principal entry, apply and stop.
209/// 3. Fall back to the `"*"` wildcard principal entry.
210/// 4. Explicit deny → `Err`; capability absent → `Err`; no entry → `Err`.
211///
212/// [`Grant`](CapabilityEntry::Grant) entries (capability→grantees) are **skipped**;
213/// they are resolved by the runtime's async `acl_check` via lazy IPFS lookups.
214///
215/// A `"*"` item inside an `Allow` set grants **all** capabilities.
216#[cfg(feature = "acl")]
217pub fn check_cap(acl: &AclMap, caller: &str, cap: &str) -> Result<()> {
218    let normalized = normalize_principal(caller);
219    if let Some(direct) = acl.get(normalized) {
220        match direct {
221            CapabilityEntry::Deny => {
222                return Err(Error::Acl(format!("operation denied for {caller}")));
223            }
224            CapabilityEntry::Allow(caps) if caps.contains(cap) || caps.contains("*") => {
225                return Ok(());
226            }
227            CapabilityEntry::Allow(_) => {
228                return Err(Error::Acl(format!(
229                    "capability '{cap}' denied for {caller}"
230                )));
231            }
232            CapabilityEntry::Grant(_) => {
233                // Capability-grant entry under a principal key — should not
234                // happen in practice. Ignore and fall through to wildcard.
235            }
236        }
237    }
238
239    match acl.get("*") {
240        Some(CapabilityEntry::Grant(_)) | None => {
241            Err(Error::Acl(format!("no ACL entry for {caller}")))
242        }
243        Some(CapabilityEntry::Deny) => Err(Error::Acl(format!("operation denied for {caller}"))),
244        Some(CapabilityEntry::Allow(caps)) if caps.contains(cap) || caps.contains("*") => Ok(()),
245        Some(CapabilityEntry::Allow(_)) => Err(Error::Acl(format!(
246            "capability '{cap}' denied for {caller}"
247        ))),
248    }
249}
250
251/// Return `true` if `key` is a valid [`AclMap`] key.
252///
253/// Two kinds of keys are valid:
254/// - **Principal keys**: `"*"`, `"did:ma:<id>"`, `"#<local>"`, `"group:<h>.<n>"`
255/// - **Capability-grant keys**: any non-empty word not matching a principal key
256///   (e.g. `"fortune"`, `"admin"`, `"emote"`)
257pub fn is_valid_acl_key(key: &str) -> bool {
258    !key.is_empty()
259}
260
261/// Return `true` if `key` is a principal key (identifies *who*).
262pub fn is_principal_key(key: &str) -> bool {
263    key == "*"
264        || (key.starts_with("did:") && !key.contains('#'))
265        || (key.starts_with('#') && key.len() > 1)
266        || is_valid_group_key(key)
267}
268
269/// Return `true` if `key` is a valid group principal (`group:<handle>.<name>`).
270fn is_valid_group_key(key: &str) -> bool {
271    if let Some(rest) = key.strip_prefix("group:") {
272        if let Some(dot) = rest.find('.') {
273            let handle = &rest[..dot];
274            let name = &rest[dot + 1..];
275            return !handle.is_empty() && !name.is_empty();
276        }
277    }
278    false
279}
280
281/// Validate all keys in an [`AclMap`], returning a descriptive error for the
282/// first invalid key found.
283///
284/// Call this immediately after loading an ACL from YAML or any external source.
285#[cfg(feature = "acl")]
286pub fn validate_acl_map(acl: &AclMap) -> Result<()> {
287    for key in acl.keys() {
288        if !is_valid_acl_key(key) {
289            return Err(Error::Acl(format!(
290                "invalid ACL key {key:?}: key must be non-empty"
291            )));
292        }
293    }
294    Ok(())
295}
296
297/// Normalise a caller identity for [`AclMap`] lookup.
298///
299/// - `did:ma:foo#bar` → `did:ma:foo` (strips fragment from DID-URLs)
300/// - `#local` → `#local` (local entity, passed through)
301/// - `*` → `*` (wildcard, passed through)
302pub fn normalize_principal(did: &str) -> &str {
303    if did.starts_with("did:") {
304        if let Some(pos) = did.find('#') {
305            return &did[..pos];
306        }
307    }
308    did
309}
310
311// ── Tests ──────────────────────────────────────────────────────────────────────
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn allow(caps: &[&str]) -> CapabilityEntry {
318        CapabilityEntry::from_caps(caps.iter().copied())
319    }
320
321    fn m(entries: &[(&str, CapabilityEntry)]) -> AclMap {
322        entries
323            .iter()
324            .map(|(k, v)| (k.to_string(), v.clone()))
325            .collect()
326    }
327
328    #[test]
329    fn wildcard_rpc_allows_rpc() {
330        let acl = m(&[("*", allow(&[CAP_RPC]))]);
331        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
332    }
333
334    #[test]
335    fn wildcard_rpc_denies_ipfs() {
336        let acl = m(&[("*", allow(&[CAP_RPC]))]);
337        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_err());
338    }
339
340    #[test]
341    fn explicit_deny_wins_over_wildcard_allow() {
342        let acl = m(&[
343            ("*", allow(&[CAP_RPC, CAP_IPFS])),
344            ("did:ma:bandit", CapabilityEntry::Deny),
345        ]);
346        assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
347    }
348
349    #[test]
350    fn exact_match_restricts_below_wildcard() {
351        let acl = m(&[
352            ("*", allow(&[CAP_RPC, CAP_IPFS])),
353            ("did:ma:bob", allow(&[CAP_RPC])),
354        ]);
355        assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
356        assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
357    }
358
359    #[test]
360    fn did_url_caller_is_normalized() {
361        let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS]))]);
362        assert!(check_cap(&acl, "did:ma:alice#sign", CAP_RPC).is_ok());
363    }
364
365    #[test]
366    fn no_entry_default_deny() {
367        assert!(check_cap(&AclMap::new(), "did:ma:anyone", CAP_RPC).is_err());
368    }
369
370    #[test]
371    fn wildcard_deny_blocks_all() {
372        let acl = m(&[("*", CapabilityEntry::Deny)]);
373        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_err());
374    }
375
376    #[test]
377    fn local_entity_key_allowed() {
378        let acl = m(&[("#agent", allow(&[CAP_RPC]))]);
379        assert!(check_cap(&acl, "#agent", CAP_RPC).is_ok());
380        assert!(check_cap(&acl, "#other", CAP_RPC).is_err());
381    }
382
383    #[test]
384    fn arbitrary_capability_works() {
385        let acl = m(&[("did:ma:alice", allow(&["emote", "reply"]))]);
386        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
387        assert!(check_cap(&acl, "did:ma:alice", "reply").is_ok());
388        assert!(check_cap(&acl, "did:ma:alice", "admin").is_err());
389    }
390
391    #[test]
392    fn wildcard_cap_grants_all_capabilities() {
393        let acl = m(&[("did:ma:alice", allow(&["*"]))]);
394        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
395        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_ok());
396        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
397        assert!(check_cap(&acl, "did:ma:alice", "admin").is_ok());
398    }
399
400    #[test]
401    fn grant_entry_is_skipped_by_check_cap() {
402        let mut acl = AclMap::new();
403        acl.insert("*".to_string(), allow(&[CAP_RPC]));
404        acl.insert(
405            "fortune".to_string(),
406            CapabilityEntry::Grant(vec!["group:carlotta.friends".to_string()]),
407        );
408        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_ok());
409        assert!(check_cap(&acl, "did:ma:anyone", "fortune").is_err());
410    }
411
412    #[test]
413    fn grant_entry_serde_round_trip() {
414        let entry = CapabilityEntry::Grant(vec![
415            "group:carlotta.friends".to_string(),
416            "did:ma:alice".to_string(),
417        ]);
418        let yaml = serde_yaml::to_string(&entry).expect("serialize");
419        assert!(yaml.contains("group:carlotta.friends"));
420        let round: CapabilityEntry = serde_yaml::from_str(yaml.trim()).expect("deserialize");
421        assert_eq!(round, entry);
422    }
423
424    #[test]
425    fn owner_capability_is_just_a_string() {
426        // "owner" still works as a plain capability string
427        let acl = m(&[("did:ma:alice", allow(&["owner"]))]);
428        assert!(check_cap(&acl, "did:ma:alice", "owner").is_ok());
429        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
430    }
431
432    #[test]
433    fn normalize_strips_fragment() {
434        assert_eq!(normalize_principal("did:ma:foo#bar"), "did:ma:foo");
435        assert_eq!(normalize_principal("did:ma:foo"), "did:ma:foo");
436        assert_eq!(normalize_principal("#local"), "#local");
437        assert_eq!(normalize_principal("*"), "*");
438    }
439
440    #[test]
441    fn valid_acl_keys() {
442        assert!(is_valid_acl_key("*"));
443        assert!(is_valid_acl_key("did:ma:Qmfoo"));
444        assert!(is_valid_acl_key("#agent"));
445        assert!(is_valid_acl_key("group:alice.venner"));
446        assert!(is_valid_acl_key("group:runtime.admins"));
447        // capability-grant keys (plain words)
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}