Skip to main content

cufflink_types/
grants.rs

1//! Permission strings: the single owner of their grammar, shared by the
2//! platform and the guest SDK. Only the scope suffix has a grammar; the part
3//! before `@` is compared exactly as before scopes existed, so no role
4//! string without `@` changes meaning.
5//!
6//! ```text
7//! grant := base [ "@" scope ]
8//! scope := "own" | "all" | "field(" column "=" claim ")"
9//! ```
10
11use serde_json::Value;
12use std::collections::HashMap;
13use std::fmt;
14
15/// Which rows a grant reaches on a table that opts into scoped permissions.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum Scope {
18    /// The caller's own rows.
19    Own,
20    /// Rows whose `column` holds the value of the caller's claim `claim`.
21    Field { column: String, claim: String },
22    /// Every row.
23    All,
24}
25
26/// A parsed permission string. A string without `@` is an `Own` grant.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Grant<'a> {
29    pub base: &'a str,
30    pub scope: Scope,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum GrantError {
35    EmptyBase,
36    InvalidScope(String),
37}
38
39impl fmt::Display for GrantError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            GrantError::EmptyBase => {
43                f.write_str("a scoped permission needs a permission before '@'")
44            }
45            GrantError::InvalidScope(scope) => write!(
46                f,
47                "invalid scope '{scope}' (expected own, all or field(column=claim))"
48            ),
49        }
50    }
51}
52
53impl std::error::Error for GrantError {}
54
55pub fn parse(s: &str) -> Result<Grant<'_>, GrantError> {
56    let Some((base, suffix)) = s.split_once('@') else {
57        return Ok(Grant {
58            base: s,
59            scope: Scope::Own,
60        });
61    };
62    if base.is_empty() {
63        return Err(GrantError::EmptyBase);
64    }
65    parse_scope(suffix)
66        .map(|scope| Grant { base, scope })
67        .ok_or_else(|| GrantError::InvalidScope(suffix.to_string()))
68}
69
70/// A string carrying a well-formed scope suffix, parsed; `None` for a
71/// string without `@` or with a malformed suffix.
72pub fn parse_scoped(s: &str) -> Option<Grant<'_>> {
73    s.contains('@').then(|| parse(s).ok()).flatten()
74}
75
76fn parse_scope(suffix: &str) -> Option<Scope> {
77    match suffix {
78        "own" => Some(Scope::Own),
79        "all" => Some(Scope::All),
80        _ => {
81            let (column, claim) = suffix
82                .strip_prefix("field(")?
83                .strip_suffix(')')?
84                .split_once('=')?;
85            (is_column(column) && is_claim(claim)).then(|| Scope::Field {
86                column: column.to_string(),
87                claim: claim.to_string(),
88            })
89        }
90    }
91}
92
93fn is_column(s: &str) -> bool {
94    let mut chars = s.chars();
95    chars
96        .next()
97        .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
98        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
99}
100
101fn is_claim(s: &str) -> bool {
102    !s.is_empty()
103        && s.chars()
104            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':' | '-'))
105}
106
107/// Whether a permission (without its scope) grants `{area}:{operation}`:
108/// exactly, as `{area}:*`, or as `*`.
109pub fn covers(base: &str, area: &str, operation: &str) -> bool {
110    base == "*"
111        || base
112            .strip_prefix(area)
113            .and_then(|rest| rest.strip_prefix(':'))
114            .is_some_and(|granted| granted == operation || granted == "*")
115}
116
117impl Grant<'_> {
118    pub fn covers(&self, area: &str, operation: &str) -> bool {
119        covers(self.base, area, operation)
120    }
121}
122
123impl fmt::Display for Scope {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Scope::Own => f.write_str("own"),
127            Scope::All => f.write_str("all"),
128            Scope::Field { column, claim } => write!(f, "field({column}={claim})"),
129        }
130    }
131}
132
133/// A token claim by name: the claim of exactly that name, else the dotted
134/// path through nested objects (Keycloak nests a mapper named `org.id` as
135/// `{"org": {"id": …}}`).
136pub fn claim<'a>(claims: &'a HashMap<String, Value>, name: &str) -> Option<&'a Value> {
137    claims.get(name).or_else(|| {
138        let mut path = name.split('.');
139        let root = claims.get(path.next()?)?;
140        path.try_fold(root, |value, segment| value.get(segment))
141    })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148
149    /// The scope-suffix grammar: each string and its `(base, scope)`.
150    const GRAMMAR: &[(&str, Option<(&str, &str)>)] = &[
151        ("books:view", Some(("books:view", "own"))),
152        ("books:view@own", Some(("books:view", "own"))),
153        ("books:view@all", Some(("books:view", "all"))),
154        (
155            "books:view@field(branch_id=branch_id)",
156            Some(("books:view", "field(branch_id=branch_id)")),
157        ),
158        (
159            "books:*@field(_org2=realm_access.org:id-1)",
160            Some(("books:*", "field(_org2=realm_access.org:id-1)")),
161        ),
162        ("*@all", Some(("*", "all"))),
163        ("books:view@", None),
164        ("books:view@All", None),
165        ("books:view@own ", None),
166        ("books:view@all@all", None),
167        ("books:view@field()", None),
168        ("books:view@field(org=)", None),
169        ("books:view@field(=org)", None),
170        ("books:view@field(Org=org)", None),
171        ("books:view@field(1org=org)", None),
172        ("books:view@field(org=a b)", None),
173        ("books:view@field(org=org", None),
174        ("books:view@field(org=org))", None),
175        ("books:view@field(org=org=x)", None),
176        ("@all", None),
177    ];
178
179    #[test]
180    fn scope_suffix_grammar() {
181        for (input, expected) in GRAMMAR {
182            let parsed = parse(input)
183                .ok()
184                .map(|g| (g.base.to_string(), g.scope.to_string()));
185            let expected = expected.map(|(b, s)| (b.to_string(), s.to_string()));
186            assert_eq!(parsed, expected, "{input}");
187            assert_eq!(
188                parse_scoped(input).is_some(),
189                input.contains('@') && expected.is_some(),
190                "{input}"
191            );
192        }
193        assert_eq!(parse("@all"), Err(GrantError::EmptyBase));
194        assert_eq!(
195            parse("x@field(org=org").map(|g| g.scope),
196            Err(GrantError::InvalidScope("field(org=org".into()))
197        );
198        assert_eq!(
199            parse("x@field(a=b)").map(|g| g.scope),
200            Ok(Scope::Field {
201                column: "a".into(),
202                claim: "b".into()
203            })
204        );
205    }
206
207    #[test]
208    fn a_base_covers_its_operation_its_area_wildcard_or_everything() {
209        for (base, area, operation, expected) in [
210            ("books:view", "books", "view", true),
211            ("books:*", "books", "edit", true),
212            ("*", "anything", "at_all", true),
213            ("books:view", "books", "edit", false),
214            ("books", "books", "view", false),
215            ("books:view:x", "books", "view", false),
216            ("a:b:c", "a:b", "c", true),
217            ("a:b:c", "a", "b:c", true),
218            ("books:*", "books", "*", true),
219            ("booksx:view", "books", "view", false),
220            ("", "", "", false),
221            (":", "", "", true),
222        ] {
223            assert_eq!(
224                covers(base, area, operation),
225                expected,
226                "{base} {area}:{operation}"
227            );
228        }
229    }
230
231    #[test]
232    fn claims_resolve_by_name_then_by_dotted_path() {
233        let claims: HashMap<String, Value> = [
234            ("branch_id".to_string(), json!("org-1")),
235            ("org".to_string(), json!({"id": "org-2", "team": {"id": 7}})),
236            ("org.id".to_string(), json!("flat")),
237            ("tags".to_string(), json!(["a", "b"])),
238        ]
239        .into_iter()
240        .collect();
241        assert_eq!(claim(&claims, "branch_id"), Some(&json!("org-1")));
242        assert_eq!(claim(&claims, "org.id"), Some(&json!("flat")));
243        assert_eq!(claim(&claims, "org.team.id"), Some(&json!(7)));
244        assert_eq!(claim(&claims, "tags"), Some(&json!(["a", "b"])));
245        for missing in [
246            "org.missing",
247            "org.team.id.more",
248            "nope",
249            "nope.id",
250            "tags.0",
251        ] {
252            assert_eq!(claim(&claims, missing), None, "{missing}");
253        }
254    }
255}