Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions docs/validation/model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ OpenFGA model validation ensures that authorization models are syntactically cor
| `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) |
| `cyclic-relation` | Semantic | Relation takes part in a cycle that cannot be resolved | [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) |
Expand All @@ -46,16 +46,17 @@ OpenFGA model validation ensures that authorization models are syntactically cor
| `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 | Unrecognised schema version | [invalid-schema.md](./invalid-schema.md) |
| `invalid-syntax` | Syntax | Invalid DSL syntax | [invalid-syntax.md](./invalid-syntax.md) |
| `graph-model-unbuildable` | Semantic | Model cannot be built into a weighted graph | [graph-model-unbuildable.md](./graph-model-unbuildable.md) |
| `graph-model-unbuildable` | Semantic | Model cannot be built into a weighted graph, and no per-relation check accounts for it | [graph-model-unbuildable.md](./graph-model-unbuildable.md) |

Five of the codes above are declared but never emitted, so no validation output
carries them: `invalid-schema-version`, `self-error`, `invalid-syntax`, `cyclic-error`
and `cyclic-relation`. An unrecognised schema version reports `invalid-schema`, and a
cycle with no entrypoint reports `relation-no-entry-point`. Their pages are kept
because each is a published URL.
Four of the codes above are declared but never emitted, so no validation output
carries them: `invalid-schema-version`, `self-error`, `invalid-syntax` and
`cyclic-error`. An unrecognised schema version reports `invalid-schema`, and a cycle
with no entrypoint reports `relation-no-entry-point`. Their pages are kept because each
is a published URL.

`graph-model-unbuildable` is emitted only when graph-backed validation is enabled,
which is not the default. Every other code above is reported whatever the options.
`cyclic-relation` and `graph-model-unbuildable` are emitted only when graph-backed
validation is enabled, which is not the default. Every other code above is reported
whatever the options.

## Usage

Expand Down
171 changes: 120 additions & 51 deletions docs/validation/model/cyclic-relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,48 @@

## Summary

A circular dependency has been detected in relation definitions, creating an infinite loop that prevents proper authorization evaluation.
A relation takes part in a cycle the resolver cannot work through. The relation itself may
be perfectly satisfiable; it is reported for the cycle it belongs to.

## 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.
Not every cycle between relations is a problem. `define member: [user, group#member]` is
the nested-group pattern every deployment has, and it terminates because each step around
the loop reads a tuple, so the set of groups to look at shrinks until it is empty.

Circular dependencies can occur:
- **Direct cycles:** `A → B → A`
- **Indirect cycles:** `A → B → C → A`
- **Self-referential cycles:** `A → A`
Two shapes do not terminate, and this error reports the relations taking part in either.

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.
**The cycle reads no tuple.** Every step is a rewrite, so going round the loop consumes
nothing and gets no closer to an answer:

```
define a: [user] or b
define b: [user] or a
```

**A step of the cycle is an operand of an `and` or a `but not`.** The cycle does read a
tuple, so it terminates, but the resolver cannot subtract or intersect a set it is still in
the middle of computing:

```
define member: [user, group#member] but not blocked
define blocked: [user, group#member]
```

Unlike [`relation-no-entry-point`](./relation-no-entry-point.md), this error is not about a
relation that can never be satisfied. In both examples above every relation holds a plain
`[user]`, so every one of them has a way in. That is exactly why a separate code exists:
the entrypoint check has nothing to say about these models, and telling someone to give
`a` an entrypoint it already has would send them looking for the wrong thing.

`errors.Is` on a finding with this code matches `errors.ErrRelationInUnresolvableCycle`. It
does not match `errors.ErrNoEntrypoints`.

It is raised only when graph-backed validation is enabled, which is not the default.

## Example

The following model would trigger this error:
The following model reports this error:

```
model
Expand All @@ -33,18 +59,37 @@ type document
relations
define viewer: [user] or editor
define editor: admin
define admin: viewer # Creates cycle: viewer → editor → admin → viewer
define admin: viewer
```

**Error Location:** The cycle involves multiple relations forming a circular dependency.
**Error Message:** ``​`viewer` on `document` takes part in a cycle that cannot be resolved: no relation in it reads a tuple, so resolving it never terminates.``

