Skip to main content

authz_core/
model_parser.rs

1//! Parser for the authorization model DSL.
2
3use crate::model_ast::{
4    AssignableTarget, ConditionDef, ConditionParam, ModelFile, RelationDef, RelationExpr, TypeDef,
5};
6use pest::Parser;
7use pest::iterators::Pair;
8use pest_derive::Parser;
9
10#[derive(Parser)]
11#[grammar = "model.pest"]
12pub struct ModelParser;
13
14/// Parses the authorization model DSL into an AST.
15pub fn parse_dsl(dsl: &str) -> Result<ModelFile, pest::error::Error<Rule>> {
16    let mut pairs = ModelParser::parse(Rule::file, dsl)?;
17    let file = pairs.next().expect("parser should return file root");
18    let mut type_defs = Vec::new();
19    let mut condition_defs = Vec::new();
20
21    for pair in file.into_inner() {
22        match pair.as_rule() {
23            Rule::type_def => type_defs.push(build_type_def(pair)?),
24            Rule::condition_def => condition_defs.push(build_condition_def(pair)?),
25            Rule::EOI => (),
26            _ => unreachable!("Unexpected rule: {:?}", pair.as_rule()),
27        }
28    }
29
30    Ok(ModelFile {
31        type_defs,
32        condition_defs,
33    })
34}
35
36fn build_type_def(pair: Pair<Rule>) -> Result<TypeDef, pest::error::Error<Rule>> {
37    let mut inner = pair.into_inner();
38    let name = inner.next().unwrap().as_str().to_string();
39    let mut relations = Vec::new();
40    let mut permissions = Vec::new();
41
42    for pair in inner {
43        match pair.as_rule() {
44            Rule::relations_block => relations = build_relations_block(pair)?,
45            Rule::permissions_block => permissions = build_permissions_block(pair)?,
46            _ => {}
47        }
48    }
49
50    Ok(TypeDef {
51        name,
52        relations,
53        permissions,
54    })
55}
56
57fn build_relations_block(pair: Pair<Rule>) -> Result<Vec<RelationDef>, pest::error::Error<Rule>> {
58    pair.into_inner().map(build_relation_def).collect()
59}
60
61fn build_permissions_block(pair: Pair<Rule>) -> Result<Vec<RelationDef>, pest::error::Error<Rule>> {
62    pair.into_inner().map(build_permission_def).collect()
63}
64
65fn build_relation_def(pair: Pair<Rule>) -> Result<RelationDef, pest::error::Error<Rule>> {
66    let mut inner = pair.into_inner();
67    let name = inner.next().unwrap().as_str().to_string();
68    let expression = build_relation_expr(inner.next().unwrap())?;
69    Ok(RelationDef { name, expression })
70}
71
72fn build_permission_def(pair: Pair<Rule>) -> Result<RelationDef, pest::error::Error<Rule>> {
73    let mut inner = pair.into_inner();
74    let name = inner.next().unwrap().as_str().to_string();
75    let expression = build_relation_expr(inner.next().unwrap())?;
76    Ok(RelationDef { name, expression })
77}
78
79fn build_relation_expr(pair: Pair<Rule>) -> Result<RelationExpr, pest::error::Error<Rule>> {
80    // relation_expr wraps exclusion_expr, so unwrap it first
81    let exclusion_pair = pair.into_inner().next().unwrap();
82    build_exclusion_expr(exclusion_pair)
83}
84
85fn build_union_expr(pair: Pair<Rule>) -> Result<RelationExpr, pest::error::Error<Rule>> {
86    let mut exprs = Vec::new();
87    for p in pair.into_inner() {
88        if p.as_rule() == Rule::primary_expr {
89            exprs.push(build_primary_expr(p)?);
90        }
91    }
92    if exprs.len() > 1 {
93        Ok(RelationExpr::Union(exprs))
94    } else {
95        Ok(exprs.pop().unwrap())
96    }
97}
98
99fn build_intersection_expr(pair: Pair<Rule>) -> Result<RelationExpr, pest::error::Error<Rule>> {
100    let mut exprs = Vec::new();
101    for p in pair.into_inner() {
102        if p.as_rule() == Rule::union_expr {
103            exprs.push(build_union_expr(p)?);
104        }
105    }
106    if exprs.len() > 1 {
107        Ok(RelationExpr::Intersection(exprs))
108    } else {
109        Ok(exprs.pop().unwrap())
110    }
111}
112
113fn build_exclusion_expr(pair: Pair<Rule>) -> Result<RelationExpr, pest::error::Error<Rule>> {
114    let mut inner = pair.into_inner();
115    let base = build_intersection_expr(inner.next().unwrap())?;
116    if let Some(subtract) = inner.next() {
117        Ok(RelationExpr::Exclusion {
118            base: Box::new(base),
119            subtract: Box::new(build_intersection_expr(subtract)?),
120        })
121    } else {
122        Ok(base)
123    }
124}
125
126fn build_primary_expr(pair: Pair<Rule>) -> Result<RelationExpr, pest::error::Error<Rule>> {
127    let inner = pair.into_inner().next().unwrap();
128    match inner.as_rule() {
129        Rule::computed_userset => Ok(RelationExpr::ComputedUserset(inner.as_str().to_string())),
130        Rule::tuple_to_userset => {
131            let mut parts = inner.into_inner();
132            let tupleset = parts.next().unwrap().as_str().to_string();
133            let computed_userset = parts.next().unwrap().as_str().to_string();
134            Ok(RelationExpr::TupleToUserset {
135                computed_userset,
136                tupleset,
137            })
138        }
139        Rule::direct_assignment => {
140            let targets = inner
141                .into_inner()
142                .map(build_assignable_target)
143                .collect::<Result<_, _>>()?;
144            Ok(RelationExpr::DirectAssignment(targets))
145        }
146        _ => unreachable!(),
147    }
148}
149
150fn build_assignable_target(pair: Pair<Rule>) -> Result<AssignableTarget, pest::error::Error<Rule>> {
151    let span = pair.as_span();
152    let text = span.as_str();
153    let mut inner = pair.into_inner();
154    let type_spec = inner.next().unwrap();
155    let type_name = type_spec.as_str().to_string();
156
157    // Check what comes after type_spec
158    // The grammar is: type_spec ~ "#" ~ identifier | type_spec ~ ":*" | type_spec ~ "with" ~ identifier | type_spec
159
160    // Check the original text to determine the variant
161    if text.ends_with(":*") {
162        // Wildcard: user:*
163        Ok(AssignableTarget::Wildcard(type_name))
164    } else if let Some(next) = inner.next() {
165        // We have a second token
166        if text.contains(" with ") {
167            // Conditional: user with condition_name
168            let condition = next.as_str().to_string();
169            Ok(AssignableTarget::Conditional {
170                target: Box::new(AssignableTarget::Type(type_name)),
171                condition,
172            })
173        } else {
174            // Userset: group#member
175            let relation = next.as_str().to_string();
176            Ok(AssignableTarget::Userset {
177                type_name,
178                relation,
179            })
180        }
181    } else {
182        // Just a plain type
183        Ok(AssignableTarget::Type(type_name))
184    }
185}
186
187fn build_condition_def(pair: Pair<Rule>) -> Result<ConditionDef, pest::error::Error<Rule>> {
188    let mut inner = pair.into_inner();
189    let name = inner.next().unwrap().as_str().to_string();
190    let mut params = Vec::new();
191    let mut expression = "".to_string();
192
193    for part in inner {
194        match part.as_rule() {
195            Rule::condition_param => params.push(build_condition_param(part)?),
196            Rule::condition_expr => expression = part.as_str().to_string(),
197            _ => (),
198        }
199    }
200
201    Ok(ConditionDef {
202        name,
203        params,
204        expression,
205    })
206}
207
208fn build_condition_param(pair: Pair<Rule>) -> Result<ConditionParam, pest::error::Error<Rule>> {
209    let mut inner = pair.into_inner();
210    let name = inner.next().unwrap().as_str().to_string();
211    let param_type = inner.next().unwrap().as_str().to_string();
212    Ok(ConditionParam { name, param_type })
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use pretty_assertions::assert_eq;
219
220    #[test]
221    fn test_parse_simple_type() {
222        let dsl = "type user {}";
223        let expected = ModelFile {
224            type_defs: vec![TypeDef {
225                name: "user".to_string(),
226                relations: vec![],
227                permissions: vec![],
228            }],
229            condition_defs: vec![],
230        };
231        assert_eq!(parse_dsl(dsl).unwrap(), expected);
232    }
233
234    #[test]
235    fn test_parse_type_with_relations() {
236        let dsl = r#"
237            type document {
238                relations
239                    define viewer: [user]
240                    define editor: [user | group#member]
241            }
242        "#;
243        let expected = ModelFile {
244            type_defs: vec![TypeDef {
245                name: "document".to_string(),
246                relations: vec![
247                    RelationDef {
248                        name: "viewer".to_string(),
249                        expression: RelationExpr::DirectAssignment(vec![AssignableTarget::Type(
250                            "user".to_string(),
251                        )]),
252                    },
253                    RelationDef {
254                        name: "editor".to_string(),
255                        expression: RelationExpr::DirectAssignment(vec![
256                            AssignableTarget::Type("user".to_string()),
257                            AssignableTarget::Userset {
258                                type_name: "group".to_string(),
259                                relation: "member".to_string(),
260                            },
261                        ]),
262                    },
263                ],
264                permissions: vec![],
265            }],
266            condition_defs: vec![],
267        };
268        assert_eq!(parse_dsl(dsl).unwrap(), expected);
269    }
270
271    #[test]
272    fn test_parse_computed_userset() {
273        let dsl = "type folder { relations define can_view: owner }";
274        let expected = ModelFile {
275            type_defs: vec![TypeDef {
276                name: "folder".to_string(),
277                relations: vec![RelationDef {
278                    name: "can_view".to_string(),
279                    expression: RelationExpr::ComputedUserset("owner".to_string()),
280                }],
281                permissions: vec![],
282            }],
283            condition_defs: vec![],
284        };
285        assert_eq!(parse_dsl(dsl).unwrap(), expected);
286    }
287
288    #[test]
289    fn test_parse_ttu() {
290        let dsl = "type document { relations define viewer: parent->viewer }";
291        let expected = ModelFile {
292            type_defs: vec![TypeDef {
293                name: "document".to_string(),
294                relations: vec![RelationDef {
295                    name: "viewer".to_string(),
296                    expression: RelationExpr::TupleToUserset {
297                        computed_userset: "viewer".to_string(),
298                        tupleset: "parent".to_string(),
299                    },
300                }],
301                permissions: vec![],
302            }],
303            condition_defs: vec![],
304        };
305        assert_eq!(parse_dsl(dsl).unwrap(), expected);
306    }
307
308    #[test]
309    fn test_parse_union() {
310        let dsl = "type document { relations define viewer: [user] + editor }";
311        let expected = RelationExpr::Union(vec![
312            RelationExpr::DirectAssignment(vec![AssignableTarget::Type("user".to_string())]),
313            RelationExpr::ComputedUserset("editor".to_string()),
314        ]);
315        let model = parse_dsl(dsl).unwrap();
316        assert_eq!(model.type_defs[0].relations[0].expression, expected);
317    }
318
319    #[test]
320    fn test_parse_whitespace_only() {
321        let whitespace_model = "   \n\t   ";
322        let result = parse_dsl(whitespace_model);
323        assert!(
324            result.is_ok(),
325            "Whitespace-only model should parse successfully as empty model"
326        );
327
328        let model = result.unwrap();
329        assert_eq!(
330            model.type_defs.len(),
331            0,
332            "Empty model should have no type definitions"
333        );
334        assert_eq!(
335            model.condition_defs.len(),
336            0,
337            "Empty model should have no condition definitions"
338        );
339    }
340
341    #[test]
342    fn test_parse_comment_only() {
343        let comment_model = "// This is just a comment\n/* Another comment */";
344        let result = parse_dsl(comment_model);
345        assert!(
346            result.is_ok(),
347            "Comment-only model should parse successfully as empty model"
348        );
349
350        let model = result.unwrap();
351        assert_eq!(
352            model.type_defs.len(),
353            0,
354            "Comment-only model should have no type definitions"
355        );
356        assert_eq!(
357            model.condition_defs.len(),
358            0,
359            "Comment-only model should have no condition definitions"
360        );
361    }
362
363    #[test]
364    fn test_parse_invalid_syntax() {
365        let invalid_model = "type user { relations define viewer: [ }";
366        let result = parse_dsl(invalid_model);
367        assert!(result.is_err(), "Invalid syntax should fail to parse");
368    }
369
370    #[test]
371    fn test_parse_condition() {
372        let dsl = r#"
373            condition ip_check(allowed_cidrs: list<string>, request_ip: string) {
374                request_ip in allowed_cidrs
375            }
376        "#;
377        let expected = ModelFile {
378            type_defs: vec![],
379            condition_defs: vec![ConditionDef {
380                name: "ip_check".to_string(),
381                params: vec![
382                    ConditionParam {
383                        name: "allowed_cidrs".to_string(),
384                        param_type: "list<string>".to_string(),
385                    },
386                    ConditionParam {
387                        name: "request_ip".to_string(),
388                        param_type: "string".to_string(),
389                    },
390                ],
391                expression: "request_ip in allowed_cidrs".to_string(),
392            }],
393        };
394        assert_eq!(parse_dsl(dsl).unwrap(), expected);
395    }
396
397    #[test]
398    fn test_parse_intersection() {
399        let dsl = "type document { relations define viewer: [user] & editor }";
400        let expected = RelationExpr::Intersection(vec![
401            RelationExpr::DirectAssignment(vec![AssignableTarget::Type("user".to_string())]),
402            RelationExpr::ComputedUserset("editor".to_string()),
403        ]);
404        let model = parse_dsl(dsl).unwrap();
405        assert_eq!(model.type_defs[0].relations[0].expression, expected);
406    }
407
408    #[test]
409    fn test_parse_exclusion() {
410        let dsl = "type document { relations define viewer: [user] - banned }";
411        let expected = RelationExpr::Exclusion {
412            base: Box::new(RelationExpr::DirectAssignment(vec![
413                AssignableTarget::Type("user".to_string()),
414            ])),
415            subtract: Box::new(RelationExpr::ComputedUserset("banned".to_string())),
416        };
417        let model = parse_dsl(dsl).unwrap();
418        assert_eq!(model.type_defs[0].relations[0].expression, expected);
419    }
420
421    #[test]
422    fn test_parse_nested_set_ops() {
423        // Test union with exclusion.
424        // With arrow syntax precedence, union binds tighter than exclusion.
425        // So: [user] + editor - banned
426        // Parses as: ([user] + editor) - banned
427        let dsl = "type document { relations define viewer: [user] + editor - banned }";
428        let model = parse_dsl(dsl).unwrap();
429
430        // Should be: Exclusion(Union([DirectAssignment([user]), ComputedUserset(editor)]), ComputedUserset(banned))
431        match &model.type_defs[0].relations[0].expression {
432            RelationExpr::Exclusion { base, subtract } => {
433                match &**base {
434                    RelationExpr::Union(exprs) => {
435                        assert_eq!(exprs.len(), 2);
436                        assert!(matches!(exprs[0], RelationExpr::DirectAssignment(_)));
437                        assert!(matches!(exprs[1], RelationExpr::ComputedUserset(_)));
438                    }
439                    _ => panic!("Expected Union expression"),
440                }
441                assert!(matches!(**subtract, RelationExpr::ComputedUserset(_)));
442            }
443            _ => panic!("Expected Exclusion expression"),
444        }
445    }
446
447    #[test]
448    fn test_parse_wildcard() {
449        let dsl = "type document { relations define viewer: [user:*] }";
450        let expected = ModelFile {
451            type_defs: vec![TypeDef {
452                name: "document".to_string(),
453                relations: vec![RelationDef {
454                    name: "viewer".to_string(),
455                    expression: RelationExpr::DirectAssignment(vec![AssignableTarget::Wildcard(
456                        "user".to_string(),
457                    )]),
458                }],
459                permissions: vec![],
460            }],
461            condition_defs: vec![],
462        };
463        assert_eq!(parse_dsl(dsl).unwrap(), expected);
464    }
465
466    #[test]
467    fn test_parse_conditional_type() {
468        let dsl = "type document { relations define viewer: [user with ip_check] }";
469        let expected = ModelFile {
470            type_defs: vec![TypeDef {
471                name: "document".to_string(),
472                relations: vec![RelationDef {
473                    name: "viewer".to_string(),
474                    expression: RelationExpr::DirectAssignment(vec![
475                        AssignableTarget::Conditional {
476                            target: Box::new(AssignableTarget::Type("user".to_string())),
477                            condition: "ip_check".to_string(),
478                        },
479                    ]),
480                }],
481                permissions: vec![],
482            }],
483            condition_defs: vec![],
484        };
485        assert_eq!(parse_dsl(dsl).unwrap(), expected);
486    }
487
488    #[test]
489    fn test_parse_multiple_types() {
490        let dsl = r#"
491            type user {}
492            type document {
493                relations
494                    define viewer: [user]
495            }
496            type folder {
497                relations
498                    define parent: [folder]
499            }
500        "#;
501        let model = parse_dsl(dsl).unwrap();
502        assert_eq!(model.type_defs.len(), 3);
503        assert_eq!(model.type_defs[0].name, "user");
504        assert_eq!(model.type_defs[1].name, "document");
505        assert_eq!(model.type_defs[2].name, "folder");
506    }
507
508    #[test]
509    fn test_parse_complex_real_world() {
510        // Google Drive-like schema with 10+ relations
511        let dsl = r#"
512            type user {}
513            
514            type organization {
515                relations
516                    define member: [user]
517                    define admin: [user]
518            }
519            
520            type folder {
521                relations
522                    define parent: [folder]
523                    define owner: [user]
524                    define editor: [user | organization#member]
525                    define viewer: [user | organization#member]
526                    define can_view: viewer + editor + owner
527                    define can_edit: editor + owner
528                    define can_delete: owner
529                    define can_share: owner
530            }
531            
532            type document {
533                relations
534                    define parent: [folder]
535                    define owner: [user]
536                    define editor: [user | group#member | team#member]
537                    define viewer: [user | group#member]
538                    define can_view: viewer + editor + owner + parent->can_view
539                    define can_edit: editor + owner + parent->can_edit
540                    define can_delete: owner
541                    define can_comment: can_view
542            }
543        "#;
544        let model = parse_dsl(dsl).unwrap();
545        assert_eq!(model.type_defs.len(), 4);
546
547        // Verify folder has 8 relations
548        let folder = model.type_defs.iter().find(|t| t.name == "folder").unwrap();
549        assert_eq!(folder.relations.len(), 8);
550
551        // Verify document has 8 relations
552        let document = model
553            .type_defs
554            .iter()
555            .find(|t| t.name == "document")
556            .unwrap();
557        assert_eq!(document.relations.len(), 8);
558    }
559
560    #[test]
561    fn test_parse_empty_string() {
562        let dsl = "";
563        let result = parse_dsl(dsl).unwrap();
564        assert_eq!(result.type_defs.len(), 0);
565        assert_eq!(result.condition_defs.len(), 0);
566    }
567}
568
569#[test]
570fn test_parse_mixed_precedence_first_and_second_plus_third() {
571    // Test: first & second + third
572    // According to grammar: union binds tighter than intersection
573    // Should parse as: first & (second + third)
574    let dsl = "type document {
575      relations
576        define first: [user]
577        define second: [user]
578        define third: [user]
579      permissions
580        define mixed_precedence2 = first & second + third
581    }";
582
583    let model = parse_dsl(dsl).unwrap();
584
585    // Should be: Intersection([ComputedUserset(first)], Union([ComputedUserset(second), ComputedUserset(third)]))
586    match &model.type_defs[0].permissions[0].expression {
587        RelationExpr::Intersection(exprs) => {
588            assert_eq!(exprs.len(), 2);
589            assert!(matches!(&exprs[0], RelationExpr::ComputedUserset(name) if name == "first"));
590            match &exprs[1] {
591                RelationExpr::Union(union_exprs) => {
592                    assert_eq!(union_exprs.len(), 2);
593                    assert!(
594                        matches!(&union_exprs[0], RelationExpr::ComputedUserset(name) if name == "second")
595                    );
596                    assert!(
597                        matches!(&union_exprs[1], RelationExpr::ComputedUserset(name) if name == "third")
598                    );
599                }
600                _ => panic!("Expected Union expression as second operand of Intersection"),
601            }
602        }
603        _ => panic!(
604            "Expected Intersection expression, got: {:?}",
605            model.type_defs[0].permissions[0].expression
606        ),
607    }
608}