1use serde_json::Value;
12use std::collections::HashMap;
13use std::fmt;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum Scope {
18 Own,
20 Field { column: String, claim: String },
22 All,
24}
25
26#[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
70pub 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
107pub 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
133pub 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 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}