Skip to main content

ma_core/acl/
mod.rs

1//! Access control lists for ma identities and DID URLs.
2//!
3//! An [`Acl`] is a list of allow/deny rules keyed by DID URL or fragment.
4//! Deny always wins over allow; an identity-level deny covers all DID-URLs
5//! under that identity. The wildcard `*` grants public access.
6//!
7//! # YAML format
8//!
9//! ```yaml
10//! acl:
11//!   - "*"           # public access
12//!   - "did:ma:alice"
13//!   - "!did:ma:eve"
14//!   - "#read"
15//!   - "!#write"
16//! ```
17//!
18//! # Example
19//!
20//! ```rust
21//! # use ma_core::Acl;
22//! let yaml = "acl:\n  - \"*\"\n  - \"!did:ma:Qmevil\"\n";
23//! let acl = Acl::new_from_yaml(yaml).unwrap();
24//! assert!(acl.is_allowed("did:ma:Qmgood#read"));
25//! assert!(!acl.is_allowed("did:ma:Qmevil#read"));
26//! ```
27
28use std::collections::HashSet;
29#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
30use std::sync::{Arc, Mutex};
31#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
32use web_time::Duration;
33
34use crate::Did;
35use cid::Cid;
36
37#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
38use crate::kubo::{ipfs_add, name_publish_with_retry, IpnsPublishOptions};
39use crate::{Error, Result};
40#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
41use tokio::task::JoinHandle;
42#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
43use tokio::time::sleep;
44
45// ── Internal entry representation ─────────────────────────────────────────────
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48enum Entry {
49    /// `*` — public access
50    Any,
51    /// `did:ma:…` — allow a full identity or DID-URL
52    Allow(Did),
53    /// `!did:ma:…` — deny a full identity or DID-URL
54    Deny(Did),
55    /// `#fragment` — allow by bare fragment (no identity check)
56    AllowFragment(String),
57    /// `!#fragment` — deny by bare fragment
58    DenyFragment(String),
59}
60
61impl Entry {
62    fn parse(s: &str) -> Result<Self> {
63        let s = s.trim();
64        if s == "*" {
65            return Ok(Entry::Any);
66        }
67        if let Some(rest) = s.strip_prefix("!#") {
68            return Ok(Entry::DenyFragment(rest.to_owned()));
69        }
70        if let Some(rest) = s.strip_prefix('#') {
71            return Ok(Entry::AllowFragment(rest.to_owned()));
72        }
73        if let Some(rest) = s.strip_prefix('!') {
74            let did = Did::try_from(rest)
75                .map_err(|e| Error::Acl(format!("invalid DID in deny entry '{rest}': {e}")))?;
76            return Ok(Entry::Deny(did));
77        }
78        if s.starts_with("did:") {
79            let did =
80                Did::try_from(s).map_err(|e| Error::Acl(format!("invalid DID '{s}': {e}")))?;
81            return Ok(Entry::Allow(did));
82        }
83        Err(Error::Acl(format!("unrecognised ACL entry: '{s}'")))
84    }
85}
86
87// ── Compiled lookup tables ─────────────────────────────────────────────────────
88
89#[derive(Debug, Clone, Default)]
90struct Compiled {
91    /// `*` was present
92    public: bool,
93    /// identity-level denies (ipns key, no fragment)
94    deny_identities: HashSet<String>,
95    /// DID-URL denies (ipns, fragment)
96    deny_urls: HashSet<(String, String)>,
97    /// identity-level allows
98    allow_identities: HashSet<String>,
99    /// DID-URL allows
100    allow_urls: HashSet<(String, String)>,
101    /// bare-fragment denies
102    deny_fragments: HashSet<String>,
103    /// bare-fragment allows
104    allow_fragments: HashSet<String>,
105}
106
107impl Compiled {
108    fn build(entries: &[Entry]) -> Self {
109        let mut c = Compiled::default();
110        for e in entries {
111            match e {
112                Entry::Any => c.public = true,
113                Entry::Allow(did) => {
114                    if let Some(frag) = &did.fragment {
115                        c.allow_urls.insert((did.ipns.clone(), frag.clone()));
116                    } else {
117                        c.allow_identities.insert(did.ipns.clone());
118                    }
119                }
120                Entry::Deny(did) => {
121                    if let Some(frag) = &did.fragment {
122                        c.deny_urls.insert((did.ipns.clone(), frag.clone()));
123                    } else {
124                        c.deny_identities.insert(did.ipns.clone());
125                    }
126                }
127                Entry::AllowFragment(f) => {
128                    c.allow_fragments.insert(f.clone());
129                }
130                Entry::DenyFragment(f) => {
131                    c.deny_fragments.insert(f.clone());
132                }
133            }
134        }
135        c
136    }
137}
138
139// ── Public ACL type ────────────────────────────────────────────────────────────
140
141/// An access control list for an ma entity.
142///
143/// Create with [`Acl::new_from_yaml`] or [`Acl::new_from_cid`].
144#[derive(Debug, Clone)]
145pub struct Acl {
146    entries: Vec<Entry>,
147    compiled: Compiled,
148    /// `true` when entries have changed since last publish.
149    pub dirty: bool,
150    generation: u64,
151    /// CID of the last successfully published DAG-CBOR node.
152    pub cid: Option<Cid>,
153}
154
155impl Acl {
156    // ── Constructors ──────────────────────────────────────────────────────────
157
158    /// Parse an ACL from a YAML string.
159    ///
160    /// The YAML must contain an `acl:` key whose value is a sequence of
161    /// strings. Any unrecognised entry is a hard error (fail-fast).
162    ///
163    /// # Errors
164    /// Returns [`Error::Acl`] if the YAML is malformed or any entry is invalid.
165    pub fn new_from_yaml(yaml: &str) -> Result<Self> {
166        #[derive(serde::Deserialize)]
167        struct Wrapper {
168            acl: Vec<String>,
169        }
170        let w: Wrapper =
171            serde_yaml::from_str(yaml).map_err(|e| Error::Acl(format!("YAML parse error: {e}")))?;
172
173        let entries: Result<Vec<Entry>> = w.acl.iter().map(|s| Entry::parse(s)).collect();
174        let entries = entries?;
175        let compiled = Compiled::build(&entries);
176        Ok(Self {
177            entries,
178            compiled,
179            dirty: true,
180            generation: 0,
181            cid: None,
182        })
183    }
184
185    /// Reconstruct an ACL from a previously published YAML payload and its CID.
186    ///
187    /// Marks the ACL as clean (`dirty = false`) and records the CID.
188    ///
189    /// # Errors
190    /// Returns [`Error::Acl`] if the bytes are not valid UTF-8 or the YAML is
191    /// malformed.
192    pub fn new_from_cid(cid: Cid, data: &[u8]) -> Result<Self> {
193        let yaml = std::str::from_utf8(data)
194            .map_err(|e| Error::Acl(format!("ACL data is not UTF-8: {e}")))?;
195        let mut acl = Self::new_from_yaml(yaml)?;
196        acl.dirty = false;
197        acl.cid = Some(cid);
198        Ok(acl)
199    }
200
201    // ── Mutation ──────────────────────────────────────────────────────────────
202
203    /// Add an allow rule for `did_str`.
204    ///
205    /// `did_str` may be a bare `#fragment`, `did:ma:…`, or `did:ma:…#fragment`.
206    ///
207    /// # Errors
208    /// Returns [`Error::Acl`] if `did_str` cannot be parsed.
209    pub fn allow(&mut self, did_str: &str) -> Result<()> {
210        let entry = Entry::parse(did_str)?;
211        self.add_entry(entry)
212    }
213
214    /// Add a deny rule for `did_str`.
215    ///
216    /// Prefix with `!` is optional — this method adds the deny semantics
217    /// regardless. `did_str` may be `#fragment`, `did:ma:…`, or
218    /// `did:ma:…#fragment`.
219    ///
220    /// # Errors
221    /// Returns [`Error::Acl`] if `did_str` cannot be parsed as a DID or fragment.
222    pub fn deny(&mut self, did_str: &str) -> Result<()> {
223        // Strip a leading `!` if the caller already included it.
224        let s = did_str.strip_prefix('!').unwrap_or(did_str);
225        let deny_str = if s.starts_with('#') || s.starts_with("did:") {
226            format!("!{s}")
227        } else {
228            return Err(Error::Acl(format!(
229                "cannot deny '{did_str}': not a DID or fragment"
230            )));
231        };
232        let entry = Entry::parse(&deny_str)?;
233        self.add_entry(entry)
234    }
235
236    #[allow(clippy::unnecessary_wraps)]
237    fn add_entry(&mut self, entry: Entry) -> Result<()> {
238        if !self.entries.contains(&entry) {
239            self.entries.push(entry);
240            self.compiled = Compiled::build(&self.entries);
241            self.dirty = true;
242            self.generation = self.generation.wrapping_add(1);
243        }
244        Ok(())
245    }
246
247    // ── Query ─────────────────────────────────────────────────────────────────
248
249    /// Return `true` if `did_str` is permitted by this ACL.
250    ///
251    /// `did_str` is matched as:
252    /// - `did:ma:…#fragment` — full DID-URL
253    /// - `did:ma:…` — bare identity
254    /// - `#fragment` — bare fragment (no identity context)
255    ///
256    /// Deny always wins over allow. An identity-level deny blocks all
257    /// DID-URLs under that identity.
258    pub fn is_allowed(&self, did_str: &str) -> bool {
259        let c = &self.compiled;
260
261        // Bare fragment shortcut
262        if let Some(frag) = did_str.strip_prefix('#') {
263            if c.deny_fragments.contains(frag) {
264                return false;
265            }
266            if c.public {
267                return true;
268            }
269            return c.allow_fragments.contains(frag);
270        }
271
272        // Parse as DID (lenient — we already validated on insert)
273        let did = match Did::try_from(did_str) {
274            Ok(d) => d,
275            Err(_) => return false,
276        };
277
278        // Identity-level deny knocks out all DID-URLs under that identity
279        if c.deny_identities.contains(&did.ipns) {
280            return false;
281        }
282
283        if let Some(ref frag) = did.fragment {
284            // DID-URL deny
285            if c.deny_urls.contains(&(did.ipns.clone(), frag.clone())) {
286                return false;
287            }
288            if c.public {
289                return true;
290            }
291            // Allow by full DID-URL or by identity
292            if c.allow_urls.contains(&(did.ipns.clone(), frag.clone())) {
293                return true;
294            }
295            c.allow_identities.contains(&did.ipns)
296        } else {
297            // Bare identity check
298            if c.public {
299                return true;
300            }
301            c.allow_identities.contains(&did.ipns)
302        }
303    }
304
305    // ── Serialisation ─────────────────────────────────────────────────────────
306
307    /// Serialise the ACL to a canonical YAML string.
308    ///
309    /// # Errors
310    /// Returns [`Error::Acl`] if serialisation fails (should not happen in
311    /// practice).
312    pub fn to_yaml(&self) -> Result<String> {
313        let strings: Vec<String> = self.entries.iter().map(entry_to_string).collect();
314        #[derive(serde::Serialize)]
315        struct Wrapper<'a> {
316            acl: &'a [String],
317        }
318        serde_yaml::to_string(&Wrapper { acl: &strings })
319            .map_err(|e| Error::Acl(format!("YAML serialisation error: {e}")))
320    }
321
322    // ── Publish bookkeeping ───────────────────────────────────────────────────
323
324    /// Record a successful publish.
325    ///
326    /// Only updates [`Acl::cid`] and clears [`Acl::dirty`] when `gen` matches
327    /// the current generation (i.e. no mutations happened between the publish
328    /// call and this confirmation).
329    pub fn mark_published(&mut self, cid: Cid, gen: u64) {
330        if gen == self.generation {
331            self.cid = Some(cid);
332            self.dirty = false;
333        }
334    }
335
336    /// Current generation counter.
337    ///
338    /// Increments on every mutating operation. Pass this value to
339    /// [`Acl::mark_published`] to guard against race conditions.
340    pub fn generation(&self) -> u64 {
341        self.generation
342    }
343}
344
345#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
346#[derive(Debug)]
347pub struct AclPublishWorker {
348    kubo_url: String,
349    ipns_key_name: String,
350    retry_delay: Duration,
351    publish_task: Option<JoinHandle<()>>,
352}
353
354#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
355impl AclPublishWorker {
356    pub fn new(kubo_url: impl AsRef<str>, ipns_key_name: impl AsRef<str>) -> Result<Self> {
357        let kubo_url = kubo_url.as_ref().trim().to_string();
358        let ipns_key_name = ipns_key_name.as_ref().trim().to_string();
359
360        if kubo_url.is_empty() {
361            return Err(Error::Acl("kubo_url must not be empty".to_string()));
362        }
363        if ipns_key_name.is_empty() {
364            return Err(Error::Acl("ipns_key_name must not be empty".to_string()));
365        }
366
367        Ok(Self {
368            kubo_url,
369            ipns_key_name,
370            retry_delay: Duration::from_secs(2),
371            publish_task: None,
372        })
373    }
374
375    #[must_use]
376    pub fn with_retry_delay(mut self, retry_delay: Duration) -> Self {
377        self.retry_delay = retry_delay;
378        self
379    }
380
381    pub fn on_acl_changed(&mut self, acl: Arc<Mutex<Acl>>) {
382        if let Some(task) = self.publish_task.take() {
383            task.abort();
384        }
385
386        let kubo_url = self.kubo_url.clone();
387        let ipns_key_name = self.ipns_key_name.clone();
388        let retry_delay = self.retry_delay;
389
390        self.publish_task = Some(tokio::spawn(async move {
391            loop {
392                let snapshot = {
393                    let guard = match acl.lock() {
394                        Ok(guard) => guard,
395                        Err(_) => return,
396                    };
397
398                    if !guard.dirty {
399                        return;
400                    }
401
402                    let yaml = match guard.to_yaml() {
403                        Ok(yaml) => yaml,
404                        Err(_) => return,
405                    };
406
407                    (guard.generation(), yaml)
408                };
409
410                match publish_acl_once(&kubo_url, &ipns_key_name, &snapshot.1).await {
411                    Ok(cid) => {
412                        let mut guard = match acl.lock() {
413                            Ok(guard) => guard,
414                            Err(_) => return,
415                        };
416                        guard.mark_published(cid, snapshot.0);
417                        if !guard.dirty {
418                            return;
419                        }
420                    }
421                    Err(_) => {
422                        sleep(retry_delay).await;
423                    }
424                }
425            }
426        }));
427    }
428}
429
430#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
431impl Drop for AclPublishWorker {
432    fn drop(&mut self) {
433        if let Some(task) = self.publish_task.take() {
434            task.abort();
435        }
436    }
437}
438
439#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
440async fn publish_acl_once(kubo_url: &str, ipns_key_name: &str, yaml: &str) -> Result<Cid> {
441    let cid_str = ipfs_add(kubo_url, yaml.as_bytes().to_vec())
442        .await
443        .map_err(|e| Error::Acl(format!("ACL IPFS add failed: {e}")))?;
444
445    name_publish_with_retry(
446        kubo_url,
447        ipns_key_name,
448        &cid_str,
449        &IpnsPublishOptions::default(),
450        3,
451        Duration::from_secs(1),
452    )
453    .await
454    .map_err(|e| Error::Acl(format!("ACL IPNS publish failed: {e}")))?;
455
456    cid_str
457        .parse::<Cid>()
458        .map_err(|e| Error::Acl(format!("invalid CID from IPFS add: {e}")))
459}
460
461// ── Helpers ────────────────────────────────────────────────────────────────────
462
463fn entry_to_string(e: &Entry) -> String {
464    match e {
465        Entry::Any => "*".to_owned(),
466        Entry::Allow(did) => did.id(),
467        Entry::Deny(did) => format!("!{}", did.id()),
468        Entry::AllowFragment(f) => format!("#{f}"),
469        Entry::DenyFragment(f) => format!("!#{f}"),
470    }
471}
472
473// ── Tests ──────────────────────────────────────────────────────────────────────
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn public_yaml() -> &'static str {
480        "acl:\n  - \"*\"\n"
481    }
482
483    fn restricted_yaml() -> &'static str {
484        "acl:\n  - \"did:ma:Qmalice\"\n  - \"!did:ma:Qmeve\"\n"
485    }
486
487    // ── new_from_yaml ─────────────────────────────────────────────────────────
488
489    #[test]
490    fn parse_public() {
491        let acl = Acl::new_from_yaml(public_yaml()).unwrap();
492        assert!(acl.compiled.public);
493        assert!(acl.dirty);
494    }
495
496    #[test]
497    fn parse_restricted() {
498        let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
499        assert!(!acl.compiled.public);
500        assert!(acl.compiled.allow_identities.contains("Qmalice"));
501        assert!(acl.compiled.deny_identities.contains("Qmeve"));
502    }
503
504    #[test]
505    fn parse_fragments() {
506        let yaml = "acl:\n  - \"#read\"\n  - \"!#write\"\n";
507        let acl = Acl::new_from_yaml(yaml).unwrap();
508        assert!(acl.compiled.allow_fragments.contains("read"));
509        assert!(acl.compiled.deny_fragments.contains("write"));
510    }
511
512    #[test]
513    fn parse_did_url() {
514        let yaml = "acl:\n  - \"did:ma:Qmalice#edit\"\n";
515        let acl = Acl::new_from_yaml(yaml).unwrap();
516        assert!(acl
517            .compiled
518            .allow_urls
519            .contains(&("Qmalice".to_owned(), "edit".to_owned())));
520    }
521
522    #[test]
523    fn parse_unknown_entry_fails() {
524        let yaml = "acl:\n  - \"ftp://bad\"\n";
525        assert!(Acl::new_from_yaml(yaml).is_err());
526    }
527
528    // ── is_allowed ────────────────────────────────────────────────────────────
529
530    #[test]
531    fn public_allows_all() {
532        let acl = Acl::new_from_yaml(public_yaml()).unwrap();
533        assert!(acl.is_allowed("did:ma:Qmanyone#read"));
534        assert!(acl.is_allowed("did:ma:Qmanyone"));
535    }
536
537    #[test]
538    fn deny_identity_blocks_all_urls() {
539        let acl = Acl::new_from_yaml("acl:\n  - \"*\"\n  - \"!did:ma:Qmeve\"\n").unwrap();
540        assert!(!acl.is_allowed("did:ma:Qmeve"));
541        assert!(!acl.is_allowed("did:ma:Qmeve#read"));
542    }
543
544    #[test]
545    fn allow_identity_permits_urls() {
546        let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
547        assert!(acl.is_allowed("did:ma:Qmalice"));
548        assert!(acl.is_allowed("did:ma:Qmalice#edit"));
549    }
550
551    #[test]
552    fn unknown_identity_denied_in_restricted() {
553        let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
554        assert!(!acl.is_allowed("did:ma:Qmbob"));
555    }
556
557    #[test]
558    fn bare_fragment_allow_deny() {
559        let acl = Acl::new_from_yaml("acl:\n  - \"#read\"\n  - \"!#write\"\n").unwrap();
560        assert!(acl.is_allowed("#read"));
561        assert!(!acl.is_allowed("#write"));
562        assert!(!acl.is_allowed("#other"));
563    }
564
565    // ── allow / deny mutators ─────────────────────────────────────────────────
566
567    #[test]
568    fn allow_mutator_idempotent() {
569        let mut acl = Acl::new_from_yaml("acl: []\n").unwrap();
570        let gen0 = acl.generation();
571        acl.allow("did:ma:Qmbob").unwrap();
572        let gen1 = acl.generation();
573        acl.allow("did:ma:Qmbob").unwrap(); // duplicate — no change
574        assert_eq!(acl.generation(), gen1);
575        assert!(gen1 > gen0);
576        assert!(acl.is_allowed("did:ma:Qmbob"));
577    }
578
579    #[test]
580    fn deny_mutator() {
581        let mut acl = Acl::new_from_yaml("acl:\n  - \"*\"\n").unwrap();
582        acl.deny("did:ma:Qmeve").unwrap();
583        assert!(acl.dirty);
584        assert!(!acl.is_allowed("did:ma:Qmeve"));
585    }
586
587    #[test]
588    fn deny_mutator_with_bang_prefix() {
589        let mut acl = Acl::new_from_yaml("acl:\n  - \"*\"\n").unwrap();
590        acl.deny("!did:ma:Qmeve").unwrap(); // leading `!` is stripped
591        assert!(!acl.is_allowed("did:ma:Qmeve"));
592    }
593
594    // ── to_yaml round-trip ────────────────────────────────────────────────────
595
596    #[test]
597    fn yaml_round_trip() {
598        let yaml = "acl:\n  - \"*\"\n  - did:ma:Qmalice\n  - '!did:ma:Qmeve'\n";
599        let acl = Acl::new_from_yaml(yaml).unwrap();
600        let out = acl.to_yaml().unwrap();
601        let acl2 = Acl::new_from_yaml(&out).unwrap();
602        // Re-serialise must produce the same entries
603        assert_eq!(acl.entries, acl2.entries);
604    }
605
606    // ── mark_published ────────────────────────────────────────────────────────
607
608    #[test]
609    fn mark_published_clears_dirty() {
610        let mut acl = Acl::new_from_yaml(public_yaml()).unwrap();
611        let gen = acl.generation();
612        let cid: Cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4aq3b6b2x6xkhu"
613            .parse()
614            .unwrap();
615        acl.mark_published(cid, gen);
616        assert!(!acl.dirty);
617        assert!(acl.cid.is_some());
618    }
619
620    #[test]
621    fn mark_published_stale_gen_noop() {
622        let mut acl = Acl::new_from_yaml(public_yaml()).unwrap();
623        let old_gen = acl.generation();
624        acl.allow("did:ma:Qmbob").unwrap(); // bumps generation
625        let cid: Cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4aq3b6b2x6xkhu"
626            .parse()
627            .unwrap();
628        acl.mark_published(cid, old_gen); // stale — must be ignored
629        assert!(acl.dirty);
630        assert!(acl.cid.is_none());
631    }
632}