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
4 changes: 4 additions & 0 deletions docs/validation/model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +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) |

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.

`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.

## Usage

Each error documentation includes:
Expand Down
165 changes: 165 additions & 0 deletions docs/validation/model/graph-model-unbuildable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Graph Model Unbuildable

**Error Code:** `graph-model-unbuildable`

**Category:** Semantic Validation

## Summary

The model could not be built into a weighted authorization model graph, so the checks
that read that graph had nothing to read. The message carries the reason the graph
builder gave.

## Description

Entrypoint and cycle validation can be answered two ways: by walking the model's
rewrite tree, or by building the same weighted graph the server builds and reading it.
The second route needs a graph, and the builder refuses to produce one for some
models. This code reports that refusal.

It is raised only when graph-backed validation is enabled, which is not the default.
With the default options the models below report
[`relation-no-entry-point`](./relation-no-entry-point.md) instead, one finding per
affected relation, each carrying a line and column.

The builder refuses a model for reasons of its own, and the message ends with the one
it gave:

- `model cycle`, when relations refer to each other in a loop that admits no
assignment
- `tuple cycle: ...`, when a cycle cannot be resolved, most often because it runs
through an intersection or an exclusion
- `invalid model: ...`, when the graph cannot be constructed from the definitions
given, with the specific reason following the colon

A finding with this code wraps two errors. `errors.Is` matches
`errors.ErrModelNotBuildable` for the refusal itself, and it also matches whichever
sentinel the graph package returned, so a caller can branch on the specific reason
without parsing the message:

```go
var findings *validation.ValidationErrors
if errors.As(err, &findings) {
for _, f := range findings.GetErrors() {
if errors.Is(f, fgaerrors.ErrModelNotBuildable) {
// the build was refused
}
if errors.Is(f, graph.ErrTupleCycle) {
// and this is why
}
}
}
```

## Example

The following model would trigger this error:

```
model
schema 1.1

type user

type document
relations
define admin: [user]
define viewer: admin and editor
define editor: viewer
```

**Error Message:** `the model cannot be built into a weighted graph: model cycle`

`viewer` requires `editor`, `editor` is `viewer`, and neither can be entered. The
builder stops there.

A cycle that runs through an exclusion is refused with a different reason:

```
model
schema 1.1

type user

type folder
relations
define parent: [folder]
define viewer: [user] but not banned
define banned: viewer from parent
```

**Error Message:** `the model cannot be built into a weighted graph: tuple cycle: operands AND or BUT NOT cannot be involved in a cycle`

## Resolution

The model is invalid, and the fix is the same fix the reason calls for. Read the
message after the colon and treat it as the finding.

For a cycle, break the loop, so that one relation in it stops depending on the rest.
Giving a relation in the loop a way to be entered is not enough on its own, because the
loop still resolves to itself:

```
model
schema 1.1

type user

type document
relations
define admin: [user]
define viewer: admin and editor
define editor: [user]
```

For a cycle through `and` or `but not`, take the cyclic relation out of the operand:

```
model
schema 1.1

type user

