diff --git a/docs/validation/model/README.md b/docs/validation/model/README.md index 159cb380..41ae045d 100644 --- a/docs/validation/model/README.md +++ b/docs/validation/model/README.md @@ -1,6 +1,6 @@ # OpenFGA Model Validation Errors -This directory contains comprehensive documentation for all OpenFGA model validation errors. These error codes and messages are consistent across Go, JavaScript, and Java implementations to provide a unified validation experience. +This directory documents the OpenFGA model validation errors. ## Overview @@ -21,31 +21,23 @@ OpenFGA model validation ensures that authorization models are syntactically cor |------------|------------|---------|---------------| | `schema-version-required` | Schema | Schema version must be specified | [schema-version-required.md](./schema-version-required.md) | | `schema-version-unsupported` | Schema | Unsupported schema version | [schema-version-unsupported.md](./schema-version-unsupported.md) | -| `invalid-schema-version` | Schema | Invalid schema version format | [invalid-schema-version.md](./invalid-schema-version.md) | +| `invalid-schema` | Schema | Unsupported or unrecognized schema version | [invalid-schema.md](./invalid-schema.md) | | `reserved-type-keywords` | Naming | Type name uses reserved keyword | [reserved-type-keywords.md](./reserved-type-keywords.md) | | `reserved-relation-keywords` | Naming | Relation name uses reserved keyword | [reserved-relation-keywords.md](./reserved-relation-keywords.md) | -| `self-error` | Naming | Invalid use of 'self' or 'this' | [self-error.md](./self-error.md) | | `invalid-name` | Naming | Invalid type or relation name format | [invalid-name.md](./invalid-name.md) | | `duplicated-error` | Structure | Duplicate type or relation definition | [duplicated-error.md](./duplicated-error.md) | | `missing-definition` | Semantic | Referenced type or relation not defined | [missing-definition.md](./missing-definition.md) | -| `undefined-type` | Semantic | Type is referenced but not defined | [undefined-type.md](./undefined-type.md) | -| `undefined-relation` | Semantic | Relation is referenced but not defined | [undefined-relation.md](./undefined-relation.md) | | `invalid-relation-type` | Semantic | Invalid relation type in reference | [invalid-relation-type.md](./invalid-relation-type.md) | | `invalid-type` | Semantic | Invalid type in relation definition | [invalid-type.md](./invalid-type.md) | | `relation-no-entry-point` | Semantic | Relation has no entry point for assignment | [relation-no-entry-point.md](./relation-no-entry-point.md) | -| `cyclic-error` | Semantic | Circular dependency in relations | [cyclic-error.md](./cyclic-error.md) | -| `cyclic-relation` | Semantic | Circular relation dependency detected | [cyclic-relation.md](./cyclic-relation.md) | | `invalid-relation-on-tupleset` | Structure | Invalid relation in tuple-to-userset | [invalid-relation-on-tupleset.md](./invalid-relation-on-tupleset.md) | | `tupleuserset-not-direct` | Structure | Tuple-to-userset must have direct assignment | [tupleuserset-not-direct.md](./tupleuserset-not-direct.md) | -| `invalid-wildcard-error` | Wildcard | Invalid wildcard usage in relation | [invalid-wildcard-error.md](./invalid-wildcard-error.md) | | `assignable-relation-must-have-type` | Wildcard | Assignable relation must specify type | [assignable-relation-must-have-type.md](./assignable-relation-must-have-type.md) | | `type-wildcard-relation` | Wildcard | Type restriction cannot have wildcard and relation | [type-wildcard-relation.md](./type-wildcard-relation.md) | | `condition-not-defined` | Condition | Referenced condition is not defined | [condition-not-defined.md](./condition-not-defined.md) | | `condition-not-used` | Condition | Defined condition is never used | [condition-not-used.md](./condition-not-used.md) | | `different-nested-condition-name` | Condition | Condition name mismatch in nested structure | [different-nested-condition-name.md](./different-nested-condition-name.md) | | `multiple-modules-in-file` | Multi-file | Multiple modules detected in single file | [multiple-modules-in-file.md](./multiple-modules-in-file.md) | -| `invalid-schema` | Schema | Invalid schema structure | [invalid-schema.md](./invalid-schema.md) | -| `invalid-syntax` | Syntax | Invalid DSL syntax | [invalid-syntax.md](./invalid-syntax.md) | ## Usage @@ -59,14 +51,12 @@ Each error documentation includes: ## Implementation Notes -These validation errors are implemented consistently across: +These validation errors are implemented in: - **Go**: `pkg/go/validation/` package - **JavaScript**: `pkg/js/validator/` package - **Java**: Java validation implementation -Error codes, messages, and validation logic are synchronized to ensure identical behavior across all language implementations. - ## Contributing When adding new validation rules: diff --git a/docs/validation/model/TROUBLESHOOTING_GUIDE.md b/docs/validation/model/TROUBLESHOOTING_GUIDE.md index 54752850..65409a04 100644 --- a/docs/validation/model/TROUBLESHOOTING_GUIDE.md +++ b/docs/validation/model/TROUBLESHOOTING_GUIDE.md @@ -9,8 +9,8 @@ This guide provides quick solutions to common OpenFGA validation errors. For det | Error | Quick Fix | Link | |-------|-----------|------| | `schema-version-required` | Add `schema 1.1` after `model` | [Details](./schema-version-required.md) | -| `undefined-relation` | Define the missing relation or fix typo | [Details](./undefined-relation.md) | -| `undefined-type` | Define the missing type or fix typo | [Details](./undefined-type.md) | +| `missing-definition` | Define the missing relation or fix typo | [Details](./missing-definition.md) | +| `invalid-type` | Define the missing type or fix typo | [Details](./invalid-type.md) | | `invalid-name` | Use lowercase with underscores only | [Details](./invalid-name.md) | | `duplicated-error` | Remove or rename duplicate definitions | [Details](./duplicated-error.md) | @@ -18,17 +18,14 @@ This guide provides quick solutions to common OpenFGA validation errors. For det | Error | Quick Fix | Link | |-------|----------------------------------------------|------| -| `invalid-syntax` | Check indentation and keyword spelling | [Details](./invalid-syntax.md) | -| `invalid-schema` | Ensure proper `model` and `schema` structure | [Details](./invalid-schema.md) | +| `invalid-schema` | Declare a recognized version (`1.1` or `1.2`) | [Details](./invalid-schema.md) | | `schema-version-unsupported` | Use supported version (`1.1` or `1.2`) | [Details](./schema-version-unsupported.md) | -| `invalid-schema-version` | Use format `X.Y` (e.g., `1.1`) | [Details](./invalid-schema-version.md) | ### Relationship and Reference Errors | Error | Quick Fix | Link | |-------|-----------|------| | `relation-no-entry-point` | Add `[user]` or direct assignment | [Details](./relation-no-entry-point.md) | -| `cyclic-relation` | Break circular references | [Details](./cyclic-relation.md) | | `invalid-relation-type` | Fix `type#relation` syntax | [Details](./invalid-relation-type.md) | | `tupleuserset-not-direct` | Add direct assignment to tupleset relation | [Details](./tupleuserset-not-direct.md) | @@ -36,7 +33,6 @@ This guide provides quick solutions to common OpenFGA validation errors. For det | Error | Quick Fix | Link | |-------|-----------|------| -| `self-error` | Don't use `self` or `this` as names | [Details](./self-error.md) | | `reserved-type-keywords` | Use different type names | [Details](./reserved-type-keywords.md) | | `reserved-relation-keywords` | Use different relation names | [Details](./reserved-relation-keywords.md) | @@ -44,7 +40,6 @@ This guide provides quick solutions to common OpenFGA validation errors. For det | Error | Quick Fix | Link | |-------|-----------|------| -| `invalid-wildcard-error` | Use correct `[type:*]` syntax | [Details](./invalid-wildcard-error.md) | | `type-wildcard-relation` | Don't mix wildcard with relation | [Details](./type-wildcard-relation.md) | ### 🎯 Condition Errors diff --git a/docs/validation/model/assignable-relation-must-have-type.md b/docs/validation/model/assignable-relation-must-have-type.md index c9373490..e94a72ea 100644 --- a/docs/validation/model/assignable-relation-must-have-type.md +++ b/docs/validation/model/assignable-relation-must-have-type.md @@ -143,14 +143,12 @@ type document ## Related Errors -- [`invalid-wildcard-error`](./invalid-wildcard-error.md) - General wildcard usage issues - [`type-wildcard-relation`](./type-wildcard-relation.md) - Conflicting wildcard and relation usage -- [`undefined-type`](./undefined-type.md) - When referenced types don't exist +- [`invalid-type`](./invalid-type.md) - When referenced types don't exist ## Implementation Notes -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/wildcard_validation.go` +This validation is enforced by: - JavaScript implementation: `pkg/js/validator/validate-dsl.ts` - Java implementation: Java wildcard validation package diff --git a/docs/validation/model/condition-not-defined.md b/docs/validation/model/condition-not-defined.md index 45abea70..8e451a2f 100644 --- a/docs/validation/model/condition-not-defined.md +++ b/docs/validation/model/condition-not-defined.md @@ -130,7 +130,7 @@ condition is_weekday(current_time: timestamp) { - [`condition-not-used`](./condition-not-used.md) - When conditions are defined but never used - [`different-nested-condition-name`](./different-nested-condition-name.md) - Condition name mismatches -- [`undefined-relation`](./undefined-relation.md) - Similar pattern for relation references +- [`missing-definition`](./missing-definition.md) - Similar pattern for relation references ## Implementation Notes diff --git a/docs/validation/model/cyclic-error.md b/docs/validation/model/cyclic-error.md deleted file mode 100644 index 1a6554d4..00000000 --- a/docs/validation/model/cyclic-error.md +++ /dev/null @@ -1,178 +0,0 @@ -# Cyclic Error - -**Error Code:** `cyclic-error` - -**Category:** Semantic Validation - -## Summary - -A general cycle detection error indicating circular dependencies in the authorization model that prevent proper evaluation of permissions. - -## Description - -This error represents the general category of cyclic dependencies in authorization models. Cycles can occur in various contexts: -- **Relation cycles:** Relations that reference each other in circular patterns -- **Type dependency cycles:** Types that depend on each other in circular ways -- **Computed userset cycles:** Circular references in permission computation - -Cyclic dependencies prevent OpenFGA from resolving permissions to a finite set of users or conditions, making authorization evaluation impossible or leading to infinite loops. - -## Example - -The following models would trigger this error: - -### Direct relation cycle: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: editor - define editor: viewer # Direct cycle: viewer → editor → viewer -``` - -### Indirect relation cycle: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: editor - define editor: admin - define admin: viewer # Indirect cycle: viewer → editor → admin → viewer -``` - -### Complex permission cycle: -``` -model - schema 1.1 - -type user -type group - relations - define member: [user] or admin_member - define admin_member: member # Cycle in permission computation -``` - -**Error Message:** `Cyclic dependency detected in authorization model` - -## Resolution - -Break the circular dependency by restructuring the authorization model: - -### Option 1: Create proper hierarchy - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] or viewer # Editor includes viewer - define admin: [user] or editor # Admin includes editor (and transitively viewer) -``` - -### Option 2: Add direct assignments to break cycles - -``` -model - schema 1.1 - -type user -type group - relations - define member: [user] # Direct assignment breaks cycle - define admin_member: [user] or member # Clear hierarchy -``` - -### Option 3: Restructure authorization logic - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] # Independent permissions - define admin: [user] # No hierarchical dependencies -``` - -### Steps to fix: - -1. **Identify the cycle:** - - Trace the dependency chain to understand the circular reference - - Map out which constructs depend on each other - -2. **Analyze intended authorization flow:** - - Determine the correct permission hierarchy - - Identify which permissions should include others - -3. **Restructure the model:** - - Break the cycle by removing one problematic reference - - Create clear hierarchical relationships - - Add direct assignments where needed - -4. **Validate the solution:** - - Ensure all necessary permissions are still achievable - - Test that the authorization logic meets requirements - -## Common Authorization Patterns - -### ✅ Valid hierarchical structures: -``` -# Clear hierarchy -define viewer: [user] -define editor: [user] or viewer -define admin: [user] or editor - -# Independent permissions -define read: [user] -define write: [user] -define delete: [user] - -# Tuple-to-userset without cycles -define reader: [user] or member from owner -define owner: [organization#admin] -``` - -### ❌ Invalid cyclic structures: -``` -# Direct cycle -define viewer: editor -define editor: viewer - -# Indirect cycle -define a: b -define b: c -define c: a - -# Self-referential cycle -define viewer: viewer or [user] -``` - -## Related Errors - -- [`cyclic-relation`](./cyclic-relation.md) - Specific circular relation dependencies -- [`relation-no-entry-point`](./relation-no-entry-point.md) - When cycles prevent entry points -- [`undefined-relation`](./undefined-relation.md) - Missing relations in cycle chains - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/cycle_detection.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java cycle detection package - -The validation uses depth-first search algorithms to detect cycles in the authorization model's dependency graph. diff --git a/docs/validation/model/cyclic-relation.md b/docs/validation/model/cyclic-relation.md deleted file mode 100644 index 2bcdd4e0..00000000 --- a/docs/validation/model/cyclic-relation.md +++ /dev/null @@ -1,126 +0,0 @@ -# Cyclic Relation - -**Error Code:** `cyclic-relation` - -**Category:** Semantic Validation - -## Summary - -A circular dependency has been detected in relation definitions, creating an infinite loop that prevents proper authorization evaluation. - -## Description - -This error occurs when relations reference each other in a circular pattern, creating an infinite loop during authorization evaluation. OpenFGA must be able to resolve all relation dependencies to a finite set of directly assigned users or computed values. - -Circular dependencies can occur: -- **Direct cycles:** `A → B → A` -- **Indirect cycles:** `A → B → C → A` -- **Self-referential cycles:** `A → A` - -Unlike [`relation-no-entry-point`](./relation-no-entry-point.md), this error specifically focuses on detecting cycles in the relation dependency graph, even when entry points exist. - -## Example - -The following model would trigger this error: - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] or editor - define editor: admin - define admin: viewer # Creates cycle: viewer → editor → admin → viewer -``` - -**Error Location:** The cycle involves multiple relations forming a circular dependency. - -**Error Message:** `Cyclic relation dependency detected involving relations: viewer, editor, admin` - -## Resolution - -Break the circular dependency by removing or restructuring one of the relation references: - -### Option 1: Remove problematic reference - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] or editor - define editor: [user] # Remove reference to admin - define admin: [user] or editor -``` - -### Option 2: Restructure hierarchy - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] or viewer # Editor includes viewer - define admin: [user] or editor # Admin includes editor (and transitively viewer) -``` - -### Steps to fix: - -1. **Identify the cycle:** - - Review the error message to see which relations form the cycle - - Map out the dependency chain - -2. **Analyze intended authorization hierarchy:** - - Determine the correct permission hierarchy - - Identify which direction relationships should flow - -3. **Break the cycle:** - - Remove one problematic reference - - Restructure to create a proper hierarchy - - Ensure the authorization logic still meets requirements - -4. **Validate the solution:** - - Check that all necessary permissions are still achievable - - Verify no new cycles are introduced - -## Common Authorization Patterns - -### ✅ Valid hierarchical structure: -``` -define viewer: [user] -define editor: [user] or viewer -define admin: [user] or editor -define owner: [user] or admin -``` - -### ❌ Invalid circular structure: -``` -define viewer: editor -define editor: admin -define admin: viewer # Creates cycle -``` - -## Related Errors - -- [`relation-no-entry-point`](./relation-no-entry-point.md) - When cycles prevent any entry points -- [`cyclic-error`](./cyclic-error.md) - General cyclic dependency error -- [`undefined-relation`](./undefined-relation.md) - When relations in cycle don't exist - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/cycle_detection.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java semantic validation package - -The cycle detection uses depth-first search with visited node tracking to identify circular dependencies in the relation graph. diff --git a/docs/validation/model/duplicated-error.md b/docs/validation/model/duplicated-error.md index f9e52181..86618717 100644 --- a/docs/validation/model/duplicated-error.md +++ b/docs/validation/model/duplicated-error.md @@ -146,7 +146,7 @@ type document - [`invalid-name`](./invalid-name.md) - When names don't follow proper format - [`reserved-type-keywords`](./reserved-type-keywords.md) - When using reserved words -- [`undefined-relation`](./undefined-relation.md) - Broken references after renaming +- [`missing-definition`](./missing-definition.md) - Broken references after renaming ## Implementation Notes diff --git a/docs/validation/model/invalid-name.md b/docs/validation/model/invalid-name.md index cb96773f..90401608 100644 --- a/docs/validation/model/invalid-name.md +++ b/docs/validation/model/invalid-name.md @@ -147,7 +147,6 @@ type document - [`reserved-type-keywords`](./reserved-type-keywords.md) - When names use reserved keywords - [`reserved-relation-keywords`](./reserved-relation-keywords.md) - When relation names use reserved keywords -- [`self-error`](./self-error.md) - Specific issues with 'self' and 'this' ## Implementation Notes diff --git a/docs/validation/model/invalid-relation-on-tupleset.md b/docs/validation/model/invalid-relation-on-tupleset.md index 57c8ae58..3d8f622a 100644 --- a/docs/validation/model/invalid-relation-on-tupleset.md +++ b/docs/validation/model/invalid-relation-on-tupleset.md @@ -167,13 +167,13 @@ type document ## Related Errors - [`tupleuserset-not-direct`](./tupleuserset-not-direct.md) - When tupleset lacks direct assignment -- [`undefined-relation`](./undefined-relation.md) - When relations don't exist +- [`missing-definition`](./missing-definition.md) - When relations don't exist - [`invalid-relation-type`](./invalid-relation-type.md) - When relation syntax is invalid ## Implementation Notes This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/wildcard_validation.go` +- Go implementation: `pkg/go/validation/semantic_validation.go` - JavaScript implementation: `pkg/js/validator/validate-dsl.ts` - Java implementation: Java tuple-to-userset validation package diff --git a/docs/validation/model/invalid-relation-type.md b/docs/validation/model/invalid-relation-type.md index 49d13252..6b2da56a 100644 --- a/docs/validation/model/invalid-relation-type.md +++ b/docs/validation/model/invalid-relation-type.md @@ -156,7 +156,6 @@ define owner: organization# # Incomplete reference ## Related Errors -- [`undefined-relation`](./undefined-relation.md) - When referenced relations don't exist - [`invalid-type`](./invalid-type.md) - When type references are invalid - [`missing-definition`](./missing-definition.md) - When definitions are missing diff --git a/docs/validation/model/invalid-schema-version.md b/docs/validation/model/invalid-schema-version.md deleted file mode 100644 index b8a763ac..00000000 --- a/docs/validation/model/invalid-schema-version.md +++ /dev/null @@ -1,136 +0,0 @@ -# Invalid Schema Version - -**Error Code:** `invalid-schema-version` - -**Category:** Schema Validation - -## Summary - -The schema version format is invalid or malformed, preventing proper model validation and execution. - -## Description - -OpenFGA schema versions must follow a specific format to ensure proper parsing and feature detection. Valid schema versions: -- Follow semantic versioning format (e.g., "1.1", "1.2") -- Use numeric values separated by dots -- Contain only supported version numbers -- Cannot be empty or contain invalid characters - -Invalid schema version formats prevent the validation system from determining which features are available and which validation rules to apply. - -## Example - -The following models would trigger this error: - -### Invalid version formats: -``` -model - schema v1.1 # Error: contains 'v' prefix - -model - schema 1.1.0.0 # Error: too many version parts - -model - schema 1.x # Error: non-numeric version part - -model - schema "" # Error: empty version string - -model - schema 1.1-beta # Error: contains suffix -``` - -**Error Message:** `Invalid schema version format: 'v1.1'. Schema version must be in format 'X.Y'` - -## Resolution - -Use proper schema version format: - -### Correct schema version formats: -``` -model - schema 1.1 # Valid: current recommended version - -model - schema 1.2 # Valid: current recommended version + module support -``` - -### Steps to fix: - -1. **Identify the format issue:** - - Check the error message for the specific format problem - - Review the schema version declaration in your model - -2. **Use correct format:** - - Remove any prefixes (v, version, etc.) - - Use only numeric values separated by a single dot - - Remove any suffixes or additional version parts - -3. **Choose appropriate version:** - - Use `1.1` or `1.2` for new models (recommended) - - Use `1.2` when using modules - -4. **Update and validate:** - - Correct the schema version format - - Ensure your model features are compatible with the chosen version - -## Valid Schema Version Examples - -### ✅ Correct formats: -``` -model - schema 1.1 - -model - schema 1.2 -``` - -### ❌ Invalid formats: -``` -model - schema v1.1 # Prefix not allowed - -model - schema 1.1.0 # Too many parts - -model - schema 1.x # Non-numeric - -model - schema 1.1-beta # Suffix not allowed - -model - schema version 1.1 # Extra text -``` - -## Feature Compatibility Matrix - -| Feature | Schema 1.0 | Schema 1.1 | Schema 1.2 | -|--------------------------------------|------------|------------|------------| -| Supported | ❌ | ✅ | ✅ | -| Basic relations | ✅ | ✅ | ✅ | -| Simple wildcards | ✅ | ✅ | ✅ | -| Wildcards | ✅ | ✅ | ✅ | -| Basic operations | ✅ | ✅ | ✅ | -| Type Restrictions | ❌ | ✅ | ✅ | -| Conditions | ❌ | ✅ | ✅ | -| Operator grouping `(a or (b and c))` | ❌ | ✅ | ✅ | -| Modules | ❌ | ❌ | ✅ | - -> [!WARNING] -> Schema version `1.0` is no longer supported by OpenFGA. Models using this version must be updated to `1.1` or `1.2`. See the [Schema 1.1 Migration Guide](../migrations/schema1.0-to-schema1.1.md) for assistance. - -## Related Errors - -- [`schema-version-required`](./schema-version-required.md) - When no schema version is specified -- [`schema-version-unsupported`](./schema-version-unsupported.md) - When version is not supported -- [`invalid-schema`](./invalid-schema.md) - General schema structure issues - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/schema_validation.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java schema validation package - -The validation uses regular expressions to check version format and ensures consistency across all language implementations. diff --git a/docs/validation/model/invalid-schema.md b/docs/validation/model/invalid-schema.md index 284ae7a4..7b6c690f 100644 --- a/docs/validation/model/invalid-schema.md +++ b/docs/validation/model/invalid-schema.md @@ -6,143 +6,62 @@ ## Summary -The overall schema structure is invalid or malformed, preventing proper model parsing and validation. +The model declares an unsupported or unrecognized schema version. ## Description -This error occurs when the authorization model's schema structure doesn't conform to OpenFGA's schema requirements. Unlike specific schema version errors, this represents fundamental structural problems with the schema that prevent basic parsing and validation. +`1.1` and `1.2` are the supported versions. A version that parses but is neither of those is reported as `invalid-schema`, with the version itself as the symbol. -Common schema structure issues include: -- Missing required schema components -- Malformed schema declarations -- Invalid schema syntax or formatting -- Structural inconsistencies that violate OpenFGA's schema rules +Two neighboring conditions have codes of their own. Version `1.0` is recognized and retired, so it reports [`schema-version-unsupported`](./schema-version-unsupported.md), and a model carrying no version at all reports [`schema-version-required`](./schema-version-required.md). -## Example - -The following models would trigger this error: - -### Missing model declaration: -``` -schema 1.1 # Error: Missing 'model' declaration - -type user - -type document - relations - define viewer: [user] -``` - -### Malformed schema structure: -``` -model - # Error: Schema declaration without version - schema +A malformed `schema` line never reaches validation. `schema v1.1`, `schema 1.1.0`, `schema` with no version, and a file with no `model` declaration all fail during DSL transformation with a syntax error and no error code attached. -type user -``` - -### Invalid schema syntax: -``` -model { # Error: Invalid syntax for model declaration - schema: 1.1 -} - -type user -``` - -**Error Message:** `Invalid schema structure: missing required model declaration` - -## Resolution - -Fix the schema structure to conform to OpenFGA's requirements: +## Example -### Option 1: Add missing model declaration +### DSL ``` model - schema 1.1 + schema 0.9 type user - -type document - relations - define viewer: [user] ``` -### Option 2: Fix schema syntax +**Error Message:** `invalid schema 0.9` -``` -model - schema 1.1 # Proper format: schema followed by version +The position covers the version itself. Counting from zero, that is line 1, columns 9 to 12. -type user +### JSON -type document - relations - define viewer: [user] +```json +{ + "schema_version": "1.3" +} ``` -### Steps to fix: - -1. **Identify the structural issue:** - - Check the error message for specific schema structure problems - - Review the beginning of your model file for proper format - -2. **Follow OpenFGA schema format:** - - Start with `model` declaration - - Follow with `schema X.Y` version specification - - Use proper indentation and syntax +**Error Message:** `invalid schema 1.3` -3. **Validate basic structure:** - - Ensure model declaration comes first - - Verify schema version is properly specified - - Check that type definitions follow schema declaration - -4. **Test the corrected structure:** - - Validate the model after fixing schema structure - - Ensure the model parses correctly +## Resolution -## Valid Schema Structure +Declare a supported version: -### ✅ Correct schema format: ``` model schema 1.1 type user - relations - define profile_owner: [user] type document relations define viewer: [user] - define editor: [user] or viewer ``` -### ❌ Invalid schema formats: -``` -# Missing model declaration -schema 1.1 -type user - -# Wrong syntax -model { - schema: 1.1 -} -type user - -# Missing schema version -model - schema -type user -``` +Use `1.2` if the model is split across files with `module` declarations, `1.1` otherwise. ## Related Errors -- [`schema-version-required`](./schema-version-required.md) - When schema version is missing -- [`invalid-schema-version`](./invalid-schema-version.md) - When version format is invalid -- [`invalid-syntax`](./invalid-syntax.md) - General syntax issues +- [`schema-version-required`](./schema-version-required.md) - No schema version is declared +- [`schema-version-unsupported`](./schema-version-unsupported.md) - Version `1.0`, recognized but retired ## Implementation Notes @@ -150,5 +69,3 @@ This validation is enforced consistently across: - Go implementation: `pkg/go/validation/schema_validation.go` - JavaScript implementation: `pkg/js/validator/validate-dsl.ts` - Java implementation: Java schema validation package - -The validation performs structural checks during the initial parsing phase to ensure the model follows OpenFGA's basic schema requirements. diff --git a/docs/validation/model/invalid-syntax.md b/docs/validation/model/invalid-syntax.md deleted file mode 100644 index 980c1b0b..00000000 --- a/docs/validation/model/invalid-syntax.md +++ /dev/null @@ -1,161 +0,0 @@ -# Invalid Syntax - -**Error Code:** `invalid-syntax` - -**Category:** Syntax Validation - -## Summary - -The DSL contains syntax errors that prevent proper parsing of the authorization model. - -## Description - -This error occurs when the OpenFGA DSL contains syntax that doesn't conform to the language specification. Syntax errors prevent the parser from understanding the model structure and must be fixed before semantic validation can occur. - -Common syntax issues include: -- Incorrect indentation or formatting -- Missing or extra punctuation -- Malformed keywords or identifiers -- Invalid DSL structure or organization -- Unsupported language constructs - -## Example - -The following models would trigger this error: - -### Incorrect indentation: -``` -model -schema 1.1 # Error: missing indentation - -type user -relations # Error: missing indentation -define viewer: [user] # Error: missing indentation -``` - -### Malformed keywords: -``` -model - schema 1.1 - -typ user # Error: 'typ' instead of 'type' - -type document - relation # Error: 'relation' instead of 'relations' - defin viewer: [user] # Error: 'defin' instead of 'define' -``` - -### Invalid structure: -``` -model - schema 1.1 - -type user { # Error: invalid brace syntax - relations - define viewer: [user] -} -``` - -**Error Message:** `Syntax error at line 3: expected proper indentation` - -## Resolution - -Fix the syntax errors to conform to OpenFGA DSL specification: - -### Fix indentation: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] -``` - -### Fix keywords: -``` -model - schema 1.1 - -type user # Corrected from 'typ' - -type document - relations # Corrected from 'relation' - define viewer: [user] # Corrected from 'defin' -``` - -### Fix structure: -``` -model - schema 1.1 - -type user - -type document # Removed invalid braces - relations - define viewer: [user] -``` - -### Steps to fix: - -1. **Identify the syntax error:** - - Check the error message for line number and specific issue - - Review the problematic line and surrounding context - -2. **Follow DSL specification:** - - Use proper indentation (2 spaces per level) - - Use correct keywords (`model`, `type`, `relations`, `define`) - - Follow OpenFGA DSL structure rules - -3. **Validate syntax incrementally:** - - Fix one syntax error at a time - - Validate after each fix to catch additional issues - -4. **Test the corrected model:** - - Ensure the model parses successfully - - Proceed to semantic validation - -## OpenFGA DSL Structure Reference - -### ✅ Correct DSL syntax: -``` -model - schema 1.1 - -condition condition_name(param: Type) { - param.field == "value" -} - -type type_name - relations - define relation_name: [type] or other_relation - define complex_relation: [type#relation, other_type:*] -``` - -### ❌ Invalid DSL syntax: -``` -model { # Wrong: no braces - schema: 1.1 # Wrong: colon instead of space -} - -typ user # Wrong: 'typ' instead of 'type' -relation # Wrong: 'relation' instead of 'relations' -define viewer [user] # Wrong: missing colon -``` - -## Related Errors - -- [`invalid-schema`](./invalid-schema.md) - When schema structure is invalid -- [`invalid-name`](./invalid-name.md) - When names don't follow format rules -- [`schema-version-required`](./schema-version-required.md) - When schema declaration is missing - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/` during parsing phase -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java DSL parser - -The validation occurs during the initial parsing phase before semantic analysis begins, ensuring the model structure is valid before checking logical consistency. diff --git a/docs/validation/model/invalid-type.md b/docs/validation/model/invalid-type.md index 34a166dc..43da9164 100644 --- a/docs/validation/model/invalid-type.md +++ b/docs/validation/model/invalid-type.md @@ -150,7 +150,6 @@ type document ## Related Errors - [`invalid-name`](./invalid-name.md) - General naming convention issues -- [`undefined-type`](./undefined-type.md) - When referenced types don't exist - [`reserved-type-keywords`](./reserved-type-keywords.md) - When type names use reserved words ## Implementation Notes diff --git a/docs/validation/model/invalid-wildcard-error.md b/docs/validation/model/invalid-wildcard-error.md deleted file mode 100644 index 1a8ffa0d..00000000 --- a/docs/validation/model/invalid-wildcard-error.md +++ /dev/null @@ -1,140 +0,0 @@ -# Invalid Wildcard Error - -**Error Code:** `invalid-wildcard-error` - -**Category:** Wildcard Validation - -## Summary - -Wildcard usage in type restrictions is invalid or incorrectly formatted, violating OpenFGA's wildcard syntax rules. - -## Description - -OpenFGA supports wildcard type restrictions using the `*` symbol to indicate "any user of this type." However, wildcards have specific usage rules and constraints: - -- Wildcards must be used correctly within type restrictions -- Certain combinations of wildcards and relations are not allowed -- Schema version compatibility affects wildcard availability -- Wildcards cannot be used in contexts where specific user identity is required - -## Example - -The following models would trigger this error: - -### Invalid wildcard syntax: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [*] # Error: wildcard is missing its type -``` - -### Wildcard in wrong context: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user*] # Error: Malformed wildcard - define editor: [*] # Error: Wildcard without type -``` - -**Error Message:** `Invalid wildcard usage in type restriction` - -## Resolution - -Use correct wildcard syntax according to OpenFGA specifications: - -### Correct wildcard usage: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user, user:*] # Valid: specific users or any user - define editor: [user] # Valid: no wildcard needed -``` - -### Alternative without wildcards: -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] # Simple user restriction without wildcards - define editor: [user] -``` - -### Steps to fix: - -1. **Identify the invalid wildcard usage:** - - Check the error message for the specific location - - Review the wildcard syntax in your type restrictions - -2. **Understand wildcard requirements:** - - Verify schema version supports wildcards - - Check if wildcards are appropriate for your use case - -3. **Correct the syntax:** - - Use proper wildcard format: `[type:*]` - - Ensure wildcards are used in valid contexts - - Consider if wildcards are necessary for your authorization model - -4. **Test the authorization logic:** - - Verify wildcard behavior matches expectations - - Test with actual authorization data - -## TODO: Complete Wildcard Usage Guidelines - - - -## Common Wildcard Patterns - -### ✅ Valid patterns: -``` -define viewer: [user:*] # Any user -define editor: [user, org#member:*] # Specific users or any org member -define admin: [user] # No wildcard needed -``` - -### ❌ Invalid patterns: -``` -define viewer: [*] # Missing type -define editor: [user*] # Wrong syntax -define admin: [user:*, *] # Mixed invalid syntax -``` - -## Related Errors - -- [`type-wildcard-relation`](./type-wildcard-relation.md) - Wildcard and relation conflicts -- [`schema-version-unsupported`](./schema-version-unsupported.md) - Schema version requirements -- [`assignable-relation-must-have-type`](./assignable-relation-must-have-type.md) - Type requirement issues - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/wildcard_validation.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java wildcard validation package - -The validation checks wildcard syntax during relation parsing and verifies compatibility with schema version and authorization model structure. diff --git a/docs/validation/model/missing-definition.md b/docs/validation/model/missing-definition.md index 56d89b10..8c7324ea 100644 --- a/docs/validation/model/missing-definition.md +++ b/docs/validation/model/missing-definition.md @@ -148,8 +148,7 @@ type document ## Related Errors -- [`undefined-type`](./undefined-type.md) - Specifically for missing type definitions -- [`undefined-relation`](./undefined-relation.md) - Specifically for missing relation definitions +- [`invalid-type`](./invalid-type.md) - Specifically for missing type definitions - [`condition-not-defined`](./condition-not-defined.md) - Specifically for missing condition definitions ## Implementation Notes diff --git a/docs/validation/model/multiple-modules-in-file.md b/docs/validation/model/multiple-modules-in-file.md index 878137df..9d2fafdd 100644 --- a/docs/validation/model/multiple-modules-in-file.md +++ b/docs/validation/model/multiple-modules-in-file.md @@ -123,7 +123,7 @@ type document ## Related Errors -- [`invalid-syntax`](./invalid-syntax.md) - Syntax issues in module declarations +- [`duplicated-error`](./duplicated-error.md) - Duplicate definitions across module files ## Implementation Notes diff --git a/docs/validation/model/relation-no-entry-point.md b/docs/validation/model/relation-no-entry-point.md index a7163aa0..4db60421 100644 --- a/docs/validation/model/relation-no-entry-point.md +++ b/docs/validation/model/relation-no-entry-point.md @@ -123,9 +123,7 @@ define super_admin: viewer ## Related Errors -- [`cyclic-error`](./cyclic-error.md) - When relations form circular dependencies -- [`cyclic-relation`](./cyclic-relation.md) - Specific circular relation detection -- [`undefined-relation`](./undefined-relation.md) - When referenced relations don't exist +- [`missing-definition`](./missing-definition.md) - When referenced relations don't exist ## Implementation Notes diff --git a/docs/validation/model/reserved-relation-keywords.md b/docs/validation/model/reserved-relation-keywords.md index 6fe75b82..077c44b9 100644 --- a/docs/validation/model/reserved-relation-keywords.md +++ b/docs/validation/model/reserved-relation-keywords.md @@ -72,7 +72,6 @@ type document ## Related Errors - [`reserved-type-keywords`](./reserved-type-keywords.md) - Reserved keywords for type names -- [`self-error`](./self-error.md) - Reserved error code, not currently emitted - [`invalid-name`](./invalid-name.md) - General invalid naming issues ## Implementation Notes diff --git a/docs/validation/model/reserved-type-keywords.md b/docs/validation/model/reserved-type-keywords.md index 1c2ba58c..0dd6cf13 100644 --- a/docs/validation/model/reserved-type-keywords.md +++ b/docs/validation/model/reserved-type-keywords.md @@ -63,7 +63,6 @@ type document ## Related Errors - [`reserved-relation-keywords`](./reserved-relation-keywords.md) - Reserved keywords for relation names -- [`self-error`](./self-error.md) - Reserved error code, not currently emitted - [`invalid-name`](./invalid-name.md) - General invalid naming issues ## Implementation Notes diff --git a/docs/validation/model/schema-version-required.md b/docs/validation/model/schema-version-required.md index 3860a628..f2560a93 100644 --- a/docs/validation/model/schema-version-required.md +++ b/docs/validation/model/schema-version-required.md @@ -62,7 +62,7 @@ type document ## Related Errors - [`schema-version-unsupported`](./schema-version-unsupported.md) - When an unsupported version is specified -- [`invalid-schema-version`](./invalid-schema-version.md) - When the version format is invalid +- [`invalid-schema`](./invalid-schema.md) - When the declared version is not recognized ## Implementation Notes diff --git a/docs/validation/model/schema-version-unsupported.md b/docs/validation/model/schema-version-unsupported.md index 030aeac7..50dd017e 100644 --- a/docs/validation/model/schema-version-unsupported.md +++ b/docs/validation/model/schema-version-unsupported.md @@ -18,13 +18,13 @@ OpenFGA supports specific schema versions that define the available features, sy Using an unsupported schema version prevents the system from knowing which validation rules to apply and which features are available. -## Example 1: Using a version that is not yet supported +## Example: Using a retired version The following model would trigger this error: ``` model - schema 2.0 # Error: Version 2.0 not yet supported + schema 1.0 # Error: Version 1.0 is no longer supported type user @@ -33,9 +33,9 @@ type document define viewer: [user] ``` -**Error Location:** Line 2, `schema 2.0` declaration. +**Error Location:** Line 2, `schema 1.0` declaration. -**Error Message:** `Schema version '2.0' is not supported. Supported versions are: 1.1, 1.2` +**Error Message:** `schema version no longer supported` ## Resolution @@ -57,7 +57,7 @@ type document ### Steps to fix: 1. **Check supported versions:** - - Review the error message for list of supported versions + - Supported versions are `1.1` and `1.2` - Check OpenFGA documentation for version compatibility 2. **Choose appropriate version:** @@ -92,7 +92,7 @@ type document ### Migrating from Schema 1.0 to 1.1 The main changes when migrating from Schema 1.0 to 1.1 include: -1. [Adding model schema version field](#-model-schema-versions) +1. [Adding model schema version field](#model-schema-versions) 2. [Adding type enforcements and removing need to specify `as self`](#type-enforcements--removing-as-self) 3. [Disallowing string literals in user_ids](#disallowing-string-literals-in-user_ids) 4. [Enforcing userset type restrictions](#enforcing-userset-type-restrictions) @@ -352,8 +352,7 @@ In these cases, OpenFGA will not consider those invalid tuples when evaluating q ## Related Errors - [`schema-version-required`](./schema-version-required.md) - When no schema version is specified -- [`invalid-schema-version`](./invalid-schema-version.md) - When version format is invalid -- [`invalid-schema`](./invalid-schema.md) - General schema structure issues +- [`invalid-schema`](./invalid-schema.md) - When the declared version is not recognized ## Implementation Notes @@ -361,5 +360,3 @@ This validation is enforced consistently across: - Go implementation: `pkg/go/validation/schema_validation.go` - JavaScript implementation: `pkg/js/validator/validate-dsl.ts` - Java implementation: Java schema validation package - -The validation checks against a predefined list of supported schema versions and rejects models using unsupported versions. diff --git a/docs/validation/model/self-error.md b/docs/validation/model/self-error.md deleted file mode 100644 index b3ac8c50..00000000 --- a/docs/validation/model/self-error.md +++ /dev/null @@ -1,32 +0,0 @@ -# Self Error - -**Error Code:** `self-error` - -**Category:** Naming Validation - -## Summary - -`self-error` is a reserved error code in the `ValidationError` enum. It is **not currently emitted** by the validator. - -## Description - -Using `self` or `this` as a type or relation name is a real validation error, but it is reported under the dedicated keyword codes rather than `self-error`: - -- A reserved **type** name (`self`/`this`) raises [`reserved-type-keywords`](./reserved-type-keywords.md) with the message `a type cannot be named 'self' or 'this'.` -- A reserved **relation** name (`self`/`this`) raises [`reserved-relation-keywords`](./reserved-relation-keywords.md) with the message `a relation cannot be named 'self' or 'this'.` - -The `self-error` code is defined in both the Go and JavaScript error enums for forward compatibility but is not raised by any validation path today. This page exists to document that fact and point to the codes that actually fire. - -## Related Errors - -- [`reserved-type-keywords`](./reserved-type-keywords.md) - Raised when `self`/`this` is used as a type name -- [`reserved-relation-keywords`](./reserved-relation-keywords.md) - Raised when `self`/`this` is used as a relation name -- [`invalid-name`](./invalid-name.md) - General invalid naming issues - -## Implementation Notes - -The `self-error` code is declared in: -- Go: `pkg/go/validation/errors.go` -- JavaScript: `pkg/js/errors.ts` - -No code path in either implementation currently raises it; the `self`/`this` naming checks live in the name-validation phase and emit the `reserved-*-keywords` codes above. diff --git a/docs/validation/model/tupleuserset-not-direct.md b/docs/validation/model/tupleuserset-not-direct.md index 1c10f721..587991e2 100644 --- a/docs/validation/model/tupleuserset-not-direct.md +++ b/docs/validation/model/tupleuserset-not-direct.md @@ -99,12 +99,12 @@ type document - [`relation-no-entry-point`](./relation-no-entry-point.md) - When relations lack entry points - [`invalid-relation-on-tupleset`](./invalid-relation-on-tupleset.md) - Invalid tupleset relation usage -- [`undefined-relation`](./undefined-relation.md) - When tupleset relation doesn't exist +- [`invalid-relation-type`](./invalid-relation-type.md) - When tupleset relation doesn't exist ## Implementation Notes This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/wildcard_validation.go` +- Go implementation: `pkg/go/validation/semantic_validation.go` - JavaScript implementation: `pkg/js/validator/validate-dsl.ts` - Java implementation: Java tuple-to-userset validation package diff --git a/docs/validation/model/type-wildcard-relation.md b/docs/validation/model/type-wildcard-relation.md index 9ab199ff..49d3663d 100644 --- a/docs/validation/model/type-wildcard-relation.md +++ b/docs/validation/model/type-wildcard-relation.md @@ -138,14 +138,12 @@ type document ## Related Errors -- [`invalid-wildcard-error`](./invalid-wildcard-error.md) - General wildcard usage issues - [`assignable-relation-must-have-type`](./assignable-relation-must-have-type.md) - Type requirement issues - [`schema-version-unsupported`](./schema-version-unsupported.md) - Schema version compatibility ## Implementation Notes -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/wildcard_validation.go` +This validation is enforced by: - JavaScript implementation: `pkg/js/validator/validate-dsl.ts` - Java implementation: Java wildcard validation package diff --git a/docs/validation/model/undefined-relation.md b/docs/validation/model/undefined-relation.md deleted file mode 100644 index 0c09ea98..00000000 --- a/docs/validation/model/undefined-relation.md +++ /dev/null @@ -1,113 +0,0 @@ -# Undefined Relation - -**Error Code:** `undefined-relation` - -**Category:** Semantic Validation - -## Summary - -A relation is referenced in the model but is not defined anywhere, creating a broken reference that would cause runtime errors. - -## Description - -This error occurs when a relation definition references another relation that doesn't exist within the same type or any other type in the model. OpenFGA requires all relation references to be valid and resolvable at validation time to ensure the authorization model can function correctly. - -Relation references can occur in: -- Computed usersets (`viewer`) -- Tuple-to-userset operations (`member from owner`) -- Complex operations (unions, intersections, differences) -- Grouping and mixing operators (`(((viewer or editor) and (admin or guest)) but not blocked)`) - -## Example - -The following model would trigger this error: - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] or nonexistent_relation -``` - -**Error Location:** Line 8, `nonexistent_relation` in the editor relation definition. - -**Error Message:** `Relation 'nonexistent_relation' is not defined on type 'document' (referenced in relation 'editor' of type 'document')` - -## Resolution - -Define the missing relation or correct the reference to an existing relation: - -### Option 1: Define the missing relation - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define nonexistent_relation: [user] # Define the missing relation - define editor: [user] or nonexistent_relation -``` - -### Option 2: Correct the reference - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] or viewer # Reference existing relation -``` - -### Steps to fix: - -1. **Identify the undefined relation:** - - Check the error message for the exact relation name - - Note which type and relation contains the invalid reference - -2. **Verify intended behavior:** - - Determine if the relation should exist or if it's a typo - - Review your authorization model design - -3. **Choose resolution approach:** - - Define the missing relation if it should exist - - Correct the reference if it's a typo - - Remove the reference if it's unnecessary - -4. **Test the fix:** - - Validate the model after making changes - - Ensure the authorization logic still works as expected - -## Common Causes - -- **Typos in relation names:** `veiwer` instead of `viewer` -- **Case sensitivity:** `Viewer` instead of `viewer` -- **Missing relation definitions:** Referencing planned but unimplemented relations -- **Copy-paste errors:** Copying references from other models without definitions - -## Related Errors - -- [`undefined-type`](./undefined-type.md) - When a type is referenced but not defined -- [`missing-definition`](./missing-definition.md) - General missing definition error -- [`invalid-relation-type`](./invalid-relation-type.md) - When relation type is invalid - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/semantic_validation.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java semantic validation package - -The validation performs deep traversal of all relation definitions to build a complete reference graph and identify broken links. diff --git a/docs/validation/model/undefined-type.md b/docs/validation/model/undefined-type.md deleted file mode 100644 index cd7269a5..00000000 --- a/docs/validation/model/undefined-type.md +++ /dev/null @@ -1,137 +0,0 @@ -# Undefined Type - -**Error Code:** `undefined-type` - -**Category:** Semantic Validation - -## Summary - -A type is referenced in the model but is not defined anywhere, creating a broken reference that would cause runtime errors. - -## Description - -This error occurs when a relation definition or type restriction references a type that doesn't exist in the model. OpenFGA requires all type references to be valid and resolvable at validation time to ensure the authorization model can function correctly. - -Type references can occur in: -- Direct type restrictions: `[undefined_type]` -- Relation type restrictions: `[undefined_type#member]` -- Public type restrictions: `[undefined_type:*]` - -## Example - -The following model would trigger this error: - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user, undefined_type] # Error: type not defined - define editor: [organization#member] # Error: 'organization' type not defined - define admin: [employee:*] # Error: 'employee' type not defined -``` - -**Error Location:** Line 7, `undefined_type` and `organization` in the relation definitions. - -**Error Message:** `Type 'undefined_type' is not defined (referenced in relation 'viewer' of type 'document')` - -## Resolution - -Define the missing type or correct the reference to an existing type: - -### Option 1: Define the missing type - -``` -model - schema 1.1 - -type user - -type undefined_type # Define the missing type - relations - define member: [user] - -type organization # Define the missing type - relations - define member: [user] - -type document - relations - define viewer: [user, undefined_type] - define editor: [organization#member] -``` - -### Option 2: Correct the reference - -``` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] # Remove invalid reference - define editor: [user] # Use existing type -``` - -### Option 3: Import from another module (if applicable) - -``` -module current_module - schema 1.1 - -import other_module - -type user - -type document - relations - define viewer: [user, other_module.organization#member] # Reference imported type -``` - -### Steps to fix: - -1. **Identify the undefined type:** - - Check the error message for the exact type name - - Note which relation and type contains the invalid reference - -2. **Verify intended behavior:** - - Determine if the type should exist or if it's a typo - - Review your authorization model design - -3. **Choose resolution approach:** - - Define the missing type if it should exist - - Correct the reference if it's a typo - - Import the type if it exists in another module - - Remove the reference if it's unnecessary - -4. **Test the fix:** - - Validate the model after making changes - - Ensure the authorization logic still works as expected - -## Common Causes - -- **Typos in type names:** `usr` instead of `user` -- **Case sensitivity:** `User` instead of `user` -- **Missing type definitions:** Referencing planned but unimplemented types -- **Module issues:** Referencing types from unimported modules -- **Copy-paste errors:** Copying references from other models without definitions - -## Related Errors - -- [`undefined-relation`](./undefined-relation.md) - When a relation is referenced but not defined -- [`missing-definition`](./missing-definition.md) - General missing definition error -- [`invalid-type`](./invalid-type.md) - When type format is invalid - -## Implementation Notes - -This validation is enforced consistently across: -- Go implementation: `pkg/go/validation/semantic_validation.go` -- JavaScript implementation: `pkg/js/validator/validate-dsl.ts` -- Java implementation: Java semantic validation package - -The validation builds a map of all defined types and cross-references them with type usage in relation definitions and type restrictions. diff --git a/pkg/go/validation/complex_operation_validation.go b/pkg/go/validation/complex_operation_validation.go deleted file mode 100644 index 94f23282..00000000 --- a/pkg/go/validation/complex_operation_validation.go +++ /dev/null @@ -1,204 +0,0 @@ -package validation - -import ( - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// ComplexOperationValidator handles validation of complex userset operations. -type ComplexOperationValidator struct { - model *openfgav1.AuthorizationModel - validator *SemanticValidator -} - -func NewComplexOperationValidator(model *openfgav1.AuthorizationModel) *ComplexOperationValidator { - return newComplexOperationValidator(NewSemanticValidator(model)) -} - -func newComplexOperationValidator(validator *SemanticValidator) *ComplexOperationValidator { - return &ComplexOperationValidator{ - model: validator.model, - validator: validator, - } -} - -// ValidateComplexOperations validates all complex operations in the model. -func ValidateComplexOperations(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateComplexOperations(collector, NewSemanticValidator(model), lines) -} - -func validateComplexOperations(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - opValidator := newComplexOperationValidator(validator) - for _, typeDef := range model.GetTypeDefinitions() { - for relationName, userset := range typeDef.GetRelations() { - opValidator.validateUsersetOperations(collector, typeDef.GetType(), relationName, userset, lines) - } - } -} - -func (cov *ComplexOperationValidator) validateUsersetOperations(collector *ErrorCollector, typeName, relationName string, userset *openfgav1.Userset, lines []string) { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, userset, lines, make(map[string]bool)) -} - -func (cov *ComplexOperationValidator) validateUsersetOperationsWithVisited(collector *ErrorCollector, typeName, relationName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { - if userset == nil { - return - } - if union := userset.GetUnion(); union != nil { - cov.validateUnionOperationWithVisited(collector, typeName, relationName, union, lines, visited) - } - if intersection := userset.GetIntersection(); intersection != nil { - cov.validateIntersectionOperationWithVisited(collector, typeName, relationName, intersection, lines, visited) - } - if diff := userset.GetDifference(); diff != nil { - cov.validateDifferenceOperationWithVisited(collector, typeName, relationName, diff, lines, visited) - } - cov.validateNestedOperationsWithVisited(collector, typeName, userset, lines, visited) -} - -func (cov *ComplexOperationValidator) validateUnionOperationWithVisited(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string, visited map[string]bool) { - if union == nil || len(union.GetChild()) == 0 { - return - } - cov.checkRedundantUnionMembers(collector, typeName, relationName, union, lines) - for _, child := range union.GetChild() { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, child, lines, visited) - } - cov.validateUnionSemantics(collector, typeName, relationName, union, lines) -} - -func (cov *ComplexOperationValidator) validateIntersectionOperationWithVisited(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string, visited map[string]bool) { - if intersection == nil || len(intersection.GetChild()) == 0 { - return - } - cov.checkImpossibleIntersections(collector, typeName, relationName, intersection, lines) - for _, child := range intersection.GetChild() { - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, child, lines, visited) - } - cov.validateIntersectionSemantics(collector, typeName, relationName, intersection, lines) -} - -func (cov *ComplexOperationValidator) validateDifferenceOperationWithVisited(collector *ErrorCollector, typeName, relationName string, difference *openfgav1.Difference, lines []string, visited map[string]bool) { - if difference == nil { - return - } - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, difference.GetBase(), lines, visited) - cov.validateUsersetOperationsWithVisited(collector, typeName, relationName, difference.GetSubtract(), lines, visited) - cov.validateDifferenceSemantics(collector, typeName, relationName, difference, lines) -} - -func (cov *ComplexOperationValidator) checkRedundantUnionMembers(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string) { - seenOperations := make(map[string]bool) - for _, child := range union.GetChild() { - operationKey := cov.getUsersetOperationKey(child) - if operationKey == "" { - continue - } - if seenOperations[operationKey] { - lineIndex := GetRelationLineNumber(relationName, lines, nil) - meta := cov.getTypeMeta(typeName) - collector.RaiseRedundantUnionMember(operationKey, relationName, typeName, meta, lineIndex) - } - seenOperations[operationKey] = true - } -} - -func (cov *ComplexOperationValidator) checkImpossibleIntersections(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { - typeRestrictions := make([]string, 0) - for _, child := range intersection.GetChild() { - if child.GetThis() != nil { - typeRestrictions = append(typeRestrictions, "this") - } - } - if len(typeRestrictions) <= 1 { - return - } - uniqueTypes := make(map[string]bool) - for _, t := range typeRestrictions { - uniqueTypes[t] = true - } - if len(uniqueTypes) > 1 { - lineIndex := GetRelationLineNumber(relationName, lines, nil) - meta := cov.getTypeMeta(typeName) - collector.RaiseImpossibleIntersection(relationName, typeName, typeRestrictions, meta, lineIndex) - } -} - -func (cov *ComplexOperationValidator) validateUnionSemantics(collector *ErrorCollector, typeName, relationName string, union *openfgav1.Usersets, lines []string) { - cov.checkSubsumingUnionMembers(collector, typeName, relationName, union, lines) -} - -func (cov *ComplexOperationValidator) validateIntersectionSemantics(collector *ErrorCollector, typeName, relationName string, intersection *openfgav1.Usersets, lines []string) { - cov.checkRedundantIntersectionMembers(collector, typeName, relationName, intersection, lines) -} - -func (cov *ComplexOperationValidator) validateDifferenceSemantics(collector *ErrorCollector, typeName, relationName string, difference *openfgav1.Difference, lines []string) { - baseKey := cov.getUsersetOperationKey(difference.GetBase()) - subtractKey := cov.getUsersetOperationKey(difference.GetSubtract()) - if baseKey != "" && baseKey == subtractKey { - lineIndex := GetRelationLineNumber(relationName, lines, nil) - meta := cov.getTypeMeta(typeName) - collector.RaiseEmptyDifference(relationName, typeName, baseKey, meta, lineIndex) - } -} - -func (cov *ComplexOperationValidator) validateNestedOperationsWithVisited(collector *ErrorCollector, typeName string, userset *openfgav1.Userset, lines []string, visited map[string]bool) { - if ttu := userset.GetTupleToUserset(); ttu != nil { - if targetRelation := ttu.GetComputedUserset().GetRelation(); targetRelation != "" { - key := typeName + "#" + targetRelation - if visited[key] { - return - } - visited[key] = true - if targetUserset := cov.validator.GetRelationUserset(typeName, targetRelation); targetUserset != nil { - cov.validateUsersetOperationsWithVisited(collector, typeName, targetRelation, targetUserset, lines, visited) - } - } - } -} - -func (cov *ComplexOperationValidator) getUsersetOperationKey(userset *openfgav1.Userset) string { - if userset == nil { - return "" - } - if userset.GetThis() != nil { - return "this" - } - if cu := userset.GetComputedUserset(); cu != nil { - if rel := cu.GetRelation(); rel != "" { - return "computed:" + rel - } - } - if ttu := userset.GetTupleToUserset(); ttu != nil { - tuplesetRel := ttu.GetTupleset().GetRelation() - computedRel := ttu.GetComputedUserset().GetRelation() - return "ttu:" + tuplesetRel + ":" + computedRel - } - return "" -} - -func (cov *ComplexOperationValidator) getTypeMeta(typeName string) *Meta { - if typeDef := cov.validator.GetTypeDefinition(typeName); typeDef != nil { - return &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - } - return &Meta{} -} - -func (cov *ComplexOperationValidator) checkSubsumingUnionMembers(_ *ErrorCollector, _, _ string, _ *openfgav1.Usersets, _ []string) { - // Would check for cases like [user:*, user] where the wildcard subsumes the - // specific relation. This requires detailed analysis of type restrictions. -} - -func (cov *ComplexOperationValidator) checkRedundantIntersectionMembers(_ *ErrorCollector, _, _ string, _ *openfgav1.Usersets, _ []string) { - // Would check for intersection members that don't restrict the result, e.g. - // intersecting with `this`, which adds no restriction. -} diff --git a/pkg/go/validation/condition_validation.go b/pkg/go/validation/condition_validation.go index c826d55c..3ef38e96 100644 --- a/pkg/go/validation/condition_validation.go +++ b/pkg/go/validation/condition_validation.go @@ -1,174 +1,121 @@ package validation import ( + "errors" + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// ConditionValidator handles condition-related validation. -type ConditionValidator struct { - model *openfgav1.AuthorizationModel - definedConds map[string]*openfgav1.Condition - usedConds map[string]bool - conditionRefs map[string][]ConditionReference +// conditionUse is one place a condition is referenced from: a type restriction +// on a relation. +type conditionUse struct { + typeName string + relationName string } -// ConditionReference tracks where a condition is referenced. -type ConditionReference struct { - TypeName string - RelationName string - Context string -} +// validateConditions runs the three condition checks: every referenced +// condition is defined, every condition's nested name matches its key, and +// every defined condition is referenced. +func validateConditions(model *openfgav1.AuthorizationModel, src source) error { + uses := conditionUses(model) -func NewConditionValidator(model *openfgav1.AuthorizationModel) *ConditionValidator { - validator := &ConditionValidator{ - model: model, - definedConds: make(map[string]*openfgav1.Condition), - usedConds: make(map[string]bool), - conditionRefs: make(map[string][]ConditionReference), - } - validator.buildConditionMaps() - return validator -} + var fs []*Finding -func (cv *ConditionValidator) buildConditionMaps() { - if cv.model == nil { - return - } - for conditionName, condition := range cv.model.GetConditions() { - cv.definedConds[conditionName] = condition - } - cv.scanForConditionUsage() -} + // A condition whose nested name property differs from its map key. + conditions := model.GetConditions() + names := slices.Sorted(maps.Keys(conditions)) -func (cv *ConditionValidator) scanForConditionUsage() { - for _, typeDef := range cv.model.GetTypeDefinitions() { - if metaProto := typeDef.GetMetadata(); metaProto != nil { - for relationName, relationMetadata := range metaProto.GetRelations() { - cv.scanRelationMetadataForConditions(typeDef.GetType(), relationName, relationMetadata) - } + for _, conditionKey := range names { + condition := conditions[conditionKey] + if condition != nil && condition.GetName() != conditionKey { + fs = append(fs, differentNestedConditionName(conditionKey, condition.GetName())) } } -} -func (cv *ConditionValidator) scanRelationMetadataForConditions(typeName, relationName string, rm *openfgav1.RelationMetadata) { - if rm == nil { - return - } - for _, typeRestriction := range rm.GetDirectlyRelatedUserTypes() { - if cond := typeRestriction.GetCondition(); cond != "" { - cv.usedConds[cond] = true - cv.conditionRefs[cond] = append(cv.conditionRefs[cond], ConditionReference{ - TypeName: typeName, - RelationName: relationName, - Context: "type_restriction", - }) + // A condition defined but never referenced. + for _, conditionName := range names { + if len(uses[conditionName]) > 0 { + continue } - } -} -// ValidateUnusedConditions detects and reports unused condition definitions. -func ValidateUnusedConditions(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return + condition := conditions[conditionName] + file := condition.GetMetadata().GetSourceInfo().GetFile() + module := condition.GetMetadata().GetModule() + + finding := unusedCondition(conditionName).at(src, src.conditionLine(conditionName)) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } - validateUnusedConditions(collector, NewConditionValidator(model), lines) + + // The undefined-condition findings come first, as in pkg/js. + return errors.Join(undefinedConditions(model, src, uses), joinFindings(fs...)) } -func validateUnusedConditions(collector *ErrorCollector, validator *ConditionValidator, lines []string) { - for conditionName, condition := range validator.definedConds { - if !validator.usedConds[conditionName] { - lineIndex := GetConditionLineNumber(conditionName, lines, nil) - meta := &Meta{ - File: condition.GetMetadata().GetSourceInfo().GetFile(), - Module: condition.GetMetadata().GetModule(), +// conditionUses collects where each condition is referenced, walking types in +// model order and each type's relations in name order. +func conditionUses(model *openfgav1.AuthorizationModel) map[string][]conditionUse { + uses := make(map[string][]conditionUse) + + for _, typeDef := range model.GetTypeDefinitions() { + relationsMetadata := typeDef.GetMetadata().GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + for _, restriction := range relationsMetadata[relationName].GetDirectlyRelatedUserTypes() { + if condition := restriction.GetCondition(); condition != "" { + uses[condition] = append(uses[condition], conditionUse{ + typeName: typeDef.GetType(), + relationName: relationName, + }) + } } - collector.RaiseUnusedCondition(conditionName, meta, lineIndex) } } -} -// ValidateConditionReferences validates that all referenced conditions are defined. -func ValidateConditionReferences(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateConditionReferences(collector, NewConditionValidator(model), lines) + return uses } -// reservedInlineExpression is exempt from the "condition must be defined" check -// because it is not declared in the model's conditions block. +// reservedInlineExpression marks an inline expression; it is not declared in +// the conditions block, so the undefined-condition check skips it. const reservedInlineExpression = "$expression" -func validateConditionReferences(collector *ErrorCollector, validator *ConditionValidator, lines []string) { - model := validator.model - for conditionName := range validator.usedConds { +// undefinedConditions reports, for every reference to a condition the model +// does not define, one finding per referencing relation. +func undefinedConditions(model *openfgav1.AuthorizationModel, src source, + uses map[string][]conditionUse) error { + var fs []*Finding + + defined := model.GetConditions() + + for _, conditionName := range slices.Sorted(maps.Keys(uses)) { if conditionName == reservedInlineExpression { continue } - if _, exists := validator.definedConds[conditionName]; !exists { - for _, ref := range validator.conditionRefs[conditionName] { - // Anchor the relation line lookup to the referencing type's - // declaration so the correct `define` is found when several types - // share a relation name, matching the reference. - typeLineIndex := GetTypeLineNumber(ref.TypeName, lines, nil) - lineIndex := GetRelationLineNumber(ref.RelationName, lines, typeLineIndex) - var file, module string - for _, typeDef := range model.GetTypeDefinitions() { - if typeDef.GetType() == ref.TypeName { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - module = typeDef.GetMetadata().GetModule() - break - } - } - meta := &Meta{File: file, Module: module} - collector.RaiseInvalidConditionNameInParameter(conditionName, ref.TypeName, ref.RelationName, conditionName, meta, lineIndex) - } - } - } -} -// ValidateConditionConsistency checks that each condition's nested name property -// matches its map key, mirroring the reference (validate-dsl.ts): the nested name -// is compared to the key and any difference is reported. -func ValidateConditionConsistency(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - for conditionKey, condition := range model.GetConditions() { - if condition == nil { + if _, ok := defined[conditionName]; ok { continue } - if condition.GetName() != conditionKey { - collector.RaiseDifferentNestedConditionName(conditionKey, condition.GetName()) - } - } -} -func (cv *ConditionValidator) GetDefinedConditions() []string { - conditions := make([]string, 0, len(cv.definedConds)) - for name := range cv.definedConds { - conditions = append(conditions, name) - } - return conditions -} + for _, use := range uses[conditionName] { + // Anchor the relation line lookup to the referencing type's + // declaration so the correct `define` is found when several types + // share a relation name. + line := src.relationLine(use.relationName, src.typeLine(use.typeName)) -func (cv *ConditionValidator) GetUsedConditions() []string { - conditions := make([]string, 0, len(cv.usedConds)) - for name := range cv.usedConds { - conditions = append(conditions, name) - } - return conditions -} + var file, module string + for _, typeDef := range model.GetTypeDefinitions() { + if typeDef.GetType() == use.typeName { + file, module = typeMeta(typeDef) -func (cv *ConditionValidator) IsConditionDefined(conditionName string) bool { - _, exists := cv.definedConds[conditionName] - return exists -} + break + } + } -func (cv *ConditionValidator) IsConditionUsed(conditionName string) bool { - return cv.usedConds[conditionName] -} + finding := conditionNotDefined(conditionName, use.typeName, use.relationName).at(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) + } + } -func (cv *ConditionValidator) GetConditionReferences(conditionName string) []ConditionReference { - return cv.conditionRefs[conditionName] + return joinFindings(fs...) } diff --git a/pkg/go/validation/condition_validation_test.go b/pkg/go/validation/condition_validation_test.go index 6c64c0cb..091c8e0e 100644 --- a/pkg/go/validation/condition_validation_test.go +++ b/pkg/go/validation/condition_validation_test.go @@ -5,499 +5,174 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestNewConditionValidator(t *testing.T) { - t.Run("Empty model", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{} - validator := NewConditionValidator(model) +func TestValidateConditions(t *testing.T) { + t.Parallel() - assert.NotNil(t, validator) - assert.Equal(t, model, validator.model) - assert.Empty(t, validator.definedConds) - assert.Empty(t, validator.usedConds) - assert.Empty(t, validator.conditionRefs) - }) - - t.Run("Model with conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "is_owner": {Name: "is_owner"}, - "is_admin": {Name: "is_admin"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "is_owner", - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - - assert.NotNil(t, validator) - assert.Len(t, validator.definedConds, 2) - assert.Len(t, validator.usedConds, 1) - assert.True(t, validator.IsConditionDefined("is_owner")) - assert.True(t, validator.IsConditionDefined("is_admin")) - assert.True(t, validator.IsConditionUsed("is_owner")) - assert.False(t, validator.IsConditionUsed("is_admin")) - }) -} - -func TestConditionValidator_GetDefinedConditions(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "condition1": {Name: "condition1"}, - "condition2": {Name: "condition2"}, - "condition3": {Name: "condition3"}, - }, - } - - validator := NewConditionValidator(model) - defined := validator.GetDefinedConditions() - - assert.Len(t, defined, 3) - assert.Contains(t, defined, "condition1") - assert.Contains(t, defined, "condition2") - assert.Contains(t, defined, "condition3") -} - -func TestConditionValidator_GetUsedConditions(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "used_condition": {Name: "used_condition"}, - "unused_condition": {Name: "unused_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - used := validator.GetUsedConditions() - - assert.Len(t, used, 1) - assert.Contains(t, used, "used_condition") - assert.NotContains(t, used, "unused_condition") -} + t.Run("defined and used condition yields nothing", func(t *testing.T) { + t.Parallel() -func TestConditionValidator_GetConditionReferences(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "test_condition": {Name: "test_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "test_condition", - }, - }, - }, - "editor": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "test_condition", - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - refs := validator.GetConditionReferences("test_condition") - - assert.Len(t, refs, 2) - - // Check that we have references from both viewer and editor relations - viewerFound := false - editorFound := false - for _, ref := range refs { - if ref.RelationName == "viewer" { - viewerFound = true - assert.Equal(t, "document", ref.TypeName) - assert.Equal(t, "type_restriction", ref.Context) - } - if ref.RelationName == "editor" { - editorFound = true - assert.Equal(t, "document", ref.TypeName) - assert.Equal(t, "type_restriction", ref.Context) - } - } - assert.True(t, viewerFound) - assert.True(t, editorFound) -} - -func TestValidateUnusedConditions(t *testing.T) { - t.Run("No unused conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "used_condition": {Name: "used_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateUnusedConditions(collector, model, nil) + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user with is_valid] +condition is_valid(x: int) { + x > 0 +}` - errors := collector.GetErrors() - assert.Empty(t, errors) + require.NoError(t, validateConditions(mustParse(t, dsl), newSource(dsl))) }) - t.Run("Unused condition detected", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "unused_condition": {Name: "unused_condition"}, - "used_condition": {Name: "used_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateUnusedConditions(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, ConditionNotUsed, errors[0].Metadata.ErrorType) - assert.Equal(t, "unused_condition", errors[0].Metadata.Symbol) - assert.Contains(t, errors[0].Message, "unused_condition") - assert.Contains(t, errors[0].Message, "is not used in the model") + t.Run("unused condition is reported at its declaration", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user] +condition unused(x: int) { + x > 0 +}` + + findings := ExtractAllAs[*Finding](validateConditions(mustParse(t, dsl), newSource(dsl))) + + require.Len(t, findings, 1) + assert.Equal(t, ConditionNotUsed, findings[0].Metadata.Kind) + assert.Equal(t, "unused", findings[0].Metadata.Condition) + assert.Equal(t, &Range{Start: 6, End: 6}, findings[0].Line) }) - t.Run("Multiple unused conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "unused1": {Name: "unused1"}, - "unused2": {Name: "unused2"}, - "used_condition": {Name: "used_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "used_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateUnusedConditions(collector, model, nil) + t.Run("undefined condition is reported per referencing relation", func(t *testing.T) { + t.Parallel() - errors := collector.GetErrors() - assert.Len(t, errors, 2) + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user with missing] + define editor: [user with missing]` - // Check that both unused conditions are reported - unusedConditions := make([]string, 0) - for _, err := range errors { - assert.Equal(t, ConditionNotUsed, err.Metadata.ErrorType) - unusedConditions = append(unusedConditions, err.Metadata.Symbol) - } - assert.Contains(t, unusedConditions, "unused1") - assert.Contains(t, unusedConditions, "unused2") - }) -} + findings := ExtractAllAs[*Finding](validateConditions(mustParse(t, dsl), newSource(dsl))) -func TestValidateConditionReferences(t *testing.T) { - t.Run("All referenced conditions defined", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "valid_condition": {Name: "valid_condition"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "valid_condition", - }, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionReferences(collector, model, nil) + require.Len(t, findings, 2) - errors := collector.GetErrors() - assert.Empty(t, errors) - }) - - t.Run("Undefined condition referenced", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "undefined_condition", - }, - }, - }, - }, - }, - }, - }, + for _, finding := range findings { + assert.Equal(t, ConditionNotDefined, finding.Metadata.Kind) + assert.Equal(t, "document", finding.Metadata.Type) } - collector := NewErrorCollector(nil) - ValidateConditionReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, ConditionNotDefined, errors[0].Metadata.ErrorType) - assert.Equal(t, "undefined_condition", errors[0].Metadata.Symbol) - assert.Contains(t, errors[0].Message, "undefined_condition") + // Relations are walked in name order. + assert.Equal(t, "editor", findings[0].Metadata.Relation) + assert.Equal(t, "viewer", findings[1].Metadata.Relation) }) - t.Run("Multiple undefined conditions", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "undefined1", - }, - }, - }, - "editor": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "undefined2", - }, - }, - }, - }, - }, - }, - }, - } + t.Run("undefined conditions are reported before unused ones", func(t *testing.T) { + t.Parallel() - collector := NewErrorCollector(nil) - ValidateConditionReferences(collector, model, nil) + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user with missing] +condition marked(x: int) { + x > 0 +}` - errors := collector.GetErrors() - assert.Len(t, errors, 2) + findings := ExtractAllAs[*Finding](validateConditions(mustParse(t, dsl), newSource(dsl))) - // Check that both undefined conditions are reported - undefinedConditions := make([]string, 0) - for _, err := range errors { - assert.Equal(t, ConditionNotDefined, err.Metadata.ErrorType) - undefinedConditions = append(undefinedConditions, err.Metadata.Symbol) - } - assert.Contains(t, undefinedConditions, "undefined1") - assert.Contains(t, undefinedConditions, "undefined2") + require.Len(t, findings, 2) + assert.Equal(t, ConditionNotDefined, findings[0].Metadata.Kind) + assert.Equal(t, ConditionNotUsed, findings[1].Metadata.Kind) }) -} -func TestValidateConditionConsistency(t *testing.T) { - t.Run("Valid condition consistency", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "valid_condition": {Name: "valid_condition"}, - }, - } - - collector := NewErrorCollector(nil) - ValidateConditionConsistency(collector, model, nil) - - errors := collector.GetErrors() - assert.Empty(t, errors) + t.Run("a nested name differing from its key carries no position", func(t *testing.T) { + t.Parallel() + + // The transformer always writes matching names, so build the mismatch + // directly, as a hand-written JSON model could carry it. + model := mustParse(t, `model + schema 1.1 +type user +type document + relations + define viewer: [user with is_valid] +condition is_valid(x: int) { + x > 0 +}`) + model.Conditions["is_valid"].Name = "other_name" + + findings := ExtractAllAs[*Finding](validateConditions(model, source{})) + + require.Len(t, findings, 1) + assert.Equal(t, DifferentNestedConditionName, findings[0].Metadata.Kind) + assert.Equal(t, "other_name", findings[0].Metadata.Symbol) + assert.Equal(t, "is_valid", findings[0].Metadata.Condition) + assert.Nil(t, findings[0].Line) }) - t.Run("Nested name differs from map key", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "in_office": {Name: "different_name"}, - }, - } + t.Run("the reserved inline expression needs no definition", func(t *testing.T) { + t.Parallel() - collector := NewErrorCollector(nil) - ValidateConditionConsistency(collector, model, nil) + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user with $expression]` - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, DifferentNestedConditionName, errors[0].Metadata.ErrorType) - assert.Equal(t, "condition key is `in_office` but nested name property is different_name", errors[0].Message) + require.NoError(t, validateConditions(mustParse(t, dsl), newSource(dsl))) }) - t.Run("Empty name matching empty key is consistent", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "": {Name: ""}, - }, + t.Run("findings carry the condition's file and module", func(t *testing.T) { + t.Parallel() + + model := mustParse(t, `model + schema 1.1 +type user +condition unused(x: int) { + x > 0 +}`) + model.Conditions["unused"].Metadata = &openfgav1.ConditionMetadata{ + Module: "core", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, } - collector := NewErrorCollector(nil) - ValidateConditionConsistency(collector, model, nil) + findings := ExtractAllAs[*Finding](validateConditions(model, source{})) - assert.Empty(t, collector.GetErrors()) + require.Len(t, findings, 1) + assert.Equal(t, "core.fga", findings[0].File) + assert.Equal(t, "core", findings[0].Metadata.Module) }) } -func TestScanForConditionUsage(t *testing.T) { - t.Run("Complex condition usage scanning", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - Conditions: map[string]*openfgav1.Condition{ - "condition1": {Name: "condition1"}, - "condition2": {Name: "condition2"}, - "condition3": {Name: "condition3"}, - }, - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - { - Type: "user", - Condition: "condition1", - }, - { - Type: "group", - Condition: "condition2", - }, - }, - }, - }, - }, - Relations: map[string]*openfgav1.Userset{ - "editor": { - Userset: &openfgav1.Userset_Union{ - Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - validator := NewConditionValidator(model) - - assert.True(t, validator.IsConditionUsed("condition1")) - assert.True(t, validator.IsConditionUsed("condition2")) - assert.False(t, validator.IsConditionUsed("condition3")) - - // Check condition references - refs1 := validator.GetConditionReferences("condition1") - refs2 := validator.GetConditionReferences("condition2") - refs3 := validator.GetConditionReferences("condition3") - - assert.Len(t, refs1, 1) - assert.Len(t, refs2, 1) - assert.Empty(t, refs3) - - assert.Equal(t, "document", refs1[0].TypeName) - assert.Equal(t, "viewer", refs1[0].RelationName) - assert.Equal(t, "type_restriction", refs1[0].Context) - }) +func TestConditionUses(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type folder + relations + define owner: [user with is_valid] +type document + relations + define viewer: [user with is_valid, user:* with is_valid]` + + uses := conditionUses(mustParse(t, dsl)) + + // One entry per referencing type restriction, types in model order. + require.Len(t, uses, 1) + assert.Equal(t, []conditionUse{ + {typeName: "folder", relationName: "owner"}, + {typeName: "document", relationName: "viewer"}, + {typeName: "document", relationName: "viewer"}, + }, uses["is_valid"]) } diff --git a/pkg/go/validation/context.go b/pkg/go/validation/context.go deleted file mode 100644 index 57b9b99f..00000000 --- a/pkg/go/validation/context.go +++ /dev/null @@ -1,131 +0,0 @@ -package validation - -import ( - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// ValidationContext holds the state during model validation -type ValidationContext struct { - TypeMap map[string]*openfgav1.TypeDefinition - VisitedRelations map[string]map[string]bool - UsedConditionNames map[string]bool - FileToModuleMap map[string]map[string]bool - Conditions map[string]*openfgav1.Condition - Lines []string -} - -func NewValidationContext(lines []string) *ValidationContext { - return &ValidationContext{ - TypeMap: make(map[string]*openfgav1.TypeDefinition), - VisitedRelations: make(map[string]map[string]bool), - UsedConditionNames: make(map[string]bool), - FileToModuleMap: make(map[string]map[string]bool), - Conditions: make(map[string]*openfgav1.Condition), - Lines: lines, - } -} - -func (ctx *ValidationContext) AddType(typeName string, typeDef *openfgav1.TypeDefinition) { - ctx.TypeMap[typeName] = typeDef -} - -func (ctx *ValidationContext) GetType(typeName string) (*openfgav1.TypeDefinition, bool) { - typeDef, exists := ctx.TypeMap[typeName] - return typeDef, exists -} - -func (ctx *ValidationContext) MarkRelationVisited(typeName, relationName string) { - if ctx.VisitedRelations[typeName] == nil { - ctx.VisitedRelations[typeName] = make(map[string]bool) - } - ctx.VisitedRelations[typeName][relationName] = true -} - -func (ctx *ValidationContext) IsRelationVisited(typeName, relationName string) bool { - if ctx.VisitedRelations[typeName] == nil { - return false - } - return ctx.VisitedRelations[typeName][relationName] -} - -func (ctx *ValidationContext) MarkConditionUsed(conditionName string) { - ctx.UsedConditionNames[conditionName] = true -} - -func (ctx *ValidationContext) IsConditionUsed(conditionName string) bool { - return ctx.UsedConditionNames[conditionName] -} - -func (ctx *ValidationContext) AddModuleToFile(filename, module string) { - if ctx.FileToModuleMap[filename] == nil { - ctx.FileToModuleMap[filename] = make(map[string]bool) - } - ctx.FileToModuleMap[filename][module] = true -} - -func (ctx *ValidationContext) GetModulesForFile(filename string) []string { - modules := make([]string, 0, len(ctx.FileToModuleMap[filename])) - if moduleMap := ctx.FileToModuleMap[filename]; moduleMap != nil { - for module := range moduleMap { - modules = append(modules, module) - } - } - return modules -} - -func (ctx *ValidationContext) HasMultipleModulesInFile(filename string) bool { - return len(ctx.GetModulesForFile(filename)) > 1 -} - -func (ctx *ValidationContext) DeepCopyVisitedRelations() map[string]map[string]bool { - cp := make(map[string]map[string]bool) - for typeName, relations := range ctx.VisitedRelations { - cp[typeName] = make(map[string]bool) - for relationName, visited := range relations { - cp[typeName][relationName] = visited - } - } - return cp -} - -// RelationTargetParserResult represents the result of parsing a relation target. -type RelationTargetParserResult struct { - Target string `json:"target,omitempty"` - From string `json:"from,omitempty"` - Rewrite RewriteType `json:"rewrite"` -} - -// RewriteType represents the type of rewrite operation. -type RewriteType string - -const ( - RewriteDirect RewriteType = "direct" - RewriteComputedUserset RewriteType = "computed_userset" - RewriteTupleToUserset RewriteType = "tuple_to_userset" -) - -// EntryPointResult represents the result of entry point analysis. -type EntryPointResult struct { - HasEntry bool `json:"hasEntry"` - Loop bool `json:"loop"` -} - -// DestructedAssignableType represents a parsed assignable type. -type DestructedAssignableType struct { - DecodedType string `json:"decodedType"` - DecodedRelation string `json:"decodedRelation,omitempty"` - IsWildcard bool `json:"isWildcard"` - DecodedCondition string `json:"decodedConditionName,omitempty"` -} - -// ValidationRegex represents a validation rule with regex pattern. -type ValidationRegex struct { - Rule string `json:"rule"` - Regex string `json:"regex"` -} - -// ValidationOptions represents options for validation. -type ValidationOptions struct { - TypeValidation string `json:"typeValidation,omitempty"` - RelationValidation string `json:"relationValidation,omitempty"` -} diff --git a/pkg/go/validation/context_test.go b/pkg/go/validation/context_test.go deleted file mode 100644 index 382bacaf..00000000 --- a/pkg/go/validation/context_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" -) - -func TestNewValidationContext(t *testing.T) { - lines := []string{"line 1", "line 2", "line 3"} - ctx := NewValidationContext(lines) - - assert.NotNil(t, ctx) - assert.NotNil(t, ctx.TypeMap) - assert.NotNil(t, ctx.VisitedRelations) - assert.NotNil(t, ctx.UsedConditionNames) - assert.NotNil(t, ctx.FileToModuleMap) - assert.NotNil(t, ctx.Conditions) - assert.Equal(t, lines, ctx.Lines) - - // Test that maps are initialized - assert.Empty(t, ctx.TypeMap) - assert.Empty(t, ctx.VisitedRelations) - assert.Empty(t, ctx.UsedConditionNames) - assert.Empty(t, ctx.FileToModuleMap) - assert.Empty(t, ctx.Conditions) -} - -func TestValidationContext_AddType(t *testing.T) { - ctx := NewValidationContext(nil) - - typeDef := &openfgav1.TypeDefinition{ - Type: "document", - } - - ctx.AddType("document", typeDef) - - assert.Len(t, ctx.TypeMap, 1) - assert.Equal(t, typeDef, ctx.TypeMap["document"]) -} - -func TestValidationContext_GetType(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test getting non-existent type - typeDef, exists := ctx.GetType("document") - assert.Nil(t, typeDef) - assert.False(t, exists) - - // Add type and test getting it - expectedTypeDef := &openfgav1.TypeDefinition{ - Type: "document", - } - ctx.AddType("document", expectedTypeDef) - - typeDef, exists = ctx.GetType("document") - assert.Equal(t, expectedTypeDef, typeDef) - assert.True(t, exists) -} - -func TestValidationContext_MarkRelationVisited(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no relations are visited - assert.False(t, ctx.IsRelationVisited("document", "viewer")) - - // Mark relation as visited - ctx.MarkRelationVisited("document", "viewer") - - // Check that it's now visited - assert.True(t, ctx.IsRelationVisited("document", "viewer")) - - // Check that other relations are not visited - assert.False(t, ctx.IsRelationVisited("document", "admin")) - assert.False(t, ctx.IsRelationVisited("user", "viewer")) -} - -func TestValidationContext_IsRelationVisited(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test with non-existent type - assert.False(t, ctx.IsRelationVisited("nonexistent", "relation")) - - // Test with existing type but non-existent relation - ctx.MarkRelationVisited("document", "viewer") - assert.False(t, ctx.IsRelationVisited("document", "nonexistent")) - - // Test with existing type and relation - assert.True(t, ctx.IsRelationVisited("document", "viewer")) -} - -func TestValidationContext_MultipleRelationsPerType(t *testing.T) { - ctx := NewValidationContext(nil) - - // Mark multiple relations for the same type - ctx.MarkRelationVisited("document", "viewer") - ctx.MarkRelationVisited("document", "admin") - ctx.MarkRelationVisited("document", "owner") - - // All should be visited - assert.True(t, ctx.IsRelationVisited("document", "viewer")) - assert.True(t, ctx.IsRelationVisited("document", "admin")) - assert.True(t, ctx.IsRelationVisited("document", "owner")) - - // Other types should not be affected - assert.False(t, ctx.IsRelationVisited("user", "viewer")) -} - -func TestValidationContext_MarkConditionUsed(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no conditions are used - assert.False(t, ctx.IsConditionUsed("condition1")) - - // Mark condition as used - ctx.MarkConditionUsed("condition1") - - // Check that it's now used - assert.True(t, ctx.IsConditionUsed("condition1")) - - // Check that other conditions are not used - assert.False(t, ctx.IsConditionUsed("condition2")) -} - -func TestValidationContext_IsConditionUsed(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test with non-existent condition - assert.False(t, ctx.IsConditionUsed("nonexistent")) - - // Mark condition and test - ctx.MarkConditionUsed("test_condition") - assert.True(t, ctx.IsConditionUsed("test_condition")) - assert.False(t, ctx.IsConditionUsed("other_condition")) -} - -func TestValidationContext_AddModuleToFile(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no modules - modules := ctx.GetModulesForFile("test.fga") - assert.Empty(t, modules) - - // Add module to file - ctx.AddModuleToFile("test.fga", "module1") - - // Check that module is added - modules = ctx.GetModulesForFile("test.fga") - assert.Len(t, modules, 1) - assert.Contains(t, modules, "module1") -} - -func TestValidationContext_GetModulesForFile(t *testing.T) { - ctx := NewValidationContext(nil) - - // Test with non-existent file - modules := ctx.GetModulesForFile("nonexistent.fga") - assert.Empty(t, modules) - - // Add multiple modules to same file - ctx.AddModuleToFile("test.fga", "module1") - ctx.AddModuleToFile("test.fga", "module2") - ctx.AddModuleToFile("test.fga", "module3") - - modules = ctx.GetModulesForFile("test.fga") - assert.Len(t, modules, 3) - assert.Contains(t, modules, "module1") - assert.Contains(t, modules, "module2") - assert.Contains(t, modules, "module3") - - // Test that other files are not affected - otherModules := ctx.GetModulesForFile("other.fga") - assert.Empty(t, otherModules) -} - -func TestValidationContext_HasMultipleModulesInFile(t *testing.T) { - ctx := NewValidationContext(nil) - - // Initially no modules - assert.False(t, ctx.HasMultipleModulesInFile("test.fga")) - - // Add single module - ctx.AddModuleToFile("test.fga", "module1") - assert.False(t, ctx.HasMultipleModulesInFile("test.fga")) - - // Add second module - ctx.AddModuleToFile("test.fga", "module2") - assert.True(t, ctx.HasMultipleModulesInFile("test.fga")) - - // Test with non-existent file - assert.False(t, ctx.HasMultipleModulesInFile("nonexistent.fga")) -} - -func TestValidationContext_DeepCopyVisitedRelations(t *testing.T) { - ctx := NewValidationContext(nil) - - // Add some visited relations - ctx.MarkRelationVisited("document", "viewer") - ctx.MarkRelationVisited("document", "admin") - ctx.MarkRelationVisited("user", "member") - - // Create deep copy - copied := ctx.DeepCopyVisitedRelations() - - // Verify copy has same content - assert.True(t, copied["document"]["viewer"]) - assert.True(t, copied["document"]["admin"]) - assert.True(t, copied["user"]["member"]) - - // Modify original - ctx.MarkRelationVisited("document", "owner") - - // Verify copy is not affected - assert.False(t, copied["document"]["owner"]) - assert.True(t, ctx.IsRelationVisited("document", "owner")) - - // Modify copy - copied["user"]["admin"] = true - - // Verify original is not affected - assert.False(t, ctx.IsRelationVisited("user", "admin")) -} - -func TestRewriteType(t *testing.T) { - // Test that rewrite type constants are defined correctly - assert.Equal(t, "direct", string(RewriteDirect)) - assert.Equal(t, "computed_userset", string(RewriteComputedUserset)) - assert.Equal(t, "tuple_to_userset", string(RewriteTupleToUserset)) -} - -func TestRelationTargetParserResult(t *testing.T) { - result := RelationTargetParserResult{ - Target: "viewer", - From: "parent", - Rewrite: RewriteTupleToUserset, - } - - assert.Equal(t, "viewer", result.Target) - assert.Equal(t, "parent", result.From) - assert.Equal(t, RewriteTupleToUserset, result.Rewrite) -} - -func TestEntryPointResult(t *testing.T) { - result := EntryPointResult{ - HasEntry: true, - Loop: false, - } - - assert.True(t, result.HasEntry) - assert.False(t, result.Loop) -} - -func TestDestructedAssignableType(t *testing.T) { - assignable := DestructedAssignableType{ - DecodedType: "user", - DecodedRelation: "member", - IsWildcard: false, - DecodedCondition: "condition1", - } - - assert.Equal(t, "user", assignable.DecodedType) - assert.Equal(t, "member", assignable.DecodedRelation) - assert.False(t, assignable.IsWildcard) - assert.Equal(t, "condition1", assignable.DecodedCondition) -} - -func TestValidationRegex(t *testing.T) { - regex := ValidationRegex{ - Rule: "[a-zA-Z]+", - Regex: "^[a-zA-Z]+$", - } - - assert.Equal(t, "[a-zA-Z]+", regex.Rule) - assert.Equal(t, "^[a-zA-Z]+$", regex.Regex) -} - -func TestValidationOptions(t *testing.T) { - options := ValidationOptions{ - TypeValidation: "strict", - RelationValidation: "loose", - } - - assert.Equal(t, "strict", options.TypeValidation) - assert.Equal(t, "loose", options.RelationValidation) -} - -func TestValidationContext_Integration(t *testing.T) { - // Test a more complex integration scenario - lines := []string{ - "model", - " schema 1.1", - "type document", - " relations", - " define viewer: [user]", - " define admin: [user]", - "type user", - } - - ctx := NewValidationContext(lines) - - // Add types - docType := &openfgav1.TypeDefinition{Type: "document"} - userType := &openfgav1.TypeDefinition{Type: "user"} - - ctx.AddType("document", docType) - ctx.AddType("user", userType) - - // Mark relations as visited during validation - ctx.MarkRelationVisited("document", "viewer") - ctx.MarkRelationVisited("document", "admin") - - // Mark conditions as used - ctx.MarkConditionUsed("is_owner") - - // Add modules to files - ctx.AddModuleToFile("model.fga", "main") - ctx.AddModuleToFile("permissions.fga", "permissions") - ctx.AddModuleToFile("permissions.fga", "conditions") // Multiple modules in one file - - // Verify state - assert.Len(t, ctx.TypeMap, 2) - assert.True(t, ctx.IsRelationVisited("document", "viewer")) - assert.True(t, ctx.IsRelationVisited("document", "admin")) - assert.False(t, ctx.IsRelationVisited("user", "member")) - assert.True(t, ctx.IsConditionUsed("is_owner")) - assert.False(t, ctx.IsConditionUsed("is_admin")) - assert.False(t, ctx.HasMultipleModulesInFile("model.fga")) - assert.True(t, ctx.HasMultipleModulesInFile("permissions.fga")) - - // Test deep copy doesn't affect original - copied := ctx.DeepCopyVisitedRelations() - // Initialize user map if it doesn't exist - if copied["user"] == nil { - copied["user"] = make(map[string]bool) - } - copied["user"]["member"] = true - assert.False(t, ctx.IsRelationVisited("user", "member")) -} diff --git a/pkg/go/validation/cycle_detection.go b/pkg/go/validation/cycle_detection.go index e1c831ff..301cf0c9 100644 --- a/pkg/go/validation/cycle_detection.go +++ b/pkg/go/validation/cycle_detection.go @@ -1,99 +1,70 @@ package validation import ( + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// entryPointResult mirrors the reference implementation's { hasEntry, loop }. +// entryPointResult mirrors pkg/js's { hasEntry, loop }. type entryPointResult struct { hasEntry bool loop bool } -// CycleDetector walks relation rewrites to determine whether each relation has a -// concrete entry point (a directly-assignable type that is not itself a relation -// reference) or is otherwise impossible — either because it bottoms out with no -// entry point, or because it loops back on itself. -// -// This is a port of the reference implementation's hasEntryPointOrLoop -// (pkg/js/validator/validate-dsl.ts): a single traversal per relation that -// yields exactly one outcome, rather than separate cycle and entry-point passes. -type CycleDetector struct { - validator *SemanticValidator -} - -func NewCycleDetector(validator *SemanticValidator) *CycleDetector { - return &CycleDetector{validator: validator} -} - -// ValidateCyclesAndEntryPoints reports relations that have no entry point. A -// relation with no entry point is impossible: either it never reaches a concrete -// assignable type (no entrypoint) or it forms a rewrite loop (potential loop). -func ValidateCyclesAndEntryPoints(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateCyclesAndEntryPoints(collector, NewSemanticValidator(model), lines) -} - -func validateCyclesAndEntryPoints(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - detector := NewCycleDetector(validator) +// validateEntryPoints reports relations that have no entry point. Such a +// relation is impossible: either it never reaches a concrete assignable type +// (no entrypoint) or it forms a rewrite loop (potential loop). +func validateEntryPoints(idx *index, src source) error { + var fs []*Finding - for _, typeDef := range model.GetTypeDefinitions() { + for _, typeDef := range idx.model.GetTypeDefinitions() { relations := typeDef.GetRelations() if len(relations) == 0 { continue } + typeName := typeDef.GetType() - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - for relationName, userset := range relations { - meta := relationMeta(typeDef, relationName) - result := detector.hasEntryPointOrLoop(typeName, relationName, userset, map[string]map[string]bool{}) - if !result.hasEntry { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - if result.loop { - collector.RaiseNoEntryPointLoop(relationName, typeName, meta, lineIndex) - } else { - collector.RaiseNoEntryPoint(relationName, typeName, meta, lineIndex) - } + typeLine := src.typeLine(typeName) + + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + result := hasEntryPointOrLoop(idx, typeName, relationName, relations[relationName], + map[string]map[string]bool{}) + if result.hasEntry { + continue } - } - } -} -// relationMeta resolves the file/module for a relation, falling back to the type. -func relationMeta(typeDef *openfgav1.TypeDefinition, relationName string) *Meta { - if rm, ok := typeDef.GetMetadata().GetRelations()[relationName]; ok { - file := rm.GetSourceInfo().GetFile() - module := rm.GetModule() - if file == "" { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - } - if module == "" { - module = typeDef.GetMetadata().GetModule() + file, module := relationMeta(typeDef, relationName) + line := src.relationLine(relationName, typeLine) + + finding := noEntryPoint(relationName, typeName) + if result.loop { + finding = noEntryPointLoop(relationName, typeName) + } + + finding.at(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } - return &Meta{File: file, Module: module} - } - return &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), } + + return joinFindings(fs...) } -// hasEntryPointOrLoop determines whether a rewrite reaches a concrete entry point. -// visited tracks type#relation pairs already on the current traversal so that a -// rewrite referencing a relation already being resolved is reported as a loop. +// hasEntryPointOrLoop determines whether a rewrite reaches a concrete entry +// point, porting hasEntryPointOrLoop from pkg/js's validate-dsl.ts: one +// traversal per relation with one outcome. The visited map tracks +// type#relation pairs already on the current traversal. // -// Sibling branches (the this/ttu type loops, union/intersection children, and a -// difference's base/subtract) each get an isolated copy of visited so one -// branch's path can't poison another's loop check. The lone linear successor — -// the computed-userset tail call — shares visited directly: it accumulates down -// the chain to detect back-edges, and avoids an O(n²) copy on deep chains. -func (cd *CycleDetector) hasEntryPointOrLoop(typeName, relationName string, +// Only the computed-userset branch turns a revisit into a reported loop; the +// direct type-relation and tuple-to-userset branches skip a reference already +// being resolved and answer loop: false, as pkg/js does. +// +// Sibling branches each get a copy of visited so one branch's path can't +// poison another's loop check; the computed-userset tail call shares it to +// detect back-edges without an O(n²) copy on deep chains. +func hasEntryPointOrLoop(idx *index, typeName, relationName string, rewrite *openfgav1.Userset, visited map[string]map[string]bool) entryPointResult { if relationName == "" || rewrite == nil { return entryPointResult{} @@ -102,136 +73,137 @@ func (cd *CycleDetector) hasEntryPointOrLoop(typeName, relationName string, if visited[typeName] == nil { visited[typeName] = map[string]bool{} } + visited[typeName][relationName] = true - if !cd.validator.RelationDefined(typeName, relationName) { + if !idx.relationDefined(typeName, relationName) { return entryPointResult{} } switch rewrite.GetUserset().(type) { case *openfgav1.Userset_This: // A direct assignment has an entry point if any assignable type is a - // concrete type or wildcard. A type#relation restriction only provides an - // entry point if that referenced relation itself has one. - for _, tr := range cd.directTypeRestrictions(typeName, relationName) { - decodedType := tr.GetType() - decodedRelation := tr.GetRelation() - isWildcard := tr.GetWildcard() != nil - - if decodedRelation == "" || isWildcard { + // concrete type or wildcard. A type#relation restriction only provides + // an entry point if that referenced relation itself has one. + for _, restriction := range idx.directTypeRestrictions(typeName, relationName) { + restrictedType := restriction.GetType() + restrictedRelation := restriction.GetRelation() + + if restrictedRelation == "" || restriction.GetWildcard() != nil { return entryPointResult{hasEntry: true} } - assignable := cd.validator.GetRelationUserset(decodedType, decodedRelation) + + assignable := idx.userset(restrictedType, restrictedRelation) if assignable == nil { - // Matches validate-dsl.ts: returns on the first missing reference - // rather than trying later types. Unreachable in practice (the - // reference pass + cascade gate run first). + // Matches validate-dsl.ts: returns on the first missing + // reference rather than trying later types. Unreachable through + // validate, which reports the missing relation and skips this + // phase. return entryPointResult{} } - if visited[decodedType][decodedRelation] { + + if visited[restrictedType][restrictedRelation] { continue } - if cd.hasEntryPointOrLoop(decodedType, decodedRelation, assignable, copyVisited(visited)).hasEntry { + + if hasEntryPointOrLoop(idx, restrictedType, restrictedRelation, assignable, copyVisited(visited)).hasEntry { return entryPointResult{hasEntry: true} } } + return entryPointResult{} case *openfgav1.Userset_ComputedUserset: computed := rewrite.GetComputedUserset().GetRelation() - if computed == "" || !cd.validator.RelationDefined(typeName, computed) { + if computed == "" || !idx.relationDefined(typeName, computed) { return entryPointResult{} } + if visited[typeName][computed] { return entryPointResult{loop: true} } - // Linear successor: share visited so the chain accumulates (see doc above). - return cd.hasEntryPointOrLoop(typeName, computed, cd.validator.GetRelationUserset(typeName, computed), visited) + + // Linear successor: share visited so the chain accumulates (see above). + return hasEntryPointOrLoop(idx, typeName, computed, idx.userset(typeName, computed), visited) case *openfgav1.Userset_TupleToUserset: ttu := rewrite.GetTupleToUserset() tupleset := ttu.GetTupleset().GetRelation() computed := ttu.GetComputedUserset().GetRelation() - if tupleset == "" || computed == "" { - return entryPointResult{} - } - if !cd.validator.RelationDefined(typeName, tupleset) { + + if tupleset == "" || computed == "" || !idx.relationDefined(typeName, tupleset) { return entryPointResult{} } - for _, tr := range cd.directTypeRestrictions(typeName, tupleset) { - assignableType := tr.GetType() - assignable := cd.validator.GetRelationUserset(assignableType, computed) + + for _, restriction := range idx.directTypeRestrictions(typeName, tupleset) { + assignableType := restriction.GetType() + + assignable := idx.userset(assignableType, computed) if assignable == nil { continue } + if visited[assignableType][computed] { continue } - if cd.hasEntryPointOrLoop(assignableType, computed, assignable, copyVisited(visited)).hasEntry { + + if hasEntryPointOrLoop(idx, assignableType, computed, assignable, copyVisited(visited)).hasEntry { return entryPointResult{hasEntry: true} } } + return entryPointResult{} case *openfgav1.Userset_Union: hasLoop := false + for _, child := range rewrite.GetUnion().GetChild() { - res := cd.hasEntryPointOrLoop(typeName, relationName, child, copyVisited(visited)) - if res.hasEntry { + result := hasEntryPointOrLoop(idx, typeName, relationName, child, copyVisited(visited)) + if result.hasEntry { return entryPointResult{hasEntry: true} } - hasLoop = hasLoop || res.loop + + hasLoop = hasLoop || result.loop } + return entryPointResult{loop: hasLoop} case *openfgav1.Userset_Intersection: for _, child := range rewrite.GetIntersection().GetChild() { - res := cd.hasEntryPointOrLoop(typeName, relationName, child, copyVisited(visited)) - if !res.hasEntry { - return entryPointResult{loop: res.loop} + result := hasEntryPointOrLoop(idx, typeName, relationName, child, copyVisited(visited)) + if !result.hasEntry { + return entryPointResult{loop: result.loop} } } + return entryPointResult{hasEntry: true} case *openfgav1.Userset_Difference: diff := rewrite.GetDifference() - base := cd.hasEntryPointOrLoop(typeName, relationName, diff.GetBase(), copyVisited(visited)) + + base := hasEntryPointOrLoop(idx, typeName, relationName, diff.GetBase(), copyVisited(visited)) if !base.hasEntry { return entryPointResult{loop: base.loop} } - subtract := cd.hasEntryPointOrLoop(typeName, relationName, diff.GetSubtract(), copyVisited(visited)) + + subtract := hasEntryPointOrLoop(idx, typeName, relationName, diff.GetSubtract(), copyVisited(visited)) if !subtract.hasEntry { return entryPointResult{loop: subtract.loop} } + return entryPointResult{hasEntry: true} } return entryPointResult{} } -// directTypeRestrictions returns the directly-related user types declared for a -// relation in its metadata. -func (cd *CycleDetector) directTypeRestrictions(typeName, relationName string) []*openfgav1.RelationReference { - typeDef := cd.validator.GetTypeDefinition(typeName) - if typeDef == nil { - return nil - } - rm, ok := typeDef.GetMetadata().GetRelations()[relationName] - if !ok { - return nil - } - return rm.GetDirectlyRelatedUserTypes() -} - // copyVisited deep-copies the visited map so sibling branches don't share state. func copyVisited(src map[string]map[string]bool) map[string]map[string]bool { dst := make(map[string]map[string]bool, len(src)) + for typeName, relations := range src { - inner := make(map[string]bool, len(relations)) - for relationName, v := range relations { - inner[relationName] = v - } - dst[typeName] = inner + dst[typeName] = maps.Clone(relations) } + return dst } diff --git a/pkg/go/validation/cycle_detection_stress_test.go b/pkg/go/validation/cycle_detection_stress_test.go index c238ed2f..ed984a34 100644 --- a/pkg/go/validation/cycle_detection_stress_test.go +++ b/pkg/go/validation/cycle_detection_stress_test.go @@ -25,9 +25,10 @@ func buildDeepChainDSL(depth int) string { return b.String() } -// buildWideUnionDSL builds a relation whose rewrite is a wide union of computed -// usersets, each resolving down its own path, stressing the sibling-isolating -// copies in the union branch. +// buildWideUnionDSL builds a relation `wide` that is a union of width computed +// usersets. Every member but the last computes itself and has no entry point; +// the last resolves through base, so the union reaches it only after walking +// every other member. func buildWideUnionDSL(width int) string { var b strings.Builder b.WriteString("model\n schema 1.1\ntype user\n") @@ -36,51 +37,58 @@ func buildWideUnionDSL(width int) string { members := make([]string, 0, width) for i := 0; i < width; i++ { name := fmt.Sprintf("m%d", i) - fmt.Fprintf(&b, " define %s: base\n", name) + target := name + if i == width-1 { + target = "base" + } + fmt.Fprintf(&b, " define %s: %s\n", name, target) members = append(members, name) } fmt.Fprintf(&b, " define wide: %s\n", strings.Join(members, " or ")) return b.String() } -// TestCycleDetection_DeepChainTerminatesWithEntry verifies a long linear chain -// of computed usersets terminating in a direct assignment resolves with an entry -// point and terminates. Guards that the shared visited map still flows down the -// chain after dropping the per-call copy. -func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { - dsl := buildDeepChainDSL(1000) +// entryPointsFor transforms the DSL and runs the entry-point phase alone. +func entryPointsFor(t *testing.T, dsl string) []*Finding { + t.Helper() + model, err := transformer.TransformDSLToProto(dsl) if err != nil { t.Fatalf("failed to transform DSL: %v", err) } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - ValidateCyclesAndEntryPoints(collector, model, lines) + return ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), newSource(dsl))) +} + +// TestCycleDetection_DeepChainTerminatesWithEntry checks that a chain of a +// thousand computed usersets ending in a direct assignment resolves with an +// entry point. +func TestCycleDetection_DeepChainTerminatesWithEntry(t *testing.T) { + findings := entryPointsFor(t, buildDeepChainDSL(1000)) - if collector.HasErrors() { + if len(findings) > 0 { t.Fatalf("deep computed-userset chain ending in a direct assignment should "+ - "have an entry point, got %d errors: %v", collector.Count(), collector.GetErrors()) + "have an entry point, got %d findings: %v", len(findings), findings) } } -// TestCycleDetection_WideUnionTerminatesWithEntry verifies that a wide union of -// computed usersets, all reachable down to a concrete type, resolves with an -// entry point and that the sibling-isolating copies don't change the outcome. +// TestCycleDetection_WideUnionTerminatesWithEntry checks that a union keeps +// looking past members with no entry point: `wide` resolves through its last +// member, after 999 that loop. func TestCycleDetection_WideUnionTerminatesWithEntry(t *testing.T) { - dsl := buildWideUnionDSL(1000) - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) + const width = 1000 + findings := entryPointsFor(t, buildWideUnionDSL(width)) - ValidateCyclesAndEntryPoints(collector, model, lines) + for _, finding := range findings { + if finding.Metadata.Relation == "wide" { + t.Fatalf("union with a resolvable last member should have an entry "+ + "point, but got finding: %s", finding.Message) + } + } - if collector.HasErrors() { - t.Fatalf("wide union of resolvable members should have an entry point, "+ - "got %d errors: %v", collector.Count(), collector.GetErrors()) + // Each looping member is reported on its own. + if len(findings) != width-1 { + t.Fatalf("expected %d no-entry-point findings, got %d", width-1, len(findings)) } } @@ -88,34 +96,26 @@ func TestCycleDetection_WideUnionTerminatesWithEntry(t *testing.T) { // computes itself through a chain (a->b->c->a) is still detected as a loop with // no entry point — the shared visited map must accumulate to see the back-edge. func TestCycleDetection_SelfReferentialChainIsLoop(t *testing.T) { - dsl := `model + findings := entryPointsFor(t, `model schema 1.1 type user type doc relations define a: b define b: c - define c: a` - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - - ValidateCyclesAndEntryPoints(collector, model, lines) + define c: a`) - if !collector.HasErrors() { + if len(findings) == 0 { t.Fatal("self-referential computed chain a->b->c->a should be reported as " + "having no entry point") } } -// TestCycleDetection_UnionSiblingIsolation verifies a looping union member does -// not poison a sibling that has an entry point: `mixed: loops or direct` resolves -// with an entry point. This is what the per-branch copyVisited must preserve. +// TestCycleDetection_UnionSiblingIsolation checks that a looping union member +// does not stop the union from finding a sibling's entry point: `mixed: loops or +// direct` resolves through direct. func TestCycleDetection_UnionSiblingIsolation(t *testing.T) { - dsl := `model + findings := entryPointsFor(t, `model schema 1.1 type user type doc @@ -123,53 +123,35 @@ type doc define direct: [user] define loops: selfref define selfref: loops - define mixed: loops or direct` - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - - ValidateCyclesAndEntryPoints(collector, model, lines) + define mixed: loops or direct`) // `loops` and `selfref` legitimately have no entry point and are reported. // `mixed` and `direct` must NOT be reported. - for _, e := range collector.GetErrors() { - if strings.Contains(e.Message, "mixed") || strings.Contains(e.Message, "`direct`") { + for _, finding := range findings { + if strings.Contains(finding.Message, "mixed") || strings.Contains(finding.Message, "`direct`") { t.Fatalf("relation with a resolvable union branch should have an entry "+ - "point, but got error: %s", e.Message) + "point, but got finding: %s", finding.Message) } } } -// TestCycleDetection_DeepChainCountStable checks a chain whose base self-loops -// yields exactly one no-entry-point report per relation — the optimization must -// not suppress or duplicate findings. -func TestCycleDetection_DeepChainCountStable(t *testing.T) { - var b strings.Builder - b.WriteString("model\n schema 1.1\ntype user\ntype doc\n relations\n") - b.WriteString(" define base: base\n") - prev := "base" - const depth = 50 - for i := 0; i < depth; i++ { - name := fmt.Sprintf("r%d", i) - fmt.Fprintf(&b, " define %s: %s\n", name, prev) - prev = name - } - dsl := b.String() - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - t.Fatalf("failed to transform DSL: %v", err) - } - lines := strings.Split(dsl, "\n") - collector := NewErrorCollector(lines) - - ValidateCyclesAndEntryPoints(collector, model, lines) +// TestCycleDetection_IntersectionSiblingIsolation checks that one intersection +// operand's walk does not hide a relation from the next: `a` and `b` both reach +// `x` through a type restriction, and `b` must still find the entry point there +// after `a` has visited it. This holds only if copyVisited copies the inner +// per-type maps as well as the outer one. +func TestCycleDetection_IntersectionSiblingIsolation(t *testing.T) { + findings := entryPointsFor(t, `model + schema 1.1 +type user +type doc + relations + define x: [user] + define a: [doc#x] + define b: [doc#x] + define r: a and b`) - // base + r0..r49 = depth+1 relations, all with no entry point. - if collector.Count() != depth+1 { - t.Fatalf("expected %d no-entry-point errors, got %d: %v", - depth+1, collector.Count(), collector.GetErrors()) + for _, finding := range findings { + t.Errorf("unexpected finding: %s", finding.Message) } } diff --git a/pkg/go/validation/cycle_detection_test.go b/pkg/go/validation/cycle_detection_test.go index 8dbb736f..fbb8820e 100644 --- a/pkg/go/validation/cycle_detection_test.go +++ b/pkg/go/validation/cycle_detection_test.go @@ -7,28 +7,14 @@ import ( "github.com/stretchr/testify/assert" ) -// hasEntry is a small test helper that runs the entry-point traversal for a -// single relation from a fresh visited set. -func (cd *CycleDetector) hasEntry(typeName, relationName string) entryPointResult { - return cd.hasEntryPointOrLoop(typeName, relationName, - cd.validator.GetRelationUserset(typeName, relationName), map[string]map[string]bool{}) +// entryOf runs the entry-point traversal for a single relation from a fresh +// visited set. +func entryOf(idx *index, typeName, relationName string) entryPointResult { + return hasEntryPointOrLoop(idx, typeName, relationName, + idx.userset(typeName, relationName), map[string]map[string]bool{}) } -func TestCycleDetector(t *testing.T) { - t.Run("NewCycleDetector", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - }, - } - - validator := NewSemanticValidator(model) - detector := NewCycleDetector(validator) - - assert.NotNil(t, detector) - assert.Equal(t, validator, detector.validator) - }) - +func TestValidateEntryPoints(t *testing.T) { t.Run("Mutual computed-userset loop has no entry point", func(t *testing.T) { // viewer -> editor -> viewer, neither directly assignable. model := &openfgav1.AuthorizationModel{ @@ -51,20 +37,18 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) + findings := ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{})) - errors := collector.GetErrors() - // Each relation is impossible: one error per relation, all RelationNoEntrypoint. - assert.Len(t, errors, 2) - for _, err := range errors { - assert.Equal(t, RelationNoEntrypoint, err.Metadata.ErrorType) - assert.Contains(t, err.Message, "is an impossible relation") - assert.Contains(t, err.Message, "(potential loop)") + // Each relation is impossible: one finding per relation, all RelationNoEntrypoint. + assert.Len(t, findings, 2) + for _, finding := range findings { + assert.Equal(t, RelationNoEntrypoint, finding.Metadata.Kind) + assert.Contains(t, finding.Message, "is an impossible relation") + assert.Contains(t, finding.Message, "(potential loop)") } }) - t.Run("No errors when relations are reachable", func(t *testing.T) { + t.Run("No findings when relations are reachable", func(t *testing.T) { model := &openfgav1.AuthorizationModel{ TypeDefinitions: []*openfgav1.TypeDefinition{ { @@ -93,9 +77,7 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{}))) }) t.Run("Computed chain terminating in a direct assignment is reachable", func(t *testing.T) { @@ -118,10 +100,8 @@ func TestCycleDetector(t *testing.T) { }, } - collector := NewErrorCollector(nil) - ValidateCyclesAndEntryPoints(collector, model, nil) // All three relations resolve to owner's direct assignment. - assert.Empty(t, collector.GetErrors()) + assert.Empty(t, ExtractAllAs[*Finding](validateEntryPoints(newIndex(model), source{}))) }) } @@ -144,8 +124,7 @@ func TestHasEntryPointOrLoop(t *testing.T) { }, } - detector := NewCycleDetector(NewSemanticValidator(model)) - assert.True(t, detector.hasEntry("document", "viewer").hasEntry) + assert.True(t, entryOf(newIndex(model), "document", "viewer").hasEntry) }) t.Run("Union with this has entry point", func(t *testing.T) { @@ -175,8 +154,7 @@ func TestHasEntryPointOrLoop(t *testing.T) { }, } - detector := NewCycleDetector(NewSemanticValidator(model)) - assert.True(t, detector.hasEntry("document", "viewer").hasEntry) + assert.True(t, entryOf(newIndex(model), "document", "viewer").hasEntry) }) t.Run("Self-referential computed userset is a loop", func(t *testing.T) { @@ -191,10 +169,9 @@ func TestHasEntryPointOrLoop(t *testing.T) { }, } - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.False(t, res.hasEntry) - assert.True(t, res.loop) + result := entryOf(newIndex(model), "document", "viewer") + assert.False(t, result.hasEntry) + assert.True(t, result.loop) }) } @@ -215,7 +192,7 @@ func TestHasEntryPointOrLoop_TupleToUserset(t *testing.T) { if folderViewer != nil { folder.Relations["viewer"] = folderViewer } else { - delete(folder.Metadata.Relations, "viewer") + delete(folder.GetMetadata().GetRelations(), "viewer") } return &openfgav1.AuthorizationModel{ TypeDefinitions: []*openfgav1.TypeDefinition{ @@ -244,39 +221,34 @@ func TestHasEntryPointOrLoop_TupleToUserset(t *testing.T) { t.Run("TTU resolving to a direct assignment has an entry point", func(t *testing.T) { // folder#viewer is directly assignable to user, so document#viewer reaches it. - model := ttuModel(&openfgav1.Userset{Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}) - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.True(t, res.hasEntry) - assert.False(t, res.loop) + idx := newIndex(ttuModel(&openfgav1.Userset{Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}})) + + result := entryOf(idx, "document", "viewer") + assert.True(t, result.hasEntry) + assert.False(t, result.loop) // folder#viewer is itself directly assignable to user, so it also has an entry point. - folderRes := detector.hasEntry("folder", "viewer") - assert.True(t, folderRes.hasEntry) + assert.True(t, entryOf(idx, "folder", "viewer").hasEntry) // document#parent is directly assignable to folder, so it also has an entry point. - parentRes := detector.hasEntry("document", "parent") - assert.True(t, parentRes.hasEntry) + assert.True(t, entryOf(idx, "document", "parent").hasEntry) }) t.Run("TTU whose computed relation is missing on the assignable type has no entry point", func(t *testing.T) { // folder has no `viewer` relation at all, so the computed lookup is nil and // the TTU branch skips it (the assignable == nil continue). - model := ttuModel(nil) - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.False(t, res.hasEntry) - assert.False(t, res.loop) + result := entryOf(newIndex(ttuModel(nil)), "document", "viewer") + assert.False(t, result.hasEntry) + assert.False(t, result.loop) }) t.Run("TTU through a self-looping computed relation has no entry point", func(t *testing.T) { // folder#viewer computes itself, so it never bottoms out at a concrete type. // The TTU branch swallows the looping sub-result and reports no entry point. - model := ttuModel(&openfgav1.Userset{ + idx := newIndex(ttuModel(&openfgav1.Userset{ Userset: &openfgav1.Userset_ComputedUserset{ ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}, }, - }) - detector := NewCycleDetector(NewSemanticValidator(model)) - res := detector.hasEntry("document", "viewer") - assert.False(t, res.hasEntry) + })) + + assert.False(t, entryOf(idx, "document", "viewer").hasEntry) }) } diff --git a/pkg/go/validation/duplicate_detection.go b/pkg/go/validation/duplicate_detection.go index b476d87a..e929ac22 100644 --- a/pkg/go/validation/duplicate_detection.go +++ b/pkg/go/validation/duplicate_detection.go @@ -1,174 +1,169 @@ package validation import ( + "errors" + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// DuplicateTypeTracker tracks type names to detect duplicates. -type DuplicateTypeTracker struct { - typeNames map[string]bool -} +// validateDuplicates reports everything the model defines twice: a type +// declared twice, a type restriction repeated in a relation, and a partial +// relation definition repeated in a union, intersection or difference. +func validateDuplicates(model *openfgav1.AuthorizationModel, src source) error { + var errs []error -func NewDuplicateTypeTracker() *DuplicateTypeTracker { - return &DuplicateTypeTracker{typeNames: make(map[string]bool)} -} + seenTypes := make(map[string]bool) + + for _, typeDef := range model.GetTypeDefinitions() { + typeName := typeDef.GetType() + if typeName == "" { + continue + } + + file, module := typeMeta(typeDef) + typeLine := src.typeLine(typeName) + + if seenTypes[typeName] { + finding := duplicateTypeName(typeName).at(src, typeLine) + finding.File, finding.Metadata.Module = file, module + errs = append(errs, finding) + } -func (dt *DuplicateTypeTracker) CheckAndAddType(typeName string, collector *ErrorCollector, - meta *Meta, lines []string) bool { - if dt.typeNames[typeName] { - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - collector.RaiseDuplicateTypeName(typeName, meta, typeLineIndex) - return false + seenTypes[typeName] = true + + relationsMetadata := typeDef.GetMetadata().GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + errs = append(errs, duplicateRestrictionsIn(src, relationsMetadata[relationName], + relationName, typeDef, typeLine)) + errs = append(errs, duplicateOperandsIn(src, typeDef, relationName, typeLine)) + } } - dt.typeNames[typeName] = true - return true + + return errors.Join(errs...) } -// CheckForDuplicateTypeNamesInRelation checks for duplicate type restrictions within a relation. -func CheckForDuplicateTypeNamesInRelation(collector *ErrorCollector, relationMetadata *openfgav1.RelationMetadata, - relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { +// duplicateRestrictionsIn flags a type restriction repeated in one relation. +// Restrictions are compared as written: `user`, `user:*`, `user#member` and +// `user with cond` are all distinct. +func duplicateRestrictionsIn(src source, relationMetadata *openfgav1.RelationMetadata, + relationName string, typeDef *openfgav1.TypeDefinition, typeLine int) error { if relationMetadata == nil { - return + return nil } - typeRestrictions := make(map[string]bool) - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - if typeRestriction.GetType() == "" { + + var fs []*Finding + + typeName := typeDef.GetType() + file, module := typeMeta(typeDef) + seen := make(map[string]bool) + + for _, restriction := range relationMetadata.GetDirectlyRelatedUserTypes() { + if restriction.GetType() == "" { continue } - typeRestrictionString := typeRestriction.GetType() - if typeRestriction.GetWildcard() != nil { - typeRestrictionString += ":*" - } else if rel := typeRestriction.GetRelation(); rel != "" { - typeRestrictionString += "#" + rel + + written := restriction.GetType() + if restriction.GetWildcard() != nil { + written += ":*" + } else if rel := restriction.GetRelation(); rel != "" { + written += "#" + rel } - if cond := typeRestriction.GetCondition(); cond != "" { - typeRestrictionString += " with " + cond + + if condition := restriction.GetCondition(); condition != "" { + written += " with " + condition } - if typeRestrictions[typeRestrictionString] { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateTypeRestriction(typeRestrictionString, relationName, typeName, meta, lineIndex) - } else { - typeRestrictions[typeRestrictionString] = true + + if seen[written] { + line := src.relationLine(relationName, typeLine) + finding := duplicateTypeRestriction(written, relationName, typeName).at(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } + + seen[written] = true } + + return joinFindings(fs...) } -// CheckForDuplicatesInRelation checks for duplicate relations in type definitions. -func CheckForDuplicatesInRelation(collector *ErrorCollector, typeDef *openfgav1.TypeDefinition, - relationName string, typeLineIndex *int, lines []string) { - if typeDef == nil { - return +// duplicateOperandsIn flags a partial relation definition repeated in a union +// or intersection, and a difference that subtracts an operand from itself. +func duplicateOperandsIn(src source, typeDef *openfgav1.TypeDefinition, + relationName string, typeLine int) error { + relation, ok := typeDef.GetRelations()[relationName] + if !ok { + return nil } - relations := typeDef.GetRelations() - relation, exists := relations[relationName] - if !exists { - return + + file, module := relationMeta(typeDef, relationName) + + var fs []*Finding + + raise := func(operand string) { + line := src.relationLine(relationName, typeLine) + finding := duplicatePartialRelation(operand, relationName, typeDef.GetType()).at(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } - var file, module string - if meta := typeDef.GetMetadata(); meta != nil { - if rm, ok := meta.GetRelations()[relationName]; ok { - file = rm.GetSourceInfo().GetFile() - module = rm.GetModule() - } - if file == "" { - file = meta.GetSourceInfo().GetFile() - } - if module == "" { - module = meta.GetModule() + // Union and intersection both store their members as a *openfgav1.Usersets + // and treat a repeated member as a duplicate, so they share the check. + for _, operands := range []*openfgav1.Usersets{relation.GetUnion(), relation.GetIntersection()} { + if operands == nil { + continue } - } - meta := &Meta{File: file, Module: module} - if union := relation.GetUnion(); union != nil { - checkDuplicatesInOperands(collector, union, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } - if intersection := relation.GetIntersection(); intersection != nil { - checkDuplicatesInOperands(collector, intersection, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } - if diff := relation.GetDifference(); diff != nil { - checkDuplicatesInDifference(collector, diff, relationName, typeDef.GetType(), meta, typeLineIndex, lines) - } -} + seen := make(map[string]bool) -// checkDuplicatesInOperands flags duplicate operands within a union or -// intersection. Both operators store their members as a *openfgav1.Usersets and -// treat a repeated member as redundant, so they share this check. -func checkDuplicatesInOperands(collector *ErrorCollector, operands *openfgav1.Usersets, - relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { - if operands == nil { - return - } - relationDefs := make(map[string]bool) - for _, child := range operands.GetChild() { - if relationDef := getRelationDefName(child); relationDef != "" { - if relationDefs[relationDef] { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateType(relationDef, relationName, typeName, meta, lineIndex) - } else { - relationDefs[relationDef] = true + for _, child := range operands.GetChild() { + name := operandName(child) + if name == "" { + continue + } + + if seen[name] { + raise(name) } + + seen[name] = true } } -} -func checkDuplicatesInDifference(collector *ErrorCollector, difference *openfgav1.Difference, - relationName, typeName string, meta *Meta, typeLineIndex *int, lines []string) { - if difference == nil { - return - } - baseName := getRelationDefName(difference.GetBase()) - subtractName := getRelationDefName(difference.GetSubtract()) - if baseName != "" && baseName == subtractName { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseDuplicateType(baseName, relationName, typeName, meta, lineIndex) + if diff := relation.GetDifference(); diff != nil { + base := operandName(diff.GetBase()) + if base != "" && base == operandName(diff.GetSubtract()) { + raise(base) + } } + + return joinFindings(fs...) } -func getRelationDefName(userset *openfgav1.Userset) string { +// operandName renders a union/intersection/difference member the way it was +// written: a computed userset as its relation, a tuple-to-userset as +// `target from tupleset`. +func operandName(userset *openfgav1.Userset) string { if userset == nil { return "" } - if cu := userset.GetComputedUserset(); cu != nil { - if rel := cu.GetRelation(); rel != "" { - return rel - } + + if computed := userset.GetComputedUserset(); computed.GetRelation() != "" { + return computed.GetRelation() } + if ttu := userset.GetTupleToUserset(); ttu != nil { target := ttu.GetComputedUserset().GetRelation() from := ttu.GetTupleset().GetRelation() - if target != "" && from != "" { + + switch { + case target != "" && from != "": return target + " from " + from - } - if target != "" { + case target != "": return target } } - return "" -} -// ValidateDuplicates performs comprehensive duplicate detection on a model. -func ValidateDuplicates(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - typeTracker := NewDuplicateTypeTracker() - for _, typeDef := range model.GetTypeDefinitions() { - typeName := typeDef.GetType() - if typeName == "" { - continue - } - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - typeTracker.CheckAndAddType(typeName, collector, meta, lines) - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - if metaProto := typeDef.GetMetadata(); metaProto != nil { - for relationName, relationMetadata := range metaProto.GetRelations() { - CheckForDuplicateTypeNamesInRelation(collector, relationMetadata, relationName, typeName, meta, typeLineIndex, lines) - CheckForDuplicatesInRelation(collector, typeDef, relationName, typeLineIndex, lines) - } - } - } + return "" } diff --git a/pkg/go/validation/duplicate_detection_test.go b/pkg/go/validation/duplicate_detection_test.go index e09dbb43..18ba2515 100644 --- a/pkg/go/validation/duplicate_detection_test.go +++ b/pkg/go/validation/duplicate_detection_test.go @@ -7,635 +7,43 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewDuplicateTypeTracker(t *testing.T) { - tracker := NewDuplicateTypeTracker() +// TestOperandName covers the operand shapes the DSL cannot produce, such as a +// tuple-to-userset with one side missing. The shared cases cover the rest. +func TestOperandName(t *testing.T) { + t.Parallel() - assert.NotNil(t, tracker) - assert.NotNil(t, tracker.typeNames) - assert.Empty(t, tracker.typeNames) -} - -func TestDuplicateTypeTracker_CheckAndAddType(t *testing.T) { - tests := []struct { - name string - typeNames []string - expectedErrorCount int - expectedDuplicate string - }{ - { - name: "no duplicates", - typeNames: []string{"document", "user", "group"}, - expectedErrorCount: 0, - }, - { - name: "single duplicate", - typeNames: []string{"document", "user", "document"}, - expectedErrorCount: 1, - expectedDuplicate: "document", - }, - { - name: "multiple duplicates", - typeNames: []string{"document", "user", "document", "user", "group"}, - expectedErrorCount: 2, - }, - { - name: "empty type name", - typeNames: []string{"", "document", ""}, - expectedErrorCount: 1, - }, + computed := func(relation string) *openfgav1.Userset { + return &openfgav1.Userset{Userset: &openfgav1.Userset_ComputedUserset{ + ComputedUserset: &openfgav1.ObjectRelation{Relation: relation}, + }} } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tracker := NewDuplicateTypeTracker() - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - for _, typeName := range tt.typeNames { - tracker.CheckAndAddType(typeName, collector, meta, nil) - } - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 && tt.expectedDuplicate != "" { - found := false - for _, err := range errors { - if err.Metadata.Symbol == tt.expectedDuplicate { - assert.Equal(t, DuplicatedError, err.Metadata.ErrorType) - assert.Contains(t, err.Message, "is a duplicate") - found = true - break - } - } - assert.True(t, found, "Expected duplicate error for %s", tt.expectedDuplicate) - } - }) - } -} - -func TestCheckForDuplicateTypeNamesInRelation(t *testing.T) { - tests := []struct { - name string - relationMetadata *openfgav1.RelationMetadata - relationName string - typeName string - expectedErrorCount int - }{ - { - name: "nil relation metadata", - relationMetadata: nil, - expectedErrorCount: 0, - }, - { - name: "no duplicates", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "group"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 0, - }, - { - name: "duplicate type restrictions", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "duplicate with wildcards", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Wildcard{Wildcard: &openfgav1.Wildcard{}}}, - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Wildcard{Wildcard: &openfgav1.Wildcard{}}}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "duplicate with relations", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "group", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "member"}}, - {Type: "group", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "member"}}, - }, + tupleToUserset := func(target, from string) *openfgav1.Userset { + return &openfgav1.Userset{Userset: &openfgav1.Userset_TupleToUserset{ + TupleToUserset: &openfgav1.TupleToUserset{ + ComputedUserset: &openfgav1.ObjectRelation{Relation: target}, + Tupleset: &openfgav1.ObjectRelation{Relation: from}, }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "duplicate with conditions", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user", Condition: "is_owner"}, - {Type: "user", Condition: "is_owner"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 1, - }, - { - name: "no duplicates with different combinations", - relationMetadata: &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Wildcard{Wildcard: &openfgav1.Wildcard{}}}, - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "member"}}, - {Type: "user", Condition: "is_owner"}, - }, - }, - relationName: "viewer", - typeName: "document", - expectedErrorCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - CheckForDuplicateTypeNamesInRelation(collector, tt.relationMetadata, tt.relationName, tt.typeName, meta, nil, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "is a duplicate") - } - }) + }} } -} -func TestGetRelationDefName(t *testing.T) { tests := []struct { - name string - userset *openfgav1.Userset - expected string + name string + userset *openfgav1.Userset + want string }{ - { - name: "computed userset", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - }, - }, - expected: "viewer", - }, - { - name: "tuple to userset with target and from", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_TupleToUserset{ - TupleToUserset: &openfgav1.TupleToUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - Tupleset: &openfgav1.ObjectRelation{ - Relation: "parent", - }, - }, - }, - }, - expected: "viewer from parent", - }, - { - name: "tuple to userset with target only", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_TupleToUserset{ - TupleToUserset: &openfgav1.TupleToUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - Tupleset: &openfgav1.ObjectRelation{}, - }, - }, - }, - expected: "viewer", - }, - { - name: "tuple to userset with from only", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_TupleToUserset{ - TupleToUserset: &openfgav1.TupleToUserset{ - ComputedUserset: &openfgav1.ObjectRelation{}, - Tupleset: &openfgav1.ObjectRelation{ - Relation: "parent", - }, - }, - }, - }, - expected: "", - }, - { - name: "empty userset", - userset: &openfgav1.Userset{}, - expected: "", - }, - { - name: "computed userset with nil relation", - userset: &openfgav1.Userset{ - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "", - }, - }, - }, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getRelationDefName(tt.userset) - assert.Equal(t, tt.expected, result) + {"computed userset", computed("viewer"), "viewer"}, + {"computed userset with no relation", computed(""), ""}, + {"tuple to userset", tupleToUserset("viewer", "parent"), "viewer from parent"}, + {"tuple to userset with no tupleset relation", tupleToUserset("viewer", ""), "viewer"}, + {"tuple to userset with no computed relation", tupleToUserset("", "parent"), ""}, + {"direct assignment", &openfgav1.Userset{Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, ""}, + {"empty userset", &openfgav1.Userset{}, ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.want, operandName(test.userset)) }) } } - -func TestCheckForDuplicatesInRelation(t *testing.T) { - tests := []struct { - name string - typeDef *openfgav1.TypeDefinition - relationName string - expectedErrorCount int - }{ - { - name: "nil type definition", - typeDef: nil, - relationName: "viewer", - expectedErrorCount: 0, - }, - { - name: "nil relations", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: nil, - }, - relationName: "viewer", - expectedErrorCount: 0, - }, - { - name: "simple relation with no duplicates", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - relationName: "viewer", - expectedErrorCount: 0, - }, - { - name: "union with duplicates", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_Union{ - Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - }, - }, - }, - }, - }, - }, - relationName: "viewer", - expectedErrorCount: 1, - }, - { - name: "intersection with duplicates", - typeDef: &openfgav1.TypeDefinition{ - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "can_edit": { - Userset: &openfgav1.Userset_Intersection{ - Intersection: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - }, - }, - }, - }, - }, - }, - relationName: "can_edit", - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - - CheckForDuplicatesInRelation(collector, tt.typeDef, tt.relationName, nil, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - } - }) - } -} - -func TestValidateDuplicates(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - expectedErrorCount int - expectedErrorTypes []ValidationErrorType - }{ - { - name: "nil model", - model: nil, - expectedErrorCount: 0, - }, - { - name: "nil type definitions", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: nil, - }, - expectedErrorCount: 0, - }, - { - name: "no duplicates", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - { - Type: "user", - }, - }, - }, - expectedErrorCount: 0, - }, - { - name: "duplicate type names", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - }, - { - Type: "document", - }, - }, - }, - expectedErrorCount: 1, - expectedErrorTypes: []ValidationErrorType{DuplicatedError}, - }, - { - name: "duplicate type restrictions in relation", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, - }, - }, - }, - }, - }, - }, - }, - expectedErrorCount: 1, - expectedErrorTypes: []ValidationErrorType{DuplicatedError}, - }, - { - name: "multiple types of duplicates", - model: &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, - }, - }, - }, - }, - }, - { - Type: "document", // Duplicate type name - }, - }, - }, - expectedErrorCount: 2, - expectedErrorTypes: []ValidationErrorType{DuplicatedError, DuplicatedError}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - - ValidateDuplicates(collector, tt.model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - for i, expectedType := range tt.expectedErrorTypes { - if i < len(errors) { - assert.Equal(t, expectedType, errors[i].Metadata.ErrorType) - } - } - }) - } -} - -func TestCheckDuplicatesInUnion(t *testing.T) { - tests := []struct { - name string - union *openfgav1.Usersets - expectedErrorCount int - }{ - { - name: "nil union", - union: nil, - expectedErrorCount: 0, - }, - { - name: "union with nil child", - union: &openfgav1.Usersets{ - Child: nil, - }, - expectedErrorCount: 0, - }, - { - name: "union with no duplicates", - union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "viewer", - }, - }, - }, - }, - }, - expectedErrorCount: 0, - }, - { - name: "union with duplicates", - union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "admin", - }, - }, - }, - }, - }, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - - checkDuplicatesInOperands(collector, tt.union, "test_relation", "test_type", meta, nil, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - } - }) - } -} - -func TestValidateDuplicates_Integration(t *testing.T) { - t.Run("Duplicate Type Detection", func(t *testing.T) { - collector := NewErrorCollector(nil) - - // Model with duplicate type names - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "document"}, // Duplicate - }, - } - - ValidateDuplicates(collector, model, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "is a duplicate") - }) - - t.Run("Duplicate Type Restriction Detection", func(t *testing.T) { - collector := NewErrorCollector(nil) - - // Model with duplicate type restrictions in relation - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "user"}, // Duplicate - }, - }, - }, - }, - }, - }, - } - - ValidateDuplicates(collector, model, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - }) -} diff --git a/pkg/go/validation/error_collector.go b/pkg/go/validation/error_collector.go deleted file mode 100644 index 386a091e..00000000 --- a/pkg/go/validation/error_collector.go +++ /dev/null @@ -1,350 +0,0 @@ -package validation - -import ( - "fmt" - "strings" -) - -// wordIndex returns the index of symbol in rawLine matched on word boundaries, -// mirroring the reference's `\bsymbol\b` lookup. This avoids matching a symbol -// as a substring of another word (e.g. finding `t` inside `type`). Returns 0 -// when the symbol is not found, matching the reference's fallback. -// -// The boundary check is done directly rather than via a per-call compiled -// regexp: `\b` only requires that the characters flanking the match are not word -// characters, which is cheap to test in place and avoids recompiling a pattern -// for every error. -func wordIndex(rawLine, symbol string) int { - if symbol == "" { - return 0 - } - // Only attempt a word-boundary match when the symbol begins and ends with a - // word character; symbols containing non-word characters (e.g. `user:*`) - // can't match `\bsymbol\b` and fall through to the substring search. - if isWordChar(symbol[0]) && isWordChar(symbol[len(symbol)-1]) { - for off := 0; ; { - idx := strings.Index(rawLine[off:], symbol) - if idx < 0 { - break - } - pos := off + idx - beforeOK := pos == 0 || !isWordChar(rawLine[pos-1]) - afterPos := pos + len(symbol) - afterOK := afterPos == len(rawLine) || !isWordChar(rawLine[afterPos]) - if beforeOK && afterOK { - return pos - } - off = pos + 1 - } - } - if idx := strings.Index(rawLine, symbol); idx >= 0 { - return idx - } - return 0 -} - -// isWordChar reports whether b is a regexp `\w` character ([0-9A-Za-z_]). -func isWordChar(b byte) bool { - return b == '_' || - (b >= '0' && b <= '9') || - (b >= 'a' && b <= 'z') || - (b >= 'A' && b <= 'Z') -} - -// ErrorCollector collects validation errors during model validation -// This is equivalent to the JS ExceptionCollector class -type ErrorCollector struct { - errors []*ValidationError - lines []string // DSL lines for line number resolution -} - -// NewErrorCollector creates a new error collector. -func NewErrorCollector(lines []string) *ErrorCollector { - return &ErrorCollector{ - errors: make([]*ValidationError, 0), - lines: lines, - } -} - -// GetErrors returns all collected errors. -func (c *ErrorCollector) GetErrors() []*ValidationError { - return c.errors -} - -// HasErrors returns true if any errors have been collected. -func (c *ErrorCollector) HasErrors() bool { - return len(c.errors) > 0 -} - -// Count returns the number of errors collected. -func (c *ErrorCollector) Count() int { - return len(c.errors) -} - -// addError is a helper to add an error to the collection. -func (c *ErrorCollector) addError(message string, errorType ValidationErrorType, symbol string, - lineIndex *int, meta *Meta, customResolver ErrorCustomResolver) { - var line *LineRange - var column *ColumnRange - - // Calculate line and column positions if lineIndex is provided - if lineIndex != nil && *lineIndex >= 0 && *lineIndex < len(c.lines) { - line = &LineRange{Start: *lineIndex, End: *lineIndex} - - // Find symbol position in line for column calculation, matching on word - // boundaries as the reference does. - rawLine := c.lines[*lineIndex] - symbolPos := wordIndex(rawLine, symbol) - - if customResolver != nil { - symbolPos = customResolver(symbolPos, rawLine, symbol) - } - - if symbolPos >= 0 { - column = &ColumnRange{ - Start: symbolPos, - End: symbolPos + len(symbol), - } - } - } - - metadata := &ErrorMetadata{ - Symbol: symbol, - ErrorType: errorType, - } - - if meta != nil { - metadata.Module = meta.Module - // Set file in both metadata and error for consistency with JS implementation - } - - validationErr := &ValidationError{ - Message: message, - Line: line, - Column: column, - Metadata: metadata, - } - - if meta != nil { - validationErr.File = meta.File - } - - c.errors = append(c.errors, validationErr) -} - -// RaiseInvalidName raises an invalid name error. -func (c *ErrorCollector) RaiseInvalidName(symbol, clause string, typeName *string, lineIndex *int, meta *Meta) { - var message string - if typeName != nil { - message = fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", symbol, *typeName, clause) - } else { - message = fmt.Sprintf("type '%s' does not match naming rule: '%s'.", symbol, clause) - } - c.addError(message, InvalidName, symbol, lineIndex, meta, nil) -} - -// RaiseReservedTypeName raises a reserved type name error. -func (c *ErrorCollector) RaiseReservedTypeName(symbol string, lineIndex *int, meta *Meta) { - message := "a type cannot be named 'self' or 'this'." - c.addError(message, ReservedTypeKeywords, symbol, lineIndex, meta, nil) -} - -// RaiseReservedRelationName raises a reserved relation name error. -func (c *ErrorCollector) RaiseReservedRelationName(symbol string, lineIndex *int, meta *Meta) { - message := "a relation cannot be named 'self' or 'this'." - c.addError(message, ReservedRelationKeywords, symbol, lineIndex, meta, nil) -} - -// RaiseTupleUsersetRequiresDirect raises an error for tuple-to-userset not being direct. -func (c *ErrorCollector) RaiseTupleUsersetRequiresDirect(symbol, typeName, relation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` relation used inside from allows only direct relation.", symbol) - - // Custom resolver for "from" clause positioning - customResolver := func(wordIdx int, rawLine, value string) int { - clauseStartsAt := strings.Index(rawLine, "from") + len("from") - if clauseStartsAt >= len("from") { - wordIdx = clauseStartsAt + strings.Index(rawLine[clauseStartsAt:], value) - } - return wordIdx - } - - c.addError(message, TuplesetNotDirect, symbol, lineIndex, meta, customResolver) -} - -// RaiseDuplicateTypeName raises a duplicate type name error. -func (c *ErrorCollector) RaiseDuplicateTypeName(symbol string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the type `%s` is a duplicate.", symbol) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseDuplicateTypeRestriction raises a duplicate type restriction error. -func (c *ErrorCollector) RaiseDuplicateTypeRestriction(symbol, relationName, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the type restriction `%s` is a duplicate in the relation `%s`.", symbol, relationName) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseUndefinedType raises an error for undefined type references. -func (c *ErrorCollector) RaiseUndefinedType(typeName, relationName, parentTypeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Type '%s' is not defined (referenced in relation '%s' of type '%s')", typeName, relationName, parentTypeName) - c.addError(message, UndefinedType, typeName, lineIndex, meta, nil) -} - -// RaiseUndefinedRelation raises an error for undefined relation references. -func (c *ErrorCollector) RaiseUndefinedRelation(relationName, typeName, parentRelation, parentTypeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Relation '%s' is not defined on type '%s' (referenced in relation '%s' of type '%s')", relationName, typeName, parentRelation, parentTypeName) - c.addError(message, UndefinedRelation, relationName, lineIndex, meta, nil) -} - -// RaiseDuplicateType raises a duplicate type error in relation. -func (c *ErrorCollector) RaiseDuplicateType(symbol, relationName, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", - symbol, relationName) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseDuplicateRelationshipDefinition raises a duplicate relationship definition error. -func (c *ErrorCollector) RaiseDuplicateRelationshipDefinition(symbol string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the relation '%s' is defined more than once.", symbol) - c.addError(message, DuplicatedError, symbol, lineIndex, meta, nil) -} - -// RaiseNoEntryPointLoop raises an error for impossible relation with potential loop. -func (c *ErrorCollector) RaiseNoEntryPointLoop(symbol, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is an impossible relation for `%s` (potential loop).", symbol, typeName) - c.addError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil) -} - -// RaiseNoEntryPoint raises an error for impossible relation without entry point. -func (c *ErrorCollector) RaiseNoEntryPoint(symbol, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is an impossible relation for `%s` (no entrypoint).", symbol, typeName) - c.addError(message, RelationNoEntrypoint, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidRelationOnTupleset raises an error for invalid relation on tupleset. -func (c *ErrorCollector) RaiseInvalidRelationOnTupleset(symbol, typeName, typeDef, relationName, - offendingRelation, parent string, lineIndex *int, meta *Meta) { - message := fmt.Sprintf("the `%s` relation definition on type `%s` is not valid: `%s` does not exist on `%s`, which is of type `%s`.", - offendingRelation, typeDef, offendingRelation, parent, typeName) - c.addError(message, InvalidRelationOnTupleset, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidTypeRelation raises an error for invalid type relation. -func (c *ErrorCollector) RaiseInvalidTypeRelation(symbol, typeName, relationName, offendingRelation, - offendingType string, lineIndex *int, meta *Meta) { - message := fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName) - c.addError(message, InvalidRelationType, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidType raises an error for invalid type. -func (c *ErrorCollector) RaiseInvalidType(symbol, typeName, relation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is not a valid type.", symbol) - // The invalid type appears in the assignable-types list (after the colon), - // which may share a name with the relation key before the colon. Resolve the - // column to the value side so it marks the type, not the relation name — - // mirroring the reference's customResolver. - resolver := func(_ int, rawLine, sym string) int { - colon := strings.Index(rawLine, ":") - if colon < 0 { - return wordIndex(rawLine, sym) - } - value := rawLine[colon+1:] - idx := wordIndex(value, sym) - return colon + 1 + idx - } - c.addError(message, InvalidType, symbol, lineIndex, meta, resolver) -} - -// RaiseAssignableRelationMustHaveTypes raises an error for assignable relations without types. -func (c *ErrorCollector) RaiseAssignableRelationMustHaveTypes(symbol string, lineIndex *int) { - message := fmt.Sprintf("the assignable relation '%s' must have at least one assignable type.", symbol) - c.addError(message, AssignableRelationsMustHaveType, symbol, lineIndex, nil, nil) -} - -// RaiseAssignableTypeWildcardRelation raises an error for wildcard with relation. -func (c *ErrorCollector) RaiseAssignableTypeWildcardRelation(symbol, typeName, relation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("the type restriction '%s' on relation '%s' of type '%s' is not allowed to have both a wildcard and a relation.", - symbol, relation, typeName) - c.addError(message, TypeRestrictionCannotHaveWildcardAndRelation, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidRelationError raises an error for invalid relation reference. -func (c *ErrorCollector) RaiseInvalidRelationError(symbol, typeName, relation string, validRelations []string, - lineIndex *int, meta *Meta) { - message := fmt.Sprintf("the relation `%s` does not exist.", symbol) - c.addError(message, MissingDefinition, symbol, lineIndex, meta, nil) -} - -// RaiseInvalidSchemaVersion raises an error for a schema version that was never -// valid (e.g. "0.9", "2.0"). This is distinct from a version that is recognized -// but no longer supported (see RaiseSchemaVersionUnsupported). -func (c *ErrorCollector) RaiseInvalidSchemaVersion(symbol string, lineIndex *int) { - message := fmt.Sprintf("invalid schema %s", symbol) - c.addError(message, InvalidSchema, symbol, lineIndex, nil, nil) -} - -// RaiseSchemaVersionUnsupported raises an error for a recognized but retired -// schema version (e.g. "1.0"). -func (c *ErrorCollector) RaiseSchemaVersionUnsupported(symbol string, lineIndex *int) { - message := "schema version no longer supported" - c.addError(message, SchemaVersionUnsupported, symbol, lineIndex, nil, nil) -} - -// RaiseSchemaVersionRequired raises an error for missing schema version. -func (c *ErrorCollector) RaiseSchemaVersionRequired(symbol string, lineIndex *int) { - message := "schema version required" - c.addError(message, SchemaVersionRequired, symbol, lineIndex, nil, nil) -} - -// RaiseMaximumOneDirectRelationship raises an error for multiple direct relationships. -func (c *ErrorCollector) RaiseMaximumOneDirectRelationship(symbol string, lineIndex *int) { - message := fmt.Sprintf("the relation '%s' can have at most one direct relationship.", symbol) - c.addError(message, DuplicatedError, symbol, lineIndex, nil, nil) -} - -// RaiseInvalidConditionNameInParameter raises an error for invalid condition names. -func (c *ErrorCollector) RaiseInvalidConditionNameInParameter(symbol, typeName, relationName, conditionName string, - meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` is not a defined condition in the model.", conditionName) - c.addError(message, ConditionNotDefined, symbol, lineIndex, meta, nil) -} - -// RaiseUnusedCondition raises an error for unused conditions. -func (c *ErrorCollector) RaiseUnusedCondition(symbol string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("`%s` condition is not used in the model.", symbol) - c.addError(message, ConditionNotUsed, symbol, lineIndex, meta, nil) -} - -// RaiseDifferentNestedConditionName raises an error for a condition whose nested -// name property differs from its map key. The message mirrors the reference. -func (c *ErrorCollector) RaiseDifferentNestedConditionName(condition, nestedConditionName string) { - message := fmt.Sprintf("condition key is `%s` but nested name property is %s", condition, nestedConditionName) - c.addError(message, DifferentNestedConditionName, nestedConditionName, nil, nil, nil) -} - -// RaiseMultipleModulesInSingleFile raises an error for multiple modules in single file. -func (c *ErrorCollector) RaiseMultipleModulesInSingleFile(file string, modules []string) { - moduleList := strings.Join(modules, ", ") - message := fmt.Sprintf("file '%s' contains multiple modules: %s.", file, moduleList) - c.addError(message, MultipleModulesInFile, file, nil, nil, nil) -} - -// Complex operation validation error methods - -// RaiseRedundantUnionMember raises an error for redundant members in union operations. -func (c *ErrorCollector) RaiseRedundantUnionMember(operation, relationName, typeName string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Redundant operation '%s' found in union for relation '%s' of type '%s'", operation, relationName, typeName) - c.addError(message, DuplicatedError, operation, lineIndex, meta, nil) -} - -// RaiseImpossibleIntersection raises an error for intersection operations that cannot succeed. -func (c *ErrorCollector) RaiseImpossibleIntersection(relationName, typeName string, conflictingTypes []string, meta *Meta, lineIndex *int) { - typeList := strings.Join(conflictingTypes, ", ") - message := fmt.Sprintf("Impossible intersection in relation '%s' of type '%s': conflicting types [%s]", relationName, typeName, typeList) - c.addError(message, InvalidRelationType, relationName, lineIndex, meta, nil) -} - -// RaiseEmptyDifference raises an error for difference operations that result in empty sets. -func (c *ErrorCollector) RaiseEmptyDifference(relationName, typeName, operation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Empty difference operation in relation '%s' of type '%s': subtracting '%s' from itself", relationName, typeName, operation) - c.addError(message, RelationNoEntrypoint, relationName, lineIndex, meta, nil) -} diff --git a/pkg/go/validation/error_collector_test.go b/pkg/go/validation/error_collector_test.go deleted file mode 100644 index d9d85ae8..00000000 --- a/pkg/go/validation/error_collector_test.go +++ /dev/null @@ -1,398 +0,0 @@ -package validation - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - - - -func TestWordIndex(t *testing.T) { - tests := []struct { - name string - rawLine string - symbol string - want int - }{ - {"empty symbol returns 0", "define viewer: [user]", "", 0}, - {"not found returns 0", "define viewer: [user]", "missing", 0}, - {"word-boundary match", "define viewer: [user]", "user", 16}, - {"prefers boundary over earlier substring", "define ownerx: owner", "owner", 15}, - {"falls back to substring when no boundary", "type usergroup", "user", 5}, - {"non-word symbol falls back to substring", "define x: [user:*]", "user:*", 11}, - {"first occurrence wins on boundary", "a or a", "a", 0}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, wordIndex(tt.rawLine, tt.symbol)) - }) - } -} - -func TestNewErrorCollector(t *testing.T) { - lines := []string{"line 1", "line 2", "line 3"} - collector := NewErrorCollector(lines) - - assert.NotNil(t, collector) - assert.Equal(t, lines, collector.lines) - assert.Equal(t, 0, collector.Count()) - assert.False(t, collector.HasErrors()) -} - -func TestErrorCollector_GetErrors(t *testing.T) { - collector := NewErrorCollector(nil) - - // Initially no errors - errors := collector.GetErrors() - assert.Empty(t, errors) - - // Add an error - collector.RaiseInvalidName("test", "rule", nil, nil, nil) - - errors = collector.GetErrors() - assert.Len(t, errors, 1) - assert.Contains(t, errors[0].Message, "test") -} - -func TestErrorCollector_HasErrors(t *testing.T) { - collector := NewErrorCollector(nil) - - assert.False(t, collector.HasErrors()) - - collector.RaiseInvalidName("test", "rule", nil, nil, nil) - - assert.True(t, collector.HasErrors()) -} - -func TestErrorCollector_Count(t *testing.T) { - collector := NewErrorCollector(nil) - - assert.Equal(t, 0, collector.Count()) - - collector.RaiseInvalidName("test1", "rule", nil, nil, nil) - assert.Equal(t, 1, collector.Count()) - - collector.RaiseInvalidName("test2", "rule", nil, nil, nil) - assert.Equal(t, 2, collector.Count()) -} - -func TestErrorCollector_RaiseInvalidName(t *testing.T) { - tests := []struct { - name string - symbol string - clause string - typeName *string - lineIndex *int - meta *Meta - expectedMsg string - expectedType ValidationErrorType - }{ - { - name: "type invalid name", - symbol: "invalid-type", - clause: "[a-zA-Z]+", - typeName: nil, - expectedMsg: "type 'invalid-type' does not match naming rule: '[a-zA-Z]+'.", - expectedType: InvalidName, - }, - { - name: "relation invalid name", - symbol: "invalid-relation", - clause: "[a-zA-Z]+", - typeName: ptrString("document"), - expectedMsg: "relation 'invalid-relation' of type 'document' does not match naming rule: '[a-zA-Z]+'.", - expectedType: InvalidName, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - collector.RaiseInvalidName(tt.symbol, tt.clause, tt.typeName, tt.lineIndex, tt.meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, tt.expectedMsg, errors[0].Message) - assert.Equal(t, tt.expectedType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.symbol, errors[0].Metadata.Symbol) - }) - } -} - -func TestErrorCollector_RaiseReservedTypeName(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} - - collector.RaiseReservedTypeName("self", &lineIndex, meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "a type cannot be named 'self' or 'this'.", errors[0].Message) - assert.Equal(t, ReservedTypeKeywords, errors[0].Metadata.ErrorType) - assert.Equal(t, "self", errors[0].Metadata.Symbol) - assert.Equal(t, "test.fga", errors[0].File) -} - -func TestErrorCollector_RaiseReservedRelationName(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 3 - meta := &Meta{File: "test.fga", Module: "test"} - - collector.RaiseReservedRelationName("this", &lineIndex, meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "a relation cannot be named 'self' or 'this'.", errors[0].Message) - assert.Equal(t, ReservedRelationKeywords, errors[0].Metadata.ErrorType) - assert.Equal(t, "this", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseTupleUsersetRequiresDirect(t *testing.T) { - lines := []string{ - "type document", - " relations", - " define viewer: user from parent", - " define admin: [user]", - } - collector := NewErrorCollector(lines) - lineIndex := 2 - meta := &Meta{File: "test.fga"} - - collector.RaiseTupleUsersetRequiresDirect("user", "document", "viewer", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`user` relation used inside from allows only direct relation.", errors[0].Message) - assert.Equal(t, TuplesetNotDirect, errors[0].Metadata.ErrorType) - assert.Equal(t, "user", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseDuplicateTypeName(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 10 - - collector.RaiseDuplicateTypeName("document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the type `document` is a duplicate.", errors[0].Message) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Equal(t, "document", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseDuplicateTypeRestriction(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga"} - lineIndex := 5 - - collector.RaiseDuplicateTypeRestriction("user", "viewer", "document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the type restriction `user` is a duplicate in the relation `viewer`.", errors[0].Message) - assert.Equal(t, DuplicatedError, errors[0].Metadata.ErrorType) - assert.Equal(t, "user", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseNoEntryPointLoop(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 8 - - collector.RaiseNoEntryPointLoop("viewer", "document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`viewer` is an impossible relation for `document` (potential loop).", errors[0].Message) - assert.Equal(t, RelationNoEntrypoint, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseNoEntryPoint(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 12 - - collector.RaiseNoEntryPoint("viewer", "document", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`viewer` is an impossible relation for `document` (no entrypoint).", errors[0].Message) - assert.Equal(t, RelationNoEntrypoint, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseInvalidType(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 3 - - collector.RaiseInvalidType("unknown_type", "document", "viewer", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`unknown_type` is not a valid type.", errors[0].Message) - assert.Equal(t, InvalidType, errors[0].Metadata.ErrorType) - assert.Equal(t, "unknown_type", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseAssignableRelationMustHaveTypes(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 6 - - collector.RaiseAssignableRelationMustHaveTypes("viewer", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the assignable relation 'viewer' must have at least one assignable type.", errors[0].Message) - assert.Equal(t, AssignableRelationsMustHaveType, errors[0].Metadata.ErrorType) - assert.Equal(t, "viewer", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseInvalidRelationError(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 4 - validRelations := []string{"admin", "viewer"} - - collector.RaiseInvalidRelationError("unknown", "document", "relation", validRelations, &lineIndex, meta) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "the relation `unknown` does not exist.", errors[0].Message) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Equal(t, "unknown", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseSchemaVersionRequired(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 0 - - collector.RaiseSchemaVersionRequired("", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "schema version required", errors[0].Message) - assert.Equal(t, SchemaVersionRequired, errors[0].Metadata.ErrorType) -} - -func TestErrorCollector_RaiseInvalidSchemaVersion(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 1 - - collector.RaiseInvalidSchemaVersion("2.0", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "invalid schema 2.0", errors[0].Message) - assert.Equal(t, InvalidSchema, errors[0].Metadata.ErrorType) - assert.Equal(t, "2.0", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseSchemaVersionUnsupported(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 1 - - collector.RaiseSchemaVersionUnsupported("1.0", &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "schema version no longer supported", errors[0].Message) - assert.Equal(t, SchemaVersionUnsupported, errors[0].Metadata.ErrorType) - assert.Equal(t, "1.0", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseUnusedCondition(t *testing.T) { - collector := NewErrorCollector(nil) - meta := &Meta{File: "test.fga", Module: "test"} - lineIndex := 15 - - collector.RaiseUnusedCondition("unused_condition", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "`unused_condition` condition is not used in the model.", errors[0].Message) - assert.Equal(t, ConditionNotUsed, errors[0].Metadata.ErrorType) - assert.Equal(t, "unused_condition", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseDifferentNestedConditionName(t *testing.T) { - collector := NewErrorCollector(nil) - - collector.RaiseDifferentNestedConditionName("condition1", "condition2") - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "condition key is `condition1` but nested name property is condition2", errors[0].Message) - assert.Equal(t, DifferentNestedConditionName, errors[0].Metadata.ErrorType) - assert.Equal(t, "condition2", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_RaiseMultipleModulesInSingleFile(t *testing.T) { - collector := NewErrorCollector(nil) - modules := []string{"module1", "module2", "module3"} - - collector.RaiseMultipleModulesInSingleFile("test.fga", modules) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, "file 'test.fga' contains multiple modules: module1, module2, module3.", errors[0].Message) - assert.Equal(t, MultipleModulesInFile, errors[0].Metadata.ErrorType) - assert.Equal(t, "test.fga", errors[0].Metadata.Symbol) -} - -func TestErrorCollector_LineAndColumnResolution(t *testing.T) { - lines := []string{ - "model", - " schema 1.1", - "type document", - " relations", - " define viewer: [user]", - } - collector := NewErrorCollector(lines) - lineIndex := 4 - - collector.RaiseInvalidName("viewer", "rule", nil, &lineIndex, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - - // Check line information - assert.NotNil(t, errors[0].Line) - assert.Equal(t, 4, errors[0].Line.Start) - assert.Equal(t, 4, errors[0].Line.End) - - // Check column information (should find "viewer" in the line) - assert.NotNil(t, errors[0].Column) - line := lines[4] - expectedStart := strings.Index(line, "viewer") - assert.Equal(t, expectedStart, errors[0].Column.Start) - assert.Equal(t, expectedStart+len("viewer"), errors[0].Column.End) -} - -func TestErrorCollector_CustomResolver(t *testing.T) { - lines := []string{ - "type document", - " relations", - " define viewer: user from parent", - } - collector := NewErrorCollector(lines) - lineIndex := 2 - meta := &Meta{File: "test.fga"} - - collector.RaiseTupleUsersetRequiresDirect("user", "document", "viewer", meta, &lineIndex) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - - // The custom resolver should position the error after "from" keyword - assert.NotNil(t, errors[0].Column) - line := lines[2] - fromIndex := strings.Index(line, "from") - expectedStart := fromIndex + len("from") + strings.Index(line[fromIndex+len("from"):], "user") - assert.Equal(t, expectedStart, errors[0].Column.Start) -} diff --git a/pkg/go/validation/errors.go b/pkg/go/validation/errors.go deleted file mode 100644 index 213467d1..00000000 --- a/pkg/go/validation/errors.go +++ /dev/null @@ -1,156 +0,0 @@ -package validation - -import ( - "fmt" - "strings" -) - -// ValidationErrorType represents the different types of validation errors. -type ValidationErrorType string - -const ( - SchemaVersionRequired ValidationErrorType = "schema-version-required" - SchemaVersionUnsupported ValidationErrorType = "schema-version-unsupported" - ReservedTypeKeywords ValidationErrorType = "reserved-type-keywords" - ReservedRelationKeywords ValidationErrorType = "reserved-relation-keywords" - SelfError ValidationErrorType = "self-error" - InvalidName ValidationErrorType = "invalid-name" - MissingDefinition ValidationErrorType = "missing-definition" - InvalidRelationType ValidationErrorType = "invalid-relation-type" - InvalidRelationOnTupleset ValidationErrorType = "invalid-relation-on-tupleset" - InvalidType ValidationErrorType = "invalid-type" - RelationNoEntrypoint ValidationErrorType = "relation-no-entry-point" - TuplesetNotDirect ValidationErrorType = "tupleuserset-not-direct" - DuplicatedError ValidationErrorType = "duplicated-error" - // Undefined reference errors. - UndefinedType ValidationErrorType = "undefined-type" - UndefinedRelation ValidationErrorType = "undefined-relation" - - // Cycle and entry point errors. - CyclicError ValidationErrorType = "cyclic-error" - - // Wildcard validation errors. - InvalidWildcardError ValidationErrorType = "invalid-wildcard-error" - AssignableRelationsMustHaveType ValidationErrorType = "assignable-relation-must-have-type" - InvalidSchema ValidationErrorType = "invalid-schema" - InvalidSyntax ValidationErrorType = "invalid-syntax" - TypeRestrictionCannotHaveWildcardAndRelation ValidationErrorType = "type-wildcard-relation" - ConditionNotDefined ValidationErrorType = "condition-not-defined" - ConditionNotUsed ValidationErrorType = "condition-not-used" - DifferentNestedConditionName ValidationErrorType = "different-nested-condition-name" - MultipleModulesInFile ValidationErrorType = "multiple-modules-in-file" - CyclicRelation ValidationErrorType = "cyclic-relation" - InvalidSchemaVersion ValidationErrorType = "invalid-schema-version" -) - -// LineRange represents line start and end positions. -type LineRange struct { - Start int `json:"start"` - End int `json:"end"` -} - -// ColumnRange represents column start and end positions. -type ColumnRange struct { - Start int `json:"start"` - End int `json:"end"` -} - -// ErrorMetadata contains metadata about the validation error. -type ErrorMetadata struct { - Symbol string `json:"symbol"` - ErrorType ValidationErrorType `json:"errorType"` - Module string `json:"module,omitempty"` - Type string `json:"type,omitempty"` - Relation string `json:"relation,omitempty"` - Condition string `json:"condition,omitempty"` - OffendingType string `json:"offendingType,omitempty"` -} - -// ValidationError represents a single validation error. -type ValidationError struct { - Message string `json:"msg"` - Line *LineRange `json:"line,omitempty"` - Column *ColumnRange `json:"column,omitempty"` - File string `json:"file,omitempty"` - Metadata *ErrorMetadata `json:"metadata,omitempty"` -} - -// Error implements the error interface. -func (e *ValidationError) Error() string { - location := "" - if e.Line != nil && e.Column != nil { - location = fmt.Sprintf(" at line=%d, column=%d", e.Line.Start, e.Column.Start) - } - return fmt.Sprintf("validation error%s: %s", location, e.Message) -} - -// String returns a string representation of the error. -func (e *ValidationError) String() string { - return e.Error() -} - -// ValidationErrors represents a collection of validation errors. -// -//nolint:errname // plural name intentionally describes a collection of errors -type ValidationErrors struct { - Errors []*ValidationError `json:"errors"` -} - -// Error implements the error interface for ValidationErrors. -func (e *ValidationErrors) Error() string { - if len(e.Errors) == 0 { - return "no validation errors" - } - - plural := "" - if len(e.Errors) > 1 { - plural = "s" - } - - var errorStrings []string - for _, err := range e.Errors { - errorStrings = append(errorStrings, err.String()) - } - - return fmt.Sprintf("%d error%s occurred:\n\t* %s\n\n", - len(e.Errors), plural, strings.Join(errorStrings, "\n\t* ")) -} - -// Add adds a validation error to the collection. -func (e *ValidationErrors) Add(err *ValidationError) { - e.Errors = append(e.Errors, err) -} - -// GetErrors returns a slice of all validation errors. -func (e *ValidationErrors) GetErrors() []*ValidationError { - return e.Errors -} - -// NewValidationErrors creates a new ValidationErrors instance from a slice of ValidationError -func NewValidationErrors(errors []*ValidationError) *ValidationErrors { - if errors == nil { - errors = make([]*ValidationError, 0) - } - return &ValidationErrors{ - Errors: errors, - } -} - -// HasErrors returns true if there are any errors. -func (e *ValidationErrors) HasErrors() bool { - return len(e.Errors) > 0 -} - -// Count returns the number of errors. -func (e *ValidationErrors) Count() int { - return len(e.Errors) -} - -// Meta represents file and module metadata. -type Meta struct { - File string `json:"file,omitempty"` - Module string `json:"module,omitempty"` -} - -// ErrorCustomResolver is a function type for custom error position resolution. -type ErrorCustomResolver func(wordIndex int, rawLine string, symbol string) int diff --git a/pkg/go/validation/errors_test.go b/pkg/go/validation/errors_test.go deleted file mode 100644 index c558d32e..00000000 --- a/pkg/go/validation/errors_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestValidationError_Error(t *testing.T) { - tests := []struct { - name string - error *ValidationError - expected string - }{ - { - name: "error with line and column", - error: &ValidationError{ - Message: "test error message", - Line: &LineRange{Start: 5, End: 5}, - Column: &ColumnRange{Start: 10, End: 15}, - Metadata: &ErrorMetadata{ - Symbol: "test_symbol", - ErrorType: InvalidName, - }, - }, - expected: "validation error at line=5, column=10: test error message", - }, - { - name: "error without line/column", - error: &ValidationError{ - Message: "test error message", - Metadata: &ErrorMetadata{ - Symbol: "test_symbol", - ErrorType: InvalidType, - }, - }, - expected: "validation error: test error message", - }, - { - name: "error with file", - error: &ValidationError{ - Message: "test error message", - File: "test.fga", - Line: &LineRange{Start: 3, End: 3}, - Column: &ColumnRange{Start: 0, End: 4}, - }, - expected: "validation error at line=3, column=0: test error message", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.error.Error() - assert.Equal(t, tt.expected, actual) - }) - } -} - -func TestValidationError_String(t *testing.T) { - valErr := &ValidationError{ - Message: "test error", - Line: &LineRange{Start: 1, End: 1}, - Column: &ColumnRange{Start: 5, End: 10}, - } - - // String() should return the same as Error() - assert.Equal(t, valErr.Error(), valErr.String()) -} - -func TestValidationErrors_Error(t *testing.T) { - tests := []struct { - name string - errors *ValidationErrors - expected string - }{ - { - name: "no errors", - errors: &ValidationErrors{Errors: []*ValidationError{}}, - expected: "no validation errors", - }, - { - name: "single error", - errors: &ValidationErrors{ - Errors: []*ValidationError{ - {Message: "first error"}, - }, - }, - expected: "1 error occurred:\n\t* validation error: first error\n\n", - }, - { - name: "multiple errors", - errors: &ValidationErrors{ - Errors: []*ValidationError{ - {Message: "first error"}, - {Message: "second error"}, - }, - }, - expected: "2 errors occurred:\n\t* validation error: first error\n\t* validation error: second error\n\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.errors.Error() - assert.Equal(t, tt.expected, actual) - }) - } -} - -func TestValidationErrors_Add(t *testing.T) { - errors := &ValidationErrors{} - - // Initially no errors - assert.False(t, errors.HasErrors()) - assert.Equal(t, 0, errors.Count()) - - // Add first error - error1 := &ValidationError{Message: "first error"} - errors.Add(error1) - - assert.True(t, errors.HasErrors()) - assert.Equal(t, 1, errors.Count()) - assert.Equal(t, error1, errors.Errors[0]) - - // Add second error - error2 := &ValidationError{Message: "second error"} - errors.Add(error2) - - assert.Equal(t, 2, errors.Count()) - assert.Equal(t, error2, errors.Errors[1]) -} - -func TestValidationErrors_HasErrors(t *testing.T) { - errors := &ValidationErrors{} - assert.False(t, errors.HasErrors()) - - errors.Add(&ValidationError{Message: "test"}) - assert.True(t, errors.HasErrors()) -} - -func TestValidationErrors_Count(t *testing.T) { - errors := &ValidationErrors{} - assert.Equal(t, 0, errors.Count()) - - errors.Add(&ValidationError{Message: "test1"}) - assert.Equal(t, 1, errors.Count()) - - errors.Add(&ValidationError{Message: "test2"}) - assert.Equal(t, 2, errors.Count()) -} - -func TestErrorMetadata(t *testing.T) { - metadata := &ErrorMetadata{ - Symbol: "test_symbol", - ErrorType: InvalidName, - Module: "test_module", - Type: "test_type", - Relation: "test_relation", - Condition: "test_condition", - OffendingType: "offending_type", - } - - assert.Equal(t, "test_symbol", metadata.Symbol) - assert.Equal(t, InvalidName, metadata.ErrorType) - assert.Equal(t, "test_module", metadata.Module) - assert.Equal(t, "test_type", metadata.Type) - assert.Equal(t, "test_relation", metadata.Relation) - assert.Equal(t, "test_condition", metadata.Condition) - assert.Equal(t, "offending_type", metadata.OffendingType) -} - -func TestLineRange(t *testing.T) { - line := &LineRange{Start: 5, End: 10} - assert.Equal(t, 5, line.Start) - assert.Equal(t, 10, line.End) -} - -func TestColumnRange(t *testing.T) { - column := &ColumnRange{Start: 15, End: 25} - assert.Equal(t, 15, column.Start) - assert.Equal(t, 25, column.End) -} - -func TestValidationErrorTypes(t *testing.T) { - // Test that all validation error types are defined as expected - errorTypes := []ValidationErrorType{ - SchemaVersionRequired, - SchemaVersionUnsupported, - ReservedTypeKeywords, - ReservedRelationKeywords, - SelfError, - InvalidName, - MissingDefinition, - InvalidRelationType, - InvalidRelationOnTupleset, - InvalidType, - RelationNoEntrypoint, - TuplesetNotDirect, - DuplicatedError, - AssignableRelationsMustHaveType, - InvalidSchema, - InvalidSyntax, - TypeRestrictionCannotHaveWildcardAndRelation, - ConditionNotDefined, - ConditionNotUsed, - DifferentNestedConditionName, - MultipleModulesInFile, - } - - // Ensure all error types have string values - for _, errorType := range errorTypes { - assert.NotEmpty(t, string(errorType)) - assert.Contains(t, string(errorType), "-") - } - - // Test specific error type values match JS implementation - assert.Equal(t, "schema-version-required", string(SchemaVersionRequired)) - assert.Equal(t, "missing-definition", string(MissingDefinition)) - assert.Equal(t, "reserved-type-keywords", string(ReservedTypeKeywords)) - assert.Equal(t, "relation-no-entry-point", string(RelationNoEntrypoint)) -} - -func TestMeta(t *testing.T) { - meta := &Meta{ - File: "test.fga", - Module: "test_module", - } - - assert.Equal(t, "test.fga", meta.File) - assert.Equal(t, "test_module", meta.Module) -} diff --git a/pkg/go/validation/findings.go b/pkg/go/validation/findings.go new file mode 100644 index 00000000..5a22bd9b --- /dev/null +++ b/pkg/go/validation/findings.go @@ -0,0 +1,154 @@ +// Package validation checks OpenFGA authorization models for semantic errors +// the DSL grammar does not catch. The shared cases under tests/data fix each +// finding's message, code, symbol and position across pkg/go, pkg/js and +// pkg/java. +package validation + +import ( + "errors" + "fmt" + "slices" +) + +// Kind is a finding's machine-readable code, the wire `errorType`. The values +// are shared with pkg/js and pkg/java, so they are fixed. This package never +// emits SelfError, InvalidSyntax, AssignableRelationsMustHaveType or +// TypeRestrictionCannotHaveWildcardAndRelation. +type Kind string + +const ( + SchemaVersionRequired Kind = "schema-version-required" + SchemaVersionUnsupported Kind = "schema-version-unsupported" + ReservedTypeKeywords Kind = "reserved-type-keywords" + ReservedRelationKeywords Kind = "reserved-relation-keywords" + SelfError Kind = "self-error" + InvalidName Kind = "invalid-name" + MissingDefinition Kind = "missing-definition" + InvalidRelationType Kind = "invalid-relation-type" + InvalidRelationOnTupleset Kind = "invalid-relation-on-tupleset" + InvalidType Kind = "invalid-type" + RelationNoEntrypoint Kind = "relation-no-entry-point" + TuplesetNotDirect Kind = "tupleuserset-not-direct" + DuplicatedError Kind = "duplicated-error" + + AssignableRelationsMustHaveType Kind = "assignable-relation-must-have-type" + InvalidSchema Kind = "invalid-schema" + InvalidSyntax Kind = "invalid-syntax" + TypeRestrictionCannotHaveWildcardAndRelation Kind = "type-wildcard-relation" + ConditionNotDefined Kind = "condition-not-defined" + ConditionNotUsed Kind = "condition-not-used" + DifferentNestedConditionName Kind = "different-nested-condition-name" + MultipleModulesInFile Kind = "multiple-modules-in-file" +) + +// Range is a start and end position in the source text, used for both the line +// and the column a finding is at. +// +// The two are indexed differently: a line Range repeats the same zero-based +// index in Start and End, while a column Range is half-open, End being one past +// the symbol's last character. +type Range struct { + Start int `json:"start"` + End int `json:"end"` +} + +// Metadata is the structured half of a finding: the offending symbol, the code, +// and which part of the model the finding is about. The field names and JSON +// shape match pkg/js and pkg/java. +type Metadata struct { + Symbol string `json:"symbol"` + Kind Kind `json:"errorType"` + Module string `json:"module,omitempty"` + Type string `json:"type,omitempty"` + Relation string `json:"relation,omitempty"` + Condition string `json:"condition,omitempty"` + OffendingType string `json:"offendingType,omitempty"` +} + +// Finding is one validation diagnostic. Its fields are the cross-language wire +// shape, so it marshals without any custom JSON code. +// +// Line and Column are nil when the finding could not be located in the source, +// which is always the case for a model validated from JSON. +// +//nolint:errname // a diagnostic first; it implements error so validators can return it +type Finding struct { + Message string `json:"msg"` + Line *Range `json:"line,omitempty"` + Column *Range `json:"column,omitempty"` + File string `json:"file,omitempty"` + Metadata Metadata `json:"metadata"` +} + +// Error implements the error interface, formatting the finding with its +// position when it has one. +func (f *Finding) Error() string { + if f.Line != nil && f.Column != nil { + return fmt.Sprintf("validation error at line=%d, column=%d: %s", f.Line.Start, f.Column.Start, f.Message) + } + + return "validation error: " + f.Message +} + +// joinFindings joins findings into a single error, or nil when there are none. +// A nil *Finding is dropped, so a check that found nothing can be appended +// without a guard. The findings are joined in the order given, and ExtractAllAs +// recovers them in that order. +func joinFindings(findings ...*Finding) error { + errs := make([]error, 0, len(findings)) + for _, f := range findings { + if f != nil { + errs = append(errs, f) + } + } + + return errors.Join(errs...) +} + +// ExtractAllAs walks an error tree and returns every error of type E, in the +// order errors.Join holds them: pre-order, left to right. It recovers the +// findings behind a validation error: +// +// for _, finding := range validation.ExtractAllAs[*validation.Finding](err) { +// ... +// } +// +// An error that is an E is collected and not descended into; anything else is +// expanded through its Unwrap() []error or Unwrap() error. +func ExtractAllAs[E error](err error) []E { + var found []E + + stack := []error{err} + for len(stack) > 0 { + ndx := len(stack) - 1 + current := stack[ndx] + stack = stack[:ndx] + + // errors.Unwrap below yields nil for a leaf with no Unwrap() error, and + // the input itself may be nil; skip it so the walk terminates instead of + // unwrapping nil to nil forever. + if current == nil { + continue + } + + if e, ok := current.(E); ok { + found = append(found, e) + + continue + } + + if unwrapped, ok := current.(interface{ Unwrap() []error }); ok { + children := unwrapped.Unwrap() + // Push last to first so the stack pops them left to right. + for _, child := range slices.Backward(children) { + stack = append(stack, child) + } + + continue + } + + stack = append(stack, errors.Unwrap(current)) + } + + return found +} diff --git a/pkg/go/validation/findings_test.go b/pkg/go/validation/findings_test.go new file mode 100644 index 00000000..130d1a43 --- /dev/null +++ b/pkg/go/validation/findings_test.go @@ -0,0 +1,135 @@ +package validation + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindingError(t *testing.T) { + t.Parallel() + + t.Run("with position", func(t *testing.T) { + t.Parallel() + + finding := &Finding{ + Message: "the relation `viewer` does not exist.", + Line: &Range{Start: 4, End: 4}, + Column: &Range{Start: 12, End: 18}, + } + + assert.Equal(t, "validation error at line=4, column=12: the relation `viewer` does not exist.", finding.Error()) + }) + + t.Run("without position", func(t *testing.T) { + t.Parallel() + + finding := &Finding{Message: "schema version required"} + + assert.Equal(t, "validation error: schema version required", finding.Error()) + }) +} + +func TestJoinFindings(t *testing.T) { + t.Parallel() + + t.Run("nil when there is nothing to report", func(t *testing.T) { + t.Parallel() + + require.NoError(t, joinFindings()) + require.NoError(t, joinFindings(nil, nil)) + }) + + t.Run("drops nil findings", func(t *testing.T) { + t.Parallel() + + err := joinFindings(nil, &Finding{Message: "boom"}, nil) + require.Error(t, err) + + found := ExtractAllAs[*Finding](err) + require.Len(t, found, 1) + assert.Equal(t, "boom", found[0].Message) + }) + + t.Run("keeps the order it is given", func(t *testing.T) { + t.Parallel() + + err := joinFindings(&Finding{Message: "first"}, &Finding{Message: "second"}) + + found := ExtractAllAs[*Finding](err) + require.Len(t, found, 2) + assert.Equal(t, "first", found[0].Message) + assert.Equal(t, "second", found[1].Message) + }) +} + +func TestExtractAllAs(t *testing.T) { + t.Parallel() + + t.Run("nil error yields nothing", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, ExtractAllAs[*Finding](nil)) + }) + + t.Run("recovers findings from a nested tree in order", func(t *testing.T) { + t.Parallel() + + // A join of joins, the shape validate builds from its phases. + left := joinFindings(&Finding{Message: "1"}, &Finding{Message: "2"}) + right := joinFindings(&Finding{Message: "3"}, &Finding{Message: "4"}) + + found := ExtractAllAs[*Finding](errors.Join(left, right)) + require.Len(t, found, 4) + assert.Equal(t, []string{"1", "2", "3", "4"}, []string{ + found[0].Message, found[1].Message, found[2].Message, found[3].Message, + }) + }) + + t.Run("terminates on a leaf that is neither the target nor a wrapper", func(t *testing.T) { + t.Parallel() + + // errors.New has neither an Unwrap() []error nor an Unwrap() error, so + // the walk has nothing to descend into and ends its branch there. + found := ExtractAllAs[*Finding](errors.Join(&Finding{Message: "boom"}, errors.New("unrelated"))) + require.Len(t, found, 1) + assert.Equal(t, "boom", found[0].Message) + }) + + t.Run("descends through a single-error wrapper", func(t *testing.T) { + t.Parallel() + + found := ExtractAllAs[*Finding](fmt.Errorf("context: %w", &Finding{Message: "wrapped"})) + require.Len(t, found, 1) + assert.Equal(t, "wrapped", found[0].Message) + }) +} + +// TestFindingErrorsInterop checks that the standard errors helpers still reach a +// finding, so a caller that only wants the first is not forced through +// ExtractAllAs. +func TestFindingErrorsInterop(t *testing.T) { + t.Parallel() + + first := &Finding{Message: "first", Metadata: Metadata{Kind: InvalidName}} + second := &Finding{Message: "second", Metadata: Metadata{Kind: DuplicatedError}} + err := joinFindings(first, second) + + t.Run("errors.As reaches the first finding", func(t *testing.T) { + t.Parallel() + + var finding *Finding + require.ErrorAs(t, err, &finding) + assert.Same(t, first, finding) + }) + + t.Run("errors.Is matches each finding", func(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, err, error(first)) + require.ErrorIs(t, err, error(second)) + }) +} diff --git a/pkg/go/validation/index.go b/pkg/go/validation/index.go new file mode 100644 index 00000000..a70406e2 --- /dev/null +++ b/pkg/go/validation/index.go @@ -0,0 +1,116 @@ +package validation + +import ( + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +// index is the model indexed for lookup: types and relations by name. It is +// built once per validation run and shared by every phase that resolves +// references, so each phase does not rebuild its own maps. +// +// When a type is declared more than once the maps are built last-wins, matching +// pkg/js's typeMap; the duplicate itself is reported by the duplicates phase. +type index struct { + model *openfgav1.AuthorizationModel + types map[string]*openfgav1.TypeDefinition + relations map[string]map[string]*openfgav1.Userset +} + +func newIndex(model *openfgav1.AuthorizationModel) *index { + idx := &index{ + model: model, + types: make(map[string]*openfgav1.TypeDefinition, len(model.GetTypeDefinitions())), + relations: make(map[string]map[string]*openfgav1.Userset, len(model.GetTypeDefinitions())), + } + + for _, typeDef := range model.GetTypeDefinitions() { + idx.types[typeDef.GetType()] = typeDef + + if relations := typeDef.GetRelations(); len(relations) > 0 { + idx.relations[typeDef.GetType()] = relations + } + } + + return idx +} + +func (idx *index) typeDefined(typeName string) bool { + _, ok := idx.types[typeName] + + return ok +} + +func (idx *index) relationDefined(typeName, relationName string) bool { + _, ok := idx.relations[typeName][relationName] + + return ok +} + +func (idx *index) typeDef(typeName string) *openfgav1.TypeDefinition { + return idx.types[typeName] +} + +func (idx *index) userset(typeName, relationName string) *openfgav1.Userset { + return idx.relations[typeName][relationName] +} + +// directTypeRestrictions returns the directly-related user types declared for a +// relation in its metadata. +func (idx *index) directTypeRestrictions(typeName, relationName string) []*openfgav1.RelationReference { + typeDef := idx.types[typeName] + if typeDef == nil { + return nil + } + + relationMetadata, ok := typeDef.GetMetadata().GetRelations()[relationName] + if !ok { + return nil + } + + return relationMetadata.GetDirectlyRelatedUserTypes() +} + +// directlyAssignableTypes returns the type restrictions a relation is directly +// assignable to, but only when that relation is a single direct assignment +// (i.e. `define r: [a, b]` rather than a rewrite). The bool reports whether the +// relation is such a single direct assignment. This mirrors pkg/js's +// allowableTypes helper used for tuple-to-userset validation. +func (idx *index) directlyAssignableTypes(typeName, relationName string) ([]*openfgav1.RelationReference, bool) { + userset := idx.userset(typeName, relationName) + if userset == nil { + return nil, false + } + + if _, ok := userset.GetUserset().(*openfgav1.Userset_This); !ok { + return nil, false + } + + return idx.directTypeRestrictions(typeName, relationName), true +} + +// typeMeta resolves the file and module a type was declared in. +func typeMeta(typeDef *openfgav1.TypeDefinition) (file, module string) { + return typeDef.GetMetadata().GetSourceInfo().GetFile(), typeDef.GetMetadata().GetModule() +} + +// relationMeta resolves the file and module for a relation, falling back to its +// type's for whichever of the two the relation leaves unset. +func relationMeta(typeDef *openfgav1.TypeDefinition, relationName string) (file, module string) { + relationMetadata, ok := typeDef.GetMetadata().GetRelations()[relationName] + if !ok { + return typeMeta(typeDef) + } + + file = relationMetadata.GetSourceInfo().GetFile() + module = relationMetadata.GetModule() + + if file == "" { + file = typeDef.GetMetadata().GetSourceInfo().GetFile() + } + + if module == "" { + module = typeDef.GetMetadata().GetModule() + } + + return file, module +} diff --git a/pkg/go/validation/json_validation_test.go b/pkg/go/validation/json_validation_test.go new file mode 100644 index 00000000..8f9b2b6a --- /dev/null +++ b/pkg/go/validation/json_validation_test.go @@ -0,0 +1,63 @@ +package validation + +import ( + "os" + "path/filepath" + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "gopkg.in/yaml.v3" +) + +// jsonTestCase is one case from tests/data/json-validation-cases.yaml, the shared +// cases for models supplied as JSON. A JSON model carries no source text to resolve a +// position against, so no case in it states a line or a column. +type jsonTestCase struct { + Name string `yaml:"name"` + JSON string `yaml:"json"` + Skip bool `yaml:"skip,omitempty"` + ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` +} + +// TestJSONValidationSharedCases runs the shared JSON cases against ValidateJSON. The +// same file is run by pkg/js and pkg/java, so what only a JSON model can reach, such as +// findings with no position, is checked the same way in all three. +func TestJSONValidationSharedCases(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(filepath.Join(sharedDataDir, "json-validation-cases.yaml")) + require.NoError(t, err) + + var cases []jsonTestCase + require.NoError(t, yaml.Unmarshal(data, &cases)) + require.NotEmpty(t, cases, "shared test file loaded no cases") + + for _, testCase := range cases { + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + + if testCase.Skip { + t.Skip("marked skip in the shared test file") + } + + model := &openfgav1.AuthorizationModel{} + + // DiscardUnknown: the test file is shared with implementations whose model + // type may have fields this one does not, and an unknown field is not what + // a case is testing. + require.NoError(t, + protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(testCase.JSON), model), + "the case's JSON must parse as an authorization model") + + result := compareWithExpected(testCase.ExpectedErrors, ExtractAllAs[*Finding](ValidateJSON(model))) + + for _, problem := range result.Problems { + t.Error(problem) + } + + require.Equal(t, casePass, result.Status) + }) + } +} diff --git a/pkg/go/validation/keywords.go b/pkg/go/validation/keywords.go deleted file mode 100644 index 4299170c..00000000 --- a/pkg/go/validation/keywords.go +++ /dev/null @@ -1,29 +0,0 @@ -package validation - -// Reserved keywords that cannot be used as type or relation names. -const ( - KeywordSelf = "self" - KeywordDefine = "DEFINE" - KeywordThis = "this" -) - -// ReservedKeywords contains all reserved keywords. -var ReservedKeywords = map[string]bool{ - KeywordSelf: true, - KeywordThis: true, -} - -// IsReservedKeyword checks if a given string is a reserved keyword. -func IsReservedKeyword(keyword string) bool { - return ReservedKeywords[keyword] -} - -// IsReservedTypeName checks if a type name is reserved. -func IsReservedTypeName(typeName string) bool { - return IsReservedKeyword(typeName) -} - -// IsReservedRelationName checks if a relation name is reserved. -func IsReservedRelationName(relationName string) bool { - return IsReservedKeyword(relationName) -} diff --git a/pkg/go/validation/keywords_test.go b/pkg/go/validation/keywords_test.go deleted file mode 100644 index ff92a8cf..00000000 --- a/pkg/go/validation/keywords_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package validation - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestKeywordConstants(t *testing.T) { - // Test that keyword constants are defined correctly - assert.Equal(t, "self", KeywordSelf) - assert.Equal(t, "DEFINE", KeywordDefine) - assert.Equal(t, "this", KeywordThis) -} - -func TestReservedKeywords(t *testing.T) { - // Test that reserved keywords map is properly initialized - assert.NotNil(t, ReservedKeywords) - - // Test that all expected keywords are in the map - assert.True(t, ReservedKeywords[KeywordSelf]) - assert.True(t, ReservedKeywords[KeywordThis]) - - // Test that non-reserved words are not in the map - assert.False(t, ReservedKeywords["document"]) - assert.False(t, ReservedKeywords["user"]) - assert.False(t, ReservedKeywords["viewer"]) -} - -func TestIsReservedKeyword(t *testing.T) { - tests := []struct { - name string - keyword string - expected bool - }{ - { - name: "self is reserved", - keyword: "self", - expected: true, - }, - { - name: "this is reserved", - keyword: "this", - expected: true, - }, - { - name: "document is not reserved", - keyword: "document", - expected: false, - }, - { - name: "user is not reserved", - keyword: "user", - expected: false, - }, - { - name: "viewer is not reserved", - keyword: "viewer", - expected: false, - }, - { - name: "admin is not reserved", - keyword: "admin", - expected: false, - }, - { - name: "empty string is not reserved", - keyword: "", - expected: false, - }, - { - name: "SELF (uppercase) is not reserved", - keyword: "SELF", - expected: false, - }, - { - name: "THIS (uppercase) is not reserved", - keyword: "THIS", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsReservedKeyword(tt.keyword) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsReservedTypeName(t *testing.T) { - tests := []struct { - name string - typeName string - expected bool - }{ - { - name: "self is reserved type name", - typeName: "self", - expected: true, - }, - { - name: "this is reserved type name", - typeName: "this", - expected: true, - }, - { - name: "document is not reserved type name", - typeName: "document", - expected: false, - }, - { - name: "user is not reserved type name", - typeName: "user", - expected: false, - }, - { - name: "folder is not reserved type name", - typeName: "folder", - expected: false, - }, - { - name: "group is not reserved type name", - typeName: "group", - expected: false, - }, - { - name: "empty string is not reserved type name", - typeName: "", - expected: false, - }, - { - name: "SELF (uppercase) is not reserved type name", - typeName: "SELF", - expected: false, - }, - { - name: "THIS (uppercase) is not reserved type name", - typeName: "THIS", - expected: false, - }, - { - name: "Self (mixed case) is not reserved type name", - typeName: "Self", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsReservedTypeName(tt.typeName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsReservedRelationName(t *testing.T) { - tests := []struct { - name string - relationName string - expected bool - }{ - { - name: "self is reserved relation name", - relationName: "self", - expected: true, - }, - { - name: "this is reserved relation name", - relationName: "this", - expected: true, - }, - { - name: "viewer is not reserved relation name", - relationName: "viewer", - expected: false, - }, - { - name: "admin is not reserved relation name", - relationName: "admin", - expected: false, - }, - { - name: "owner is not reserved relation name", - relationName: "owner", - expected: false, - }, - { - name: "member is not reserved relation name", - relationName: "member", - expected: false, - }, - { - name: "can_view is not reserved relation name", - relationName: "can_view", - expected: false, - }, - { - name: "empty string is not reserved relation name", - relationName: "", - expected: false, - }, - { - name: "SELF (uppercase) is not reserved relation name", - relationName: "SELF", - expected: false, - }, - { - name: "THIS (uppercase) is not reserved relation name", - relationName: "THIS", - expected: false, - }, - { - name: "This (mixed case) is not reserved relation name", - relationName: "This", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsReservedRelationName(tt.relationName) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestReservedKeywordsConsistency(t *testing.T) { - // Test that IsReservedKeyword, IsReservedTypeName, and IsReservedRelationName - // are consistent with each other for reserved keywords - - reservedKeywords := []string{"self", "this"} - - for _, keyword := range reservedKeywords { - assert.True(t, IsReservedKeyword(keyword), "IsReservedKeyword should return true for %s", keyword) - assert.True(t, IsReservedTypeName(keyword), "IsReservedTypeName should return true for %s", keyword) - assert.True(t, IsReservedRelationName(keyword), "IsReservedRelationName should return true for %s", keyword) - } - - // Test that they're consistent for non-reserved words - nonReservedWords := []string{"document", "user", "viewer", "admin", "owner"} - - for _, word := range nonReservedWords { - assert.False(t, IsReservedKeyword(word), "IsReservedKeyword should return false for %s", word) - assert.False(t, IsReservedTypeName(word), "IsReservedTypeName should return false for %s", word) - assert.False(t, IsReservedRelationName(word), "IsReservedRelationName should return false for %s", word) - } -} - -func TestReservedKeywordsMatchJSImplementation(t *testing.T) { - // Test that our reserved keywords match exactly with the JS implementation - // Based on the JS keywords.ts file: - // - Keyword.SELF = "self" - // - ReservedKeywords.THIS = "this" - - // Test exact keyword values - assert.Equal(t, "self", KeywordSelf) - assert.Equal(t, "this", KeywordThis) - - // Test that these are the only reserved keywords for types and relations - assert.True(t, IsReservedTypeName("self")) - assert.True(t, IsReservedTypeName("this")) - assert.True(t, IsReservedRelationName("self")) - assert.True(t, IsReservedRelationName("this")) - - // Test case sensitivity (JS implementation is case-sensitive) - assert.False(t, IsReservedTypeName("SELF")) - assert.False(t, IsReservedTypeName("Self")) - assert.False(t, IsReservedTypeName("THIS")) - assert.False(t, IsReservedTypeName("This")) -} - -func TestReservedKeywordsValidation(t *testing.T) { - collector := NewErrorCollector(nil) - - // Test type name validation - should pass for valid names - isValid := ValidateTypeName("document", collector, nil, nil) - assert.True(t, isValid) - assert.Empty(t, collector.GetErrors()) - - // Test type name validation - should fail for reserved keywords - collector = NewErrorCollector(nil) - isValid = ValidateTypeName("this", collector, nil, nil) - assert.False(t, isValid) - assert.NotEmpty(t, collector.GetErrors()) - - // Test relation name validation - should pass for valid names - collector = NewErrorCollector(nil) - isValid = ValidateRelationName("viewer", "document", collector, nil, nil) - assert.True(t, isValid) - assert.Empty(t, collector.GetErrors()) - - // Test relation name validation - should fail for reserved keywords - collector = NewErrorCollector(nil) - isValid = ValidateRelationName("self", "document", collector, nil, nil) - assert.False(t, isValid) - assert.NotEmpty(t, collector.GetErrors()) -} diff --git a/pkg/go/validation/messages.go b/pkg/go/validation/messages.go new file mode 100644 index 00000000..a5140a30 --- /dev/null +++ b/pkg/go/validation/messages.go @@ -0,0 +1,201 @@ +package validation + +import ( + "fmt" + "strings" +) + +// Each function here builds one finding: its message, its code and the part of +// the model at fault. The raise site stamps the position, through at or one of +// its variants, and the file and module from the model's metadata. + +// invalidTypeName reports a type name that breaks the naming rule. +func invalidTypeName(name string) *Finding { + return &Finding{ + Message: fmt.Sprintf("type '%s' does not match naming rule: '%s'.", name, typeNameRule), + Metadata: Metadata{Kind: InvalidName, Symbol: name, Type: name}, + } +} + +// invalidRelationName reports a relation name that breaks the naming rule. +func invalidRelationName(name, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("relation '%s' of type '%s' does not match naming rule: '%s'.", name, typeName, relationNameRule), + Metadata: Metadata{Kind: InvalidName, Symbol: name, Type: typeName, Relation: name}, + } +} + +// invalidConditionName reports a condition name that breaks the naming rule. +func invalidConditionName(name string) *Finding { + return &Finding{ + Message: fmt.Sprintf("condition '%s' does not match naming rule: '%s'.", name, conditionNameRule), + Metadata: Metadata{Kind: InvalidName, Symbol: name, Condition: name}, + } +} + +// reservedTypeName reports a type named with a reserved keyword. +func reservedTypeName(name string) *Finding { + return &Finding{ + Message: "a type cannot be named 'self' or 'this'.", + Metadata: Metadata{Kind: ReservedTypeKeywords, Symbol: name, Type: name}, + } +} + +// reservedRelationName reports a relation named with a reserved keyword. +func reservedRelationName(name, typeName string) *Finding { + return &Finding{ + Message: "a relation cannot be named 'self' or 'this'.", + Metadata: Metadata{Kind: ReservedRelationKeywords, Symbol: name, Type: typeName, Relation: name}, + } +} + +// tupleUsersetRequiresDirect reports a tuple-to-userset whose tupleset relation +// is not a direct assignment. Stamped with atFromClause so the column marks the +// relation after the `from` keyword. +func tupleUsersetRequiresDirect(fromRelation, typeName, relation string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` relation used inside from allows only direct relation.", fromRelation), + Metadata: Metadata{Kind: TuplesetNotDirect, Symbol: fromRelation, Type: typeName, Relation: relation}, + } +} + +// duplicateTypeName reports a duplicated type. +func duplicateTypeName(name string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the type `%s` is a duplicate.", name), + Metadata: Metadata{Kind: DuplicatedError, Symbol: name, Type: name}, + } +} + +// duplicateTypeRestriction reports a duplicated type restriction on a relation. +func duplicateTypeRestriction(restriction, relationName, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the type restriction `%s` is a duplicate in the relation `%s`.", restriction, relationName), + Metadata: Metadata{Kind: DuplicatedError, Symbol: restriction, Type: typeName, Relation: relationName}, + } +} + +// duplicatePartialRelation reports a duplicated partial relation definition. +func duplicatePartialRelation(operand, relationName, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the partial relation definition `%s` is a duplicate in the relation `%s`.", operand, relationName), + Metadata: Metadata{Kind: DuplicatedError, Symbol: operand, Type: typeName, Relation: relationName}, + } +} + +// noEntryPointLoop reports an impossible relation with a potential loop. +func noEntryPointLoop(relation, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is an impossible relation for `%s` (potential loop).", relation, typeName), + Metadata: Metadata{Kind: RelationNoEntrypoint, Symbol: relation, Type: typeName, Relation: relation}, + } +} + +// noEntryPoint reports an impossible relation with no entry point. +func noEntryPoint(relation, typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is an impossible relation for `%s` (no entrypoint).", relation, typeName), + Metadata: Metadata{Kind: RelationNoEntrypoint, Symbol: relation, Type: typeName, Relation: relation}, + } +} + +// invalidRelationOnTupleset reports a tuple-to-userset whose computed relation +// does not exist on the type the tupleset relation is assignable to. +func invalidRelationOnTupleset(symbol, missingRelation, typeName, tuplesetRelation, targetType, relationName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the `%s` relation definition on type `%s` is not valid: `%s` does not exist on `%s`, which is of type `%s`.", + missingRelation, typeName, missingRelation, tuplesetRelation, targetType), + Metadata: Metadata{Kind: InvalidRelationOnTupleset, Symbol: symbol, Type: typeName, Relation: relationName}, + } +} + +// invalidTypeRelation reports a relation reference that is not valid for a +// type. The offendingType is the enclosing type the reference was written in. +func invalidTypeRelation(symbol, typeName, relationName, offendingRelation, offendingType string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is not a valid relation for `%s`.", offendingRelation, typeName), + Metadata: Metadata{Kind: InvalidRelationType, Symbol: symbol, Type: typeName, Relation: relationName, OffendingType: offendingType}, + } +} + +// invalidType reports an invalid type in an assignable-types list. Stamped with +// atRestriction so the column marks the type, not a relation key sharing its +// name. +func invalidType(typeName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is not a valid type.", typeName), + Metadata: Metadata{Kind: InvalidType, Symbol: typeName, Type: typeName}, + } +} + +// missingRelation reports a rewrite that names a relation the type does not +// define. +func missingRelation(name, typeName, relation string) *Finding { + return &Finding{ + Message: fmt.Sprintf("the relation `%s` does not exist.", name), + Metadata: Metadata{Kind: MissingDefinition, Symbol: name, Type: typeName, Relation: relation}, + } +} + +// invalidSchemaVersion reports a schema version that was never valid (e.g. +// "0.9", "2.0"), as distinct from one that is recognized but no longer +// supported. Its code is InvalidSchema, as in pkg/js. +func invalidSchemaVersion(version string) *Finding { + return &Finding{ + Message: "invalid schema " + version, + Metadata: Metadata{Kind: InvalidSchema, Symbol: version}, + } +} + +// schemaVersionUnsupported reports a recognized but retired schema version +// (e.g. "1.0"). +func schemaVersionUnsupported(version string) *Finding { + return &Finding{ + Message: "schema version no longer supported", + Metadata: Metadata{Kind: SchemaVersionUnsupported, Symbol: version}, + } +} + +// schemaVersionRequired reports a model with no schema version. +func schemaVersionRequired() *Finding { + return &Finding{ + Message: "schema version required", + Metadata: Metadata{Kind: SchemaVersionRequired}, + } +} + +// conditionNotDefined reports a reference to a condition the model does not +// define. +func conditionNotDefined(condition, typeName, relationName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` is not a defined condition in the model.", condition), + Metadata: Metadata{Kind: ConditionNotDefined, Symbol: condition, Type: typeName, Relation: relationName, Condition: condition}, + } +} + +// unusedCondition reports a condition defined but never referenced. +func unusedCondition(condition string) *Finding { + return &Finding{ + Message: fmt.Sprintf("`%s` condition is not used in the model.", condition), + Metadata: Metadata{Kind: ConditionNotUsed, Symbol: condition, Condition: condition}, + } +} + +// differentNestedConditionName reports a condition whose nested name property +// differs from its map key. It carries no position, as in pkg/js. +func differentNestedConditionName(conditionKey, nestedName string) *Finding { + return &Finding{ + Message: fmt.Sprintf("condition key is `%s` but nested name property is %s", conditionKey, nestedName), + Metadata: Metadata{Kind: DifferentNestedConditionName, Symbol: nestedName, Condition: conditionKey}, + } +} + +// multipleModulesInSingleFile reports a file that would contain more than one +// module. It carries no position: it is about the file, not a line in it. +func multipleModulesInSingleFile(file string, modules []string) *Finding { + return &Finding{ + Message: fmt.Sprintf("file %s would contain multiple module definitions (%s) when transforming to DSL. "+ + "Only one module can be defined per file.", file, strings.Join(modules, ", ")), + Metadata: Metadata{Kind: MultipleModulesInFile, Symbol: file}, + } +} diff --git a/pkg/go/validation/multi_file_validation.go b/pkg/go/validation/multi_file_validation.go index 700acdc1..97ca0aaa 100644 --- a/pkg/go/validation/multi_file_validation.go +++ b/pkg/go/validation/multi_file_validation.go @@ -1,152 +1,73 @@ package validation import ( + "maps" "path/filepath" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// MultiFileValidator handles validation across multiple files and modules. -type MultiFileValidator struct { - model *openfgav1.AuthorizationModel - fileToModuleMap map[string]map[string]bool - moduleToFileMap map[string]map[string]bool - typeModuleMap map[string]string - conditionModuleMap map[string]string -} - -// ModuleInfo represents information about a module. -type ModuleInfo struct { - Name string - Files []string - Types []string -} - -// FileInfo represents information about a file. -type FileInfo struct { - Path string - Modules []string -} - -func NewMultiFileValidator(model *openfgav1.AuthorizationModel) *MultiFileValidator { - validator := &MultiFileValidator{ - model: model, - fileToModuleMap: make(map[string]map[string]bool), - moduleToFileMap: make(map[string]map[string]bool), - typeModuleMap: make(map[string]string), - conditionModuleMap: make(map[string]string), - } - validator.buildFileMappings() - return validator -} +// validateMultiFile reports every file that would contain more than one module +// when transformed back to DSL. Findings carry no position: they are about +// files, not lines. +func validateMultiFile(model *openfgav1.AuthorizationModel) error { + var fs []*Finding -func (mfv *MultiFileValidator) buildFileMappings() { - if mfv.model == nil { - return - } - for _, typeDef := range mfv.model.GetTypeDefinitions() { - file := typeDef.GetMetadata().GetSourceInfo().GetFile() - module := typeDef.GetMetadata().GetModule() - if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - mfv.typeModuleMap[typeDef.GetType()] = module - } - } - for conditionName, condition := range mfv.model.GetConditions() { - file := condition.GetMetadata().GetSourceInfo().GetFile() - module := condition.GetMetadata().GetModule() - if file != "" && module != "" { - mfv.addFileModuleMapping(file, module) - mfv.conditionModuleMap[conditionName] = module + files, modules := modulesByFile(model) + for _, file := range files { + if len(modules[file]) > 1 { + fs = append(fs, multipleModulesInSingleFile(file, modules[file])) } } -} -func (mfv *MultiFileValidator) addFileModuleMapping(file, module string) { - file = filepath.Clean(file) - if mfv.fileToModuleMap[file] == nil { - mfv.fileToModuleMap[file] = make(map[string]bool) - } - mfv.fileToModuleMap[file][module] = true - if mfv.moduleToFileMap[module] == nil { - mfv.moduleToFileMap[module] = make(map[string]bool) - } - mfv.moduleToFileMap[module][file] = true + return joinFindings(fs...) } -// ValidateMultiFileConsistency validates consistency across multiple files. -func ValidateMultiFileConsistency(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validator := NewMultiFileValidator(model) - validator.validateMultipleModulesInFile(collector) -} +// modulesByFile returns the files named in the model's metadata, in the order +// first seen, and each file's modules, in the same order. A file's modules are +// joined into its message, and pkg/js lists them in the order it meets them, +// so a map's order or a sorted one would fail the shared test cases. +// +// The model is walked as pkg/js walks it: every type, then every relation, +// then every condition. The passes are separate because a relation's module is +// recorded after the module of every type, not after its own type's. +func modulesByFile(model *openfgav1.AuthorizationModel) ([]string, map[string][]string) { + var files []string + modules := make(map[string][]string) -func (mfv *MultiFileValidator) validateMultipleModulesInFile(collector *ErrorCollector) { - for file, modules := range mfv.fileToModuleMap { - if len(modules) > 1 { - moduleNames := make([]string, 0, len(modules)) - for module := range modules { - moduleNames = append(moduleNames, module) - } - collector.RaiseMultipleModulesInSingleFile(file, moduleNames) + record := func(file, module string) { + if file == "" || module == "" { + return } - } -} -func (mfv *MultiFileValidator) GetModuleInfo() []ModuleInfo { - modules := make([]ModuleInfo, 0, len(mfv.moduleToFileMap)) - for moduleName, files := range mfv.moduleToFileMap { - mi := ModuleInfo{Name: moduleName, Files: make([]string, 0, len(files)), Types: make([]string, 0)} - for file := range files { - mi.Files = append(mi.Files, file) + file = filepath.Clean(file) + if _, seen := modules[file]; !seen { + files = append(files, file) } - for typeName, typeModule := range mfv.typeModuleMap { - if typeModule == moduleName { - mi.Types = append(mi.Types, typeName) - } + + if !slices.Contains(modules[file], module) { + modules[file] = append(modules[file], module) } - modules = append(modules, mi) } - return modules -} -func (mfv *MultiFileValidator) GetFileInfo() []FileInfo { - files := make([]FileInfo, 0, len(mfv.fileToModuleMap)) - for filePath, modules := range mfv.fileToModuleMap { - fi := FileInfo{Path: filePath, Modules: make([]string, 0, len(modules))} - for module := range modules { - fi.Modules = append(fi.Modules, module) - } - files = append(files, fi) + for _, typeDef := range model.GetTypeDefinitions() { + record(typeMeta(typeDef)) } - return files -} -func (mfv *MultiFileValidator) IsMultiModuleProject() bool { return len(mfv.moduleToFileMap) > 1 } -func (mfv *MultiFileValidator) IsMultiFileProject() bool { return len(mfv.fileToModuleMap) > 1 } -func (mfv *MultiFileValidator) GetModuleForType(typeName string) string { - return mfv.typeModuleMap[typeName] -} -func (mfv *MultiFileValidator) GetModuleForCondition(conditionName string) string { - return mfv.conditionModuleMap[conditionName] -} -func (mfv *MultiFileValidator) GetFilesForModule(moduleName string) []string { - files := make([]string, 0, len(mfv.moduleToFileMap[moduleName])) - if moduleFiles, exists := mfv.moduleToFileMap[moduleName]; exists { - for file := range moduleFiles { - files = append(files, file) + for _, typeDef := range model.GetTypeDefinitions() { + // Relation names arrive in a proto map, which has no order of its own, + // so they are walked in name order. + for _, relationName := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { + record(relationMeta(typeDef, relationName)) } } - return files -} -func (mfv *MultiFileValidator) GetModulesForFile(filePath string) []string { - modules := make([]string, 0, len(mfv.fileToModuleMap[filePath])) - if fileModules, exists := mfv.fileToModuleMap[filePath]; exists { - for module := range fileModules { - modules = append(modules, module) - } + + conditions := model.GetConditions() + for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionName] + record(condition.GetMetadata().GetSourceInfo().GetFile(), condition.GetMetadata().GetModule()) } - return modules + + return files, modules } diff --git a/pkg/go/validation/name_validation.go b/pkg/go/validation/name_validation.go index 0d5bd4c9..aad60f6c 100644 --- a/pkg/go/validation/name_validation.go +++ b/pkg/go/validation/name_validation.go @@ -1,249 +1,120 @@ package validation import ( + "errors" "fmt" + "maps" "regexp" - "strings" + "slices" openfgav1 "github.com/openfga/api/proto/openfga/v1" - - "github.com/openfga/language/pkg/go/utils" ) -// ValidationRegexRules contains the regex rules for validation. -// These match the Rules from the JS implementation. -var ValidationRegexRules = struct { - Type string - Relation string - Condition string - ID string - Object string -}{ - Type: "[^:#@\\*\\s]{1,254}", - Relation: "[^:#@\\*\\s]{1,50}", - Condition: "[^\\*\\s]{1,50}", - ID: "[^#:\\s*][a-zA-Z0-9_|*@.+]*", - Object: "[^\\s]{2,256}", -} - -// The anchored type, relation, and condition name rules are fixed, so compile -// them once. compiledNameRules caches them by their anchored rule string, which -// is also the clause reported in the error, so validateFieldValue can look up -// the compiled pattern without recompiling on every name. +// The anchored name rules, compiled once. Each rule string is also quoted in +// the invalid-name message. var ( - typeNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Type) - relationNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Relation) - conditionNameRule = fmt.Sprintf("^%s$", ValidationRegexRules.Condition) - - compiledNameRules = map[string]*regexp.Regexp{ - typeNameRule: regexp.MustCompile(typeNameRule), - relationNameRule: regexp.MustCompile(relationNameRule), - conditionNameRule: regexp.MustCompile(conditionNameRule), - } -) - -// ValidateTypeName validates a type name with both regex and reserved keyword -// checking. This enhances the basic regex validation with semantic checks. -func ValidateTypeName(typeName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { - // First check if it's a reserved keyword - if IsReservedTypeName(typeName) { - collector.RaiseReservedTypeName(typeName, lineIndex, meta) - return false - } - - // Then check regex pattern. The clause passed to the error is the full - // anchored rule, matching the reference implementation's reported rule. - if !validateFieldValue(typeNameRule, typeName) { - collector.RaiseInvalidName(typeName, typeNameRule, nil, lineIndex, meta) - return false - } - - return true -} - -// ValidateRelationName validates a relation name with both regex and reserved -// keyword checking. This enhances the basic regex validation with semantic checks. -func ValidateRelationName(relationName, typeName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { - // First check if it's a reserved keyword - if IsReservedRelationName(relationName) { - collector.RaiseReservedRelationName(relationName, lineIndex, meta) - return false - } - - // Then check regex pattern. The clause passed to the error is the full - // anchored rule, matching the reference implementation's reported rule. - if !validateFieldValue(relationNameRule, relationName) { - collector.RaiseInvalidName(relationName, relationNameRule, &typeName, lineIndex, meta) - return false - } - - return true -} + typeNameRule = fmt.Sprintf("^%s$", RuleType) + relationNameRule = fmt.Sprintf("^%s$", RuleRelation) + conditionNameRule = fmt.Sprintf("^%s$", RuleCondition) -// ValidateConditionName validates a condition name with regex pattern. -func ValidateConditionName(conditionName string, collector *ErrorCollector, lineIndex *int, meta *Meta) bool { - if !validateFieldValue(conditionNameRule, conditionName) { - collector.RaiseInvalidName(conditionName, conditionNameRule, nil, lineIndex, meta) - return false - } - - return true -} + typeNameRegex = regexp.MustCompile(typeNameRule) + relationNameRegex = regexp.MustCompile(relationNameRule) + conditionNameRegex = regexp.MustCompile(conditionNameRule) +) -// validateFieldValue validates a field against a regex rule. Fixed rules are -// served from the precompiled cache; any other rule is compiled on demand. -func validateFieldValue(rule, value string) bool { - if regex, ok := compiledNameRules[rule]; ok { - return regex.MatchString(value) - } - regex, err := regexp.Compile(rule) - if err != nil { - return false - } - return regex.MatchString(value) +// reservedKeywords are the words a type or relation cannot be named. +var reservedKeywords = map[string]bool{ + "self": true, + "this": true, } -// GetTypeLineNumber finds the line number where a type is defined. -// This is equivalent to the getTypeLineNumber function in JS. -func GetTypeLineNumber(typeName string, lines []string, skipIndex *int) *int { - if len(lines) == 0 { - return nil - } - - for i, line := range lines { - // Skip the specified index if provided - if skipIndex != nil && i == *skipIndex { - continue - } - - // Look for "type typeName" pattern. Runs of inline whitespace are folded - // first so `type\tuser` passes the "type " prefix gate like `type user`. - trimmedLine := utils.NormalizeWhitespace(strings.TrimSpace(line)) - if strings.HasPrefix(trimmedLine, "type ") { - parts := strings.Fields(trimmedLine) - if len(parts) >= 2 && parts[1] == typeName { - return &i - } - } +// validateTypeName checks one type name against the reserved keywords and the +// naming rule, returning the finding as an error or nil. It knows nothing about +// the source text; the caller recovers the finding to stamp position. +func validateTypeName(name string) error { + switch { + case reservedKeywords[name]: + return reservedTypeName(name) + case !typeNameRegex.MatchString(name): + return invalidTypeName(name) } return nil } -// GetRelationLineNumber finds the line number where a relation is defined. -// skipIndex, when provided, is the index to begin searching from (inclusive) — -// matching the reference implementation's getRelationLineNumber, which slices -// the lines from skipIndex onward. This lets callers anchor the search to a -// specific type block so the correct occurrence is found when several types -// declare a relation of the same name. -func GetRelationLineNumber(relationName string, lines []string, skipIndex *int) *int { - if len(lines) == 0 { - return nil - } - - start := 0 - if skipIndex != nil && *skipIndex > 0 { - start = *skipIndex - } - - for i := start; i < len(lines); i++ { - // Look for "define relationName:" pattern. Runs of inline whitespace are - // folded first so `define\towner:` is found like `define owner:`. - trimmedLine := utils.NormalizeWhitespace(strings.TrimSpace(lines[i])) - if strings.HasPrefix(trimmedLine, "define ") { - // Extract relation name from "define relationName:" - definePart := strings.TrimPrefix(trimmedLine, "define ") - colonIndex := strings.Index(definePart, ":") - if colonIndex > 0 { - relationInLine := strings.TrimSpace(definePart[:colonIndex]) - if relationInLine == relationName { - return &i - } - } - } +// validateRelationName checks one relation name, returning the finding as an +// error or nil. +func validateRelationName(name, typeName string) error { + switch { + case reservedKeywords[name]: + return reservedRelationName(name, typeName) + case !relationNameRegex.MatchString(name): + return invalidRelationName(name, typeName) } return nil } -// GetConditionLineNumber finds the line number where a condition is defined. -// This is equivalent to the geConditionLineNumber function in JS. -func GetConditionLineNumber(conditionName string, lines []string, skipIndex *int) *int { - if len(lines) == 0 { - return nil - } - - start := 0 - if skipIndex != nil && *skipIndex > 0 { - start = *skipIndex - } - - conditionPrefix := "condition " + conditionName - for i := start; i < len(lines); i++ { - // The name must end at the prefix, so `less` does not match a declaration - // of `less_than`. Inline whitespace is folded first so `condition\tless(` - // is found like `condition less(`. - trimmedLine := utils.NormalizeWhitespace(strings.TrimSpace(lines[i])) - if !strings.HasPrefix(trimmedLine, conditionPrefix) { - continue - } - - rest := trimmedLine[len(conditionPrefix):] - if rest == "" || !utils.IsNameByte(rest[0]) { - return &i - } +// validateConditionName checks one condition name, returning the finding as an +// error or nil. Go-only: pkg/js and pkg/java accept any condition name the +// grammar parses. +func validateConditionName(name string) error { + if !conditionNameRegex.MatchString(name) { + return invalidConditionName(name) } return nil } -// ValidateNameRules validates naming rules for types and relations in a model. -// This is equivalent to the populateRelations function's naming validation in JS. -func ValidateNameRules(collector *ErrorCollector, typeName string, relationNames []string, - typeLineIndex *int, meta *Meta, lines []string) { - // Validate type name - ValidateTypeName(typeName, collector, typeLineIndex, meta) - - // Validate relation names - for _, relationName := range relationNames { - relationLineIndex := GetRelationLineNumber(relationName, lines, nil) - ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) - } -} - -// ValidateNames checks every type, relation, and condition name in the model -// against the reserved-keyword and naming-rule constraints. It mirrors the name -// validation performed in the JS reference implementation's populateRelations. -func ValidateNames(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } +// validateNames checks every type, relation and condition name in the model +// against the reserved keywords and the naming rules. +func validateNames(model *openfgav1.AuthorizationModel, src source) error { + var fs []*Finding for _, typeDef := range model.GetTypeDefinitions() { typeName := typeDef.GetType() if typeName == "" { continue } - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - ValidateTypeName(typeName, collector, typeLineIndex, meta) + file, module := typeMeta(typeDef) - for relationName := range typeDef.GetRelations() { - relationLineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - ValidateRelationName(relationName, typeName, collector, relationLineIndex, meta) + typeLine := src.typeLine(typeName) + + var finding *Finding + if errors.As(validateTypeName(typeName), &finding) { + finding.at(src, typeLine) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) + } + + // Relations reach us in a proto map, which has no order, so they are + // walked in name order here and in every other phase to report the same + // model's findings in the same order from run to run. + for _, relationName := range slices.Sorted(maps.Keys(typeDef.GetRelations())) { + relationLine := src.relationLine(relationName, typeLine) + var finding *Finding + if errors.As(validateRelationName(relationName, typeName), &finding) { + finding.at(src, relationLine) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) + } } } - for conditionName, condition := range model.GetConditions() { - conditionLineIndex := GetConditionLineNumber(conditionName, lines, nil) - meta := &Meta{ - File: condition.GetMetadata().GetSourceInfo().GetFile(), - Module: condition.GetMetadata().GetModule(), + conditions := model.GetConditions() + for _, conditionName := range slices.Sorted(maps.Keys(conditions)) { + condition := conditions[conditionName] + file := condition.GetMetadata().GetSourceInfo().GetFile() + module := condition.GetMetadata().GetModule() + + var finding *Finding + if errors.As(validateConditionName(conditionName), &finding) { + finding.at(src, src.conditionLine(conditionName)) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } - ValidateConditionName(conditionName, collector, conditionLineIndex, meta) } + + return joinFindings(fs...) } diff --git a/pkg/go/validation/name_validation_test.go b/pkg/go/validation/name_validation_test.go index 39714c09..9fb072e5 100644 --- a/pkg/go/validation/name_validation_test.go +++ b/pkg/go/validation/name_validation_test.go @@ -4,669 +4,127 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestValidationRegexRules(t *testing.T) { - // Test that regex rules match the JS implementation - assert.Equal(t, "[^:#@\\*\\s]{1,254}", ValidationRegexRules.Type) - assert.Equal(t, "[^:#@\\*\\s]{1,50}", ValidationRegexRules.Relation) - assert.Equal(t, "[^\\*\\s]{1,50}", ValidationRegexRules.Condition) - assert.Equal(t, "[^#:\\s*][a-zA-Z0-9_|*@.+]*", ValidationRegexRules.ID) - assert.Equal(t, "[^\\s]{2,256}", ValidationRegexRules.Object) -} - -func TestValidateFieldValue(t *testing.T) { - tests := []struct { - name string - rule string - value string - expected bool - }{ - { - name: "valid type name", - rule: "^[a-zA-Z]+$", - value: "document", - expected: true, - }, - { - name: "invalid type name with numbers", - rule: "^[a-zA-Z]+$", - value: "document123", - expected: false, - }, - { - name: "valid relation name", - rule: "^[a-zA-Z_]+$", - value: "can_view", - expected: true, - }, - { - name: "empty string with appropriate rule", - rule: "^$", - value: "", - expected: true, - }, - { - name: "invalid regex pattern", - rule: "[unclosed", - value: "test", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := validateFieldValue(tt.rule, tt.value) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestValidateTypeName(t *testing.T) { - tests := []struct { - name string - typeName string - expectedValid bool - expectedErrorType ValidationErrorType - expectedErrorCount int - }{ - { - name: "valid type name", - typeName: "document", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "reserved keyword self", - typeName: "self", - expectedValid: false, - expectedErrorType: ReservedTypeKeywords, - expectedErrorCount: 1, - }, - { - name: "reserved keyword this", - typeName: "this", - expectedValid: false, - expectedErrorType: ReservedTypeKeywords, - expectedErrorCount: 1, - }, - { - name: "valid type name with underscore", - typeName: "user_group", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "type name with invalid characters", - typeName: "document:invalid", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "type name with space", - typeName: "document name", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "type name with hash", - typeName: "document#tag", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - } + t.Parallel() + + t.Run("valid names yield nothing", func(t *testing.T) { + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 5 - meta := &Meta{File: "test.fga", Module: "test"} + for _, name := range []string{"document", "user_group", "group2"} { + assert.NoError(t, validateTypeName(name), name) + } + }) - result := ValidateTypeName(tt.typeName, collector, &lineIndex, meta) + t.Run("reserved keywords", func(t *testing.T) { + t.Parallel() - assert.Equal(t, tt.expectedValid, result) + for _, reserved := range []string{"self", "this"} { + var finding *Finding + require.ErrorAs(t, validateTypeName(reserved), &finding) + assert.Equal(t, ReservedTypeKeywords, finding.Metadata.Kind) + assert.Equal(t, reserved, finding.Metadata.Symbol) + assert.Equal(t, reserved, finding.Metadata.Type) + } + }) - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) + t.Run("names breaking the rule are reported as invalid-name", func(t *testing.T) { + t.Parallel() - if tt.expectedErrorCount > 0 { - assert.Equal(t, tt.expectedErrorType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.typeName, errors[0].Metadata.Symbol) - } - }) - } + for _, name := range []string{"doc:ument", "document name", "document#tag", "doc@ument", "doc*ument", ""} { + var finding *Finding + require.ErrorAs(t, validateTypeName(name), &finding, name) + assert.Equal(t, InvalidName, finding.Metadata.Kind, name) + } + }) } func TestValidateRelationName(t *testing.T) { - tests := []struct { - name string - relationName string - typeName string - expectedValid bool - expectedErrorType ValidationErrorType - expectedErrorCount int - }{ - { - name: "valid relation name", - relationName: "viewer", - typeName: "document", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "reserved keyword self", - relationName: "self", - typeName: "document", - expectedValid: false, - expectedErrorType: ReservedRelationKeywords, - expectedErrorCount: 1, - }, - { - name: "reserved keyword this", - relationName: "this", - typeName: "document", - expectedValid: false, - expectedErrorType: ReservedRelationKeywords, - expectedErrorCount: 1, - }, - { - name: "valid relation name with underscore", - relationName: "can_view", - typeName: "document", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "relation name with invalid characters", - relationName: "viewer:invalid", - typeName: "document", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "relation name with space", - relationName: "can view", - typeName: "document", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - { - name: "relation name with hash", - relationName: "view#tag", - typeName: "document", - expectedValid: false, - expectedErrorType: InvalidName, - expectedErrorCount: 1, - }, - } + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 8 - meta := &Meta{File: "test.fga", Module: "test"} + t.Run("valid names yield nothing", func(t *testing.T) { + t.Parallel() - result := ValidateRelationName(tt.relationName, tt.typeName, collector, &lineIndex, meta) + for _, name := range []string{"viewer", "can_view", "viewer2"} { + assert.NoError(t, validateRelationName(name, "document"), name) + } + }) - assert.Equal(t, tt.expectedValid, result) + t.Run("reserved keywords", func(t *testing.T) { + t.Parallel() - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) + for _, reserved := range []string{"self", "this"} { + var finding *Finding + require.ErrorAs(t, validateRelationName(reserved, "document"), &finding) + assert.Equal(t, ReservedRelationKeywords, finding.Metadata.Kind) + assert.Equal(t, "document", finding.Metadata.Type) + assert.Equal(t, reserved, finding.Metadata.Relation) + } + }) - if tt.expectedErrorCount > 0 { - assert.Equal(t, tt.expectedErrorType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.relationName, errors[0].Metadata.Symbol) + t.Run("names breaking the rule are reported as invalid-name", func(t *testing.T) { + t.Parallel() - // Check that error message includes type name for relation errors - if tt.expectedErrorType == InvalidName { - assert.Contains(t, errors[0].Message, tt.typeName) - } - } - }) - } + for _, name := range []string{"view#er", "viewer:invalid", "can view", "view@er", "view*er", ""} { + var finding *Finding + require.ErrorAs(t, validateRelationName(name, "document"), &finding, name) + assert.Equal(t, InvalidName, finding.Metadata.Kind, name) + } + }) } func TestValidateConditionName(t *testing.T) { - tests := []struct { - name string - conditionName string - expectedValid bool - expectedErrorCount int - }{ - { - name: "valid condition name", - conditionName: "is_owner", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "valid condition name with numbers", - conditionName: "condition123", - expectedValid: true, - expectedErrorCount: 0, - }, - { - name: "condition name with space", - conditionName: "is owner", - expectedValid: false, - expectedErrorCount: 1, - }, - { - name: "condition name with asterisk", - conditionName: "condition*", - expectedValid: false, - expectedErrorCount: 1, - }, - { - name: "empty condition name", - conditionName: "", - expectedValid: false, - expectedErrorCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) - lineIndex := 10 - meta := &Meta{File: "test.fga", Module: "test"} - - result := ValidateConditionName(tt.conditionName, collector, &lineIndex, meta) + t.Parallel() - assert.Equal(t, tt.expectedValid, result) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, InvalidName, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.conditionName, errors[0].Metadata.Symbol) - } - }) + for _, name := range []string{"is_valid", "is_owner", "condition123"} { + require.NoError(t, validateConditionName(name), name) } -} -func TestGetTypeLineNumber(t *testing.T) { - tests := []struct { - name string - typeName string - lines []string - skipIndex *int - expected *int - }{ - { - name: "finds type on line 0", - typeName: "document", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - }, - expected: ptrInt(0), - }, - { - name: "finds type on line 2", - typeName: "user", - lines: []string{ - "model", - " schema 1.1", - "type user", - "type document", - }, - expected: ptrInt(2), - }, - { - name: "type not found", - typeName: "nonexistent", - lines: []string{ - "type document", - "type user", - }, - expected: nil, - }, - { - name: "skips specified index", - typeName: "document", - lines: []string{ - "type document", - " relations", - "type document", - }, - skipIndex: ptrInt(0), - expected: ptrInt(2), - }, - { - // Tabs are folded before the `type ` prefix gate, so a tab after - // `type` works. - name: "finds type separated by a tab", - typeName: "user", - lines: []string{ - "model", - "type\tuser", - }, - expected: ptrInt(1), - }, - { - name: "empty lines", - typeName: "document", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - typeName: "document", - lines: nil, - expected: nil, - }, + for _, name := range []string{"condition*", ""} { + var invalid *Finding + require.ErrorAs(t, validateConditionName(name), &invalid, name) + assert.Equal(t, InvalidName, invalid.Metadata.Kind, name) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetTypeLineNumber(tt.typeName, tt.lines, tt.skipIndex) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } + var finding *Finding + require.ErrorAs(t, validateConditionName("has space"), &finding) + assert.Equal(t, InvalidName, finding.Metadata.Kind) + assert.Equal(t, "condition 'has space' does not match naming rule: '^[^\\*\\s]{1,50}$'.", finding.Message) + assert.Equal(t, "has space", finding.Metadata.Condition) } -func TestGetRelationLineNumber(t *testing.T) { - tests := []struct { - name string - relationName string - lines []string - skipIndex *int - expected *int - }{ - { - name: "finds relation on line 2", - relationName: "viewer", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - " define admin: [user]", - }, - expected: ptrInt(2), - }, - { - name: "finds relation with complex definition", - relationName: "can_view", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - " define can_view: viewer or admin", - }, - expected: ptrInt(3), - }, - { - name: "relation not found", - relationName: "nonexistent", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - }, - expected: nil, - }, - { - name: "searches from skipIndex onward", - relationName: "viewer", - lines: []string{ - " define viewer: [user]", - " relations", - " define viewer: [group]", - }, - // skipIndex is a start offset: searching from index 1 finds the - // second occurrence at index 2. - skipIndex: ptrInt(1), - expected: ptrInt(2), - }, - { - // WHITESPACE is ('\t' | ' ' | '\u000C')+, so `define\tviewer:` parses - // and must be findable. - name: "finds relation separated by a tab", - relationName: "viewer", - lines: []string{ - "type document", - " relations", - " define\tviewer: [user]", - }, - expected: ptrInt(2), - }, - { - name: "finds relation separated by a mixed whitespace run", - relationName: "viewer", - lines: []string{ - "type document", - " relations", - " define \t viewer: [user]", - }, - expected: ptrInt(2), - }, - { - name: "empty lines", - relationName: "viewer", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - relationName: "viewer", - lines: nil, - expected: nil, - }, - } +func TestValidateNames(t *testing.T) { + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetRelationLineNumber(tt.relationName, tt.lines, tt.skipIndex) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } -} + t.Run("positions resolve to the declaring lines", func(t *testing.T) { + t.Parallel() -func TestGetConditionLineNumber(t *testing.T) { - tests := []struct { - name string - conditionName string - lines []string - skipIndex *int - expected *int - }{ - { - name: "finds condition declaration", - conditionName: "is_owner", - lines: []string{ - "type document", - " relations", - " define viewer: [user with is_owner]", - "condition is_owner(x: int) {", - }, - expected: ptrInt(3), - }, - { - name: "condition not found", - conditionName: "nonexistent", - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - }, - expected: nil, - }, - { - name: "searches from skipIndex onward", - conditionName: "is_owner", - lines: []string{ - "condition is_owner(x: int) {", - " relations", - "condition is_owner(y: int) {", - }, - // skipIndex is a start offset: from index 1 the next declaration is at 2. - skipIndex: ptrInt(1), - expected: ptrInt(2), - }, - { - name: "finds condition separated by a tab", - conditionName: "is_owner", - lines: []string{ - "type document", - "condition\tis_owner(x: int) {", - }, - expected: ptrInt(1), - }, - { - name: "empty lines", - conditionName: "is_owner", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - conditionName: "is_owner", - lines: nil, - expected: nil, - }, - } + dsl := "model\n schema 1.1\ntype self\n relations\n define this: [self]" - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetConditionLineNumber(tt.conditionName, tt.lines, tt.skipIndex) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } -} - -func TestValidateNameRules(t *testing.T) { - tests := []struct { - name string - typeName string - relationNames []string - lines []string - expectedErrorCount int - }{ - { - name: "valid type and relations", - typeName: "document", - relationNames: []string{"viewer", "admin", "owner"}, - lines: []string{ - "type document", - " relations", - " define viewer: [user]", - " define admin: [user] ", - " define owner: [user]", - }, - expectedErrorCount: 0, - }, - { - name: "reserved type name", - typeName: "self", - relationNames: []string{"viewer"}, - lines: []string{ - "type self", - " relations", - " define viewer: [user]", - }, - expectedErrorCount: 1, - }, - { - name: "reserved relation name", - typeName: "document", - relationNames: []string{"this", "viewer"}, - lines: []string{ - "type document", - " relations", - " define this: [user]", - " define viewer: [user]", - }, - expectedErrorCount: 1, - }, - { - name: "multiple validation errors", - typeName: "self", - relationNames: []string{"this", "viewer"}, - lines: []string{ - "type self", - " relations", - " define this: [user]", - " define viewer: [user]", - }, - expectedErrorCount: 2, - }, - { - name: "invalid type name characters", - typeName: "document:invalid", - relationNames: []string{"viewer"}, - lines: []string{ - "type document:invalid", - " relations", - " define viewer: [user]", - }, - expectedErrorCount: 1, - }, - } + findings := ExtractAllAs[*Finding](validateNames(mustParse(t, dsl), newSource(dsl))) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) - typeLineIndex := GetTypeLineNumber(tt.typeName, tt.lines, nil) - meta := &Meta{File: "test.fga", Module: "test"} + require.Len(t, findings, 2) - ValidateNameRules(collector, tt.typeName, tt.relationNames, typeLineIndex, meta, tt.lines) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - }) - } -} + assert.Equal(t, ReservedTypeKeywords, findings[0].Metadata.Kind) + assert.Equal(t, &Range{Start: 2, End: 2}, findings[0].Line) + assert.Equal(t, &Range{Start: 5, End: 9}, findings[0].Column) -// TestNameValidationIntegration tests name validation with proto types. -func TestNameValidationIntegration(t *testing.T) { - t.Run("Valid Names", func(t *testing.T) { - validNames := []string{"document", "user", "group", "viewer", "editor", "admin"} - - for _, name := range validNames { - collector := NewErrorCollector(nil) - typeValid := ValidateTypeName(name, collector, nil, nil) - assert.True(t, typeValid, "Expected %s to be valid type name", name) - - collector = NewErrorCollector(nil) - relationValid := ValidateRelationName(name, "parent_type", collector, nil, nil) - assert.True(t, relationValid, "Expected %s to be valid relation name", name) - } + assert.Equal(t, ReservedRelationKeywords, findings[1].Metadata.Kind) + assert.Equal(t, &Range{Start: 4, End: 4}, findings[1].Line) + assert.Equal(t, &Range{Start: 11, End: 15}, findings[1].Column) }) - t.Run("Reserved Keywords", func(t *testing.T) { - reservedKeywords := []string{"this", "self"} + t.Run("no source text means no positions", func(t *testing.T) { + t.Parallel() - for _, keyword := range reservedKeywords { - collector := NewErrorCollector(nil) - typeValid := ValidateTypeName(keyword, collector, nil, nil) - assert.False(t, typeValid, "Expected %s to be invalid type name", keyword) + dsl := "model\n schema 1.1\ntype self\n relations\n define viewer: [self]" - collector = NewErrorCollector(nil) - relationValid := ValidateRelationName(keyword, "parent_type", collector, nil, nil) - assert.False(t, relationValid, "Expected %s to be invalid relation name", keyword) - } + findings := ExtractAllAs[*Finding](validateNames(mustParse(t, dsl), source{})) + + require.Len(t, findings, 1) + assert.Nil(t, findings[0].Line) + assert.Nil(t, findings[0].Column) }) } diff --git a/pkg/go/validation/schema_validation.go b/pkg/go/validation/schema_validation.go index d80f0462..a99c116c 100644 --- a/pkg/go/validation/schema_validation.go +++ b/pkg/go/validation/schema_validation.go @@ -1,87 +1,25 @@ package validation import ( - "regexp" - "strings" - openfgav1 "github.com/openfga/api/proto/openfga/v1" - - "github.com/openfga/language/pkg/go/utils" ) -const ( - SchemaVersion11 = "1.1" - SchemaVersion12 = "1.2" -) - -var SupportedSchemaVersions = map[string]bool{ - SchemaVersion11: true, - SchemaVersion12: true, -} +// validateSchemaVersion reports a missing, retired, or never-valid schema +// version. A model with no version at all is reported at line zero, as in +// pkg/js. +func validateSchemaVersion(model *openfgav1.AuthorizationModel, src source) error { + version := model.GetSchemaVersion() -func IsValidSchemaVersion(version string) bool { - return SupportedSchemaVersions[version] -} - -func GetSchemaLineNumber(schemaVersion string, lines []string) *int { - if len(lines) == 0 { + switch version { + case "": + return schemaVersionRequired().at(src, 0) + case "1.1", "1.2": return nil - } - // A trailing comment may follow the version (`schema 1.5 # note`), matching the - // reference's `(\s+#.*)?`. - pattern := `^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `(\s+#.*)?$` - regex := regexp.MustCompile(pattern) - for i, line := range lines { - // Fold inline whitespace with the helper the other line-number lookups - // use, so `schema\t1.5` resolves like `schema 1.5`. - normalizedLine := utils.NormalizeWhitespace(strings.TrimSpace(line)) - if regex.MatchString(normalizedLine) { - return &i - } - } - return nil -} - -// ValidateSchemaVersion validates the schema version of an authorization model. -func ValidateSchemaVersion(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - schemaVersion := model.GetSchemaVersion() - if schemaVersion == "" { - lineIndex := 0 - collector.RaiseSchemaVersionRequired("", &lineIndex) - return - } - switch schemaVersion { - case SchemaVersion11, SchemaVersion12: - // Supported — nothing to report. case "1.0": // Recognized but retired. - collector.RaiseSchemaVersionUnsupported(schemaVersion, GetSchemaLineNumber(schemaVersion, lines)) + return schemaVersionUnsupported(version).at(src, src.schemaLine(version)) default: // Never a valid schema version. - collector.RaiseInvalidSchemaVersion(schemaVersion, GetSchemaLineNumber(schemaVersion, lines)) + return invalidSchemaVersion(version).at(src, src.schemaLine(version)) } } - -// ValidateMultipleModulesInFile checks for multiple modules defined in single files. -func ValidateMultipleModulesInFile(collector *ErrorCollector, fileToModuleMap map[string]map[string]bool) { - for file, moduleMap := range fileToModuleMap { - if len(moduleMap) <= 1 { - continue - } - modules := make([]string, 0, len(moduleMap)) - for module := range moduleMap { - modules = append(modules, module) - } - collector.RaiseMultipleModulesInSingleFile(file, modules) - } -} - -// ValidateBasicModelStructure performs basic model structure validation. -func ValidateBasicModelStructure(collector *ErrorCollector, model *openfgav1.AuthorizationModel, - fileToModuleMap map[string]map[string]bool, lines []string) { - ValidateSchemaVersion(collector, model, lines) - ValidateMultipleModulesInFile(collector, fileToModuleMap) -} diff --git a/pkg/go/validation/schema_validation_test.go b/pkg/go/validation/schema_validation_test.go index 53c494a5..0d5289a9 100644 --- a/pkg/go/validation/schema_validation_test.go +++ b/pkg/go/validation/schema_validation_test.go @@ -5,394 +5,40 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) - -func TestIsValidSchemaVersion(t *testing.T) { - tests := []struct { - name string - version string - expected bool - }{ - { - name: "version 1.1 is valid", - version: "1.1", - expected: true, - }, - { - name: "version 1.2 is valid", - version: "1.2", - expected: true, - }, - { - name: "version 1.0 is invalid", - version: "1.0", - expected: false, - }, - { - name: "version 2.0 is invalid", - version: "2.0", - expected: false, - }, - { - name: "empty string is invalid", - version: "", - expected: false, - }, - { - name: "random string is invalid", - version: "invalid", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsValidSchemaVersion(tt.version) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestGetSchemaLineNumber(t *testing.T) { - tests := []struct { - name string - schemaVersion string - lines []string - expected *int - }{ - { - name: "finds schema version on line 1", - schemaVersion: "1.1", - lines: []string{ - "model", - " schema 1.1", - "type document", - }, - expected: ptrInt(1), - }, - { - name: "finds schema version with extra whitespace", - schemaVersion: "1.2", - lines: []string{ - "model", - " schema 1.2 ", - "type document", - }, - expected: ptrInt(1), - }, - { - name: "schema version not found", - schemaVersion: "1.1", - lines: []string{ - "model", - "type document", - }, - expected: nil, - }, - { - name: "empty lines", - schemaVersion: "1.1", - lines: []string{}, - expected: nil, - }, - { - name: "nil lines", - schemaVersion: "1.1", - lines: nil, - expected: nil, - }, - { - name: "finds first occurrence when multiple matches", - schemaVersion: "1.1", - lines: []string{ - "model", - " schema 1.1", - "# comment about schema 1.1", - "type document", - }, - expected: ptrInt(1), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetSchemaLineNumber(tt.schemaVersion, tt.lines) - if tt.expected == nil { - assert.Nil(t, result) - } else { - assert.NotNil(t, result) - assert.Equal(t, *tt.expected, *result) - } - }) - } -} - func TestValidateSchemaVersion(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - lines []string - expectedErrorCount int - expectedErrorType ValidationErrorType - expectedErrorSymbol string - }{ - { - name: "nil model", - model: nil, - expectedErrorCount: 0, - }, - { - name: "missing schema version", - model: &openfgav1.AuthorizationModel{}, - expectedErrorCount: 1, - expectedErrorType: SchemaVersionRequired, - expectedErrorSymbol: "", - }, - { - name: "empty schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "", - }, - expectedErrorCount: 1, - expectedErrorType: SchemaVersionRequired, - expectedErrorSymbol: "", - }, - { - name: "valid schema version 1.1", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - expectedErrorCount: 0, - }, - { - name: "valid schema version 1.2", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.2", - }, - expectedErrorCount: 0, - }, - { - name: "invalid schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - }, - lines: []string{ - "model", - " schema 2.0", - "type document", - }, - expectedErrorCount: 1, - expectedErrorType: InvalidSchema, - expectedErrorSymbol: "2.0", - }, - { - name: "retired schema version 1.0", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.0", - }, - lines: []string{ - "model", - " schema 1.0", - "type document", - }, - expectedErrorCount: 1, - expectedErrorType: SchemaVersionUnsupported, - expectedErrorSymbol: "1.0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) + t.Parallel() - ValidateSchemaVersion(collector, tt.model, tt.lines) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - - if tt.expectedErrorCount > 0 { - assert.Equal(t, tt.expectedErrorType, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.expectedErrorSymbol, errors[0].Metadata.Symbol) - } - }) + model := func(version string) *openfgav1.AuthorizationModel { + return &openfgav1.AuthorizationModel{SchemaVersion: version} } -} -func TestValidateMultipleModulesInFile(t *testing.T) { - tests := []struct { - name string - fileToModuleMap map[string]map[string]bool - expectedErrorCount int - expectedFile string - expectedModules []string - }{ - { - name: "no files", - fileToModuleMap: map[string]map[string]bool{}, - expectedErrorCount: 0, - }, - { - name: "single module per file", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - "file2.fga": {"module2": true}, - }, - expectedErrorCount: 0, - }, - { - name: "multiple modules in single file", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - "module3": true, - }, - }, - expectedErrorCount: 1, - expectedFile: "file1.fga", - expectedModules: []string{"module1", "module2", "module3"}, - }, - { - name: "mixed: some files with single, some with multiple modules", - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - "file2.fga": { - "module2": true, - "module3": true, - }, - "file3.fga": {"module4": true}, - }, - expectedErrorCount: 1, - expectedFile: "file2.fga", - expectedModules: []string{"module2", "module3"}, - }, - } + t.Run("supported versions yield nothing", func(t *testing.T) { + t.Parallel() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(nil) + assert.Empty(t, ExtractAllAs[*Finding](validateSchemaVersion(model("1.1"), source{}))) + assert.Empty(t, ExtractAllAs[*Finding](validateSchemaVersion(model("1.2"), source{}))) + }) - ValidateMultipleModulesInFile(collector, tt.fileToModuleMap) + t.Run("missing version is required at line zero", func(t *testing.T) { + t.Parallel() - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model(""), newSource("model\ntype user"))) - if tt.expectedErrorCount > 0 { - assert.Equal(t, MultipleModulesInFile, errors[0].Metadata.ErrorType) - assert.Equal(t, tt.expectedFile, errors[0].Metadata.Symbol) + require.Len(t, findings, 1) + assert.Equal(t, SchemaVersionRequired, findings[0].Metadata.Kind) + assert.Equal(t, &Range{Start: 0, End: 0}, findings[0].Line) + }) - // Check that all expected modules are mentioned in the error message - for _, module := range tt.expectedModules { - assert.Contains(t, errors[0].Message, module) - } - } - }) - } -} - -func TestValidateBasicModelStructure(t *testing.T) { - tests := []struct { - name string - model *openfgav1.AuthorizationModel - fileToModuleMap map[string]map[string]bool - lines []string - expectedErrorCount int - }{ - { - name: "valid model structure", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": {"module1": true}, - }, - expectedErrorCount: 0, - }, - { - name: "missing schema version", - model: &openfgav1.AuthorizationModel{}, - fileToModuleMap: map[string]map[string]bool{}, - expectedErrorCount: 1, - }, - { - name: "invalid schema version", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - }, - fileToModuleMap: map[string]map[string]bool{}, - expectedErrorCount: 1, - }, - { - name: "multiple modules in file", - model: &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - }, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - }, - }, - expectedErrorCount: 1, - }, - { - name: "multiple errors", - model: &openfgav1.AuthorizationModel{}, - fileToModuleMap: map[string]map[string]bool{ - "file1.fga": { - "module1": true, - "module2": true, - }, - }, - expectedErrorCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - collector := NewErrorCollector(tt.lines) - - ValidateBasicModelStructure(collector, tt.model, tt.fileToModuleMap, tt.lines) - - errors := collector.GetErrors() - assert.Len(t, errors, tt.expectedErrorCount) - }) - } -} - -func TestSupportedSchemaVersions(t *testing.T) { - // Test that the supported schema versions map is properly initialized - assert.NotNil(t, SupportedSchemaVersions) - assert.True(t, SupportedSchemaVersions[SchemaVersion11]) - assert.True(t, SupportedSchemaVersions[SchemaVersion12]) - assert.False(t, SupportedSchemaVersions["1.0"]) - assert.False(t, SupportedSchemaVersions["2.0"]) -} - -func TestSchemaVersionConstants(t *testing.T) { - // Test that schema version constants are defined correctly - assert.Equal(t, "1.1", SchemaVersion11) - assert.Equal(t, "1.2", SchemaVersion12) -} - -func TestSchemaVersionValidation(t *testing.T) { - collector := NewErrorCollector(nil) + t.Run("no source text means no position", func(t *testing.T) { + t.Parallel() - // Test valid schema version - validModel := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - } - ValidateSchemaVersion(collector, validModel, nil) - assert.Empty(t, collector.GetErrors()) + findings := ExtractAllAs[*Finding](validateSchemaVersion(model("1.3"), source{})) - // Test invalid schema version - collector = NewErrorCollector(nil) - invalidModel := &openfgav1.AuthorizationModel{ - SchemaVersion: "2.0", - } - ValidateSchemaVersion(collector, invalidModel, nil) - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, InvalidSchema, errors[0].Metadata.ErrorType) + require.Len(t, findings, 1) + assert.Nil(t, findings[0].Line) + assert.Nil(t, findings[0].Column) + }) } diff --git a/pkg/go/validation/semantic_validation.go b/pkg/go/validation/semantic_validation.go index 273a2d44..dbe99e73 100644 --- a/pkg/go/validation/semantic_validation.go +++ b/pkg/go/validation/semantic_validation.go @@ -1,261 +1,220 @@ package validation import ( + "errors" + "maps" + "slices" + openfgav1 "github.com/openfga/api/proto/openfga/v1" ) -// SemanticValidator handles semantic validation of authorization models. -type SemanticValidator struct { - model *openfgav1.AuthorizationModel - typeMap map[string]*openfgav1.TypeDefinition - relationMap map[string]map[string]*openfgav1.Userset -} - -func NewSemanticValidator(model *openfgav1.AuthorizationModel) *SemanticValidator { - validator := &SemanticValidator{ - model: model, - typeMap: make(map[string]*openfgav1.TypeDefinition, len(model.GetTypeDefinitions())), - relationMap: make(map[string]map[string]*openfgav1.Userset, len(model.GetTypeDefinitions())), - } - validator.buildMaps() - return validator -} - -func (sv *SemanticValidator) buildMaps() { - if sv.model == nil { - return - } - for _, typeDef := range sv.model.GetTypeDefinitions() { - sv.typeMap[typeDef.GetType()] = typeDef - if relations := typeDef.GetRelations(); len(relations) > 0 { - sv.relationMap[typeDef.GetType()] = make(map[string]*openfgav1.Userset, len(relations)) - for relationName, userset := range relations { - sv.relationMap[typeDef.GetType()][relationName] = userset - } - } - } -} - -func (sv *SemanticValidator) RelationDefined(typeName, relationName string) bool { - if relations, exists := sv.relationMap[typeName]; exists { - _, relationExists := relations[relationName] - return relationExists - } - return false -} - -func (sv *SemanticValidator) TypeDefined(typeName string) bool { - _, exists := sv.typeMap[typeName] - return exists -} - -func (sv *SemanticValidator) GetTypeDefinition(typeName string) *openfgav1.TypeDefinition { - return sv.typeMap[typeName] -} - -func (sv *SemanticValidator) GetRelationUserset(typeName, relationName string) *openfgav1.Userset { - if relations, exists := sv.relationMap[typeName]; exists { - return relations[relationName] - } - return nil -} - -// GetRelationNames returns the names of every relation defined on a type. -func (sv *SemanticValidator) GetRelationNames(typeName string) []string { - relations := sv.relationMap[typeName] - names := make([]string, 0, len(relations)) - for name := range relations { - names = append(names, name) - } - return names -} - -// GetDirectlyAssignableTypes returns the type restrictions a relation is -// directly assignable to, but only when that relation is a single direct -// assignment (i.e. `define r: [a, b]` rather than a rewrite). The bool reports -// whether the relation is such a single direct assignment. This mirrors the -// reference implementation's allowableTypes helper used for tuple-to-userset -// validation. -func (sv *SemanticValidator) GetDirectlyAssignableTypes(typeName, relationName string) ([]*openfgav1.RelationReference, bool) { - userset := sv.GetRelationUserset(typeName, relationName) - if userset == nil { - return nil, false - } - if _, ok := userset.GetUserset().(*openfgav1.Userset_This); !ok { - return nil, false - } - typeDef := sv.typeMap[typeName] - if typeDef == nil { - return nil, false - } - relMeta := typeDef.GetMetadata().GetRelations()[relationName] - return relMeta.GetDirectlyRelatedUserTypes(), true -} +// validateRelationReferences checks that every type and relation a relation +// refers to, in its type restrictions and in its rewrites, exists in the model. +func validateRelationReferences(idx *index, src source) error { + var errs []error -// ValidateRelationReferences validates that all relation references in the model are valid. -func ValidateRelationReferences(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateRelationReferences(collector, NewSemanticValidator(model), lines) -} - -func validateRelationReferences(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { + for _, typeDef := range idx.model.GetTypeDefinitions() { typeName := typeDef.GetType() - // When a type is declared more than once, the relation maps are built - // last-wins (matching the reference's typeMap). Only the winning - // definition is validated; a shadowed duplicate's relations are resolved - // against the winning type by the reference, so validating the winning - // definition alone avoids spurious reference errors. The duplicate-type - // error itself is reported by duplicate detection. - if winning := validator.GetTypeDefinition(typeName); winning != nil && winning != typeDef { + + // When a type is declared more than once only the winning (last) + // definition is validated: pkg/js resolves a shadowed duplicate's + // relations against the winning type, so validating the winner alone + // avoids spurious reference errors. The duplicate itself is reported by + // the duplicates phase. + if winning := idx.typeDef(typeName); winning != nil && winning != typeDef { continue } // Anchor relation line lookups to the type's declaration so the correct - // `define` occurrence is found when several types share a relation name. - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) + // `define` occurrence is found when several types declare a relation of + // the same name. + typeLine := src.typeLine(typeName) if meta := typeDef.GetMetadata(); meta != nil { - for relationName, relationMetadata := range meta.GetRelations() { - validateTypeRestrictions(collector, validator, typeName, relationName, relationMetadata, typeLineIndex, lines) + relationsMetadata := meta.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relationsMetadata)) { + errs = append(errs, validateTypeRestrictions(idx, src, typeDef, relationName, + relationsMetadata[relationName], typeLine)) } } - for relationName, userset := range typeDef.GetRelations() { - validateUsersetReferences(collector, validator, typeName, relationName, userset, typeLineIndex, lines) + + relations := typeDef.GetRelations() + for _, relationName := range slices.Sorted(maps.Keys(relations)) { + errs = append(errs, validateUsersetReferences(idx, src, typeDef, relationName, + relations[relationName], typeLine)) } } + + return errors.Join(errs...) } -func validateTypeRestrictions(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, relationMetadata *openfgav1.RelationMetadata, typeLineIndex *int, lines []string) { +// validateTypeRestrictions checks a relation's directly-related user types: +// each restriction must name a defined type, and a `type#relation` restriction +// a relation defined on that type. +func validateTypeRestrictions(idx *index, src source, typeDef *openfgav1.TypeDefinition, + relationName string, relationMetadata *openfgav1.RelationMetadata, typeLine int) error { if relationMetadata == nil { - return - } - meta := &Meta{ - File: relationMetadata.GetSourceInfo().GetFile(), - Module: relationMetadata.GetModule(), + return nil } - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - restrictedType := typeRestriction.GetType() + + var fs []*Finding + + typeName := typeDef.GetType() + file := relationMetadata.GetSourceInfo().GetFile() + module := relationMetadata.GetModule() + + for _, restriction := range relationMetadata.GetDirectlyRelatedUserTypes() { + restrictedType := restriction.GetType() if restrictedType == "" { continue } + // A directly-related type that doesn't exist: `X` is not a valid type. - if !validator.TypeDefined(restrictedType) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseInvalidType(restrictedType, typeName, relationName, meta, lineIndex) + if !idx.typeDefined(restrictedType) { + line := src.relationLine(relationName, typeLine) + finding := invalidType(restrictedType).atRestriction(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) + continue } - // A type#relation restriction whose relation doesn't exist on that type: - // `rel` is not a valid relation for `X`. - if rel := typeRestriction.GetRelation(); rel != "" { - if !validator.RelationDefined(restrictedType, rel) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - symbol := restrictedType + "#" + rel - collector.RaiseInvalidTypeRelation(symbol, restrictedType, relationName, rel, restrictedType, lineIndex, meta) - } + + // A type#relation restriction whose relation doesn't exist on that + // type: `rel` is not a valid relation for `X`. + if rel := restriction.GetRelation(); rel != "" && !idx.relationDefined(restrictedType, rel) { + line := src.relationLine(relationName, typeLine) + finding := invalidTypeRelation(restrictedType+"#"+rel, restrictedType, relationName, rel, typeName). + at(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } } + + return joinFindings(fs...) } -func validateUsersetReferences(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, userset *openfgav1.Userset, typeLineIndex *int, lines []string) { +// validateUsersetReferences checks the relations a rewrite names: a computed +// userset must exist on the type, and a tuple-to-userset must satisfy +// validateTupleToUsersetReferences. Union, intersection and difference are +// walked into. +func validateUsersetReferences(idx *index, src source, typeDef *openfgav1.TypeDefinition, + relationName string, userset *openfgav1.Userset, typeLine int) error { if userset == nil { - return + return nil } - var file, module string - if typeDef := validator.GetTypeDefinition(typeName); typeDef != nil { - file = typeDef.GetMetadata().GetSourceInfo().GetFile() - module = typeDef.GetMetadata().GetModule() - } - meta := &Meta{File: file, Module: module} - if cu := userset.GetComputedUserset(); cu != nil { + var errs []error + + typeName := typeDef.GetType() + file, module := typeMeta(typeDef) + + if computed := userset.GetComputedUserset(); computed != nil { // `define a: b` where b is not a relation on this type. - if targetRelation := cu.GetRelation(); targetRelation != "" { - if !validator.RelationDefined(typeName, targetRelation) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - validRelations := validator.GetRelationNames(typeName) - collector.RaiseInvalidRelationError(targetRelation, typeName, relationName, validRelations, lineIndex, meta) - } + if target := computed.GetRelation(); target != "" && !idx.relationDefined(typeName, target) { + line := src.relationLine(relationName, typeLine) + finding := missingRelation(target, typeName, relationName).at(src, line) + finding.File, finding.Metadata.Module = file, module + errs = append(errs, finding) } } if ttu := userset.GetTupleToUserset(); ttu != nil { - validateTupleToUsersetReferences(collector, validator, typeName, relationName, ttu, meta, typeLineIndex, lines) + errs = append(errs, validateTupleToUsersetReferences(idx, src, typeDef, relationName, ttu, typeLine)) } if union := userset.GetUnion(); union != nil { for _, child := range union.GetChild() { - validateUsersetReferences(collector, validator, typeName, relationName, child, typeLineIndex, lines) + errs = append(errs, validateUsersetReferences(idx, src, typeDef, relationName, child, typeLine)) } } + if intersection := userset.GetIntersection(); intersection != nil { for _, child := range intersection.GetChild() { - validateUsersetReferences(collector, validator, typeName, relationName, child, typeLineIndex, lines) + errs = append(errs, validateUsersetReferences(idx, src, typeDef, relationName, child, typeLine)) } } + if diff := userset.GetDifference(); diff != nil { - validateUsersetReferences(collector, validator, typeName, relationName, diff.GetBase(), typeLineIndex, lines) - validateUsersetReferences(collector, validator, typeName, relationName, diff.GetSubtract(), typeLineIndex, lines) + errs = append(errs, validateUsersetReferences(idx, src, typeDef, relationName, diff.GetBase(), typeLine)) + errs = append(errs, validateUsersetReferences(idx, src, typeDef, relationName, diff.GetSubtract(), typeLine)) } + + return errors.Join(errs...) } -// validateTupleToUsersetReferences validates a `target from from` rewrite, -// mirroring the reference implementation: -// - the `from` (tupleset) relation must exist on the current type; -// - the `from` relation must be a plain direct assignment whose assignable +// validateTupleToUsersetReferences validates a `target from tupleset` rewrite, +// mirroring pkg/js: +// - the tupleset relation must exist on the current type; +// - the tupleset relation must be a plain direct assignment whose assignable // types are concrete (no wildcard, no type#relation); -// - the computed `target` relation must exist on at least one of the types the -// `from` relation is assignable to. -func validateTupleToUsersetReferences(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, ttu *openfgav1.TupleToUserset, meta *Meta, typeLineIndex *int, lines []string) { +// - the computed target relation must exist on at least one of the types the +// tupleset relation is assignable to. +func validateTupleToUsersetReferences(idx *index, src source, typeDef *openfgav1.TypeDefinition, + relationName string, ttu *openfgav1.TupleToUserset, typeLine int) error { fromRelation := ttu.GetTupleset().GetRelation() targetRelation := ttu.GetComputedUserset().GetRelation() + if fromRelation == "" || targetRelation == "" { - return + return nil } - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) + + typeName := typeDef.GetType() + file, module := typeMeta(typeDef) + line := src.relationLine(relationName, typeLine) symbol := targetRelation + " from " + fromRelation - // 1. The `from` relation must exist on the current type. - if !validator.RelationDefined(typeName, fromRelation) { - collector.RaiseInvalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName, lineIndex, meta) - return + // 1. The tupleset relation must exist on the current type. + if !idx.relationDefined(typeName, fromRelation) { + finding := invalidTypeRelation(symbol, typeName, relationName, fromRelation, typeName). + at(src, line) + finding.File, finding.Metadata.Module = file, module + + return finding } - // 2. The `from` relation must be a single direct assignment. - fromTypes, isValid := validator.GetDirectlyAssignableTypes(typeName, fromRelation) - if !isValid || len(fromTypes) == 0 { - collector.RaiseTupleUsersetRequiresDirect(fromRelation, typeName, relationName, meta, lineIndex) - return + // 2. The tupleset relation must be a single direct assignment. + fromTypes, isDirect := idx.directlyAssignableTypes(typeName, fromRelation) + if !isDirect || len(fromTypes) == 0 { + finding := tupleUsersetRequiresDirect(fromRelation, typeName, relationName). + atFromClause(src, line) + finding.File, finding.Metadata.Module = file, module + + return finding } - // 3. Each assignable type of `from` must be a concrete type (no wildcard, no - // type#relation), and the computed `target` must exist on at least one of - // them. + // 3. Each assignable type of the tupleset relation must be a concrete type + // (no wildcard, no type#relation), and the computed target must exist on + // at least one of them. + var fs []*Finding + notValid := make([]*openfgav1.RelationReference, 0, len(fromTypes)) - for _, tr := range fromTypes { - decodedType := tr.GetType() - if tr.GetWildcard() != nil || tr.GetRelation() != "" { + + for _, restriction := range fromTypes { + if restriction.GetWildcard() != nil || restriction.GetRelation() != "" { // A wildcard or type#relation cannot be used as a tupleset target. - collector.RaiseTupleUsersetRequiresDirect(fromRelation, typeName, relationName, meta, lineIndex) + finding := tupleUsersetRequiresDirect(fromRelation, typeName, relationName). + atFromClause(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) + continue } - if !validator.TypeDefined(decodedType) || !validator.RelationDefined(decodedType, targetRelation) { - notValid = append(notValid, tr) + + targetType := restriction.GetType() + if !idx.typeDefined(targetType) || !idx.relationDefined(targetType, targetRelation) { + notValid = append(notValid, restriction) } } + // If the target is missing on every assignable type, report it per type. if len(notValid) == len(fromTypes) { - for _, tr := range notValid { - collector.RaiseInvalidRelationOnTupleset(symbol, tr.GetType(), typeName, relationName, targetRelation, fromRelation, lineIndex, meta) + for _, restriction := range notValid { + finding := invalidRelationOnTupleset(symbol, targetRelation, typeName, fromRelation, + restriction.GetType(), relationName).at(src, line) + finding.File, finding.Metadata.Module = file, module + fs = append(fs, finding) } } + + return joinFindings(fs...) } diff --git a/pkg/go/validation/semantic_validation_test.go b/pkg/go/validation/semantic_validation_test.go deleted file mode 100644 index 977d7582..00000000 --- a/pkg/go/validation/semantic_validation_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package validation - -import ( - "testing" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" - "github.com/stretchr/testify/assert" -) - -func TestSemanticValidator(t *testing.T) { - t.Run("NewSemanticValidator", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - validator := NewSemanticValidator(model) - - assert.NotNil(t, validator) - assert.Equal(t, model, validator.model) - assert.Len(t, validator.typeMap, 2) - assert.Len(t, validator.relationMap, 1) - }) - - t.Run("TypeDefined", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "user"}, - }, - } - - validator := NewSemanticValidator(model) - - assert.True(t, validator.TypeDefined("document")) - assert.True(t, validator.TypeDefined("user")) - assert.False(t, validator.TypeDefined("group")) - assert.False(t, validator.TypeDefined("")) - }) - - t.Run("RelationDefined", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - "editor": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - validator := NewSemanticValidator(model) - - assert.True(t, validator.RelationDefined("document", "viewer")) - assert.True(t, validator.RelationDefined("document", "editor")) - assert.False(t, validator.RelationDefined("document", "admin")) - assert.False(t, validator.RelationDefined("user", "viewer")) - assert.False(t, validator.RelationDefined("group", "viewer")) - }) - - t.Run("GetTypeDefinition", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - {Type: "document"}, - {Type: "user"}, - }, - } - - validator := NewSemanticValidator(model) - - docType := validator.GetTypeDefinition("document") - assert.NotNil(t, docType) - assert.Equal(t, "document", docType.Type) - - userType := validator.GetTypeDefinition("user") - assert.NotNil(t, userType) - assert.Equal(t, "user", userType.Type) - - groupType := validator.GetTypeDefinition("group") - assert.Nil(t, groupType) - }) -} - -func TestValidateRelationReferences(t *testing.T) { - t.Run("Valid references", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Empty(t, errors) - }) - - t.Run("Undefined type in restriction", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "undefined_type"}, - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, InvalidType, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_type") - }) - - t.Run("Undefined relation in restriction", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "undefined_relation"}}, - }, - }, - }, - }, - }, - { - Type: "user", - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, InvalidRelationType, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - }) - - t.Run("Undefined relation in computed userset", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "undefined_relation", - }, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - }) - - t.Run("Complex userset validation", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_Union{ - Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "editor", - }, - }, - }, - { - Userset: &openfgav1.Userset_ComputedUserset{ - ComputedUserset: &openfgav1.ObjectRelation{ - Relation: "undefined_relation", - }, - }, - }, - }, - }, - }, - }, - "editor": { - Userset: &openfgav1.Userset_This{ - This: &openfgav1.DirectUserset{}, - }, - }, - }, - }, - }, - } - - collector := NewErrorCollector(nil) - ValidateRelationReferences(collector, model, nil) - - errors := collector.GetErrors() - assert.Len(t, errors, 1) - assert.Equal(t, MissingDefinition, errors[0].Metadata.ErrorType) - assert.Contains(t, errors[0].Message, "undefined_relation") - }) -} diff --git a/pkg/go/validation/source.go b/pkg/go/validation/source.go new file mode 100644 index 00000000..1354bd37 --- /dev/null +++ b/pkg/go/validation/source.go @@ -0,0 +1,181 @@ +package validation + +import ( + "regexp" + "strings" + + "github.com/openfga/language/pkg/go/utils" +) + +// source is the DSL text findings are located in. The zero value is a model +// that arrived as JSON: there is no text, so every lookup reports absent and +// every stamp is a no-op, and findings carry no position. +// +// The line lookups match against lines folded by utils.NormalizeWhitespace, so +// `define\towner:` is found like `define owner:`. Columns are still resolved +// against the raw line, so folding never shifts a reported position. +type source struct { + lines []string +} + +func newSource(dsl string) source { + return source{lines: strings.Split(dsl, "\n")} +} + +// typeLine returns the line index a type is declared on, or -1 when the source +// does not declare it. +func (s source) typeLine(typeName string) int { + return utils.GetTypeLineNumber(typeName, s.lines) +} + +// relationLine returns the line index a relation is defined on, searching from +// the given line so the right `define` is found when several types declare a +// relation of the same name. A negative from searches the whole source. +func (s source) relationLine(relationName string, from int) int { + from = min(max(from, 0), len(s.lines)) + + line := utils.GetRelationLineNumber(relationName, s.lines[from:]) + if line < 0 { + return -1 + } + + return from + line +} + +// conditionLine returns the line index a condition is declared on, or -1. +func (s source) conditionLine(conditionName string) int { + return utils.GetConditionLineNumber(conditionName, s.lines) +} + +// schemaLine returns the line index the schema version is declared on, or -1. +// +// A trailing comment may follow the version, as in `schema 1.1 # note`. The `#` +// has to be preceded by whitespace, so one written against the version is part +// of the version and does not match here. +func (s source) schemaLine(schemaVersion string) int { + if len(s.lines) == 0 { + return -1 + } + + pattern := regexp.MustCompile(`^\s*schema\s+` + regexp.QuoteMeta(schemaVersion) + `(\s+#.*)?$`) + + for i, line := range s.lines { + normalized := utils.NormalizeWhitespace(strings.TrimSpace(line)) + if pattern.MatchString(normalized) { + return i + } + } + + return -1 +} + +// at stamps the position a finding points at: the given line, and the column +// its symbol sits at on that line. It does nothing for a nil finding or for a +// line the source does not have, which covers both a failed line search (-1) +// and a model with no source text. It returns f, so a raise site reads +// `unusedCondition(name).at(src, line)`. +func (f *Finding) at(src source, line int) *Finding { + if f == nil || line < 0 || line >= len(src.lines) { + return f + } + + f.Line = &Range{Start: line, End: line} + + col := wordIndex(src.lines[line], f.Metadata.Symbol) + f.Column = &Range{Start: col, End: col + len(f.Metadata.Symbol)} + + return f +} + +// atFromClause is at, with the column searched after the `from` keyword so it +// marks the offending tupleset relation rather than an earlier occurrence of +// the same name on the line. +func (f *Finding) atFromClause(src source, line int) *Finding { + if f == nil { + return nil + } + + f.at(src, line) + + if f.Column == nil { + return f + } + + rawLine := src.lines[line] + if clause := strings.Index(rawLine, "from"); clause >= 0 { + col := clause + len("from") + strings.Index(rawLine[clause+len("from"):], f.Metadata.Symbol) + f.Column = &Range{Start: col, End: col + len(f.Metadata.Symbol)} + } + + return f +} + +// atRestriction is at, with the column searched on the value side of the `:` so +// it marks the type restriction rather than a relation key sharing its name. +func (f *Finding) atRestriction(src source, line int) *Finding { + if f == nil { + return nil + } + + f.at(src, line) + + if f.Column == nil { + return f + } + + rawLine := src.lines[line] + if colon := strings.Index(rawLine, ":"); colon >= 0 { + col := colon + 1 + wordIndex(rawLine[colon+1:], f.Metadata.Symbol) + f.Column = &Range{Start: col, End: col + len(f.Metadata.Symbol)} + } + + return f +} + +// wordIndex returns the index of symbol in rawLine, preferring a match on word +// boundaries, as pkg/js's `\bsymbol\b` lookup does, so `t` is found on its own +// rather than inside `type`. With no such match it falls back to the first +// occurrence anywhere, where pkg/js answers 0, and it returns 0 when symbol is +// absent, as pkg/js does. The boundary test is done in place to avoid compiling +// a regexp per finding. +func wordIndex(rawLine, symbol string) int { + if symbol == "" { + return 0 + } + // Only attempt a word-boundary match when the symbol begins and ends with a + // word character; symbols containing non-word characters (e.g. `user:*`) + // can't match `\bsymbol\b` and fall through to the substring search. + if isWordChar(symbol[0]) && isWordChar(symbol[len(symbol)-1]) { + for off := 0; ; { + idx := strings.Index(rawLine[off:], symbol) + if idx < 0 { + break + } + + pos := off + idx + beforeOK := pos == 0 || !isWordChar(rawLine[pos-1]) + afterPos := pos + len(symbol) + afterOK := afterPos == len(rawLine) || !isWordChar(rawLine[afterPos]) + + if beforeOK && afterOK { + return pos + } + + off = pos + 1 + } + } + + if idx := strings.Index(rawLine, symbol); idx >= 0 { + return idx + } + + return 0 +} + +// isWordChar reports whether b is a regexp `\w` character ([0-9A-Za-z_]). +func isWordChar(b byte) bool { + return b == '_' || + (b >= '0' && b <= '9') || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') +} diff --git a/pkg/go/validation/source_test.go b/pkg/go/validation/source_test.go new file mode 100644 index 00000000..3c17a546 --- /dev/null +++ b/pkg/go/validation/source_test.go @@ -0,0 +1,162 @@ +package validation + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSourceTypeLine(t *testing.T) { + t.Parallel() + + src := newSource("model\n schema 1.1\ntype user\ntype document\n relations\n define viewer: [user]") + + assert.Equal(t, 2, src.typeLine("user")) + assert.Equal(t, 3, src.typeLine("document")) + assert.Equal(t, -1, src.typeLine("missing")) + assert.Equal(t, -1, source{}.typeLine("user"), "no source, no line") +} + +func TestSourceRelationLine(t *testing.T) { + t.Parallel() + + src := newSource(`model + schema 1.1 +type user +type folder + relations + define viewer: [user] +type document + relations + define viewer: [user]`) + + assert.Equal(t, 5, src.relationLine("viewer", -1), "negative from searches the whole source") + assert.Equal(t, 8, src.relationLine("viewer", src.typeLine("document")), + "anchoring to the type finds its own define") + assert.Equal(t, -1, src.relationLine("missing", -1)) + assert.Equal(t, -1, source{}.relationLine("viewer", -1), "no source, no line") +} + +func TestSourceConditionLine(t *testing.T) { + t.Parallel() + + src := newSource(`model + schema 1.1 +type user +condition less(x: int) { + x < 5 +} +condition less_than(x: int) { + x < 10 +}`) + + assert.Equal(t, 3, src.conditionLine("less")) + assert.Equal(t, 6, src.conditionLine("less_than")) + assert.Equal(t, -1, src.conditionLine("missing")) + assert.Equal(t, -1, source{}.conditionLine("less"), "no source, no line") +} + +func TestSourceSchemaLine(t *testing.T) { + t.Parallel() + + assert.Equal(t, 1, newSource("model\n schema 1.0\ntype user").schemaLine("1.0")) + assert.Equal(t, 1, newSource("model\n schema 1.0 # retired\ntype user").schemaLine("1.0"), + "a trailing comment and repeated spaces still match") + assert.Equal(t, -1, newSource("model\n schema 1.1\ntype user").schemaLine("1.0")) + assert.Equal(t, -1, source{}.schemaLine("1.0")) +} + +func TestWordIndex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawLine string + symbol string + want int + }{ + {"word boundary skips substring", " define type_a: [t]", "t", 20}, + {"first boundary match wins", "a or a", "a", 0}, + {"falls back to a substring with no boundary match", "type usergroup", "user", 5}, + {"plain match", " define viewer: [user]", "viewer", 11}, + {"symbol with non-word characters", " define viewer: [user:*]", "user:*", 20}, + {"missing symbol falls back to zero", " define viewer: [user]", "absent", 0}, + {"empty symbol", "anything", "", 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.want, wordIndex(test.rawLine, test.symbol)) + }) + } +} + +func TestFindingAt(t *testing.T) { + t.Parallel() + + src := newSource("model\n schema 1.1\ntype document\n relations\n define viewer: [user]") + + t.Run("stamps line and column", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{Metadata: Metadata{Symbol: "viewer"}}).at(src, 4) + + assert.Equal(t, &Range{Start: 4, End: 4}, finding.Line) + assert.Equal(t, &Range{Start: 11, End: 17}, finding.Column, "column is half-open over the symbol") + }) + + t.Run("no source means no position", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{Metadata: Metadata{Symbol: "viewer"}}).at(source{}, 0) + + assert.Nil(t, finding.Line) + assert.Nil(t, finding.Column) + }) + + t.Run("failed line search means no position", func(t *testing.T) { + t.Parallel() + + finding := (&Finding{Metadata: Metadata{Symbol: "viewer"}}).at(src, -1) + + assert.Nil(t, finding.Line) + assert.Nil(t, finding.Column) + }) + + t.Run("nil finding stays nil", func(t *testing.T) { + t.Parallel() + + var finding *Finding + assert.Nil(t, finding.at(src, 0)) + }) +} + +func TestFindingAtFromClause(t *testing.T) { + t.Parallel() + + // `owner` is both the computed relation and the tupleset after `from`; the + // from-clause stamp must mark the occurrence after `from`. + src := newSource(" define viewer: owner from owner") + + finding := (&Finding{Metadata: Metadata{Symbol: "owner"}}).atFromClause(src, 0) + + require.NotNil(t, finding.Column) + assert.Equal(t, 30, finding.Column.Start) + assert.Equal(t, 35, finding.Column.End) +} + +func TestFindingAtRestriction(t *testing.T) { + t.Parallel() + + // `group` is both the relation name and the restricted type; the + // restriction stamp must mark the occurrence after the colon. + src := newSource(" define group: [group]") + + finding := (&Finding{Metadata: Metadata{Symbol: "group"}}).atRestriction(src, 0) + + require.NotNil(t, finding.Column) + assert.Equal(t, 19, finding.Column.Start) + assert.Equal(t, 24, finding.Column.End) +} diff --git a/pkg/go/validation/test_helpers_test.go b/pkg/go/validation/test_helpers_test.go deleted file mode 100644 index 4491b0d6..00000000 --- a/pkg/go/validation/test_helpers_test.go +++ /dev/null @@ -1,4 +0,0 @@ -package validation - -func ptrString(s string) *string { return &s } -func ptrInt(i int) *int { return &i } diff --git a/pkg/go/validation/validate.go b/pkg/go/validation/validate.go new file mode 100644 index 00000000..71d1b712 --- /dev/null +++ b/pkg/go/validation/validate.go @@ -0,0 +1,75 @@ +package validation + +import ( + "errors" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +// ValidateDSL validates model, which was parsed from dsl, and uses dsl only to +// find each finding's line and column. +// +// It returns nil for a valid model. Otherwise the error joins every finding in +// the order raised, which ExtractAllAs recovers: +// +// for _, finding := range validation.ExtractAllAs[*validation.Finding](err) { +// ... +// } +func ValidateDSL(model *openfgav1.AuthorizationModel, dsl string) error { + return validate(model, newSource(dsl)) +} + +// ValidateJSON validates a model that has no DSL source, such as one received +// as JSON. Its findings have no Line or Column but otherwise match what +// ValidateDSL reports for the same model. The name matches pkg/js's +// validateJSON and pkg/java's validateJson. +func ValidateJSON(model *openfgav1.AuthorizationModel) error { + return validate(model, source{}) +} + +// validate runs the validation phases over model and joins their findings. +// +// Schema, name and relation-reference validation always run. Duplicate +// detection runs only if they found nothing, and entry-point detection only if +// nothing has been found at all: a model with bad references or duplicates +// would otherwise produce a cascade of derived entry-point errors for the same +// root cause. Multi-file and condition checks always run. +// +// The gating in pkg/js differs, so for some models it reports fewer findings +// than this does. A schema or name finding skips every later check but the +// multi-file one, and an undefined condition, which pkg/js reports with the +// relation references, skips duplicate and entry-point detection. A skipped +// check also leaves the modules of the relations or conditions it would have +// visited out of the multi-file check. Findings are also not always in +// pkg/js's order, which this package's shared-case test does not check. +func validate(model *openfgav1.AuthorizationModel, src source) error { + if model == nil { + return nil + } + + idx := newIndex(model) + + var errs []error + add := func(err error) { + if err != nil { + errs = append(errs, err) + } + } + + add(validateSchemaVersion(model, src)) + add(validateNames(model, src)) + add(validateRelationReferences(idx, src)) + + if len(errs) == 0 { + add(validateDuplicates(model, src)) + } + + if len(errs) == 0 { + add(validateEntryPoints(idx, src)) + } + + add(validateMultiFile(model)) + add(validateConditions(model, src)) + + return errors.Join(errs...) +} diff --git a/pkg/go/validation/validate_test.go b/pkg/go/validation/validate_test.go new file mode 100644 index 00000000..c1702a54 --- /dev/null +++ b/pkg/go/validation/validate_test.go @@ -0,0 +1,151 @@ +package validation + +import ( + "strings" + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openfga/language/pkg/go/transformer" +) + +// mustParse transforms a DSL that is expected to be grammatical. +func mustParse(t *testing.T, dsl string) *openfgav1.AuthorizationModel { + t.Helper() + + model, err := transformer.TransformDSLToProto(dsl) + require.NoError(t, err) + + return model +} + +func TestValidateDSL(t *testing.T) { + t.Parallel() + + t.Run("valid model returns nil", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user]` + + require.NoError(t, ValidateDSL(mustParse(t, dsl), dsl)) + }) + + t.Run("nil model returns nil", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateDSL(nil, "")) + require.NoError(t, ValidateJSON(nil)) + }) + + t.Run("findings are recovered with ExtractAllAs", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: editor` + + err := ValidateDSL(mustParse(t, dsl), dsl) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 1) + + finding := findings[0] + assert.Equal(t, MissingDefinition, finding.Metadata.Kind) + assert.Equal(t, "editor", finding.Metadata.Symbol) + assert.Equal(t, &Range{Start: 5, End: 5}, finding.Line) + assert.Equal(t, &Range{Start: 19, End: 25}, finding.Column) + }) + + t.Run("an invalid condition name is reported at its declaration", func(t *testing.T) { + t.Parallel() + + // The naming rule caps a condition name at 50 characters; the grammar + // does not. + name := strings.Repeat("c", 51) + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user with ` + name + `] +condition ` + name + `(x: int) { + x > 0 +}` + + err := ValidateDSL(mustParse(t, dsl), dsl) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 1) + + finding := findings[0] + assert.Equal(t, InvalidName, finding.Metadata.Kind) + assert.Equal(t, &Range{Start: 6, End: 6}, finding.Line) + assert.Equal(t, &Range{Start: 10, End: 61}, finding.Column) + }) +} + +func TestValidateJSON(t *testing.T) { + t.Parallel() + + t.Run("valid model returns nil", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: [user]` + + require.NoError(t, ValidateJSON(mustParse(t, dsl))) + }) + + t.Run("findings carry no position", func(t *testing.T) { + t.Parallel() + + dsl := `model + schema 1.1 +type user +type document + relations + define viewer: editor` + + err := ValidateJSON(mustParse(t, dsl)) + require.Error(t, err) + + findings := ExtractAllAs[*Finding](err) + require.Len(t, findings, 1) + assert.Nil(t, findings[0].Line) + assert.Nil(t, findings[0].Column) + }) +} + +// TestValidateFileAndModule checks where file and module come from: the proto's +// source info, per declaration, not the validated text. +func TestValidateFileAndModule(t *testing.T) { + t.Parallel() + + model := mustParse(t, "model\n schema 1.1\ntype self") + model.TypeDefinitions[0].Metadata = &openfgav1.Metadata{ + Module: "core", + SourceInfo: &openfgav1.SourceInfo{File: "core.fga"}, + } + + findings := ExtractAllAs[*Finding](validate(model, source{})) + + require.Len(t, findings, 1) + assert.Equal(t, "core.fga", findings[0].File) + assert.Equal(t, "core", findings[0].Metadata.Module) +} diff --git a/pkg/go/validation/validation_engine.go b/pkg/go/validation/validation_engine.go deleted file mode 100644 index 31e616ea..00000000 --- a/pkg/go/validation/validation_engine.go +++ /dev/null @@ -1,224 +0,0 @@ -package validation - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// ValidationEngine is the main entry point for all validation operations. -type ValidationEngine struct { - model *openfgav1.AuthorizationModel - lines []string - collector *ErrorCollector - // semantic and condition index the model once and are shared across every - // phase that needs them, rather than each phase rebuilding its own. - semantic *SemanticValidator - condition *ConditionValidator -} - -// EngineOptions configures validation behavior. -type EngineOptions struct { - SkipSemanticValidation bool - SkipComplexOperationValidation bool - SkipWildcardValidation bool - SkipMultiFileValidation bool - SkipConditionValidation bool -} - -func DefaultEngineOptions() *EngineOptions { - return &EngineOptions{} -} - -func NewValidationEngine(model *openfgav1.AuthorizationModel, dslContent string) *ValidationEngine { - lines := strings.Split(dslContent, "\n") - collector := NewErrorCollector(lines) - ve := &ValidationEngine{model: model, lines: lines, collector: collector} - if model != nil { - ve.semantic = NewSemanticValidator(model) - ve.condition = NewConditionValidator(model) - } - return ve -} - -// ValidateDSL validates a DSL model with all available validations. -func ValidateDSL(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) *ValidationErrors { - if options == nil { - options = DefaultEngineOptions() - } - return NewValidationEngine(model, dslContent).RunAllValidations(options) -} - -// ValidateJSON validates a JSON model. -func ValidateJSON(model *openfgav1.AuthorizationModel, options *EngineOptions) *ValidationErrors { - if options == nil { - options = DefaultEngineOptions() - } - return NewValidationEngine(model, "").RunAllValidations(options) -} - -// RunAllValidations executes all validation phases in the correct order. -func (ve *ValidationEngine) RunAllValidations(options *EngineOptions) *ValidationErrors { - if ve.model == nil { - return NewValidationErrors(nil) - } - - // Schema and name validation run first and unconditionally. - ve.runSchemaValidation() - ve.runNameValidation() - - // Relation-reference validation always runs. The phases that follow are - // gated on there being no errors yet: a model with bad references or - // duplicates would otherwise produce a cascade of derived entry-point and - // complex-operation errors for the same root cause. This mirrors the - // reference implementation's modelValidation, which skips the later passes - // once any error has been recorded. - if !options.SkipSemanticValidation { - validateRelationReferences(ve.collector, ve.semantic, ve.lines) - } - - if !ve.collector.HasErrors() { - ve.runDuplicateDetection() - } - - if !ve.collector.HasErrors() { - if !options.SkipSemanticValidation { - validateCyclesAndEntryPoints(ve.collector, ve.semantic, ve.lines) - validateTupleToUsersetRequirements(ve.collector, ve.semantic, ve.lines) - } - if !options.SkipComplexOperationValidation { - validateComplexOperations(ve.collector, ve.semantic, ve.lines) - } - if !options.SkipWildcardValidation { - validateWildcardUsage(ve.collector, ve.semantic, ve.lines) - } - } - - // Multi-file and condition checks are independent of the cascade and always - // run, matching the reference's handling of conditions. - if !options.SkipMultiFileValidation { - ve.runMultiFileValidation() - } - if !options.SkipConditionValidation { - ve.runConditionValidation() - } - - return NewValidationErrors(ve.collector.GetErrors()) -} - -func (ve *ValidationEngine) runSchemaValidation() { - ValidateSchemaVersion(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runNameValidation() { - ValidateNames(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runDuplicateDetection() { - ValidateDuplicates(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runMultiFileValidation() { - ValidateMultiFileConsistency(ve.collector, ve.model, ve.lines) -} - -func (ve *ValidationEngine) runConditionValidation() { - validateConditionReferences(ve.collector, ve.condition, ve.lines) - ValidateConditionConsistency(ve.collector, ve.model, ve.lines) - validateUnusedConditions(ve.collector, ve.condition, ve.lines) -} - -// ValidateModel is a convenience function that validates a model with default options. -func ValidateModel(model *openfgav1.AuthorizationModel, dslContent string) *ValidationErrors { - return ValidateDSL(model, dslContent, DefaultEngineOptions()) -} - -// ValidateModelJSON is a convenience function that validates a JSON model with default options. -func ValidateModelJSON(model *openfgav1.AuthorizationModel) *ValidationErrors { - return ValidateJSON(model, DefaultEngineOptions()) -} - -func (ve *ValidationEngine) GetValidationSummary() ValidationSummary { - errors := ve.collector.GetErrors() - summary := ValidationSummary{ - TotalErrors: len(errors), - ErrorsByType: make(map[ValidationErrorType]int), - ErrorsByFile: make(map[string]int), - HasCriticalErrors: false, - } - for _, err := range errors { - if err == nil || err.Metadata == nil { - // Metadata is always set by the collector, but a directly-constructed - // error (e.g. in a consumer or test) could omit it; don't panic. - continue - } - summary.ErrorsByType[err.Metadata.ErrorType]++ - if err.File != "" { - summary.ErrorsByFile[err.File]++ - } - if ve.isCriticalError(err.Metadata.ErrorType) { - summary.HasCriticalErrors = true - } - } - return summary -} - -// ValidationSummary provides a high-level overview of validation results. -type ValidationSummary struct { - TotalErrors int - ErrorsByType map[ValidationErrorType]int - ErrorsByFile map[string]int - HasCriticalErrors bool -} - -// criticalErrorTypes is the fixed set of error types considered critical. It is -// a package-level lookup table so it isn't rebuilt on every isCriticalError call -// (which runs once per error while summarizing). -var criticalErrorTypes = map[ValidationErrorType]bool{ - RelationNoEntrypoint: true, - CyclicRelation: true, - UndefinedType: true, - UndefinedRelation: true, - InvalidRelationType: true, - DuplicatedError: true, - InvalidSchema: true, - InvalidSchemaVersion: true, - MultipleModulesInFile: true, -} - -func (ve *ValidationEngine) isCriticalError(errorType ValidationErrorType) bool { - return criticalErrorTypes[errorType] -} - -// CreateValidationReport creates a detailed validation report. -func CreateValidationReport(model *openfgav1.AuthorizationModel, dslContent string, options *EngineOptions) ValidationReport { - engine := NewValidationEngine(model, dslContent) - validationErrors := engine.RunAllValidations(options) - summary := engine.GetValidationSummary() - return ValidationReport{ - Model: model, - ValidationErrors: validationErrors, - Summary: summary, - Options: options, - } -} - -// ValidationReport contains comprehensive validation results. -type ValidationReport struct { - Model *openfgav1.AuthorizationModel - ValidationErrors *ValidationErrors - Summary ValidationSummary - Options *EngineOptions -} - -func (vr *ValidationReport) IsValid() bool { return vr.ValidationErrors.Count() == 0 } -func (vr *ValidationReport) HasCriticalErrors() bool { return vr.Summary.HasCriticalErrors } -func (vr *ValidationReport) GetErrorsByType(errorType ValidationErrorType) []*ValidationError { - var matchingErrors []*ValidationError - for _, err := range vr.ValidationErrors.GetErrors() { - if err.Metadata.ErrorType == errorType { - matchingErrors = append(matchingErrors, err) - } - } - return matchingErrors -} diff --git a/pkg/go/validation/validation_engine_test.go b/pkg/go/validation/validation_engine_test.go deleted file mode 100644 index 48b28dd3..00000000 --- a/pkg/go/validation/validation_engine_test.go +++ /dev/null @@ -1,498 +0,0 @@ -package validation - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// TestValidationEngine_BasicIntegration tests the basic integration of all validation components -func TestValidationEngine_BasicIntegration(t *testing.T) { - t.Run("Valid model passes all validations", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - }, - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - "editor": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}}}, - }, - }}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "editor": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define editor: [user] or viewer -` - - // Test ValidateDSL - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - assert.Equal(t, 0, errors.Count()) - - // Test ValidateJSON - jsonErrors := ValidateJSON(model, DefaultEngineOptions()) - assert.NotNil(t, jsonErrors) - assert.Equal(t, 0, jsonErrors.Count()) - - // Test convenience functions - modelErrors := ValidateModel(model, dslContent) - assert.NotNil(t, modelErrors) - assert.Equal(t, 0, modelErrors.Count()) - - jsonModelErrors := ValidateModelJSON(model) - assert.NotNil(t, jsonModelErrors) - assert.Equal(t, 0, jsonModelErrors.Count()) - }) - - t.Run("Model with validation errors", func(t *testing.T) { - // Create model with various validation issues - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.0", // Older schema version - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "nonexistent"}}, - }, - }, - }, - { - Type: "document", // Duplicate type - Relations: map[string]*openfgav1.Userset{ - "editor": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.0 - -type document - relations - define viewer: nonexistent - define editor: [user] - -type document - relations - define admin: [user] -` - - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - assert.Greater(t, errors.Count(), 0) - - // Check that we have various types of errors - errorList := errors.GetErrors() - errorTypes := make(map[ValidationErrorType]bool) - for _, err := range errorList { - errorTypes[err.Metadata.ErrorType] = true - } - - // Should have duplicate errors - assert.True(t, len(errorTypes) > 0, "Should have validation errors") - }) -} - -// TestValidationEngine_OptionsConfiguration tests different validation options -func TestValidationEngine_OptionsConfiguration(t *testing.T) { - t.Run("Skip semantic validation", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "nonexistent"}}, - }, - }, - }, - }, - } - - // With semantic validation (default) - normalErrors := ValidateDSL(model, "", DefaultEngineOptions()) - normalErrorCount := normalErrors.Count() - - // Skip semantic validation - options := &EngineOptions{ - SkipSemanticValidation: true, - } - skippedErrors := ValidateDSL(model, "", options) - skippedErrorCount := skippedErrors.Count() - - // Should have fewer errors when semantic validation is skipped - assert.True(t, skippedErrorCount <= normalErrorCount, "Skipping semantic validation should reduce or maintain error count") - }) - - t.Run("Skip complex operation validation", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - }, - }}, - }, - }, - }, - }, - } - - options := &EngineOptions{ - SkipComplexOperationValidation: true, - } - errors := ValidateDSL(model, "", options) - assert.NotNil(t, errors) - // Complex operation validation should be skipped - }) -} - -// TestValidationReport tests the comprehensive validation report functionality -func TestValidationReport(t *testing.T) { - t.Run("Complete validation report", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - }, - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "viewer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] -` - - report := CreateValidationReport(model, dslContent, DefaultEngineOptions()) - - assert.NotNil(t, report.Model) - assert.NotNil(t, report.ValidationErrors) - assert.NotNil(t, report.Options) - assert.Equal(t, model, report.Model) - - // Test report methods - assert.True(t, report.IsValid(), "Valid model should pass IsValid()") - assert.False(t, report.HasCriticalErrors(), "Valid model should not have critical errors") - - // Test summary - summary := report.Summary - assert.Equal(t, 0, summary.TotalErrors) - assert.False(t, summary.HasCriticalErrors) - assert.NotNil(t, summary.ErrorsByType) - assert.NotNil(t, summary.ErrorsByFile) - }) - - t.Run("Report with errors", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "invalid", // Invalid schema version - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "document", - Relations: map[string]*openfgav1.Userset{ - "viewer": { - Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "nonexistent"}}, - }, - }, - }, - }, - } - - report := CreateValidationReport(model, "", DefaultEngineOptions()) - - if report.ValidationErrors.Count() > 0 { - assert.False(t, report.IsValid(), "Invalid model should fail IsValid()") - - summary := report.Summary - assert.Greater(t, summary.TotalErrors, 0) - - // Test GetErrorsByType functionality - for errorType := range summary.ErrorsByType { - errorsOfType := report.GetErrorsByType(errorType) - assert.Greater(t, len(errorsOfType), 0, "Should find errors of type %s", errorType) - } - } - }) -} - -// TestValidationEngine_RealWorldScenarios tests realistic authorization model scenarios -func TestValidationEngine_RealWorldScenarios(t *testing.T) { - t.Run("GitHub-like authorization model", func(t *testing.T) { - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: []*openfgav1.TypeDefinition{ - { - Type: "user", - }, - { - Type: "organization", - Relations: map[string]*openfgav1.Userset{ - "member": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - "owner": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "member": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "owner": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - }, - }, - }, - { - Type: "repository", - Relations: map[string]*openfgav1.Userset{ - "reader": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_TupleToUserset{TupleToUserset: &openfgav1.TupleToUserset{ - Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, - ComputedUserset: &openfgav1.ObjectRelation{Relation: "member"}, - }}}, - }, - }}, - }, - "writer": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "admin"}}}, - }, - }}, - }, - "admin": { - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_TupleToUserset{TupleToUserset: &openfgav1.TupleToUserset{ - Tupleset: &openfgav1.ObjectRelation{Relation: "owner"}, - ComputedUserset: &openfgav1.ObjectRelation{Relation: "owner"}, - }}}, - }, - }}, - }, - "owner": { - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - }, - }, - Metadata: &openfgav1.Metadata{ - Relations: map[string]*openfgav1.RelationMetadata{ - "reader": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "writer": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "admin": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - }, - "owner": { - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - {Type: "organization", RelationOrWildcard: &openfgav1.RelationReference_Relation{Relation: "owner"}}, - }, - }, - }, - }, - }, - }, - } - - dslContent := ` -model - schema 1.1 - -type user - -type organization - relations - define member: [user] - define owner: [user] - -type repository - relations - define owner: [user, organization#owner] - define admin: [user] or owner from owner - define writer: [user] or admin - define reader: [user] or writer from owner -` - - errors := ValidateDSL(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, errors) - - // This complex model should pass validation - if errors.Count() > 0 { - t.Logf("Validation errors found: %d", errors.Count()) - for _, err := range errors.GetErrors() { - t.Logf("Error: %s (Type: %s)", err.Message, err.Metadata.ErrorType) - } - } - - // Create validation report - report := CreateValidationReport(model, dslContent, DefaultEngineOptions()) - assert.NotNil(t, report) - - t.Logf("Validation Summary:") - t.Logf("- Total Errors: %d", report.Summary.TotalErrors) - t.Logf("- Has Critical Errors: %v", report.Summary.HasCriticalErrors) - t.Logf("- Valid Model: %v", report.IsValid()) - }) -} - -// TestValidationEngine_PerformanceBasics tests basic performance characteristics -func TestValidationEngine_PerformanceBasics(t *testing.T) { - t.Run("Large model validation performance", func(t *testing.T) { - // Create a moderately large model - typeDefs := make([]*openfgav1.TypeDefinition, 0, 50) - - // Add user type - typeDefs = append(typeDefs, &openfgav1.TypeDefinition{Type: "user"}) - - // Add many document types with relations - for i := 0; i < 49; i++ { - typeName := fmt.Sprintf("document%d", i) - relations := make(map[string]*openfgav1.Userset) - relationMetadata := make(map[string]*openfgav1.RelationMetadata) - - // Add viewer relation - relations["viewer"] = &openfgav1.Userset{ - Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}, - } - relationMetadata["viewer"] = &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - } - - // Add editor relation with union - relations["editor"] = &openfgav1.Userset{ - Userset: &openfgav1.Userset_Union{Union: &openfgav1.Usersets{ - Child: []*openfgav1.Userset{ - {Userset: &openfgav1.Userset_This{This: &openfgav1.DirectUserset{}}}, - {Userset: &openfgav1.Userset_ComputedUserset{ComputedUserset: &openfgav1.ObjectRelation{Relation: "viewer"}}}, - }, - }}, - } - relationMetadata["editor"] = &openfgav1.RelationMetadata{ - DirectlyRelatedUserTypes: []*openfgav1.RelationReference{ - {Type: "user"}, - }, - } - - typeDefs = append(typeDefs, &openfgav1.TypeDefinition{ - Type: typeName, - Relations: relations, - Metadata: &openfgav1.Metadata{ - Relations: relationMetadata, - }, - }) - } - - model := &openfgav1.AuthorizationModel{ - SchemaVersion: "1.1", - TypeDefinitions: typeDefs, - } - - // Test validation performance - errors := ValidateDSL(model, "", DefaultEngineOptions()) - assert.NotNil(t, errors) - - // Should complete validation in reasonable time - t.Logf("Large model validation completed with %d errors", errors.Count()) - - // Test JSON validation performance - jsonErrors := ValidateJSON(model, DefaultEngineOptions()) - assert.NotNil(t, jsonErrors) - t.Logf("Large model JSON validation completed with %d errors", jsonErrors.Count()) - }) -} diff --git a/pkg/go/validation/wildcard_validation.go b/pkg/go/validation/wildcard_validation.go deleted file mode 100644 index 8a4ea4e5..00000000 --- a/pkg/go/validation/wildcard_validation.go +++ /dev/null @@ -1,159 +0,0 @@ -package validation - -import ( - "fmt" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -// ValidateWildcardUsage validates wildcard relation usage rules. -func ValidateWildcardUsage(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateWildcardUsage(collector, NewSemanticValidator(model), lines) -} - -func validateWildcardUsage(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { - if typeDef.GetMetadata() == nil { - continue - } - for relationName, relationMetadata := range typeDef.GetMetadata().GetRelations() { - validateWildcardInRelation(collector, validator, typeDef.GetType(), relationName, relationMetadata, lines) - } - } -} - -func validateWildcardInRelation(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, relationMetadata *openfgav1.RelationMetadata, lines []string) { - if relationMetadata == nil { - return - } - meta := &Meta{ - File: relationMetadata.GetSourceInfo().GetFile(), - Module: relationMetadata.GetModule(), - } - // Anchor relation line lookups to this type's declaration so the correct - // `define` is found when several types share a relation name. - typeLineIndex := GetTypeLineNumber(typeName, lines, nil) - for _, typeRestriction := range relationMetadata.GetDirectlyRelatedUserTypes() { - if typeRestriction.GetType() == "" { - continue - } - if typeRestriction.GetWildcard() != nil { - validateWildcardRestriction(collector, validator, typeRestriction, relationName, typeName, meta, lines, typeLineIndex) - // wildcard and explicit relation together is invalid - if typeRestriction.GetRelation() != "" { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseInvalidWildcardUsage(typeRestriction.GetType(), relationName, typeName, - "wildcard cannot be used with specific relation", meta, lineIndex) - } - } - } -} - -func validateWildcardRestriction(collector *ErrorCollector, validator *SemanticValidator, - typeRestriction *openfgav1.RelationReference, relationName, typeName string, meta *Meta, lines []string, typeLineIndex *int) { - if !validator.TypeDefined(typeRestriction.GetType()) { - lineIndex := GetRelationLineNumber(relationName, lines, typeLineIndex) - collector.RaiseUndefinedType(typeRestriction.GetType(), relationName, typeName, meta, lineIndex) - } -} - -// ValidateTupleToUsersetRequirements validates tuple-to-userset usage requirements. -func ValidateTupleToUsersetRequirements(collector *ErrorCollector, model *openfgav1.AuthorizationModel, lines []string) { - if model == nil { - return - } - validateTupleToUsersetRequirements(collector, NewSemanticValidator(model), lines) -} - -func validateTupleToUsersetRequirements(collector *ErrorCollector, validator *SemanticValidator, lines []string) { - model := validator.model - if model == nil { - return - } - for _, typeDef := range model.GetTypeDefinitions() { - for relationName, userset := range typeDef.GetRelations() { - validateTupleToUsersetInUserset(collector, validator, typeDef.GetType(), relationName, userset, lines) - } - } -} - -func validateTupleToUsersetInUserset(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, userset *openfgav1.Userset, lines []string) { - if userset == nil { - return - } - - if ttu := userset.GetTupleToUserset(); ttu != nil { - typeDef := validator.GetTypeDefinition(typeName) - meta := &Meta{ - File: typeDef.GetMetadata().GetSourceInfo().GetFile(), - Module: typeDef.GetMetadata().GetModule(), - } - validateTupleToUsersetOperation(collector, validator, typeName, relationName, ttu, meta, lines) - } - if union := userset.GetUnion(); union != nil { - for _, child := range union.GetChild() { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, child, lines) - } - } - if intersection := userset.GetIntersection(); intersection != nil { - for _, child := range intersection.GetChild() { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, child, lines) - } - } - if diff := userset.GetDifference(); diff != nil { - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, diff.GetBase(), lines) - validateTupleToUsersetInUserset(collector, validator, typeName, relationName, diff.GetSubtract(), lines) - } -} - -func validateTupleToUsersetOperation(collector *ErrorCollector, validator *SemanticValidator, - typeName, relationName string, ttu *openfgav1.TupleToUserset, meta *Meta, lines []string) { - tuplesetRelation := ttu.GetTupleset().GetRelation() - if tuplesetRelation == "" { - return - } - // Whether the tupleset/computed relations exist is validated in the - // relation-reference pass (semantic_validation.go). Here we only check that - // an existing tupleset relation is directly assignable. - if !validator.RelationDefined(typeName, tuplesetRelation) { - return - } - validateTuplesetDirectAssignment(collector, validator, typeName, tuplesetRelation, relationName, meta, lines) -} - -func validateTuplesetDirectAssignment(collector *ErrorCollector, validator *SemanticValidator, - typeName, tuplesetRelation, parentRelation string, meta *Meta, lines []string) { - typeDef := validator.GetTypeDefinition(typeName) - if typeDef == nil { - return - } - if metaProto := typeDef.GetMetadata(); metaProto != nil { - if rm, ok := metaProto.GetRelations()[tuplesetRelation]; ok { - if len(rm.GetDirectlyRelatedUserTypes()) == 0 { - lineIndex := GetRelationLineNumber(parentRelation, lines, nil) - collector.RaiseTuplesetNotDirect(tuplesetRelation, typeName, parentRelation, meta, lineIndex) - } - } - } -} - -func (c *ErrorCollector) RaiseInvalidWildcardUsage(typeName, relationName, parentTypeName, reason string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Invalid wildcard usage for type '%s' in relation '%s' of type '%s': %s", - typeName, relationName, parentTypeName, reason) - c.addError(message, InvalidWildcardError, typeName, lineIndex, meta, nil) -} - -func (c *ErrorCollector) RaiseTuplesetNotDirect(tuplesetRelation, typeName, parentRelation string, meta *Meta, lineIndex *int) { - message := fmt.Sprintf("Tupleset relation '%s' on type '%s' must allow direct assignment (used in relation '%s')", - tuplesetRelation, typeName, parentRelation) - c.addError(message, TuplesetNotDirect, tuplesetRelation, lineIndex, meta, nil) -} diff --git a/pkg/go/validation/yaml_integration_test.go b/pkg/go/validation/yaml_integration_test.go index 81756e27..d4d87b1d 100644 --- a/pkg/go/validation/yaml_integration_test.go +++ b/pkg/go/validation/yaml_integration_test.go @@ -4,363 +4,204 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestYAMLTestRunner_Basic tests the basic functionality of the YAML test runner -func TestYAMLTestRunner_Basic(t *testing.T) { - // Get the path to test data relative to the current working directory during tests - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Get available test suites", func(t *testing.T) { - suites, err := runner.GetAvailableTestSuites() - if err != nil { - t.Skipf("Could not access YAML test files (path: %s): %v", testDataPath, err) - return - } - - require.NoError(t, err) - assert.Greater(t, len(suites), 0, "Should find YAML test files") - - // Check for expected YAML files - expectedFiles := []string{ - "dsl-semantic-validation-cases.yaml", - "dsl-syntax-validation-cases.yaml", - "json-validation-cases.yaml", - } - - for _, expectedFile := range expectedFiles { - found := false - for _, suite := range suites { - if suite == expectedFile { - found = true - break - } - } - if found { - t.Logf("✅ Found expected test file: %s", expectedFile) - } else { - t.Logf("⚠️ Expected test file not found: %s", expectedFile) - } - } - }) - - t.Run("Load semantic validation test suite", func(t *testing.T) { - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - t.Skipf("Could not load semantic validation test suite: %v", err) - return - } - - require.NoError(t, err) - require.NotNil(t, suite) - assert.Greater(t, len(suite.TestCases), 0, "Should have test cases") - - t.Logf("📊 Loaded %d semantic validation test cases", len(suite.TestCases)) - - // Check first few test cases - if len(suite.TestCases) > 0 { - firstTest := suite.TestCases[0] - t.Logf("First test case: %s", firstTest.Name) - assert.NotEmpty(t, firstTest.DSL, "Test case should have DSL content") - } - }) -} +// sharedDataDir is the shared test data directory, which sits at the repository root and +// is read by the Go, JS and Java implementations alike. +var sharedDataDir = filepath.Join("..", "..", "..", "tests", "data") + +// TestSemanticValidationSharedCases runs every case in the shared semantic validation +// file. It fails if the file cannot be loaded or has no cases. Cases the file +// marks skip are reported as skipped. +func TestSemanticValidationSharedCases(t *testing.T) { + t.Parallel() + + runner := NewYAMLTestRunner(sharedDataDir) -// TestYAMLTestRunner_SemanticValidation tests running semantic validation cases -func TestYAMLTestRunner_SemanticValidation(t *testing.T) { - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - t.Skipf("Could not load semantic validation test suite: %v", err) - return - } - - t.Run("Run sample semantic validation tests", func(t *testing.T) { - // Run first few test cases to validate framework - maxTests := 5 - if len(suite.TestCases) < maxTests { - maxTests = len(suite.TestCases) - } - - passedTests := 0 - failedTests := 0 - skippedTests := 0 - - for i := 0; i < maxTests; i++ { - testCase := suite.TestCases[i] - t.Logf("Running test case %d: %s", i+1, testCase.Name) - - result, err := runner.RunTestCase(testCase) - require.NoError(t, err, "Should not error when running test case") - - if result != nil { - switch result.Status { - case "PASS": - passedTests++ - t.Logf(" ✅ PASS: %s", result.Message) - case "FAIL": - failedTests++ - t.Logf(" ❌ FAIL: %s", result.Message) - for _, detail := range result.ErrorDetails { - t.Logf(" • %s", detail) - } - case "SKIPPED": - skippedTests++ - t.Logf(" ⏭️ SKIP: %s", result.Message) - default: - t.Logf(" ❓ %s: %s", result.Status, result.Message) - } - } - } - - t.Logf("📊 Sample Test Results: %d passed, %d failed, %d skipped", passedTests, failedTests, skippedTests) - }) -} + require.NoError(t, err) + require.NotEmpty(t, suite.TestCases, "shared test file loaded no cases") -// TestYAMLTestRunner_ComprehensiveValidation runs comprehensive validation against YAML test cases -func TestYAMLTestRunner_ComprehensiveValidation(t *testing.T) { - if testing.Short() { - t.Skip("Skipping comprehensive YAML validation tests in short mode") - } - - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Run all semantic validation test cases", func(t *testing.T) { - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - t.Skipf("Could not load semantic validation test suite: %v", err) - return - } - - passedTests := 0 - failedTests := 0 - skippedTests := 0 - errorTests := 0 - - // Run all test cases - for i, testCase := range suite.TestCases { - if testing.Verbose() { - t.Logf("Running test case %d/%d: %s", i+1, len(suite.TestCases), testCase.Name) - } - - result, err := runner.RunTestCase(testCase) - if err != nil { - t.Errorf("Error running test case %s: %v", testCase.Name, err) - errorTests++ - continue + for _, testCase := range suite.TestCases { + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + + result := runner.RunTestCase(testCase) + if result.Status == caseSkipped { + t.Skip("marked skip in the shared test file") } - - switch result.Status { - case "PASS": - passedTests++ - case "FAIL": - failedTests++ - t.Errorf("❌ FAIL: %s: %s", testCase.Name, result.Message) - for _, detail := range result.ErrorDetails { - t.Errorf(" • %s", detail) - } - case "SKIPPED": - skippedTests++ - case "ERROR": - errorTests++ - t.Errorf("💥 ERROR: %s: %s", testCase.Name, result.Message) + + for _, problem := range result.Problems { + t.Error(problem) } - } - - totalTests := len(suite.TestCases) - passRate := float64(passedTests) / float64(totalTests) * 100.0 - - t.Logf("📊 Comprehensive Semantic Validation Results:") - t.Logf(" Total Tests: %d", totalTests) - t.Logf(" Passed: %d (%.1f%%)", passedTests, passRate) - t.Logf(" Failed: %d", failedTests) - t.Logf(" Skipped: %d", skippedTests) - t.Logf(" Errors: %d", errorTests) - // Every non-skipped case must match the JS reference. Individual FAIL and - // ERROR cases already call t.Errorf above; this guards against a case - // silently vanishing (e.g. all cases skipped or none loaded). - require.Positive(t, passedTests, "expected passing semantic validation cases") - }) + // Catches a failed status with no problems listed. + require.Equal(t, casePass, result.Status) + }) + } } -// TestYAMLTestRunner_AllSuites runs tests against all available YAML test suites -func TestYAMLTestRunner_AllSuites(t *testing.T) { - if testing.Short() { - t.Skip("Skipping comprehensive all-suite YAML tests in short mode") +// TestCompareWithExpected tests the comparison every shared case relies on. If it +// accepted a prefix of the message, or a finding with no position, every shared case +// would still pass. +func TestCompareWithExpected(t *testing.T) { + t.Parallel() + + expected := YAMLExpectedError{ + Message: "the relation `viewer` does not exist.", + Line: &YAMLRange{Start: 4, End: 4}, + Column: &YAMLRange{Start: 12, End: 18}, + Metadata: YAMLErrorMetadata{ + Symbol: "viewer", + ErrorType: string(MissingDefinition), + }, } - - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Run all available test suites", func(t *testing.T) { - results, err := runner.RunAllTestSuites() - if err != nil { - t.Skipf("Could not run all test suites: %v", err) - return - } - - report := runner.GenerateTestReport(results) - - // Print report to test output - t.Logf("📊 YAML Test Integration Report") - t.Logf("================================") - t.Logf("Total Tests: %d", report.Summary["TOTAL"]) - t.Logf("Passed: %d", report.Summary["PASS"]) - t.Logf("Failed: %d", report.Summary["FAIL"]) - t.Logf("Skipped: %d", report.Summary["SKIPPED"]) - t.Logf("Errors: %d", report.Summary["ERROR"]) - t.Logf("Pass Rate: %.1f%%", report.PassRate) - - // Detailed suite breakdown - for suiteName, suiteResults := range results { - passed := 0 - failed := 0 - skipped := 0 - errors := 0 - - for _, result := range suiteResults { - switch result.Status { - case "PASS": - passed++ - case "FAIL": - failed++ - case "SKIPPED": - skipped++ - case "ERROR": - errors++ - } - } - - t.Logf("📋 Suite: %s - Tests: %d, Passed: %d, Failed: %d, Skipped: %d, Errors: %d", - suiteName, len(suiteResults), passed, failed, skipped, errors) - } - - // Validate that we have a working test framework - assert.Greater(t, report.Summary["TOTAL"], 0, "Should have run some tests") - - // Log insights about current validation system state - if report.Summary["PASS"] > 0 { - t.Logf("✅ Validation system working - %d tests passed", report.Summary["PASS"]) - } - - if report.Summary["FAIL"] > 0 { - t.Logf("🔧 Validation improvements needed - %d tests failed", report.Summary["FAIL"]) - } - - if report.PassRate > 50 { - t.Logf("🎯 Good validation coverage - %.1f%% pass rate", report.PassRate) - } else if report.PassRate > 0 { - t.Logf("⚠️ Moderate validation coverage - %.1f%% pass rate", report.PassRate) - } - }) -} -// TestYAMLTestRunner_SpecificScenarios tests specific validation scenarios -func TestYAMLTestRunner_SpecificScenarios(t *testing.T) { - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - t.Run("Test error matching functionality", func(t *testing.T) { - // Create a test case to validate our error matching logic - testCase := YAMLTestCase{ - Name: "test error matching", - DSL: ` -model - schema 1.1 -type user -type document - relations - define viewer: nonexistent -`, - ExpectedErrors: []YAMLExpectedError{ - { - Message: "relation `nonexistent` does not exist", - Metadata: YAMLErrorMetadata{ - ErrorType: "undefined-relation", - }, - }, + finding := func(mutate func(*Finding)) *Finding { + found := &Finding{ + Message: "the relation `viewer` does not exist.", + Line: &Range{Start: 4, End: 4}, + Column: &Range{Start: 12, End: 18}, + Metadata: Metadata{ + Symbol: "viewer", + Kind: MissingDefinition, }, } - - result, err := runner.RunTestCase(testCase) - require.NoError(t, err) - require.NotNil(t, result) - - t.Logf("Test case result: %s - %s", result.Status, result.Message) - - if result.Status == "FAIL" { - for _, detail := range result.ErrorDetails { - t.Logf(" Error detail: %s", detail) - } + if mutate != nil { + mutate(found) } - - // For validation - we expect this to work but may need refinement - if len(result.ActualErrors) > 0 { - t.Logf("✅ Validation system detected %d errors", len(result.ActualErrors)) - for _, err := range result.ActualErrors { - t.Logf(" - %s (Type: %s)", err.Message, err.Metadata.ErrorType) - } - } - }) - - t.Run("Test schema version validation", func(t *testing.T) { - testCase := YAMLTestCase{ - Name: "invalid schema version", - DSL: ` -model - schema 0.9 -type user -`, - ExpectedErrors: []YAMLExpectedError{ - { - Message: "invalid schema 0.9", - Metadata: YAMLErrorMetadata{ - ErrorType: "invalid-schema", - }, - }, - }, - } - - result, err := runner.RunTestCase(testCase) - require.NoError(t, err) - require.NotNil(t, result) - - t.Logf("Schema validation result: %s - %s", result.Status, result.Message) - }) -} -// BenchmarkYAMLTestRunner benchmarks the YAML test runner performance -func BenchmarkYAMLTestRunner(b *testing.B) { - testDataPath := filepath.Join("..", "..", "..", "tests", "data") - runner := NewYAMLTestRunner(testDataPath) - - // Load a test suite for benchmarking - suite, err := runner.LoadTestSuite("dsl-semantic-validation-cases.yaml") - if err != nil { - b.Skipf("Could not load test suite for benchmarking: %v", err) - return + return found } - - if len(suite.TestCases) == 0 { - b.Skip("No test cases available for benchmarking") - return + + tests := []struct { + name string + expected []YAMLExpectedError + findings []*Finding + problems int + }{ + { + name: "match", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(nil)}, + }, + { + name: "no errors expected and none found", + expected: nil, + findings: nil, + }, + { + // A message that only starts with the expected one is a different message, so + // it satisfies nothing and is reported twice: the expectation went + // unmatched and the finding was not expected. + name: "message is longer than the case states", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Message += " Did you mean `view`?" + })}, + problems: 2, + }, + { + name: "wrong line", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Line = &Range{Start: 5, End: 5} + })}, + problems: 1, + }, + { + name: "line end differs", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Line = &Range{Start: 4, End: 6} + })}, + problems: 1, + }, + { + name: "no position at all", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Line, f.Column = nil, nil + })}, + problems: 1, + }, + { + name: "wrong column", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Column = &Range{Start: 12, End: 17} + })}, + problems: 1, + }, + { + name: "wrong symbol", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Metadata.Symbol = "editor" + })}, + problems: 1, + }, + { + name: "wrong error type", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(func(f *Finding) { + f.Metadata.Kind = DuplicatedError + })}, + problems: 1, + }, + { + name: "one finding does not satisfy two expectations", + expected: []YAMLExpectedError{expected, expected}, + findings: []*Finding{finding(nil)}, + problems: 1, + }, + { + name: "finding the case does not expect", + expected: []YAMLExpectedError{expected}, + findings: []*Finding{finding(nil), finding(func(f *Finding) { + f.Message = "the relation `editor` does not exist." + })}, + problems: 1, + }, + { + name: "position the case leaves out is not compared", + expected: []YAMLExpectedError{{Message: expected.Message}}, + findings: []*Finding{finding(func(f *Finding) { + f.Line, f.Column = nil, nil + })}, + }, } - - // Use first test case for benchmarking - testCase := suite.TestCases[0] - - b.ResetTimer() - b.Run("RunSingleTestCase", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _, _ = runner.RunTestCase(testCase) - } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + result := compareWithExpected(test.expected, test.findings) + + require.Len(t, result.Problems, test.problems, "problems: %v", result.Problems) + + if test.problems == 0 { + require.Equal(t, casePass, result.Status) + } else { + require.Equal(t, caseFail, result.Status) + } + }) + } +} + +// TestRunTestCaseUnparsableDSL covers the one outcome the shared cases cannot reach: a +// case whose DSL the transformer rejects tested nothing about validation, so it is +// reported as an error rather than passing for lack of findings. +func TestRunTestCaseUnparsableDSL(t *testing.T) { + t.Parallel() + + result := NewYAMLTestRunner(sharedDataDir).RunTestCase(YAMLTestCase{ + Name: "not a model", + DSL: "type document\n relations\n", }) + + require.Equal(t, caseError, result.Status) + require.Len(t, result.Problems, 1) + require.Contains(t, result.Problems[0], "DSL does not parse") } diff --git a/pkg/go/validation/yaml_test_integration_test.go b/pkg/go/validation/yaml_test_integration_test.go index 16f7ad15..fa12ca0f 100644 --- a/pkg/go/validation/yaml_test_integration_test.go +++ b/pkg/go/validation/yaml_test_integration_test.go @@ -4,61 +4,65 @@ import ( "fmt" "os" "path/filepath" - "strings" "gopkg.in/yaml.v3" - openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/openfga/language/pkg/go/transformer" ) -// YAMLTestCase represents a single test case from the YAML validation test files +// Outcomes of a shared test case. ERROR is a case whose DSL no longer parses, which is +// distinct from FAIL: nothing about validation was tested. +const ( + casePass = "PASS" + caseFail = "FAIL" + caseSkipped = "SKIPPED" + caseError = "ERROR" +) + +// YAMLTestCase is one case from the shared validation cases under tests/data. type YAMLTestCase struct { - Name string `yaml:"name"` - DSL string `yaml:"dsl"` - Skip bool `yaml:"skip,omitempty"` - ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` - Metadata map[string]interface{} `yaml:"metadata,omitempty"` + Name string `yaml:"name"` + DSL string `yaml:"dsl"` + Skip bool `yaml:"skip,omitempty"` + ExpectedErrors []YAMLExpectedError `yaml:"expected_errors,omitempty"` } -// YAMLExpectedError represents an expected validation error from YAML test files +// YAMLExpectedError is one error a shared case expects. +// +// Line and Column are pointers so a case that states no position is told apart from +// one that states position 0. The shared cases count lines from zero, so the zero value is +// a real position and a value type would make the two indistinguishable. type YAMLExpectedError struct { - Message string `yaml:"msg"` - Line YAMLLineRange `yaml:"line,omitempty"` - Column YAMLColumnRange `yaml:"column,omitempty"` - Metadata YAMLErrorMetadata `yaml:"metadata,omitempty"` + Message string `yaml:"msg"` + Line *YAMLRange `yaml:"line,omitempty"` + Column *YAMLRange `yaml:"column,omitempty"` + Metadata YAMLErrorMetadata `yaml:"metadata,omitempty"` } -// YAMLLineRange represents line start and end positions -type YAMLLineRange struct { +// YAMLRange is a start and end position, used for both the line and the column. +type YAMLRange struct { Start int `yaml:"start"` End int `yaml:"end"` } -// YAMLColumnRange represents column start and end positions -type YAMLColumnRange struct { - Start int `yaml:"start"` - End int `yaml:"end"` -} - -// YAMLErrorMetadata represents error metadata from YAML test files +// YAMLErrorMetadata is the metadata a shared case states: the offending symbol and the +// error type. type YAMLErrorMetadata struct { Symbol string `yaml:"symbol,omitempty"` ErrorType string `yaml:"errorType,omitempty"` } -// YAMLTestSuite represents a collection of YAML test cases +// YAMLTestSuite is a shared test file's cases. type YAMLTestSuite struct { TestCases []YAMLTestCase - FilePath string } -// YAMLTestRunner handles running YAML-based validation tests +// YAMLTestRunner loads shared test files from testDataPath and runs their cases. type YAMLTestRunner struct { testDataPath string suites map[string]*YAMLTestSuite } -// NewYAMLTestRunner creates a new YAML test runner func NewYAMLTestRunner(testDataPath string) *YAMLTestRunner { return &YAMLTestRunner{ testDataPath: testDataPath, @@ -66,323 +70,191 @@ func NewYAMLTestRunner(testDataPath string) *YAMLTestRunner { } } -// LoadTestSuite loads a YAML test suite from file +// LoadTestSuite reads and parses a shared test file, caching it by name. func (runner *YAMLTestRunner) LoadTestSuite(filename string) (*YAMLTestSuite, error) { - filePath := filepath.Join(runner.testDataPath, filename) - - // Check if already loaded if suite, exists := runner.suites[filename]; exists { return suite, nil } - - // Read YAML file + + filePath := filepath.Join(runner.testDataPath, filename) + data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read YAML file %s: %w", filePath, err) } - - // Parse YAML + var testCases []YAMLTestCase if err := yaml.Unmarshal(data, &testCases); err != nil { return nil, fmt.Errorf("failed to parse YAML file %s: %w", filePath, err) } - - suite := &YAMLTestSuite{ - TestCases: testCases, - FilePath: filePath, - } - + + suite := &YAMLTestSuite{TestCases: testCases} runner.suites[filename] = suite + return suite, nil } -// GetAvailableTestSuites returns all available YAML test suite files -func (runner *YAMLTestRunner) GetAvailableTestSuites() ([]string, error) { - files, err := os.ReadDir(runner.testDataPath) - if err != nil { - return nil, fmt.Errorf("failed to read test data directory: %w", err) - } - - var yamlFiles []string - for _, file := range files { - if !file.IsDir() && (strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml")) { - yamlFiles = append(yamlFiles, file.Name()) - } - } - - return yamlFiles, nil +// YAMLTestResult is the outcome of one shared case. +type YAMLTestResult struct { + Status string + + // Problems lists every divergence from the case, one per line, and is empty for + // a case that passed. + Problems []string } -// RunTestCase runs a single YAML test case and compares results -func (runner *YAMLTestRunner) RunTestCase(testCase YAMLTestCase) (*YAMLTestResult, error) { +// RunTestCase validates a case's DSL and compares the findings with what the case +// expects. +func (runner *YAMLTestRunner) RunTestCase(testCase YAMLTestCase) *YAMLTestResult { if testCase.Skip { - return &YAMLTestResult{ - TestCase: testCase, - Status: "SKIPPED", - Message: "Test case marked as skip in YAML", - }, nil + return &YAMLTestResult{Status: caseSkipped} } - - // Parse DSL to create authorization model - model, err := runner.parseDSLToModel(testCase.DSL) + + model, err := transformer.TransformDSLToProto(testCase.DSL) if err != nil { return &YAMLTestResult{ - TestCase: testCase, - Status: "ERROR", - Message: fmt.Sprintf("Failed to parse DSL: %v", err), - }, nil + Status: caseError, + Problems: []string{fmt.Sprintf("DSL does not parse: %v", err)}, + } } - - // Run validation - validationErrors := ValidateDSL(model, testCase.DSL, DefaultEngineOptions()) - - // Compare results - result := runner.compareResults(testCase, validationErrors) - return result, nil -} -// YAMLTestResult represents the result of running a YAML test case -type YAMLTestResult struct { - TestCase YAMLTestCase - Status string // "PASS", "FAIL", "SKIPPED", "ERROR" - Message string - ActualErrors []*ValidationError - ExpectedErrors []YAMLExpectedError - ErrorDetails []string + return compareWithExpected(testCase.ExpectedErrors, ExtractAllAs[*Finding](ValidateDSL(model, testCase.DSL))) } -// compareResults compares actual validation results with expected results from YAML -func (runner *YAMLTestRunner) compareResults(testCase YAMLTestCase, validationErrors *ValidationErrors) *YAMLTestResult { - result := &YAMLTestResult{ - TestCase: testCase, - ActualErrors: validationErrors.GetErrors(), - ExpectedErrors: testCase.ExpectedErrors, - } - - actualCount := validationErrors.Count() - expectedCount := len(testCase.ExpectedErrors) - - // Check error count match - if actualCount != expectedCount { - result.Status = "FAIL" - result.Message = fmt.Sprintf("Error count mismatch: expected %d, got %d", expectedCount, actualCount) - result.ErrorDetails = append(result.ErrorDetails, result.Message) - } - - // If no errors expected and none found, test passes - if expectedCount == 0 && actualCount == 0 { - result.Status = "PASS" - result.Message = "No errors expected and none found" - return result - } - - // Compare individual errors - errorMatches := make([]bool, len(testCase.ExpectedErrors)) - for i, expectedError := range testCase.ExpectedErrors { - matched := false - for _, actualError := range validationErrors.GetErrors() { - if runner.errorsMatch(expectedError, actualError) { - matched = true +// compareWithExpected pairs each expected error with a distinct finding, so a case +// expecting two errors is not satisfied by one finding that matches both. +func compareWithExpected(expectedErrors []YAMLExpectedError, findings []*Finding) *YAMLTestResult { + result := &YAMLTestResult{} + claimed := make([]bool, len(findings)) + matched := make([]bool, len(expectedErrors)) + + // Whole matches are paired first. Pairing on the message alone up front would let + // one expectation take the finding that another one matches outright. + for i, expected := range expectedErrors { + for j, finding := range findings { + if !claimed[j] && describeMismatch(expected, finding) == "" { + claimed[j], matched[i] = true, true + break } } - errorMatches[i] = matched - if !matched { - detail := fmt.Sprintf("Expected error not found: %s", expectedError.Message) - result.ErrorDetails = append(result.ErrorDetails, detail) - } } - - // Check for unexpected errors - for _, actualError := range validationErrors.GetErrors() { - matched := false - for _, expectedError := range testCase.ExpectedErrors { - if runner.errorsMatch(expectedError, actualError) { - matched = true + + // What is left pairs on the message alone, so a finding that resolved to the wrong + // line is reported as that one field rather than as an expectation with nothing + // behind it plus an unexplained extra finding. + for i, expected := range expectedErrors { + if matched[i] { + continue + } + + paired := false + + for j, finding := range findings { + if !claimed[j] && finding.Message == expected.Message { + claimed[j], paired = true, true + result.Problems = append(result.Problems, + fmt.Sprintf("expected %s: %s", describeExpected(expected), describeMismatch(expected, finding))) + break } } - if !matched { - detail := fmt.Sprintf("Unexpected error found: %s", actualError.Message) - result.ErrorDetails = append(result.ErrorDetails, detail) + + if !paired { + result.Problems = append(result.Problems, + fmt.Sprintf("no finding matches expected %s", describeExpected(expected))) + } + } + + for j, finding := range findings { + if !claimed[j] { + result.Problems = append(result.Problems, fmt.Sprintf("unexpected finding %s", describeFinding(finding))) } } - - // Determine overall status - if len(result.ErrorDetails) == 0 { - result.Status = "PASS" - result.Message = fmt.Sprintf("All %d errors matched correctly", expectedCount) + + if len(result.Problems) > 0 { + result.Status = caseFail } else { - result.Status = "FAIL" - result.Message = fmt.Sprintf("Found %d error mismatches", len(result.ErrorDetails)) + result.Status = casePass } - + return result } -// errorsMatch checks if an expected error matches an actual validation error -func (runner *YAMLTestRunner) errorsMatch(expected YAMLExpectedError, actual *ValidationError) bool { - // Check message content (allow partial matches for flexibility) - if !strings.Contains(strings.ToLower(actual.Message), strings.ToLower(expected.Message)) { - return false +// describeMismatch returns what keeps finding from satisfying expected, or "" when +// nothing does. +// +// The message has to be equal rather than merely contain the expected text: the shared +// cases are the contract between the implementations, so a message that only starts +// with the expected one is a divergence, not a pass. +func describeMismatch(expected YAMLExpectedError, finding *Finding) string { + if finding.Message != expected.Message { + return fmt.Sprintf("message %q, want %q", finding.Message, expected.Message) } - - // Check error type if specified - if expected.Metadata.ErrorType != "" { - expectedType := ValidationErrorType(expected.Metadata.ErrorType) - if actual.Metadata.ErrorType != expectedType { - return false - } + + if expected.Metadata.ErrorType != "" && + string(finding.Metadata.Kind) != expected.Metadata.ErrorType { + return fmt.Sprintf("errorType %q, want %q", finding.Metadata.Kind, expected.Metadata.ErrorType) } - - // Check line numbers if specified. A case that pins a line requires the error - // to carry one, so an error with no position at all is a mismatch. - if expected.Line.Start > 0 { - if actual.Line == nil || actual.Line.Start != expected.Line.Start { - return false - } + + if expected.Metadata.Symbol != "" && finding.Metadata.Symbol != expected.Metadata.Symbol { + return fmt.Sprintf("symbol %q, want %q", finding.Metadata.Symbol, expected.Metadata.Symbol) } - - // Check column numbers if specified - if expected.Column.Start > 0 { - if actual.Column == nil || actual.Column.Start != expected.Column.Start { - return false - } + + // A position a case states has to be reached, both ends of it. A finding + // with no position at all must not satisfy an expectation that states one: + // resolving to nowhere in the source is a failure mode of the line searches, + // not a match. + if problem := describeRangeMismatch("line", expected.Line, finding.Line); problem != "" { + return problem } - - return true + + return describeRangeMismatch("column", expected.Column, finding.Column) } -// parseDSLToModel converts DSL content to an AuthorizationModel -func (runner *YAMLTestRunner) parseDSLToModel(dsl string) (*openfgav1.AuthorizationModel, error) { - model, err := transformer.TransformDSLToProto(dsl) - if err != nil { - return nil, fmt.Errorf("failed to parse DSL: %w", err) - } - if model == nil { - return nil, fmt.Errorf("failed to parse DSL") +func describeRangeMismatch(name string, expected *YAMLRange, actual *Range) string { + if expected == nil { + return "" } - return model, nil -} -// RunAllTestSuites runs all available YAML test suites -func (runner *YAMLTestRunner) RunAllTestSuites() (map[string][]*YAMLTestResult, error) { - suiteFiles, err := runner.GetAvailableTestSuites() - if err != nil { - return nil, err + if actual == nil { + return "no " + name } - - results := make(map[string][]*YAMLTestResult) - - for _, suiteFile := range suiteFiles { - suite, err := runner.LoadTestSuite(suiteFile) - if err != nil { - return nil, fmt.Errorf("failed to load test suite %s: %w", suiteFile, err) - } - - var suiteResults []*YAMLTestResult - for _, testCase := range suite.TestCases { - result, err := runner.RunTestCase(testCase) - if err != nil { - result = &YAMLTestResult{ - TestCase: testCase, - Status: "ERROR", - Message: err.Error(), - } - } - suiteResults = append(suiteResults, result) - } - - results[suiteFile] = suiteResults + + if actual.Start != expected.Start || actual.End != expected.End { + return fmt.Sprintf("%s %d-%d, want %d-%d", name, actual.Start, actual.End, expected.Start, expected.End) } - - return results, nil + + return "" } -// GenerateTestReport generates a comprehensive test report -func (runner *YAMLTestRunner) GenerateTestReport(results map[string][]*YAMLTestResult) *YAMLTestReport { - report := &YAMLTestReport{ - SuiteResults: results, - Summary: make(map[string]int), - } - - totalTests := 0 - for _, suiteResults := range results { - for _, result := range suiteResults { - totalTests++ - report.Summary[result.Status]++ - } +func describeExpected(expected YAMLExpectedError) string { + return fmt.Sprintf("%q [%s]%s", expected.Message, expected.Metadata.ErrorType, + describePosition(rangeOf(expected.Line), rangeOf(expected.Column))) +} + +func describeFinding(finding *Finding) string { + return fmt.Sprintf("%q [%s]%s", finding.Message, finding.Metadata.Kind, + describePosition(finding.Line, finding.Column)) +} + +func describePosition(line, column *Range) string { + described := "" + if line != nil { + described += fmt.Sprintf(" line %d-%d", line.Start, line.End) } - - report.Summary["TOTAL"] = totalTests - - // Calculate pass rate - if totalTests > 0 { - passCount := report.Summary["PASS"] - report.PassRate = float64(passCount) / float64(totalTests) * 100.0 + + if column != nil { + described += fmt.Sprintf(" column %d-%d", column.Start, column.End) } - - return report -} -// YAMLTestReport represents a comprehensive test report -type YAMLTestReport struct { - SuiteResults map[string][]*YAMLTestResult - Summary map[string]int - PassRate float64 + return described } -// PrintReport prints a formatted test report -func (report *YAMLTestReport) PrintReport() { - fmt.Printf("YAML Test Integration Report\n") - fmt.Printf("================================\n\n") - - fmt.Printf("Summary:\n") - fmt.Printf(" Total Tests: %d\n", report.Summary["TOTAL"]) - fmt.Printf(" Passed: %d\n", report.Summary["PASS"]) - fmt.Printf(" Failed: %d\n", report.Summary["FAIL"]) - fmt.Printf(" Skipped: %d\n", report.Summary["SKIPPED"]) - fmt.Printf(" Errors: %d\n", report.Summary["ERROR"]) - fmt.Printf(" Pass Rate: %.1f%%\n\n", report.PassRate) - - // Print detailed results for each suite - for suiteName, results := range report.SuiteResults { - fmt.Printf("Test Suite: %s\n", suiteName) - fmt.Printf(" Tests: %d\n", len(results)) - - passed := 0 - failed := 0 - skipped := 0 - errors := 0 - - for _, result := range results { - switch result.Status { - case "PASS": - passed++ - case "FAIL": - failed++ - case "SKIPPED": - skipped++ - case "ERROR": - errors++ - } - } - - fmt.Printf(" Passed: %d, Failed: %d, Skipped: %d, Errors: %d\n", passed, failed, skipped, errors) - - // Show failed tests - if failed > 0 || errors > 0 { - fmt.Printf(" Failed/Error Tests:\n") - for _, result := range results { - if result.Status == "FAIL" || result.Status == "ERROR" { - fmt.Printf(" - %s: %s\n", result.TestCase.Name, result.Message) - for _, detail := range result.ErrorDetails { - fmt.Printf(" • %s\n", detail) - } - } - } - } - - fmt.Printf("\n") +func rangeOf(yamlRange *YAMLRange) *Range { + if yamlRange == nil { + return nil } + + return &Range{Start: yamlRange.Start, End: yamlRange.End} } diff --git a/pkg/java/src/main/java/dev/openfga/language/validation/ModelValidator.java b/pkg/java/src/main/java/dev/openfga/language/validation/ModelValidator.java index 5263c7ab..dd8e67dd 100644 --- a/pkg/java/src/main/java/dev/openfga/language/validation/ModelValidator.java +++ b/pkg/java/src/main/java/dev/openfga/language/validation/ModelValidator.java @@ -71,6 +71,10 @@ private void validate() throws DslErrorsException { if (schemaVersion != null && (schemaVersion.equals("1.1") || schemaVersion.equals("1.2"))) { modelValidation(); + } else if (schemaVersion != null && schemaVersion.equals("1.0")) { + // 1.0 is retired, not invalid; matches Go and JS. + var lineIndex = dsl.getSchemaLineNumber(schemaVersion); + errors.raiseSchemaVersionUnsupported(lineIndex, schemaVersion); } else if (schemaVersion != null) { var lineIndex = dsl.getSchemaLineNumber(schemaVersion); errors.raiseInvalidSchemaVersion(lineIndex, schemaVersion); diff --git a/pkg/java/src/main/java/dev/openfga/language/validation/ValidationErrorsBuilder.java b/pkg/java/src/main/java/dev/openfga/language/validation/ValidationErrorsBuilder.java index 72f49391..b81d75dc 100644 --- a/pkg/java/src/main/java/dev/openfga/language/validation/ValidationErrorsBuilder.java +++ b/pkg/java/src/main/java/dev/openfga/language/validation/ValidationErrorsBuilder.java @@ -57,6 +57,12 @@ public void raiseInvalidSchemaVersion(int lineIndex, String symbol) { errors.add(new ModelValidationSingleError(errorProperties, metadata)); } + public void raiseSchemaVersionUnsupported(int lineIndex, String symbol) { + var errorProperties = buildErrorProperties("schema version no longer supported", lineIndex, symbol); + var metadata = new ValidationMetadata(symbol, ValidationError.SchemaVersionUnsupported); + errors.add(new ModelValidationSingleError(errorProperties, metadata)); + } + public void raiseReservedTypeName(int lineIndex, String symbol) { var errorProperties = buildErrorProperties( "a type cannot be named '" + Keyword.SELF + "' or '" + Keyword.THIS + "'.", lineIndex, symbol); diff --git a/tests/data/dsl-semantic-validation-cases.yaml b/tests/data/dsl-semantic-validation-cases.yaml index 5f8553e1..e19ee043 100644 --- a/tests/data/dsl-semantic-validation-cases.yaml +++ b/tests/data/dsl-semantic-validation-cases.yaml @@ -1,4 +1,7 @@ --- +# Shared by pkg/go, pkg/js and pkg/java. Use only the fields all three read — +# name, dsl, skip, expected_errors (msg, line, column, metadata.symbol, +# metadata.errorType); an unknown field breaks the Java and JS runners. - name: model 1.1 diff in exclusion not valid and spaces are reflected correctly in error messages dsl: | model @@ -95,6 +98,18 @@ metadata: symbol: "this" errorType: reserved-relation-keywords +- name: reserved keywords are case-sensitive + dsl: | + model + schema 1.1 + type user + type Self + relations + define This: [user] + type THIS + relations + define SELF: [user] + expected_errors: [] - name: invalid type name dsl: > model @@ -732,6 +747,20 @@ end: 61 metadata: errorType: invalid-relation-on-tupleset +- name: from target relation needs to exist on only one of the assignable types + dsl: | + model + schema 1.1 + type user + type org + type folder + relations + define viewer: [user] + type document + relations + define parent: [folder, org] + define viewer: viewer from parent + expected_errors: [] - name: self reference with wildcard dsl: | model @@ -829,6 +858,28 @@ metadata: symbol: "user:*" errorType: duplicated-error +- name: duplicate type with condition in relations + dsl: | + model + schema 1.1 + type user + type document + relations + define viewer: [user with is_owner, user with is_owner] + condition is_owner(x: int) { + x > 0 + } + expected_errors: + - msg: "the type restriction `user with is_owner` is a duplicate in the relation `viewer`." + line: + start: 5 + end: 5 + column: + start: 20 + end: 38 + metadata: + symbol: "user with is_owner" + errorType: duplicated-error - name: duplicate relation union in relations dsl: | model @@ -2276,11 +2327,8 @@ symbol: "less" errorType: condition-not-used -# The four cases below contain literal tab characters (shown as \t in the names) -# between a declaration keyword and its name. Tabs and form feeds are valid -# separators per the lexer's WHITESPACE rule, so every SDK must fold them when -# locating declaration lines. Keep the tabs when editing: replacing them with -# spaces would stop these cases from guarding that behaviour. +# The four cases below use literal tab separators (\t in the names). Tabs are +# valid separators, so line lookups must fold them — keep the tabs when editing. - name: tab separator in define\treader is folded when locating the error line dsl: | model @@ -2382,3 +2430,104 @@ relations define viewer: [group#member with $expression] expected_errors: [] +- name: schema 1.0 is recognized but no longer supported + dsl: | + model + schema 1.0 + type user + expected_errors: + - msg: "schema version no longer supported" + line: + start: 1 + end: 1 + column: + start: 9 + end: 12 + metadata: + symbol: "1.0" + errorType: schema-version-unsupported +- name: an undefined condition is reported before an unused one + dsl: | + model + schema 1.1 + type user + type document + relations + define viewer: [user with missing] + condition marked(x: int) { + x > 0 + } + expected_errors: + - msg: "`missing` is not a defined condition in the model." + line: + start: 5 + end: 5 + column: + start: 30 + end: 37 + metadata: + symbol: "missing" + errorType: condition-not-defined + - msg: "`marked` condition is not used in the model." + line: + start: 6 + end: 6 + column: + start: 10 + end: 16 + metadata: + symbol: "marked" + errorType: condition-not-used +- name: an unused condition is reported even when a relation reference is invalid + dsl: | + model + schema 1.1 + type user + type document + relations + define viewer: [ghost] + condition marked(x: int) { + x > 0 + } + expected_errors: + - msg: "`ghost` is not a valid type." + line: + start: 5 + end: 5 + column: + start: 20 + end: 25 + metadata: + symbol: "ghost" + errorType: invalid-type + - msg: "`marked` condition is not used in the model." + line: + start: 6 + end: 6 + column: + start: 10 + end: 16 + metadata: + symbol: "marked" + errorType: condition-not-used +- name: a duplicate type is not reported when a relation reference is invalid + dsl: | + model + schema 1.1 + type user + type document + type document + type folder + relations + define viewer: [ghost] + expected_errors: + - msg: "`ghost` is not a valid type." + line: + start: 7 + end: 7 + column: + start: 20 + end: 25 + metadata: + symbol: "ghost" + errorType: invalid-type diff --git a/tests/data/json-validation-cases.yaml b/tests/data/json-validation-cases.yaml index 864ade38..df990693 100644 --- a/tests/data/json-validation-cases.yaml +++ b/tests/data/json-validation-cases.yaml @@ -191,3 +191,96 @@ metadata: symbol: "core.fga" errorType: multiple-modules-in-file + +- name: only the file with several modules is reported, even when a type name is invalid + json: | + { + "schema_version": "1.1", + "type_definitions": [ + { "type": "self", "metadata": { "module": "core", "source_info": { "file": "shared.fga" } } }, + { "type": "document", "metadata": { "module": "docs", "source_info": { "file": "shared.fga" } } }, + { "type": "user", "metadata": { "module": "users", "source_info": { "file": "users.fga" } } } + ] + } + expected_errors: + - msg: "a type cannot be named 'self' or 'this'." + metadata: + symbol: "self" + errorType: reserved-type-keywords + - msg: "file shared.fga would contain multiple module definitions (core, docs) when transforming to DSL. Only one module can be defined per file." + metadata: + symbol: "shared.fga" + errorType: multiple-modules-in-file + +# Duplicate checks look only at a relation's top-level operands, so repeats nested deeper are not reported. +- name: duplicate members in a nested union are not reported + json: | + { + "schema_version": "1.1", + "type_definitions": [ + { "type": "user" }, + { + "type": "doc", + "relations": { + "editor": { "this": {} }, + "viewer": { + "union": { + "child": [ + { "this": {} }, + { + "union": { + "child": [ + { "computedUserset": { "relation": "editor" } }, + { "computedUserset": { "relation": "editor" } } + ] + } + } + ] + } + } + }, + "metadata": { + "relations": { + "editor": { "directly_related_user_types": [{ "type": "user" }] }, + "viewer": { "directly_related_user_types": [{ "type": "user" }] } + } + } + } + ] + } + expected_errors: [] + +- name: a nested difference subtracting an operand from itself is not reported + json: | + { + "schema_version": "1.1", + "type_definitions": [ + { "type": "user" }, + { + "type": "doc", + "relations": { + "editor": { "this": {} }, + "viewer": { + "union": { + "child": [ + { "this": {} }, + { + "difference": { + "base": { "computedUserset": { "relation": "editor" } }, + "subtract": { "computedUserset": { "relation": "editor" } } + } + } + ] + } + } + }, + "metadata": { + "relations": { + "editor": { "directly_related_user_types": [{ "type": "user" }] }, + "viewer": { "directly_related_user_types": [{ "type": "user" }] } + } + } + } + ] + } + expected_errors: []