Skip to main content

ma_core/acl/
mod.rs

1//! Operation-level access control for ma identities.
2//!
3//! An [`AclMap`] maps principal strings to [`Permissions`].
4//! Deny always wins over allow; a wildcard deny closes access to everyone.
5//!
6//! # Permission bits
7//!
8//! | Letter | Bit | Meaning |
9//! |--------|-----|---------|
10//! | `r`    |  4  | Read — list metadata, read config, fetch entities |
11//! | `w`    |  2  | Write — mutate entities/config; required for `/ma/ipfs/0.0.1` |
12//! | `x`    |  1  | Execute — invoke entity verbs; required for `/ma/rpc/0.0.1` |
13//!
14//! # Principal key forms
15//!
16//! Valid keys in an [`AclMap`] (and in YAML) are exactly:
17//!
18//! | Form | Meaning |
19//! |------|---------|
20//! | `"*"` | Wildcard — matches any caller |
21//! | `"did:ma:<identity>"` | Bare DID — a remote runtime identity (no fragment) |
22//! | `"#<local>"` | Local entity identifier |
23//! | `"group:<handle>.<name>"` | Named group of principals |
24//!
25//! DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys.
26//! Use [`is_valid_acl_key`] to validate keys before inserting them.
27//!
28//! # YAML format
29//!
30//! ```yaml
31//! acl:
32//!   "*": "rwx"          # everyone: full access
33//!   "did:ma:bob": "rx"  # read + execute, no write
34//!   "#local-agent":     # local entity — explicit deny
35//!   "did:ma:eve":       # null / absent → explicit deny
36//! ```
37//!
38//! # Example
39//!
40//! ```rust
41//! # use ma_core::{AclMap, Permissions, check_op, PERM_X};
42//! let mut acl = AclMap::new();
43//! acl.insert("*".to_string(), Permissions::Allow(PERM_X));
44//! acl.insert("did:ma:Qmevil".to_string(), Permissions::Deny);
45//! assert!(check_op(&acl, "did:ma:Qmgood", PERM_X).is_ok());
46//! assert!(check_op(&acl, "did:ma:Qmevil", PERM_X).is_err());
47//! ```
48
49use std::collections::HashMap;
50use std::fmt;
51use std::str::FromStr;
52
53use serde::{Deserialize, Deserializer, Serialize, Serializer};
54
55#[cfg(feature = "acl")]
56use crate::{Error, Result};
57
58// ── Permission bits ────────────────────────────────────────────────────────────
59
60/// Read permission: list metadata, read config, fetch entities.
61pub const PERM_R: u8 = 0b100;
62/// Write permission: mutate entities and config; required for `/ma/ipfs/0.0.1`.
63pub const PERM_W: u8 = 0b010;
64/// Execute permission: invoke entity verbs; required for `/ma/rpc/0.0.1`.
65pub const PERM_X: u8 = 0b001;
66/// All permissions combined.
67pub const PERM_RWX: u8 = 0b111;
68
69// ── Permissions type ───────────────────────────────────────────────────────────
70
71/// Permission value for a principal in an [`AclMap`].
72///
73/// Serialises as a permission string (`"rwx"`, `"rx"`, `"x"`, …) or YAML
74/// `null` for deny.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum Permissions {
77    /// Explicit deny. Wins over any wildcard allow for the same principal.
78    Deny,
79    /// Allow with the given `r`/`w`/`x` bits.
80    Allow(u8),
81}
82
83impl Permissions {
84    /// Return `true` if this permission grants all bits in `required`.
85    pub const fn grants(self, required: u8) -> bool {
86        match self {
87            Self::Allow(p) => p & required == required,
88            Self::Deny => false,
89        }
90    }
91
92    /// Return `true` if this is an explicit deny.
93    pub fn is_deny(self) -> bool {
94        self == Self::Deny
95    }
96}
97
98impl fmt::Display for Permissions {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::Deny => write!(f, "-"),
102            Self::Allow(p) => {
103                if p & PERM_R != 0 {
104                    write!(f, "r")?;
105                }
106                if p & PERM_W != 0 {
107                    write!(f, "w")?;
108                }
109                if p & PERM_X != 0 {
110                    write!(f, "x")?;
111                }
112                Ok(())
113            }
114        }
115    }
116}
117
118impl FromStr for Permissions {
119    type Err = anyhow::Error;
120
121    fn from_str(s: &str) -> std::result::Result<Self, anyhow::Error> {
122        if s.is_empty() {
123            return Ok(Self::Deny);
124        }
125        let mut bits = 0u8;
126        for ch in s.chars() {
127            match ch {
128                'r' => bits |= PERM_R,
129                'w' => bits |= PERM_W,
130                'x' => bits |= PERM_X,
131                other => {
132                    return Err(anyhow::anyhow!(
133                        "unknown permission character '{other}' in '{s}'"
134                    ));
135                }
136            }
137        }
138        if bits == 0 {
139            return Err(anyhow::anyhow!("permission string '{s}' has no valid bits"));
140        }
141        Ok(Self::Allow(bits))
142    }
143}
144
145impl Serialize for Permissions {
146    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
147        match self {
148            Self::Deny => serializer.serialize_none(),
149            Self::Allow(_) => serializer.serialize_str(&self.to_string()),
150        }
151    }
152}
153
154impl<'de> Deserialize<'de> for Permissions {
155    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
156        let opt: Option<String> = Option::deserialize(deserializer)?;
157        match opt {
158            None => Ok(Self::Deny),
159            Some(s) if s.is_empty() => Ok(Self::Deny),
160            Some(s) => s.parse::<Permissions>().map_err(serde::de::Error::custom),
161        }
162    }
163}
164
165// ── AclMap ─────────────────────────────────────────────────────────────────────
166
167/// Operation-level access control map.
168///
169/// Keys are principal strings — exactly one of:
170/// - `"*"` — wildcard
171/// - `"did:ma:<identity>"` — bare DID, no fragment
172/// - `"#<local>"` — local entity identifier
173/// - `"group:<handle>.<name>"` — named group of principals
174///
175/// DID-URLs with fragments (`did:ma:foo#bar`) are **not** valid keys;
176/// use [`is_valid_acl_key`] to validate before inserting.
177pub type AclMap = HashMap<String, Permissions>;
178
179// ── check_op ───────────────────────────────────────────────────────────────────
180
181/// Check whether `caller` has `required` permission bits in `acl`.
182///
183/// 1. Normalise `caller` to a bare identity (strip fragment from DID-URLs).
184/// 2. Look up the normalised caller directly — if found, apply and stop.
185/// 3. Fall back to the `"*"` wildcard entry.
186/// 4. Explicit deny → `Err`; missing required bits → `Err`; no entry → `Err`.
187///
188/// Owner bypass is the caller's responsibility.
189#[cfg(feature = "acl")]
190pub fn check_op(acl: &AclMap, caller: &str, required: u8) -> Result<()> {
191    let normalized = normalize_principal(caller);
192    if let Some(direct) = acl.get(normalized) {
193        return if direct.is_deny() {
194            Err(Error::Acl(format!("operation denied for {caller}")))
195        } else if direct.grants(required) {
196            Ok(())
197        } else {
198            Err(Error::Acl(format!("permission denied for {caller}")))
199        };
200    }
201
202    match acl.get("*") {
203        None => Err(Error::Acl(format!("no ACL entry for {caller}"))),
204        Some(e) if e.is_deny() => Err(Error::Acl(format!("operation denied for {caller}"))),
205        Some(e) if e.grants(required) => Ok(()),
206        Some(_) => Err(Error::Acl(format!("permission denied for {caller}"))),
207    }
208}
209
210/// Return `true` if `key` is a valid [`AclMap`] principal key.
211///
212/// Valid forms:
213/// - `"*"` — wildcard
214/// - `"did:ma:<identity>"` — bare DID, no fragment
215/// - `"#<local>"` — local entity identifier
216/// - `"group:<handle>.<name>"` — named group (`<handle>` and `<name>` non-empty)
217pub fn is_valid_acl_key(key: &str) -> bool {
218    key == "*"
219        || (key.starts_with("did:") && !key.contains('#'))
220        || (key.starts_with('#') && key.len() > 1)
221        || is_valid_group_key(key)
222}
223
224/// Return `true` if `key` is a valid group principal (`group:<handle>.<name>`).
225fn is_valid_group_key(key: &str) -> bool {
226    if let Some(rest) = key.strip_prefix("group:") {
227        if let Some(dot) = rest.find('.') {
228            let handle = &rest[..dot];
229            let name = &rest[dot + 1..];
230            return !handle.is_empty() && !name.is_empty();
231        }
232    }
233    false
234}
235
236/// Validate all keys in an [`AclMap`], returning a descriptive error for the
237/// first invalid key found.
238///
239/// Call this immediately after loading an ACL from YAML or any external source.
240#[cfg(feature = "acl")]
241pub fn validate_acl_map(acl: &AclMap) -> Result<()> {
242    for key in acl.keys() {
243        if !is_valid_acl_key(key) {
244            return Err(Error::Acl(format!(
245                "invalid ACL key {key:?}: must be \"*\", a bare DID (\"did:ma:\u{2026}\"), \
246                 a local entity (\"#name\"), or a group (\"group:<handle>.<name>\")"
247            )));
248        }
249    }
250    Ok(())
251}
252
253/// Normalise a caller identity for [`AclMap`] lookup.
254///
255/// - `did:ma:foo#bar` → `did:ma:foo` (strips fragment from DID-URLs)
256/// - `#local` → `#local` (local entity, passed through)
257/// - `*` → `*` (wildcard, passed through)
258pub fn normalize_principal(did: &str) -> &str {
259    if did.starts_with("did:") {
260        if let Some(pos) = did.find('#') {
261            return &did[..pos];
262        }
263    }
264    did
265}
266
267// ── Tests ──────────────────────────────────────────────────────────────────────
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn m(entries: &[(&str, &str)]) -> AclMap {
274        entries
275            .iter()
276            .map(|(k, v)| (k.to_string(), v.parse().expect("valid permissions")))
277            .collect()
278    }
279
280    #[test]
281    fn wildcard_exec_allows_execute() {
282        let acl = m(&[("*", "x")]);
283        assert!(check_op(&acl, "did:ma:alice", PERM_X).is_ok());
284    }
285
286    #[test]
287    fn wildcard_exec_denies_write() {
288        let acl = m(&[("*", "x")]);
289        assert!(check_op(&acl, "did:ma:alice", PERM_W).is_err());
290    }
291
292    #[test]
293    fn explicit_deny_wins_over_wildcard_allow() {
294        let acl = m(&[("*", "rwx"), ("did:ma:bandit", "")]);
295        assert!(check_op(&acl, "did:ma:bandit", PERM_X).is_err());
296    }
297
298    #[test]
299    fn exact_match_restricts_below_wildcard() {
300        let acl = m(&[("*", "rwx"), ("did:ma:bob", "r")]);
301        assert!(check_op(&acl, "did:ma:bob", PERM_R).is_ok());
302        assert!(check_op(&acl, "did:ma:bob", PERM_X).is_err());
303    }
304
305    #[test]
306    fn did_url_caller_is_normalized() {
307        let acl = m(&[("did:ma:alice", "rwx")]);
308        assert!(check_op(&acl, "did:ma:alice#sign", PERM_X).is_ok());
309    }
310
311    #[test]
312    fn no_entry_default_deny() {
313        assert!(check_op(&AclMap::new(), "did:ma:anyone", PERM_X).is_err());
314    }
315
316    #[test]
317    fn wildcard_deny_blocks_all() {
318        let acl = m(&[("*", "")]);
319        assert!(check_op(&acl, "did:ma:anyone", PERM_X).is_err());
320    }
321
322    #[test]
323    fn local_entity_key_allowed() {
324        let acl = m(&[("#agent", "rwx")]);
325        assert!(check_op(&acl, "#agent", PERM_X).is_ok());
326        assert!(check_op(&acl, "#other", PERM_X).is_err());
327    }
328
329    #[test]
330    fn normalize_strips_fragment() {
331        assert_eq!(normalize_principal("did:ma:foo#bar"), "did:ma:foo");
332        assert_eq!(normalize_principal("did:ma:foo"), "did:ma:foo");
333        assert_eq!(normalize_principal("#local"), "#local");
334        assert_eq!(normalize_principal("*"), "*");
335    }
336
337    #[test]
338    fn valid_acl_keys() {
339        assert!(is_valid_acl_key("*"));
340        assert!(is_valid_acl_key("did:ma:Qmfoo"));
341        assert!(is_valid_acl_key("#agent"));
342        assert!(is_valid_acl_key("group:alice.venner"));
343        assert!(is_valid_acl_key("group:runtime.admins"));
344        assert!(!is_valid_acl_key("did:ma:Qmfoo#sign"));
345        assert!(!is_valid_acl_key("#"));
346        assert!(!is_valid_acl_key(""));
347        assert!(!is_valid_acl_key("group:noname"));
348        assert!(!is_valid_acl_key("group:.nohandle"));
349        assert!(!is_valid_acl_key("group:handle."));
350    }
351
352    #[test]
353    fn permissions_display() {
354        assert_eq!(Permissions::Allow(PERM_RWX).to_string(), "rwx");
355        assert_eq!(Permissions::Allow(PERM_R | PERM_X).to_string(), "rx");
356        assert_eq!(Permissions::Allow(PERM_X).to_string(), "x");
357        assert_eq!(Permissions::Deny.to_string(), "-");
358    }
359
360    #[test]
361    fn permissions_from_str() {
362        assert_eq!(
363            "rwx".parse::<Permissions>().unwrap(),
364            Permissions::Allow(PERM_RWX)
365        );
366        assert_eq!(
367            "rx".parse::<Permissions>().unwrap(),
368            Permissions::Allow(PERM_R | PERM_X)
369        );
370        assert_eq!(
371            "x".parse::<Permissions>().unwrap(),
372            Permissions::Allow(PERM_X)
373        );
374        assert_eq!("".parse::<Permissions>().unwrap(), Permissions::Deny);
375        assert!("z".parse::<Permissions>().is_err());
376    }
377
378    #[cfg(feature = "acl")]
379    #[test]
380    fn permissions_serde_roundtrip() {
381        let acl: AclMap = [
382            ("*".to_string(), Permissions::Allow(PERM_RWX)),
383            ("did:ma:bandit".to_string(), Permissions::Deny),
384        ]
385        .into_iter()
386        .collect();
387        let yaml = serde_yaml::to_string(&acl).unwrap();
388        let roundtrip: AclMap = serde_yaml::from_str(&yaml).unwrap();
389        assert_eq!(acl, roundtrip);
390    }
391
392    #[cfg(feature = "acl")]
393    #[test]
394    fn yaml_null_deserializes_to_deny() {
395        // YAML tilde (~) is canonical null
396        let yaml = "'did:ma:x': ~\n'*': rwx\n";
397        let acl: AclMap = serde_yaml::from_str(yaml).unwrap();
398        assert_eq!(acl.get("did:ma:x"), Some(&Permissions::Deny));
399        assert_eq!(acl.get("*"), Some(&Permissions::Allow(PERM_RWX)));
400    }
401}