Skip to main content

macula_rust/ucan/
capability.rs

1//! Capabilities: a `with` is an MRI, `mri:realm:<realm>`,
2//! `mri:org:<realm>/<org>` or `mri:proc:<realm>/<procedure>`, and D7's
3//! narrowing says which grant covers which. A request's realm id is SHA-256
4//! over a grant's realm name, so a grant is checked against it with nothing
5//! looked up.
6
7use serde_json::{Map, Value};
8use sha2::{Digest, Sha256};
9
10use super::{Policy, Refusal, Request};
11use crate::record::procedure_org;
12
13/// A `with` parsed: a realm, an org of that realm, or one procedure of that
14/// realm.
15#[derive(Debug, PartialEq, Eq)]
16enum Grant<'a> {
17    Realm(&'a str),
18    Org(&'a str, &'a str),
19    Proc(&'a str, &'a str),
20}
21
22impl<'a> Grant<'a> {
23    fn realm(&self) -> &'a str {
24        match self {
25            Grant::Realm(realm) | Grant::Org(realm, _) | Grant::Proc(realm, _) => realm,
26        }
27    }
28}
29
30/// A `with` parsed into its grant, or why it is not one.
31fn grant(with: &str) -> Result<Grant<'_>, Refusal> {
32    let canonical = |realm| match canonical_realm_name(realm) {
33        true => Ok(()),
34        false => Err(Refusal::RealmNameNotCanonical),
35    };
36    if let Some(realm) = with.strip_prefix("mri:realm:") {
37        canonical(realm)?;
38        return Ok(Grant::Realm(realm));
39    }
40    if let Some(rest) = with.strip_prefix("mri:org:") {
41        let (realm, org) = rest.split_once('/').ok_or(Refusal::Malformed)?;
42        if org.is_empty() {
43            return Err(Refusal::Malformed);
44        }
45        canonical(realm)?;
46        return Ok(Grant::Org(realm, org));
47    }
48    if let Some(rest) = with.strip_prefix("mri:proc:") {
49        let (realm, procedure) = rest.split_once('/').ok_or(Refusal::Malformed)?;
50        canonical(realm)?;
51        org_of(procedure)?;
52        return Ok(Grant::Proc(realm, procedure));
53    }
54    Err(Refusal::Malformed)
55}
56
57/// A procedure's org, or procedure_without_org.
58fn org_of(procedure: &str) -> Result<&str, Refusal> {
59    match procedure_org(procedure) {
60        Ok(Some(org)) => Ok(org),
61        _ => Err(Refusal::ProcedureWithoutOrg),
62    }
63}
64
65/// Whether a grant covers another grant or a request, by D7's narrowing: a
66/// realm grant covers its realm, an org grant covers that org and its
67/// procedures, a procedure grant only itself. False for a `with` that is not
68/// one of the three MRIs, whose realm name is not canonical, or whose
69/// procedure has no org.
70pub fn covers(parent: &str, child: &str) -> bool {
71    let (Ok(p), Ok(c)) = (grant(parent), grant(child)) else {
72        return false;
73    };
74    match (p, c) {
75        (Grant::Realm(realm), c) => c.realm() == realm,
76        (Grant::Org(realm, org), Grant::Org(c_realm, c_org)) => c_realm == realm && c_org == org,
77        (Grant::Org(realm, org), Grant::Proc(c_realm, procedure)) => {
78            c_realm == realm && org_of(procedure) == Ok(org)
79        }
80        (Grant::Proc(realm, procedure), Grant::Proc(c_realm, c_procedure)) => {
81            c_realm == realm && c_procedure == procedure
82        }
83        _ => false,
84    }
85}
86
87/// At least one segment, segments joined by single dots, each of a-z, 0-9,
88/// hyphen or underscore. Case is never folded.
89fn canonical_realm_name(name: &str) -> bool {
90    !name.is_empty()
91        && name.split('.').all(|segment| {
92            !segment.is_empty()
93                && segment
94                    .bytes()
95                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
96        })
97}
98
99fn realm_id(realm: &str) -> [u8; 32] {
100    Sha256::digest(realm.as_bytes()).into()
101}
102
103/// A capability entry's `with` when it is text, and its `can`, when it has
104/// both.
105fn with_and_can(entry: &Value) -> Option<(&str, &Value)> {
106    let cap = entry.as_object()?;
107    Some((cap.get("with")?.as_str()?, cap.get("can")?))
108}
109
110/// What the leaf hands up the chain: the capability the request needs, one
111/// the token grants whose `can` is the policy's where it names one and whose
112/// `with` covers the request's realm and procedure. With no request,
113/// ucan_required hands on every capability, and realm_member_required the
114/// first with its `can`.
115pub(super) fn grants(
116    p: &Policy,
117    request: Option<&Request>,
118    claims: &Map<String, Value>,
119) -> Result<Vec<Value>, Refusal> {
120    let caps = claims
121        .get("cap")
122        .and_then(Value::as_array)
123        .map(Vec::as_slice)
124        .unwrap_or(&[]);
125    if let Some(r) = request {
126        return requested(p, r, caps);
127    }
128    match p {
129        Policy::UcanRequired { .. } => Ok(caps.to_vec()),
130        Policy::RealmMemberRequired { can, .. } => caps
131            .iter()
132            .find(|entry| {
133                entry.as_object().and_then(|c| c.get("can")) == Some(&Value::String(can.clone()))
134            })
135            .map(|cap| vec![cap.clone()])
136            .ok_or_else(|| why_not_granted(caps, None)),
137    }
138}
139
140fn requested(p: &Policy, r: &Request, caps: &[Value]) -> Result<Vec<Value>, Refusal> {
141    org_of(&r.procedure)?;
142    let answers = |entry: &Value| {
143        let Some((with, can)) = with_and_can(entry) else {
144            return false;
145        };
146        let can_matches = match p {
147            Policy::RealmMemberRequired { can: wanted, .. } => {
148                can == &Value::String(wanted.clone())
149            }
150            Policy::UcanRequired { .. } => true,
151        };
152        let Ok(g) = grant(with) else {
153            return false;
154        };
155        can_matches
156            && realm_id(g.realm()) == r.realm
157            && covers(with, &format!("mri:proc:{}/{}", g.realm(), r.procedure))
158    };
159    caps.iter()
160        .find(|entry| answers(entry))
161        .map(|cap| vec![cap.clone()])
162        .ok_or_else(|| why_not_granted(caps, Some(&r.realm)))
163}
164
165/// Why no capability answered: the first text `with` decides (a grant not
166/// well formed, or one in another realm, says so), and otherwise the
167/// capability is missing. `realm` is `None` when there is no request.
168fn why_not_granted(caps: &[Value], realm: Option<&[u8; 32]>) -> Refusal {
169    let first = caps
170        .iter()
171        .find_map(|entry| entry.as_object()?.get("with")?.as_str());
172    let Some(with) = first else {
173        return Refusal::MissingCapability;
174    };
175    match (grant(with), realm) {
176        (Err(Refusal::Malformed), _) => Refusal::MissingCapability,
177        (Err(refusal), _) => refusal,
178        (Ok(_), None) => Refusal::MissingCapability,
179        (Ok(g), Some(realm)) if realm_id(g.realm()) == *realm => Refusal::MissingCapability,
180        (Ok(_), Some(_)) => Refusal::WrongRealm,
181    }
182}
183
184/// A parent carries up, for each capability its child hands on, the first
185/// of its own that covers it with the same `can`; why not, otherwise, for
186/// the first that fails in the child's order.
187pub(super) fn narrowed(parent_caps: &[Value], children: &[Value]) -> Result<Vec<Value>, Refusal> {
188    children
189        .iter()
190        .map(|child| covering(parent_caps, child))
191        .collect()
192}
193
194fn covering(caps: &[Value], child: &Value) -> Result<Value, Refusal> {
195    let child_cap = child.as_object().ok_or(Refusal::Malformed)?;
196    let (Some(child_with), Some(child_can)) = (child_cap.get("with"), child_cap.get("can")) else {
197        return Err(Refusal::Malformed);
198    };
199    let found = caps
200        .iter()
201        .find(|entry| match (with_and_can(entry), child_with.as_str()) {
202            (Some((with, can)), Some(child_with)) => can == child_can && covers(with, child_with),
203            _ => false,
204        });
205    found
206        .cloned()
207        .ok_or_else(|| why_not_narrowed(caps, child_with))
208}
209
210/// A parent that granted the same authority under another `can` changed it;
211/// one of another realm is wrong_realm; otherwise it granted less than its
212/// child hands on. The first of its capabilities decides.
213fn why_not_narrowed(caps: &[Value], child_with: &Value) -> Refusal {
214    let Some((with, _)) = caps.first().and_then(with_and_can) else {
215        return Refusal::GrantsMoreThanProof;
216    };
217    match child_with.as_str() {
218        Some(child) if same_realm(with, child) && covers(with, child) => Refusal::CanChanged,
219        Some(child) if same_realm(with, child) => Refusal::GrantsMoreThanProof,
220        _ => Refusal::WrongRealm,
221    }
222}
223
224fn same_realm(with: &str, other: &str) -> bool {
225    matches!((grant(with), grant(other)), (Ok(a), Ok(b)) if a.realm() == b.realm())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn a_realm_name_is_dotted_lowercase_segments() {
234        for good in ["io.macula", "a", "a-b_c.0"] {
235            assert!(canonical_realm_name(good), "{good}");
236        }
237        for bad in [
238            "",
239            ".",
240            "io..macula",
241            "io.macula.",
242            "IO.macula",
243            "io macula",
244            "é",
245        ] {
246            assert!(!canonical_realm_name(bad), "{bad}");
247        }
248    }
249
250    #[test]
251    fn a_with_is_one_of_three_mris() {
252        assert_eq!(grant("mri:realm:io.macula"), Ok(Grant::Realm("io.macula")));
253        assert_eq!(
254            grant("mri:org:io.macula/acme"),
255            Ok(Grant::Org("io.macula", "acme"))
256        );
257        assert_eq!(
258            grant("mri:proc:io.macula/acme/count_v1"),
259            Ok(Grant::Proc("io.macula", "acme/count_v1"))
260        );
261        assert_eq!(grant("mri:org:io.macula/"), Err(Refusal::Malformed));
262        assert_eq!(grant("mri:org:io.macula"), Err(Refusal::Malformed));
263        assert_eq!(grant("mri:realm:IO"), Err(Refusal::RealmNameNotCanonical));
264        assert_eq!(
265            grant("mri:proc:io.macula/count"),
266            Err(Refusal::ProcedureWithoutOrg)
267        );
268        assert_eq!(grant("https://x"), Err(Refusal::Malformed));
269    }
270}