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//! | `"owner"`  | Full access at this level (semantics enforced by caller) |
19//!
20//! Entity and namespace ACLs may also use arbitrary capability strings that
21//! correspond to verb names or sub-namespace names.
22//!
23//! # Principal key forms
24//!
25//! Valid keys in an [`AclMap`] (and in YAML) are exactly:
26//!
27//! | Form | Meaning |
28//! |------|---------|
29//! | `"*"` | Wildcard — matches any caller |
30//! | `"did:ma:<identity>"` | Bare DID — a remote runtime identity (no fragment) |
31//! | `"#<local>"` | Local entity identifier |
32//! | `"group:<handle>.<name>"` | Named group of principals |
33//!
34//! DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys.
35//! Use [`is_valid_acl_key`] to validate keys before inserting them.
36//!
37//! # YAML format
38//!
39//! ```yaml
40//! acl:
41//!   "*": [rpc, create]        # everyone: RPC + create
42//!   "did:ma:alice": [owner]   # alice: full access
43//!   "did:ma:bob": [rpc, read] # bob: read-only RPC
44//!   "did:ma:eve":             # null / absent → explicit deny
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/// Send RPC messages via `/ma/rpc/0.0.1`.
68pub const CAP_RPC: &str = "rpc";
69/// Publish DID documents via `/ma/ipfs/0.0.1`.
70pub const CAP_IPFS: &str = "ipfs";
71/// Read entities, config, and namespace contents.
72pub const CAP_READ: &str = "read";
73/// Create new namespaces or entities.
74pub const CAP_CREATE: &str = "create";
75/// Update existing namespaces or entities.
76pub const CAP_UPDATE: &str = "update";
77/// Delete namespaces or entities.
78pub const CAP_DELETE: &str = "delete";
79/// Full access at this level — semantics are enforced by the caller, not by [`check_cap`].
80pub const CAP_OWNER: &str = "owner";
81
82// ── CapabilityEntry ────────────────────────────────────────────────────────────
83
84/// Capability set for a principal in an [`AclMap`].
85///
86/// Serialises as a YAML sequence of capability strings (`["rpc", "create"]`)
87/// or `null` for an explicit deny.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum CapabilityEntry {
90    /// Explicit deny. Wins over any wildcard allow for the same principal.
91    Deny,
92    /// Allow the listed capabilities. An empty set behaves like `Deny`.
93    Allow(BTreeSet<String>),
94}
95
96impl CapabilityEntry {
97    /// Construct an `Allow` entry from an iterator of capability name strings.
98    pub fn from_caps<I, S>(caps: I) -> Self
99    where
100        I: IntoIterator<Item = S>,
101        S: Into<String>,
102    {
103        Self::Allow(caps.into_iter().map(Into::into).collect())
104    }
105
106    /// Return `true` if this entry grants `cap`.
107    pub fn has(&self, cap: &str) -> bool {
108        match self {
109            Self::Deny => false,
110            Self::Allow(caps) => caps.contains(cap),
111        }
112    }
113
114    /// Return `true` if this is an explicit deny.
115    pub fn is_deny(&self) -> bool {
116        matches!(self, Self::Deny)
117    }
118}
119
120impl Serialize for CapabilityEntry {
121    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
122        match self {
123            Self::Deny => serializer.serialize_none(),
124            Self::Allow(caps) => {
125                use serde::ser::SerializeSeq;
126                let mut seq = serializer.serialize_seq(Some(caps.len()))?;
127                for cap in caps {
128                    seq.serialize_element(cap)?;
129                }
130                seq.end()
131            }
132        }
133    }
134}
135
136impl<'de> Deserialize<'de> for CapabilityEntry {
137    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
138        let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
139        match opt {
140            None => Ok(Self::Deny),
141            Some(v) if v.is_empty() => Ok(Self::Deny),
142            Some(v) => Ok(Self::Allow(v.into_iter().collect())),
143        }
144    }
145}
146
147// ── AclMap ─────────────────────────────────────────────────────────────────────
148
149/// Capability-based access control map.
150///
151/// Keys are principal strings — exactly one of:
152/// - `"*"` — wildcard
153/// - `"did:ma:<identity>"` — bare DID, no fragment
154/// - `"#<local>"` — local entity identifier
155/// - `"group:<handle>.<name>"` — named group of principals
156///
157/// DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys;
158/// use [`is_valid_acl_key`] to validate before inserting.
159pub type AclMap = HashMap<String, CapabilityEntry>;
160
161// ── check_cap ──────────────────────────────────────────────────────────────────
162
163/// Check whether `caller` has capability `cap` in `acl`.
164///
165/// 1. Normalise `caller` to a bare identity (strip fragment from DID-URLs).
166/// 2. Look up the normalised caller directly — if found, apply and stop.
167/// 3. Fall back to the `"*"` wildcard entry.
168/// 4. Explicit deny → `Err`; capability absent → `Err`; no entry → `Err`.
169///
170/// The `"owner"` capability is just a string — callers that need owner-bypass
171/// semantics must call `check_cap(acl, caller, CAP_OWNER)` explicitly.
172#[cfg(feature = "acl")]
173pub fn check_cap(acl: &AclMap, caller: &str, cap: &str) -> Result<()> {
174    let normalized = normalize_principal(caller);
175    if let Some(direct) = acl.get(normalized) {
176        return match direct {
177            CapabilityEntry::Deny => Err(Error::Acl(format!("operation denied for {caller}"))),
178            CapabilityEntry::Allow(caps) if caps.contains(cap) => Ok(()),
179            CapabilityEntry::Allow(_) => Err(Error::Acl(format!(
180                "capability '{cap}' denied for {caller}"
181            ))),
182        };
183    }
184
185    match acl.get("*") {
186        None => Err(Error::Acl(format!("no ACL entry for {caller}"))),
187        Some(CapabilityEntry::Deny) => Err(Error::Acl(format!("operation denied for {caller}"))),
188        Some(CapabilityEntry::Allow(caps)) if caps.contains(cap) => Ok(()),
189        Some(CapabilityEntry::Allow(_)) => Err(Error::Acl(format!(
190            "capability '{cap}' denied for {caller}"
191        ))),
192    }
193}
194
195/// Return `true` if `key` is a valid [`AclMap`] principal key.
196///
197/// Valid forms:
198/// - `"*"` — wildcard
199/// - `"did:ma:<identity>"` — bare DID, no fragment
200/// - `"#<local>"` — local entity identifier
201/// - `"group:<handle>.<name>"` — named group (`<handle>` and `<name>` non-empty)
202pub fn is_valid_acl_key(key: &str) -> bool {
203    key == "*"
204        || (key.starts_with("did:") && !key.contains('#'))
205        || (key.starts_with('#') && key.len() > 1)
206        || is_valid_group_key(key)
207}
208
209/// Return `true` if `key` is a valid group principal (`group:<handle>.<name>`).
210fn is_valid_group_key(key: &str) -> bool {
211    if let Some(rest) = key.strip_prefix("group:") {
212        if let Some(dot) = rest.find('.') {
213            let handle = &rest[..dot];
214            let name = &rest[dot + 1..];
215            return !handle.is_empty() && !name.is_empty();
216        }
217    }
218    false
219}
220
221/// Validate all keys in an [`AclMap`], returning a descriptive error for the
222/// first invalid key found.
223///
224/// Call this immediately after loading an ACL from YAML or any external source.
225#[cfg(feature = "acl")]
226pub fn validate_acl_map(acl: &AclMap) -> Result<()> {
227    for key in acl.keys() {
228        if !is_valid_acl_key(key) {
229            return Err(Error::Acl(format!(
230                "invalid ACL key {key:?}: must be \"*\", a bare DID (\"did:ma:\u{2026}\"), \
231                 a local entity (\"#name\"), or a group (\"group:<handle>.<name>\")"
232            )));
233        }
234    }
235    Ok(())
236}
237
238/// Normalise a caller identity for [`AclMap`] lookup.
239///
240/// - `did:ma:foo#bar` → `did:ma:foo` (strips fragment from DID-URLs)
241/// - `#local` → `#local` (local entity, passed through)
242/// - `*` → `*` (wildcard, passed through)
243pub fn normalize_principal(did: &str) -> &str {
244    if did.starts_with("did:") {
245        if let Some(pos) = did.find('#') {
246            return &did[..pos];
247        }
248    }
249    did
250}
251
252// ── Tests ──────────────────────────────────────────────────────────────────────
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn allow(caps: &[&str]) -> CapabilityEntry {
259        CapabilityEntry::from_caps(caps.iter().copied())
260    }
261
262    fn m(entries: &[(&str, CapabilityEntry)]) -> AclMap {
263        entries
264            .iter()
265            .map(|(k, v)| (k.to_string(), v.clone()))
266            .collect()
267    }
268
269    #[test]
270    fn wildcard_rpc_allows_rpc() {
271        let acl = m(&[("*", allow(&[CAP_RPC]))]);
272        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
273    }
274
275    #[test]
276    fn wildcard_rpc_denies_ipfs() {
277        let acl = m(&[("*", allow(&[CAP_RPC]))]);
278        assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_err());
279    }
280
281    #[test]
282    fn explicit_deny_wins_over_wildcard_allow() {
283        let acl = m(&[
284            ("*", allow(&[CAP_RPC, CAP_IPFS])),
285            ("did:ma:bandit", CapabilityEntry::Deny),
286        ]);
287        assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
288    }
289
290    #[test]
291    fn exact_match_restricts_below_wildcard() {
292        let acl = m(&[
293            ("*", allow(&[CAP_RPC, CAP_IPFS])),
294            ("did:ma:bob", allow(&[CAP_RPC])),
295        ]);
296        assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
297        assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
298    }
299
300    #[test]
301    fn did_url_caller_is_normalized() {
302        let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS]))]);
303        assert!(check_cap(&acl, "did:ma:alice#sign", CAP_RPC).is_ok());
304    }
305
306    #[test]
307    fn no_entry_default_deny() {
308        assert!(check_cap(&AclMap::new(), "did:ma:anyone", CAP_RPC).is_err());
309    }
310
311    #[test]
312    fn wildcard_deny_blocks_all() {
313        let acl = m(&[("*", CapabilityEntry::Deny)]);
314        assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_err());
315    }
316
317    #[test]
318    fn local_entity_key_allowed() {
319        let acl = m(&[("#agent", allow(&[CAP_RPC]))]);
320        assert!(check_cap(&acl, "#agent", CAP_RPC).is_ok());
321        assert!(check_cap(&acl, "#other", CAP_RPC).is_err());
322    }
323
324    #[test]
325    fn arbitrary_capability_works() {
326        let acl = m(&[("did:ma:alice", allow(&["emote", "reply"]))]);
327        assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
328        assert!(check_cap(&acl, "did:ma:alice", "reply").is_ok());
329        assert!(check_cap(&acl, "did:ma:alice", "admin").is_err());
330    }
331
332    #[test]
333    fn owner_capability_is_just_a_string() {
334        // "owner" semantics (implies all) are the caller's responsibility
335        let acl = m(&[("did:ma:alice", allow(&[CAP_OWNER]))]);
336        assert!(check_cap(&acl, "did:ma:alice", CAP_OWNER).is_ok());
337        assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
338    }
339
340    #[test]
341    fn normalize_strips_fragment() {
342        assert_eq!(normalize_principal("did:ma:foo#bar"), "did:ma:foo");
343        assert_eq!(normalize_principal("did:ma:foo"), "did:ma:foo");
344        assert_eq!(normalize_principal("#local"), "#local");
345        assert_eq!(normalize_principal("*"), "*");
346    }
347
348    #[test]
349    fn valid_acl_keys() {
350        assert!(is_valid_acl_key("*"));
351        assert!(is_valid_acl_key("did:ma:Qmfoo"));
352        assert!(is_valid_acl_key("#agent"));
353        assert!(is_valid_acl_key("group:alice.venner"));
354        assert!(is_valid_acl_key("group:runtime.admins"));
355        assert!(!is_valid_acl_key("did:ma:Qmfoo#sign"));
356        assert!(!is_valid_acl_key("#"));
357        assert!(!is_valid_acl_key(""));
358        assert!(!is_valid_acl_key("group:noname"));
359        assert!(!is_valid_acl_key("group:.nohandle"));
360        assert!(!is_valid_acl_key("group:handle."));
361    }
362
363    #[cfg(feature = "acl")]
364    #[test]
365    fn capability_serde_roundtrip() {
366        let acl: AclMap = [
367            (
368                "*".to_string(),
369                CapabilityEntry::from_caps(["rpc", "create"]),
370            ),
371            ("did:ma:bandit".to_string(), CapabilityEntry::Deny),
372        ]
373        .into_iter()
374        .collect();
375        let yaml = serde_yaml::to_string(&acl).unwrap();
376        let roundtrip: AclMap = serde_yaml::from_str(&yaml).unwrap();
377        assert_eq!(acl, roundtrip);
378    }
379
380    #[cfg(feature = "acl")]
381    #[test]
382    fn yaml_null_deserializes_to_deny() {
383        let yaml = "'did:ma:x': ~\n'*':\n- rpc\n- create\n";
384        let acl: AclMap = serde_yaml::from_str(yaml).unwrap();
385        assert_eq!(acl.get("did:ma:x"), Some(&CapabilityEntry::Deny));
386        assert_eq!(
387            acl.get("*"),
388            Some(&CapabilityEntry::from_caps(["rpc", "create"]))
389        );
390    }
391}