Skip to main content

ikigai_script/
authority.rs

1//! The three authorities, never merged — and the one computation that decides what a run
2//! may touch.
3//!
4//! 1. **Publish or change**: `urn:cap:script:write:{name}`, and `urn:cap:script:delete:{name}`
5//!    to retire.
6//! 2. **Run**: `urn:cap:script:run:{name}`. A **public** script is also runnable by any
7//!    holder of [`CAP_RUN_PUBLIC`], which is the grant a host gives its anonymous
8//!    principal.
9//! 3. **Runs as**: what [`effective`] computes, applied to the runner's own capability by
10//!    attenuation. Never minted.
11//!
12//! Reading a script without running it is a fourth scope, `urn:cap:script:read:{name}`
13//! (or [`CAP_READ_PUBLIC`] for a public, published script): source code is not the same
14//! sensitivity as running it, the same split `ikigai-ledger` draws between read and write.
15//!
16//! # Runs as: the least of four
17//!
18//! A run executes under
19//!
20//! ```text
21//! runner.attenuate( { s in declared : s in granted-at-publish and ceiling.allows(s) }
22//!                   + the publisher's exclusions + the ceiling's exclusions )
23//! ```
24//!
25//! - **declared**: what the script says it needs (`requires=` at publish), plus the
26//!   capability its LANGUAGE needs (`urn:cap:lisp` for Lisp), because evaluating code is
27//!   itself authority.
28//! - **granted at publish**: the part of `declared` the publisher held when they published.
29//!   **A publish that declares a scope the publisher does not hold is REFUSED** with a typed
30//!   `Denied` naming the scopes. Narrowing silently instead would store a script whose
31//!   `requires` says one thing and whose runs get another, and the manifold would lie.
32//!   The snapshot is still stored and still intersected, so a hand edit of ONE record (a
33//!   version's `requires`, say, in a [`crate::DirBackend`] file) cannot widen a run past
34//!   the other. Editing both is the host operator's act — the same trust as the ceiling
35//!   files — and the ceiling bounds every run whatever the records say.
36//! - **the host's ceiling** for this script ([`Ceiling`], from the host's
37//!   `<config home>/script-authority/{name}`): the host decides, never the script.
38//! - **the runner's own capability**: [`Capability::attenuate`] keeps a grant only when the
39//!   runner holds it, so a run never exceeds the intersection of runner and script.
40//!
41//! **Exclusions travel.** A publisher who holds `urn:cap:fs:read:/root` together with the
42//! exclusion `urn:cap:fs:read:-/root/secret` could not read the secret, so neither can the
43//! script, whoever runs it: every exclusion the publisher held is stored with the version's
44//! grant and handed to `attenuate`, which keeps a requested exclusion (it only narrows).
45//! The ceiling's exclusions travel the same way.
46//!
47//! ```
48//! use ikigai_core::Capability;
49//! use ikigai_script::authority::{effective, Ceiling};
50//! use std::collections::BTreeSet;
51//!
52//! let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
53//! let declared = set(&["urn:cap:lisp", "urn:cap:net:example.com", "urn:cap:mail:send"]);
54//! let granted = declared.clone(); // the publisher held all three
55//! let exclusions = set(&["urn:cap:net:-example.com/admin"]);
56//! // The host lets this script run Lisp and reach the network, but not send mail.
57//! let ceiling = Ceiling::scoped(["urn:cap:lisp", "urn:cap:net:*"]);
58//!
59//! let request = effective(&declared, &granted, &exclusions, &ceiling);
60//! assert_eq!(
61//!     request,
62//!     set(&["urn:cap:lisp", "urn:cap:net:-example.com/admin", "urn:cap:net:example.com"])
63//! );
64//!
65//! // A runner who holds Lisp and mail, but not the network: the run gets Lisp, and the
66//! // exclusion still rides along (it only narrows).
67//! let runner = Capability::scoped(["urn:cap:lisp", "urn:cap:mail:send", "urn:cap:script:run:x"]);
68//! let runs_as = runner.attenuate(request);
69//! assert!(runs_as.allows("urn:cap:lisp"));
70//! assert!(!runs_as.allows("urn:cap:mail:send")); // the ceiling withheld it
71//! assert!(!runs_as.allows("urn:cap:net:example.com")); // the runner never held it
72//! assert!(!runs_as.allows("urn:cap:script:run:x")); // nothing undeclared survives
73//! ```
74
75use std::collections::BTreeSet;
76use std::sync::Arc;
77
78use ikigai_core::{is_deny_scope, Capability, Error, Result};
79
80/// Reading a script, as declared: "holds some script read grant". A held grant names one
81/// script — [`cap_read`] — and the exact one is checked inside.
82pub const CAP_READ: &str = "urn:cap:script:read:*";
83/// Publishing or replacing a script, as declared. Exact form: [`cap_write`].
84pub const CAP_WRITE: &str = "urn:cap:script:write:*";
85/// Retiring a script, as declared. Exact form: [`cap_delete`].
86pub const CAP_DELETE: &str = "urn:cap:script:delete:*";
87/// Running a script, as declared. Exact form: [`cap_run`], or [`CAP_RUN_PUBLIC`] for a
88/// public script.
89pub const CAP_RUN: &str = "urn:cap:script:run:*";
90/// Any script grant at all: the compiled form is readable by a reader OR a runner, and a
91/// `requires` is a conjunction, so it declares the family and checks the exact pair inside.
92pub const CAP_ANY: &str = "urn:cap:script:*";
93
94/// Run any script marked **public** (and published). The grant a host gives its anonymous
95/// principal: it is how "a public script grants the run capability to the anonymous
96/// principal" is spelled, since a host's anonymous grant cannot enumerate scripts that do
97/// not exist yet. Withhold it and nothing runs anonymously.
98pub const CAP_RUN_PUBLIC: &str = "urn:cap:script:run:public";
99/// Read the source of any script marked public (and published).
100pub const CAP_READ_PUBLIC: &str = "urn:cap:script:read:public";
101
102/// The capability `urn:lisp:eval` requires, and so every Lisp script declares implicitly
103/// (see [`crate::model::Language::capability`]). Spelled here rather than imported:
104/// the library does not link `ikigai-lisp`.
105pub const CAP_LISP: &str = "urn:cap:lisp";
106
107/// The exact grant to read `{name}`.
108pub fn cap_read(name: &str) -> String {
109    format!("urn:cap:script:read:{name}")
110}
111
112/// The exact grant to publish or replace `{name}`.
113pub fn cap_write(name: &str) -> String {
114    format!("urn:cap:script:write:{name}")
115}
116
117/// The exact grant to retire `{name}`.
118pub fn cap_delete(name: &str) -> String {
119    format!("urn:cap:script:delete:{name}")
120}
121
122/// The exact grant to run `{name}`.
123pub fn cap_run(name: &str) -> String {
124    format!("urn:cap:script:run:{name}")
125}
126
127/// What is recorded as the principal when the request's capability names none: root (the
128/// host's own authority, which holds every principal and names none), a door that minted no
129/// `urn:cap:principal:` scope, or a capability carrying several (which names nobody). Honest
130/// rather than useful, so a host that never identifies its callers cannot have its records
131/// claim an identity nobody checked. The value records written before 0.2.0 carry for a host
132/// that stamped nothing, kept so old and new records agree.
133pub const UNSTAMPED: &str = "urn:script:principal:unstamped";
134
135/// What a door conventionally mints (`Capability::with_principal`) for a caller it cannot
136/// identify. Many callers share it, so it is nobody's identity: see [`is_identity`].
137pub const ANONYMOUS: &str = "urn:script:principal:anonymous";
138
139/// Who a request comes from: the principal its CAPABILITY names, minted by the host's door
140/// (`urn:cap:principal:<iri>`, ikigai-core 0.1.93), or [`UNSTAMPED`] when it names none.
141///
142/// The library records it on every publish and every run. It is never an argument: a caller
143/// cannot name itself, and narrowing a capability can never add or change a principal, so a
144/// sub-request issued on someone's behalf carries their name or none.
145///
146/// ```
147/// use ikigai_core::Capability;
148/// use ikigai_script::authority::{principal_of, UNSTAMPED};
149///
150/// let door = Capability::scoped(["urn:cap:script:run:job"]);
151/// let alice = door.with_principal("urn:example:person:alice").unwrap();
152/// assert_eq!(principal_of(&alice), "urn:example:person:alice");
153/// assert_eq!(principal_of(&door), UNSTAMPED);
154/// // Root is the host's own authority, not a party's.
155/// assert_eq!(principal_of(&Capability::root()), UNSTAMPED);
156/// ```
157pub fn principal_of(capability: &Capability) -> String {
158    capability.principal().unwrap_or(UNSTAMPED).to_string()
159}
160
161/// Whether a recorded principal names SOMEONE: anything but [`UNSTAMPED`] and
162/// [`ANONYMOUS`], which stand for many different callers at once.
163///
164/// It decides who may see a draft. A draft is visible only to its author until it is
165/// published, and the author is the principal recorded when the draft was written; two
166/// callers minted [`ANONYMOUS`] are not one person, and a capability naming no principal
167/// cannot be told apart from any other. So a draft written under either is nobody's: only
168/// root sees it until it is published. A door that cannot identify a caller mints
169/// [`ANONYMOUS`] (or nothing) rather than inventing a shared name.
170///
171/// ```
172/// use ikigai_script::authority::{is_identity, ANONYMOUS, UNSTAMPED};
173/// assert!(is_identity("urn:example:person:brian"));
174/// assert!(!is_identity(ANONYMOUS));
175/// assert!(!is_identity(UNSTAMPED));
176/// assert!(!is_identity(""));
177/// ```
178pub fn is_identity(principal: &str) -> bool {
179    !principal.trim().is_empty() && principal != UNSTAMPED && principal != ANONYMOUS
180}
181
182/// The host's ceiling for one script: the most it will let that script's runs touch.
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub enum Ceiling {
185    /// The host imposes nothing beyond what the script declared and its publisher held.
186    Unbounded,
187    /// Exactly these scopes. A grant ending in `*` admits every scope under that prefix
188    /// (`urn:cap:net:*`); a deny-shaped scope ([`is_deny_scope`]) is an exclusion every run
189    /// of the script carries.
190    Scopes(BTreeSet<String>),
191}
192
193impl Ceiling {
194    /// No ceiling: [`Ceiling::Unbounded`].
195    pub fn unbounded() -> Ceiling {
196        Ceiling::Unbounded
197    }
198
199    /// The empty ceiling: the script may touch nothing, not even its language, so it cannot
200    /// run. The answer for a script the host has no file for, when the host fails closed.
201    pub fn nothing() -> Ceiling {
202        Ceiling::Scopes(BTreeSet::new())
203    }
204
205    /// Exactly these scopes.
206    pub fn scoped<I, S>(scopes: I) -> Ceiling
207    where
208        I: IntoIterator<Item = S>,
209        S: Into<String>,
210    {
211        Ceiling::Scopes(scopes.into_iter().map(Into::into).collect())
212    }
213
214    /// Parse the text of a ceiling file (`<config home>/script-authority/{name}`): one
215    /// `urn:cap:` scope per line, `#` to the end of a line is a comment, blank lines are
216    /// ignored. A lone `*` line means [`Ceiling::Unbounded`].
217    ///
218    /// ```
219    /// use ikigai_script::authority::Ceiling;
220    /// let ceiling = Ceiling::parse(
221    ///     "# the nightly report\nurn:cap:lisp\nurn:cap:net:*   # any host\n\
222    ///      urn:cap:net:-internal.example\n",
223    /// )
224    /// .unwrap();
225    /// assert!(ceiling.allows("urn:cap:lisp"));
226    /// assert!(ceiling.allows("urn:cap:net:example.com"));
227    /// assert!(!ceiling.allows("urn:cap:mail:send"));
228    /// assert_eq!(ceiling.exclusions(), vec!["urn:cap:net:-internal.example".to_string()]);
229    /// assert_eq!(Ceiling::parse("*\n").unwrap(), Ceiling::Unbounded);
230    /// assert!(Ceiling::parse("lisp\n").is_err()); // not a capability scope
231    /// ```
232    pub fn parse(text: &str) -> Result<Ceiling> {
233        let mut scopes = BTreeSet::new();
234        for (number, line) in text.lines().enumerate() {
235            let line = line.split('#').next().unwrap_or("").trim();
236            if line.is_empty() {
237                continue;
238            }
239            if line == "*" {
240                return Ok(Ceiling::Unbounded);
241            }
242            if !line.starts_with("urn:cap:") || line.split_whitespace().count() != 1 {
243                return Err(Error::InvalidArgument {
244                    name: "ceiling".to_string(),
245                    detail: format!(
246                        "line {}: `{line}` is not one `urn:cap:` scope (or `*` for no ceiling)",
247                        number + 1
248                    ),
249                });
250            }
251            scopes.insert(line.to_string());
252        }
253        Ok(Ceiling::Scopes(scopes))
254    }
255
256    /// Whether the ceiling admits the grant `scope`: held exactly, or under a held
257    /// `prefix*` family. An exclusion is never admitted as a grant.
258    pub fn allows(&self, scope: &str) -> bool {
259        match self {
260            Ceiling::Unbounded => !is_deny_scope(scope),
261            Ceiling::Scopes(held) => {
262                !is_deny_scope(scope)
263                    && held.iter().any(|h| match h.strip_suffix('*') {
264                        Some(prefix) => scope.starts_with(prefix),
265                        None => h == scope,
266                    })
267            }
268        }
269    }
270
271    /// The exclusions the ceiling carries, which every run of the script carries too.
272    pub fn exclusions(&self) -> Vec<String> {
273        match self {
274            Ceiling::Unbounded => Vec::new(),
275            Ceiling::Scopes(held) => held.iter().filter(|s| is_deny_scope(s)).cloned().collect(),
276        }
277    }
278}
279
280/// How the host answers "what is the ceiling for this script?" — called on every run, so a
281/// changed ceiling takes effect at the next one (and a cached result is cut by
282/// [`crate::name::authority_thread`]).
283///
284/// ```
285/// use ikigai_script::authority::{Ceiling, CeilingPolicy};
286/// use std::sync::Arc;
287/// // A host reading `<config home>/script-authority/{name}`, failing closed when the file
288/// // is missing or unreadable:
289/// let dir = std::path::PathBuf::from("/etc/my-host/script-authority");
290/// let policy: CeilingPolicy = Arc::new(move |name| {
291///     std::fs::read_to_string(dir.join(name))
292///         .ok()
293///         .and_then(|text| Ceiling::parse(&text).ok())
294///         .unwrap_or_else(Ceiling::nothing)
295/// });
296/// # let _ = policy;
297/// ```
298pub type CeilingPolicy = Arc<dyn Fn(&str) -> Ceiling + Send + Sync>;
299
300/// A ceiling policy that answers the same ceiling for every script.
301pub fn same_for_all(ceiling: Ceiling) -> CeilingPolicy {
302    Arc::new(move |_| ceiling.clone())
303}
304
305/// Parse `requires=`: whitespace-separated `urn:cap:` grants.
306///
307/// Refused: anything not under `urn:cap:`, a family wildcard (`*`: a script states the
308/// grants it needs, and attenuation keeps grants by exact name, so a wildcard would keep
309/// nothing), and an exclusion (it is not something a script can need; exclusions come
310/// from the publisher and the host).
311pub fn parse_requires(text: &str) -> Result<BTreeSet<String>> {
312    let mut scopes = BTreeSet::new();
313    for scope in text.split_whitespace() {
314        let detail = if !scope.starts_with("urn:cap:") {
315            Some("is not a `urn:cap:` scope")
316        } else if scope.ends_with('*') {
317            Some(
318                "is a family wildcard; a script declares the exact grants it needs, \
319                 because a run keeps grants by exact name",
320            )
321        } else if is_deny_scope(scope) {
322            Some("is an exclusion; a script declares what it needs, not what it may not touch")
323        } else {
324            None
325        };
326        if let Some(detail) = detail {
327            return Err(Error::InvalidArgument {
328                name: "requires".to_string(),
329                detail: format!("`{scope}` {detail}"),
330            });
331        }
332        scopes.insert(scope.to_string());
333    }
334    Ok(scopes)
335}
336
337/// What the publisher held of a script's declared scopes, taken at publish time.
338#[derive(Clone, Debug, PartialEq, Eq)]
339pub struct Grant {
340    /// The declared scopes the publisher held: all of them, since a publish declaring more
341    /// is refused. For a declared FAMILY (`prefix*`, which only a SPARQL script's derived
342    /// authority carries: [`crate::sparql::CAP_READ_GRAPH`]), the publisher's own grants
343    /// under it, each one, or the family itself when the publisher was root.
344    pub granted: BTreeSet<String>,
345    /// Every exclusion the publisher's capability carried, which every run carries too.
346    pub exclusions: BTreeSet<String>,
347}
348
349/// A declared family (`prefix*`): its prefix.
350fn family(scope: &str) -> Option<&str> {
351    scope.strip_suffix('*')
352}
353
354/// Take the publisher's grant for `declared`, or refuse with a typed `Denied` naming what
355/// they do not hold. **No elevation**: a script never runs with more than its publisher
356/// held.
357///
358/// A declared family (`prefix*`) is held when the publisher holds some grant under it,
359/// the kernel's own reading of a family in a `requires`; what is taken is every such
360/// grant, so a run's union is never wider than its publisher's.
361pub fn grant_at_publish(publisher: &Capability, declared: &BTreeSet<String>) -> Result<Grant> {
362    let held_under = |prefix: &str| -> Vec<String> {
363        publisher
364            .scopes()
365            .map(|held| {
366                held.iter()
367                    .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
368                    .cloned()
369                    .collect()
370            })
371            .unwrap_or_default()
372    };
373    let mut granted = BTreeSet::new();
374    let mut missing: Vec<&str> = Vec::new();
375    for scope in declared {
376        match family(scope) {
377            Some(_) if publisher.is_root() => {
378                granted.insert(scope.clone());
379            }
380            Some(prefix) => {
381                let held = held_under(prefix);
382                if held.is_empty() {
383                    missing.push(scope);
384                }
385                granted.extend(held);
386            }
387            None if publisher.allows(scope) => {
388                granted.insert(scope.clone());
389            }
390            None => missing.push(scope),
391        }
392    }
393    if !missing.is_empty() {
394        return Err(Error::Denied(format!(
395            "a script cannot be given more than its publisher holds, and this capability \
396             does not hold {} — declare less, or publish under a capability that holds it",
397            missing.join(", ")
398        )));
399    }
400    let exclusions = publisher
401        .scopes()
402        .map(|held| held.iter().filter(|s| is_deny_scope(s)).cloned().collect())
403        .unwrap_or_default();
404    Ok(Grant {
405        granted,
406        exclusions,
407    })
408}
409
410/// The scopes a run asks to keep: `{ s in declared : s in granted and ceiling.allows(s) }`
411/// plus the publisher's and the ceiling's exclusions. The run's capability is the runner's
412/// own [`Capability::attenuate`]d to this set — see the module documentation.
413///
414/// A declared family keeps the publisher's grants under it that the ceiling allows; when
415/// the publisher was root it keeps the family itself, a marker the SPARQL run expands
416/// into exact grants (each checked against the ceiling and the runner) before anything
417/// is attenuated, because attenuation keeps grants by exact name.
418pub fn effective(
419    declared: &BTreeSet<String>,
420    granted: &BTreeSet<String>,
421    exclusions: &BTreeSet<String>,
422    ceiling: &Ceiling,
423) -> BTreeSet<String> {
424    let mut keep: BTreeSet<String> = BTreeSet::new();
425    for scope in declared {
426        match family(scope) {
427            Some(_) if granted.contains(scope) => {
428                keep.insert(scope.clone());
429            }
430            Some(prefix) => keep.extend(
431                granted
432                    .iter()
433                    .filter(|g| g.starts_with(prefix) && ceiling.allows(g))
434                    .cloned(),
435            ),
436            None if granted.contains(scope) && ceiling.allows(scope) => {
437                keep.insert(scope.clone());
438            }
439            None => {}
440        }
441    }
442    keep.extend(exclusions.iter().filter(|s| is_deny_scope(s)).cloned());
443    keep.extend(ceiling.exclusions());
444    keep
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn set(xs: &[&str]) -> BTreeSet<String> {
452        xs.iter().map(|s| s.to_string()).collect()
453    }
454
455    #[test]
456    fn a_publisher_cannot_declare_what_they_do_not_hold() {
457        let publisher = Capability::scoped(["urn:cap:lisp", "urn:cap:script:write:x"]);
458        let err =
459            grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:mail:send"])).unwrap_err();
460        assert!(
461            matches!(&err, Error::Denied(m) if m.contains("urn:cap:mail:send") && !m.contains("urn:cap:lisp,")),
462            "{err:?}"
463        );
464    }
465
466    #[test]
467    fn the_publishers_exclusions_are_taken_with_the_grant() {
468        let publisher = Capability::scoped([
469            "urn:cap:lisp",
470            "urn:cap:fs:read:/root",
471            "urn:cap:fs:read:-/root/secret",
472        ]);
473        let grant =
474            grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:fs:read:/root"])).unwrap();
475        assert_eq!(grant.exclusions, set(&["urn:cap:fs:read:-/root/secret"]));
476        // Root holds no exclusions and every grant.
477        let grant = grant_at_publish(&Capability::root(), &set(&["urn:cap:anything"])).unwrap();
478        assert!(grant.exclusions.is_empty());
479    }
480
481    #[test]
482    fn a_hand_widened_grant_still_cannot_exceed_the_stored_snapshot() {
483        // declared names mail, but the snapshot (what the publisher held) does not.
484        let request = effective(
485            &set(&["urn:cap:lisp", "urn:cap:mail:send"]),
486            &set(&["urn:cap:lisp"]),
487            &BTreeSet::new(),
488            &Ceiling::Unbounded,
489        );
490        assert_eq!(request, set(&["urn:cap:lisp"]));
491    }
492
493    #[test]
494    fn a_family_takes_the_publishers_grants_under_it() {
495        let family = "urn:cap:store:read:graph:*";
496        let publisher = Capability::scoped([
497            "urn:cap:store:read:graph:urn:a",
498            "urn:cap:store:read:graph:urn:b",
499            "urn:cap:other",
500        ]);
501        let grant = grant_at_publish(&publisher, &set(&[family])).unwrap();
502        assert_eq!(
503            grant.granted,
504            set(&[
505                "urn:cap:store:read:graph:urn:a",
506                "urn:cap:store:read:graph:urn:b"
507            ])
508        );
509        // Holding nothing under it is holding nothing it declares.
510        assert!(grant_at_publish(&Capability::scoped(["urn:cap:other"]), &set(&[family])).is_err());
511        // Root keeps the family, as a marker for the run to expand.
512        let root = grant_at_publish(&Capability::root(), &set(&[family])).unwrap();
513        assert_eq!(root.granted, set(&[family]));
514        // The ceiling narrows the members.
515        let keep = effective(
516            &set(&[family]),
517            &grant.granted,
518            &BTreeSet::new(),
519            &Ceiling::scoped(["urn:cap:store:read:graph:urn:a"]),
520        );
521        assert_eq!(keep, set(&["urn:cap:store:read:graph:urn:a"]));
522    }
523
524    #[test]
525    fn requires_refuses_wildcards_exclusions_and_strangers() {
526        assert!(parse_requires("urn:cap:net:*").is_err());
527        assert!(parse_requires("urn:cap:fs:read:-/x").is_err());
528        assert!(parse_requires("lisp").is_err());
529        assert_eq!(
530            parse_requires(" urn:cap:a\n urn:cap:b urn:cap:a ").unwrap(),
531            set(&["urn:cap:a", "urn:cap:b"])
532        );
533    }
534
535    #[test]
536    fn a_ceiling_never_admits_an_exclusion_as_a_grant() {
537        assert!(!Ceiling::Unbounded.allows("urn:cap:net:-x"));
538        assert!(!Ceiling::scoped(["urn:cap:net:*"]).allows("urn:cap:net:-x"));
539        assert!(!Ceiling::nothing().allows("urn:cap:lisp"));
540    }
541}