One finding is reported per relation in the cycle, each with the line and column of its
`define`, so all three of `viewer`, `editor` and `admin` are reported here.

**Error Message:** `Cyclic relation dependency detected involving relations: viewer, editor, admin`
A cycle under an exclusion reports the other reason:

```
model
schema 1.1

type user

type group
relations
define member: [user, group#member] but not blocked
define blocked: [user, group#member]
```

**Error Message:** ``​`member` on `group` takes part in a cycle that cannot be resolved: a relation in it is an operand of an `and` or a `but not`.``

## Resolution

Break the circular dependency by removing or restructuring one of the relation references:
Break the cycle, or take it out of the operator.

### Option 1: Remove problematic reference
### Option 1: give the cycle a rewrite-free step

For a cycle that reads no tuple, the fix is to stop one relation depending on another:

```
model
Expand All @@ -55,11 +100,13 @@ type user
type document
relations
define viewer: [user] or editor
define editor: [user] # Remove reference to admin
define editor: [user]
define admin: [user] or editor
```

### Option 2: Restructure hierarchy
### Option 2: restructure into a hierarchy

Dependencies that all flow one way cannot close a loop:

```
model
Expand All @@ -70,57 +117,79 @@ 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)
define editor: [user] or viewer
define admin: [user] or editor
```

### Steps to fix:
### Option 3: move the recursion out of the operand

1. **Identify the cycle:**
- Review the error message to see which relations form the cycle
- Map out the dependency chain
For a cycle under an `and` or a `but not`, the recursion is what has to leave the operator.
Making the other side non-recursive is not enough: `member: [user, group#member] but not
blocked` is still reported even when `blocked` is a plain `[user]`, because `member`'s own
recursion is the operand. Give the recursion its own relation and apply the operator to
that:

2. **Analyze intended authorization hierarchy:**
- Determine the correct permission hierarchy
- Identify which direction relationships should flow
```
model
schema 1.1

3. **Break the cycle:**
- Remove one problematic reference
- Restructure to create a proper hierarchy
- Ensure the authorization logic still meets requirements
type user

4. **Validate the solution:**
- Check that all necessary permissions are still achievable
- Verify no new cycles are introduced
type group
relations
define member: [user, group#member]
define blocked: [user]
define visible: member but not blocked
```

## Common Authorization Patterns
The same applies to an intersection:

### ✅ Valid hierarchical structure:
```
define viewer: [user]
define editor: [user] or viewer
define admin: [user] or editor
define owner: [user] or admin
```
model
schema 1.1

### ❌ Invalid circular structure:
```
define viewer: editor
define editor: admin
define admin: viewer # Creates cycle
type user

type group
relations
define admin: [user]
define member: [user, group#member]
define approved: member and admin
```

### Steps to fix:

1. **Read which reason it gives:** the clause after the colon says whether the cycle reads
no tuple or sits under an operator. They call for different fixes.

2. **Use the positions:** every relation in the cycle is reported with its own line, so the
findings together are the cycle.

3. **Check the recursion, not just the other operand:** for the operator case, a relation
that refers to itself through a userset or a tupleset is a cycle on its own, and
enclosing it in an `and` or a `but not` is what makes it unresolvable.

4. **Re-validate:** breaking one cycle can leave another, and a relation can sit in more
than one.

## 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
- [`relation-no-entry-point`](./relation-no-entry-point.md) - a relation nothing can
satisfy, as against one that is satisfiable but caught in a cycle
- [`graph-model-unbuildable`](./graph-model-unbuildable.md) - a refused build that no
per-relation check could account for
- [`cyclic-error`](./cyclic-error.md) - declared but not raised; a cycle with no entry
point surfaces as `relation-no-entry-point`

## 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
This code is specific to the Go implementation's graph-backed validation path. The
JavaScript and Java validators walk the rewrite tree, which answers has-an-entry-point for
both shapes above and reports nothing, so they have no equivalent.

- Go implementation: `pkg/go/validation/cycle_shape.go`

The cycle detection uses depth-first search with visited node tracking to identify circular dependencies in the relation graph.
The weighted graph refuses both shapes, with `ErrModelCycle` and `ErrTupleCycle`, and names
no relation in either. The check reads the model to find the relations, and the graph stays
the authority on whether a model is resolvable: it may only report relations in a model the
builder refuses.
Loading
Loading