Skip to main content

authz_core/
model_validator.rs

1//! Model semantic validation — checks for duplicate types, undefined relations, cycles, etc.
2
3use crate::model_ast::{AssignableTarget, ModelFile, RelationExpr};
4use std::collections::{HashMap, HashSet};
5
6#[derive(Clone, Copy, PartialEq, Eq)]
7enum VisitState {
8    Unvisited,
9    Visiting,
10    Visited,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ValidationCategory {
15    DuplicateType,
16    DuplicateRelation,
17    UndefinedRelation,
18    UndefinedType,
19    UndefinedCondition,
20    CycleDetected,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ValidationError {
25    pub category: ValidationCategory,
26    pub message: String,
27}
28
29fn check_computed_userset_cycles(model: &ModelFile, errors: &mut Vec<ValidationError>) {
30    for type_def in &model.type_defs {
31        let graph = build_relation_dependency_graph(type_def);
32
33        let mut state: HashMap<String, VisitState> = graph
34            .keys()
35            .map(|k| (k.clone(), VisitState::Unvisited))
36            .collect();
37
38        for relation in graph.keys() {
39            if state.get(relation) == Some(&VisitState::Unvisited) {
40                let mut stack = Vec::new();
41                detect_cycle_dfs(type_def, relation, &graph, &mut state, &mut stack, errors);
42            }
43        }
44    }
45}
46
47fn build_relation_dependency_graph(
48    type_def: &crate::model_ast::TypeDef,
49) -> HashMap<String, Vec<String>> {
50    let mut graph = HashMap::new();
51
52    for relation in &type_def.relations {
53        let deps = extract_computed_userset_dependencies(&relation.expression);
54        graph.insert(relation.name.clone(), deps);
55    }
56
57    graph
58}
59
60fn extract_computed_userset_dependencies(expr: &RelationExpr) -> Vec<String> {
61    match expr {
62        RelationExpr::ComputedUserset(relation_name) => vec![relation_name.clone()],
63        RelationExpr::Union(exprs) | RelationExpr::Intersection(exprs) => exprs
64            .iter()
65            .flat_map(extract_computed_userset_dependencies)
66            .collect(),
67        RelationExpr::Exclusion { base, subtract } => {
68            let mut deps = extract_computed_userset_dependencies(base);
69            deps.extend(extract_computed_userset_dependencies(subtract));
70            deps
71        }
72        RelationExpr::DirectAssignment(_) | RelationExpr::TupleToUserset { .. } => Vec::new(),
73    }
74}
75
76fn detect_cycle_dfs(
77    type_def: &crate::model_ast::TypeDef,
78    relation: &str,
79    graph: &HashMap<String, Vec<String>>,
80    state: &mut HashMap<String, VisitState>,
81    stack: &mut Vec<String>,
82    errors: &mut Vec<ValidationError>,
83) {
84    state.insert(relation.to_string(), VisitState::Visiting);
85    stack.push(relation.to_string());
86
87    if let Some(neighbors) = graph.get(relation) {
88        for next in neighbors {
89            // Ignore unresolved relation names here; undefined relation checks handle them.
90            if !graph.contains_key(next) {
91                continue;
92            }
93
94            let next_state = state.get(next).copied();
95            if next_state == Some(VisitState::Visiting) {
96                if let Some(start_idx) = stack.iter().position(|r| r == next) {
97                    let mut cycle_nodes = stack[start_idx..].to_vec();
98                    cycle_nodes.push(next.clone());
99                    let cycle_path = cycle_nodes
100                        .iter()
101                        .map(|r| format!("{}#{}", type_def.name, r))
102                        .collect::<Vec<_>>()
103                        .join(" -> ");
104                    errors.push(ValidationError::new(
105                        ValidationCategory::CycleDetected,
106                        format!("Cycle detected in computed usersets: {}", cycle_path),
107                    ));
108                }
109                continue;
110            }
111
112            if next_state != Some(VisitState::Visited) {
113                detect_cycle_dfs(type_def, next, graph, state, stack, errors);
114            }
115        }
116    }
117
118    stack.pop();
119    state.insert(relation.to_string(), VisitState::Visited);
120}
121
122impl ValidationError {
123    fn new(category: ValidationCategory, message: impl Into<String>) -> Self {
124        Self {
125            category,
126            message: message.into(),
127        }
128    }
129}
130
131/// Validate a parsed model for semantic correctness.
132/// Returns Ok(()) if valid, or Err with a list of validation errors.
133pub fn validate_model(model: &ModelFile) -> Result<(), Vec<ValidationError>> {
134    let mut errors = Vec::new();
135
136    // Check for duplicate type names
137    check_duplicate_types(model, &mut errors);
138
139    // Check for duplicate relation names within each type
140    check_duplicate_relations(model, &mut errors);
141
142    // Check for undefined relations in computed usersets and TTU
143    check_undefined_relations(model, &mut errors);
144
145    // Check for undefined types in direct assignments
146    check_undefined_types(model, &mut errors);
147
148    // Check for undefined condition references
149    check_undefined_conditions(model, &mut errors);
150
151    // Check for cycles in computed usersets
152    check_computed_userset_cycles(model, &mut errors);
153
154    if errors.is_empty() {
155        Ok(())
156    } else {
157        Err(errors)
158    }
159}
160
161fn check_duplicate_types(model: &ModelFile, errors: &mut Vec<ValidationError>) {
162    let mut seen = HashSet::new();
163    for type_def in &model.type_defs {
164        if !seen.insert(&type_def.name) {
165            errors.push(ValidationError::new(
166                ValidationCategory::DuplicateType,
167                format!("Duplicate type definition: '{}'", type_def.name),
168            ));
169        }
170    }
171}
172
173fn check_duplicate_relations(model: &ModelFile, errors: &mut Vec<ValidationError>) {
174    for type_def in &model.type_defs {
175        let mut seen = HashSet::new();
176        for relation in &type_def.relations {
177            if !seen.insert(&relation.name) {
178                errors.push(ValidationError::new(
179                    ValidationCategory::DuplicateRelation,
180                    format!(
181                        "Duplicate relation '{}' in type '{}'",
182                        relation.name, type_def.name
183                    ),
184                ));
185            }
186        }
187    }
188}
189
190fn check_undefined_relations(model: &ModelFile, errors: &mut Vec<ValidationError>) {
191    // Build a map of type -> set of relation names
192    let type_relations: HashMap<&str, HashSet<&str>> = model
193        .type_defs
194        .iter()
195        .map(|t| {
196            let relations: HashSet<&str> = t.relations.iter().map(|r| r.name.as_str()).collect();
197            (t.name.as_str(), relations)
198        })
199        .collect();
200
201    for type_def in &model.type_defs {
202        for relation in &type_def.relations {
203            check_expr_for_undefined_relations(
204                &relation.expression,
205                &type_def.name,
206                &type_relations,
207                errors,
208            );
209        }
210    }
211}
212
213fn check_expr_for_undefined_relations(
214    expr: &RelationExpr,
215    current_type: &str,
216    type_relations: &HashMap<&str, HashSet<&str>>,
217    errors: &mut Vec<ValidationError>,
218) {
219    match expr {
220        RelationExpr::ComputedUserset(relation_name) => {
221            if let Some(relations) = type_relations.get(current_type)
222                && !relations.contains(relation_name.as_str())
223            {
224                errors.push(ValidationError::new(
225                    ValidationCategory::UndefinedRelation,
226                    format!(
227                        "Undefined relation '{}' in computed userset for type '{}'",
228                        relation_name, current_type
229                    ),
230                ));
231            }
232        }
233        RelationExpr::TupleToUserset {
234            computed_userset: _,
235            tupleset,
236        } => {
237            // Check that tupleset relation exists on current type
238            if let Some(relations) = type_relations.get(current_type)
239                && !relations.contains(tupleset.as_str())
240            {
241                errors.push(ValidationError::new(
242                    ValidationCategory::UndefinedRelation,
243                    format!(
244                        "Undefined relation '{}' in tuple-to-userset tupleset for type '{}'",
245                        tupleset, current_type
246                    ),
247                ));
248            }
249            // Note: We can't validate computed_userset here without knowing the target type
250            // That would require full type resolution (Phase 3)
251        }
252        RelationExpr::Union(exprs) | RelationExpr::Intersection(exprs) => {
253            for e in exprs {
254                check_expr_for_undefined_relations(e, current_type, type_relations, errors);
255            }
256        }
257        RelationExpr::Exclusion { base, subtract } => {
258            check_expr_for_undefined_relations(base, current_type, type_relations, errors);
259            check_expr_for_undefined_relations(subtract, current_type, type_relations, errors);
260        }
261        RelationExpr::DirectAssignment(_) => {
262            // Handled by check_undefined_types
263        }
264    }
265}
266
267fn check_undefined_types(model: &ModelFile, errors: &mut Vec<ValidationError>) {
268    let type_names: HashSet<&str> = model.type_defs.iter().map(|t| t.name.as_str()).collect();
269
270    for type_def in &model.type_defs {
271        for relation in &type_def.relations {
272            check_expr_for_undefined_types(
273                &relation.expression,
274                &type_names,
275                &type_def.name,
276                &relation.name,
277                errors,
278            );
279        }
280    }
281}
282
283fn check_expr_for_undefined_types(
284    expr: &RelationExpr,
285    type_names: &HashSet<&str>,
286    current_type: &str,
287    relation_name: &str,
288    errors: &mut Vec<ValidationError>,
289) {
290    match expr {
291        RelationExpr::DirectAssignment(targets) => {
292            for target in targets {
293                check_target_for_undefined_types(
294                    target,
295                    type_names,
296                    current_type,
297                    relation_name,
298                    errors,
299                );
300            }
301        }
302        RelationExpr::Union(exprs) | RelationExpr::Intersection(exprs) => {
303            for e in exprs {
304                check_expr_for_undefined_types(e, type_names, current_type, relation_name, errors);
305            }
306        }
307        RelationExpr::Exclusion { base, subtract } => {
308            check_expr_for_undefined_types(base, type_names, current_type, relation_name, errors);
309            check_expr_for_undefined_types(
310                subtract,
311                type_names,
312                current_type,
313                relation_name,
314                errors,
315            );
316        }
317        RelationExpr::ComputedUserset(_) | RelationExpr::TupleToUserset { .. } => {
318            // No type references to check
319        }
320    }
321}
322
323fn check_target_for_undefined_types(
324    target: &AssignableTarget,
325    type_names: &HashSet<&str>,
326    current_type: &str,
327    relation_name: &str,
328    errors: &mut Vec<ValidationError>,
329) {
330    let is_known_type = |type_name: &str| type_name == "user" || type_names.contains(type_name);
331
332    match target {
333        AssignableTarget::Type(type_name) | AssignableTarget::Wildcard(type_name) => {
334            if !is_known_type(type_name.as_str()) {
335                errors.push(ValidationError::new(
336                    ValidationCategory::UndefinedType,
337                    format!(
338                        "Undefined type '{}' in relation '{}' of type '{}'",
339                        type_name, relation_name, current_type
340                    ),
341                ));
342            }
343        }
344        AssignableTarget::Userset { type_name, .. } => {
345            if !is_known_type(type_name.as_str()) {
346                errors.push(ValidationError::new(
347                    ValidationCategory::UndefinedType,
348                    format!(
349                        "Undefined type '{}' in userset for relation '{}' of type '{}'",
350                        type_name, relation_name, current_type
351                    ),
352                ));
353            }
354        }
355        AssignableTarget::Conditional { target, .. } => {
356            check_target_for_undefined_types(
357                target,
358                type_names,
359                current_type,
360                relation_name,
361                errors,
362            );
363        }
364    }
365}
366
367fn check_undefined_conditions(model: &ModelFile, errors: &mut Vec<ValidationError>) {
368    let condition_names: HashSet<&str> = model
369        .condition_defs
370        .iter()
371        .map(|c| c.name.as_str())
372        .collect();
373
374    for type_def in &model.type_defs {
375        for relation in &type_def.relations {
376            check_expr_for_undefined_conditions(
377                &relation.expression,
378                &condition_names,
379                &type_def.name,
380                &relation.name,
381                errors,
382            );
383        }
384    }
385}
386
387fn check_expr_for_undefined_conditions(
388    expr: &RelationExpr,
389    condition_names: &HashSet<&str>,
390    current_type: &str,
391    relation_name: &str,
392    errors: &mut Vec<ValidationError>,
393) {
394    match expr {
395        RelationExpr::DirectAssignment(targets) => {
396            for target in targets {
397                check_target_for_undefined_conditions(
398                    target,
399                    condition_names,
400                    current_type,
401                    relation_name,
402                    errors,
403                );
404            }
405        }
406        RelationExpr::Union(exprs) | RelationExpr::Intersection(exprs) => {
407            for e in exprs {
408                check_expr_for_undefined_conditions(
409                    e,
410                    condition_names,
411                    current_type,
412                    relation_name,
413                    errors,
414                );
415            }
416        }
417        RelationExpr::Exclusion { base, subtract } => {
418            check_expr_for_undefined_conditions(
419                base,
420                condition_names,
421                current_type,
422                relation_name,
423                errors,
424            );
425            check_expr_for_undefined_conditions(
426                subtract,
427                condition_names,
428                current_type,
429                relation_name,
430                errors,
431            );
432        }
433        RelationExpr::ComputedUserset(_) | RelationExpr::TupleToUserset { .. } => {
434            // No condition references
435        }
436    }
437}
438
439fn check_target_for_undefined_conditions(
440    target: &AssignableTarget,
441    condition_names: &HashSet<&str>,
442    current_type: &str,
443    relation_name: &str,
444    errors: &mut Vec<ValidationError>,
445) {
446    if let AssignableTarget::Conditional { target, condition } = target {
447        if !condition_names.contains(condition.as_str()) {
448            errors.push(ValidationError::new(
449                ValidationCategory::UndefinedCondition,
450                format!(
451                    "Undefined condition '{}' in relation '{}' of type '{}'",
452                    condition, relation_name, current_type
453                ),
454            ));
455        }
456        // Recursively check nested target
457        check_target_for_undefined_conditions(
458            target,
459            condition_names,
460            current_type,
461            relation_name,
462            errors,
463        );
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::model_parser::parse_dsl;
471
472    #[test]
473    fn test_reject_duplicate_type_names() {
474        let dsl = r#"
475            type user {}
476            type user {}
477        "#;
478        let model = parse_dsl(dsl).unwrap();
479        let result = validate_model(&model);
480        assert!(result.is_err());
481        let errors = result.unwrap_err();
482        assert_eq!(errors.len(), 1);
483        assert!(
484            errors[0]
485                .message
486                .contains("Duplicate type definition: 'user'")
487        );
488    }
489
490    #[test]
491    fn test_reject_undefined_relation_in_computed_userset() {
492        let dsl = r#"
493            type document {
494                relations
495                    define viewer: editors
496            }
497        "#;
498        let model = parse_dsl(dsl).unwrap();
499        let result = validate_model(&model);
500        assert!(result.is_err());
501        let errors = result.unwrap_err();
502        assert!(
503            errors
504                .iter()
505                .any(|e| e.message.contains("Undefined relation 'editors'"))
506        );
507    }
508
509    #[test]
510    fn test_reject_undefined_relation_in_ttu() {
511        let dsl = r#"
512            type document {
513                relations
514                    define viewer: parents->viewer
515            }
516        "#;
517        let model = parse_dsl(dsl).unwrap();
518        let result = validate_model(&model);
519        assert!(result.is_err());
520        let errors = result.unwrap_err();
521        assert!(
522            errors
523                .iter()
524                .any(|e| e.message.contains("Undefined relation 'parents'"))
525        );
526    }
527
528    #[test]
529    fn test_reject_undefined_type_in_direct_assignment() {
530        let dsl = r#"
531            type document {
532                relations
533                    define viewer: [nonexistent]
534            }
535        "#;
536        let model = parse_dsl(dsl).unwrap();
537        let result = validate_model(&model);
538        assert!(result.is_err());
539        let errors = result.unwrap_err();
540        assert!(
541            errors
542                .iter()
543                .any(|e| e.message.contains("Undefined type 'nonexistent'"))
544        );
545    }
546
547    #[test]
548    fn test_reject_invalid_condition_reference() {
549        let dsl = r#"
550            type document {
551                relations
552                    define viewer: [user with unknown_cond]
553            }
554        "#;
555        let model = parse_dsl(dsl).unwrap();
556        let result = validate_model(&model);
557        assert!(result.is_err());
558        let errors = result.unwrap_err();
559        assert!(
560            errors
561                .iter()
562                .any(|e| e.message.contains("Undefined condition 'unknown_cond'"))
563        );
564    }
565
566    #[test]
567    fn test_reject_duplicate_relation_names() {
568        let dsl = r#"
569            type document {
570                relations
571                    define viewer: [user]
572                    define viewer: [user]
573            }
574        "#;
575        let model = parse_dsl(dsl).unwrap();
576        let result = validate_model(&model);
577        assert!(result.is_err());
578        let errors = result.unwrap_err();
579        assert!(
580            errors
581                .iter()
582                .any(|e| e.message.contains("Duplicate relation 'viewer'"))
583        );
584    }
585
586    #[test]
587    fn test_valid_complex_model_passes() {
588        let dsl = r#"
589            type user {}
590            
591            type folder {
592                relations
593                    define parent: [folder]
594                    define owner: [user]
595                    define viewer: [user]
596            }
597            
598            type document {
599                relations
600                    define parent: [folder]
601                    define owner: [user]
602                    define viewer: [user]
603                    define can_view: viewer + owner + parent->can_view
604            }
605            
606            condition ip_check(ip: string) {
607                ip == "127.0.0.1"
608            }
609        "#;
610        let model = parse_dsl(dsl).unwrap();
611        let result = validate_model(&model);
612        assert!(
613            result.is_ok(),
614            "Expected valid model to pass, got: {:?}",
615            result
616        );
617    }
618
619    #[test]
620    fn test_reject_self_cycle_in_computed_userset() {
621        let dsl = r#"
622            type document {
623                relations
624                    define viewer: viewer
625            }
626        "#;
627        let model = parse_dsl(dsl).unwrap();
628        let result = validate_model(&model);
629        assert!(result.is_err());
630        let errors = result.unwrap_err();
631        assert!(
632            errors
633                .iter()
634                .any(|e| e.message.contains("Cycle detected in computed usersets"))
635        );
636    }
637
638    #[test]
639    fn test_reject_two_node_cycle_in_computed_userset() {
640        let dsl = r#"
641            type document {
642                relations
643                    define viewer: editor
644                    define editor: viewer
645            }
646        "#;
647        let model = parse_dsl(dsl).unwrap();
648        let result = validate_model(&model);
649        assert!(result.is_err());
650        let errors = result.unwrap_err();
651        assert!(
652            errors
653                .iter()
654                .any(|e| e.message.contains("Cycle detected in computed usersets"))
655        );
656    }
657
658    #[test]
659    fn test_accept_acyclic_computed_userset_chain() {
660        let dsl = r#"
661            type document {
662                relations
663                    define owner: [user]
664                    define editor: owner
665                    define viewer: editor
666            }
667
668            type user {}
669        "#;
670        let model = parse_dsl(dsl).unwrap();
671        let result = validate_model(&model);
672        assert!(
673            result.is_ok(),
674            "Expected acyclic model to pass, got: {:?}",
675            result
676        );
677    }
678}