type folder
relations
define parent: [folder]
define banned: [user]
define viewer: [user] but not banned
```

### Steps to fix:

1. **Read the reason:** the text after the colon is the graph builder's own error, and
it names the class of problem.

2. **Find the relations involved:** this finding is model-level and carries no
position, because the builder stops at the first problem and returns no graph to
locate it in. Running validation with the default options reports the same model as
`relation-no-entry-point`, once per affected relation, with a line and column for
each.

3. **Fix the model, not the finding:** every reason the builder gives is a model that
cannot answer a check. There is no configuration that makes one of these models
valid.

4. **Re-validate:** a model the builder accepts still gets the graph-backed
entrypoint checks run over it, so a clean build is not on its own a clean model.

## Related Errors

- [`relation-no-entry-point`](./relation-no-entry-point.md) - what the default
validation path reports for these models, per relation and with positions
- [`cyclic-error`](./cyclic-error.md) - circular dependency between relations
- [`invalid-relation-type`](./invalid-relation-type.md) - a relation that is not valid
for the type it is used with

## Implementation Notes

This code is specific to the Go implementation's graph-backed validation path. The
JavaScript and Java validators walk the rewrite tree and have no equivalent.

- Go implementation: `pkg/go/validation/graph_validation.go`
- Graph builder: `pkg/go/graph/weighted_graph_builder.go`

Validation calls the exported builder rather than reimplementing it, so a model refused
here is refused for anything else that builds the same graph.
6 changes: 6 additions & 0 deletions pkg/go/errors/sentinels.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ var (
// module.
ErrMultipleModulesInFile = errors.New("file contains multiple modules")

// ErrModelNotBuildable is reported when a model cannot be built into a
// weighted graph. It says only that the build was refused; the reason is the
// error the builder returned, which a finding carries underneath this one, so
// errors.Is reaches either.
ErrModelNotBuildable = errors.New("model cannot be built into a weighted graph")

// ErrUnknownModelErrorKind is returned when a ModelErrorKind has no wire
// name, either marshalling a value this package does not declare or reading
// a name it does not recognise. It is not a validation finding.
Expand Down
1 change: 1 addition & 0 deletions pkg/go/validation/criticality_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func TestCriticalityOfEveryEmittedCode(t *testing.T) {
DuplicatedError: true,
InvalidSchema: true,
MultipleModulesInFile: true,
GraphModelUnbuildable: true,
}

// Nothing raises these two, so they are held at not-critical rather than listed
Expand Down
29 changes: 28 additions & 1 deletion pkg/go/validation/error_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ type scope struct {
// with different scopes: a duplicate type and a duplicate type restriction share
// one code without being the same kind of finding.
category fgaerrors.ModelErrorKind

// cause overrides the table's sentinel when set, for a finding that carries an
// error it did not raise itself. It is wrapped in the scoped type as any sentinel
// would be, so errors.Is still reaches whatever the original error wrapped.
cause error
}

// addError is a helper to add an error to the collection.
Expand Down Expand Up @@ -166,10 +171,15 @@ func (c *ErrorCollector) addScopedError(message string, errorType ValidationErro
category = errorScope.category
}

sentinel := entry.Cause
if errorScope.cause != nil {
sentinel = errorScope.cause
}

// The cause carries the scope and the metadata is derived from it, so the JSON
// and the errors.As payload cannot disagree. offendingType is metadata only, so
// it comes straight off the scope.
cause := newScopedCause(category, errorScope, entry.Cause)
cause := newScopedCause(category, errorScope, sentinel)
objectType, relation, condition := causeScope(cause)

metadata := &ErrorMetadata{
Expand Down Expand Up @@ -577,3 +587,20 @@ func (c *ErrorCollector) RaiseEmptyDifference(relationName, typeName, operation
relation: relationName,
})
}

// Graph validation error methods

// RaiseModelUnbuildable raises an error for a model the weighted graph refuses to build.
//
// The finding carries no position, and one refused model raises one finding however many
// problems it has. Both follow from the builder returning on the first problem it meets
// and returning no graph with it: there is nothing left to walk for the rest, and the
// error it returns names a count rather than the relations responsible.
//
// The builder's error is chained under ErrModelNotBuildable, so errors.Is matches the
// build being refused and the specific reason alike.
func (c *ErrorCollector) RaiseModelUnbuildable(cause error) {
message := fmt.Sprintf("the model cannot be built into a weighted graph: %s", cause)
chained := fmt.Errorf("%w: %w", fgaerrors.ErrModelNotBuildable, cause)
c.addScopedError(message, GraphModelUnbuildable, "", nil, nil, nil, scope{cause: chained})
}
12 changes: 12 additions & 0 deletions pkg/go/validation/error_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,17 @@ var errorInfoByType = map[ValidationErrorType]errorInfo{
Cause: fgaerrors.ErrMultipleModulesInFile,
Critical: true,
},

// Weighted graph. The raise site chains the error the builder returned underneath
// this sentinel, so a caller can match on the build being refused without knowing
// which of the graph's own sentinels fired. Critical, because a model the graph
// refuses has no graph, so nothing further can be reported about it.
GraphModelUnbuildable: {
Severity: fgaerrors.SeverityError,
Category: fgaerrors.ErrorKindInvalidModel,
Cause: fgaerrors.ErrModelNotBuildable,
Critical: true,
},
}

// unemittedErrorTypes are declared ValidationErrorType values that no validation
Expand Down Expand Up @@ -219,6 +230,7 @@ var allErrorTypes = []ValidationErrorType{
MultipleModulesInFile,
CyclicRelation,
InvalidSchemaVersion,
GraphModelUnbuildable,
}

// isCriticalErrorType reports whether a code invalidates the model as a whole.
Expand Down
3 changes: 3 additions & 0 deletions pkg/go/validation/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const (
MultipleModulesInFile ValidationErrorType = "multiple-modules-in-file"
CyclicRelation ValidationErrorType = "cyclic-relation"
InvalidSchemaVersion ValidationErrorType = "invalid-schema-version"

// Weighted graph errors.
GraphModelUnbuildable ValidationErrorType = "graph-model-unbuildable"
)

// Range is a start and end position in the source text, used for both the line and
Expand Down
Loading
Loading