Repository navigation
feat(pkg/go): add graph-based validation entry points #684
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
SoulPancake
wants to merge
2
commits into
feat/converged-error-taxonomy
Choose a base branch
from
feat/graph-validation-findings
base: feat/converged-error-taxonomy
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| package validation | ||
|
|
||
| import ( | ||
| "slices" | ||
| "strings" | ||
|
|
||
| "github.com/openfga/language/pkg/go/graph" | ||
| ) | ||
|
|
||
| // validateWithGraph resolves entrypoints and cycles from the weighted graph | ||
| // rather than by walking the rewrite tree. | ||
| // | ||
| // It builds through the exported WeightedAuthorizationModelGraphBuilder rather | ||
| // than reimplementing the walk, so what validation reports and what anything | ||
| // else reading that graph concludes cannot drift apart the way two | ||
| // implementations of one question would. | ||
| // | ||
| // The graph reports in two ways and only one of them can carry a position. Build | ||
| // either returns a graph or refuses the model, and on refusal it returns no | ||
| // graph, so there is nothing left to enumerate: a model with three broken | ||
| // relations yields one finding naming none of them. Every rule therefore runs | ||
| // only on a model that built, and a refused model produces the single finding | ||
| // modelUnbuildable builds. | ||
| func validateWithGraph(idx *index, src source) error { | ||
| if idx == nil || idx.model == nil { | ||
| return nil | ||
| } | ||
|
|
||
| weighted, err := graph.NewWeightedAuthorizationModelGraphBuilder().Build(idx.model) | ||
| if err != nil { | ||
| return joinFindings(modelUnbuildable(err)) | ||
| } | ||
|
|
||
| scope := &graphScope{weighted: weighted, idx: idx, src: src} | ||
|
|
||
| var fs []*Finding | ||
| for _, rule := range graphRules { | ||
| fs = append(fs, rule.check(scope)...) | ||
| } | ||
|
|
||
| return joinFindings(fs...) | ||
| } | ||
|
|
||
| // graphRule is one check over a built graph. Every rule collects all of its hits | ||
| // rather than returning on the first, which is the whole reason these run | ||
| // outside pkg/go/graph. | ||
| type graphRule struct { | ||
| // id names the rule in tests and in failure output. It is not a wire value; | ||
| // what reaches a caller is the Kind the rule's findings carry. | ||
| id string | ||
| check func(*graphScope) []*Finding | ||
| } | ||
|
|
||
| // graphRules is the registry every rule joins. A new rule is one entry here plus | ||
| // one function, so rules written in parallel meet only on this line. | ||
| var graphRules = []graphRule{ | ||
| {id: "entrypoints", check: checkRelationEntrypoints}, | ||
| } | ||
|
|
||
| // graphScope is what a rule gets: the built graph, the indexed model behind it, | ||
| // and the source text a finding's position is stamped from. | ||
| type graphScope struct { | ||
| weighted *graph.WeightedAuthorizationModelGraph | ||
| idx *index | ||
| src source | ||
| } | ||
|
|
||
| // checkRelationEntrypoints reports relations that can never be satisfied. | ||
| // | ||
| // A relation node carries one weight per terminal type it can reach. Reaching | ||
| // none means no tuple can ever satisfy the relation, which is what the | ||
| // rewrite-tree traversal calls an impossible relation. Recursion alone does not | ||
| // empty the weights: a relation with a base case keeps its terminal types and | ||
| // takes weight Infinite, so `[user] or viewer from parent` is untouched here | ||
| // while `viewer from parent` alone is reported. | ||
| // | ||
| // Only SpecificTypeAndRelation nodes are considered. A type node has no weights | ||
| // either, because a type is what weights are counted to rather than from. | ||
| func checkRelationEntrypoints(scope *graphScope) []*Finding { | ||
| var fs []*Finding | ||
|
|
||
| for _, nodeID := range sortedNodeIDs(scope.weighted) { | ||
| node, ok := scope.weighted.GetNodeByID(nodeID) | ||
| if !ok || node.GetNodeType() != graph.SpecificTypeAndRelation { | ||
| continue | ||
| } | ||
|
|
||
| if len(node.GetWeights()) > 0 { | ||
| continue | ||
| } | ||
|
|
||
| definition, ok := relationForNode(scope.weighted, node) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| objectType, relation, ok := splitRelationLabel(definition) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| finding := noEntryPoint(relation, objectType) | ||
| if scope.reachesOnlyRewrites(nodeID) { | ||
| finding = noEntryPointLoop(relation, objectType) | ||
| } | ||
|
|
||
| // Stamp position and provenance through the same helpers the rewrite-tree | ||
| // phases use, so a graph finding and a traversal finding for the same | ||
| // relation land in the same place. | ||
| file, module := relationMeta(scope.idx.typeDef(objectType), relation) | ||
| line := scope.src.relationLine(relation, scope.src.typeLine(objectType)) | ||
|
|
||
| finding.at(scope.src, line) | ||
| finding.File, finding.Metadata.Module = file, module | ||
| fs = append(fs, finding) | ||
| } | ||
|
|
||
| return fs | ||
| } | ||
|
|
||
| // reachesOnlyRewrites reports whether every edge reachable from nodeID rewrites | ||
| // another relation, with no direct assignment or tupleset anywhere. | ||
| // | ||
| // That separates the two things the traversal words differently. `define viewer: | ||
| // viewer` closes on itself through a computed rewrite and nothing else, which it | ||
| // calls a potential loop. `define viewer: viewer from parent` reaches a tupleset | ||
| // it can never satisfy, which it calls a missing entrypoint. | ||
| func (s *graphScope) reachesOnlyRewrites(nodeID string) bool { | ||
| visited := map[string]bool{nodeID: true} | ||
| queue := []string{nodeID} | ||
| sawEdge := false | ||
|
|
||
| for len(queue) > 0 { | ||
| current := queue[0] | ||
| queue = queue[1:] | ||
|
|
||
| edges, _ := s.weighted.GetEdgesFromNodeID(current) | ||
| for _, edge := range edges { | ||
| sawEdge = true | ||
|
|
||
| if edge.GetEdgeType() != graph.ComputedEdge && edge.GetEdgeType() != graph.RewriteEdge { | ||
| // A direct, tupleset or grouping edge means something outside this | ||
| // relation feeds it, so the problem is a missing entrypoint. | ||
| return false | ||
| } | ||
|
|
||
| next := edge.GetTo().GetUniqueLabel() | ||
| if !visited[next] { | ||
| visited[next] = true | ||
| queue = append(queue, next) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return sawEdge | ||
| } | ||
|
|
||
| // splitRelationLabel splits a relation definition into its object type and | ||
| // relation, e.g. "document#viewer". | ||
| func splitRelationLabel(definition string) (objectType, relation string, ok bool) { | ||
| objectType, relation, found := strings.Cut(definition, "#") | ||
| if !found || objectType == "" || relation == "" { | ||
| return "", "", false | ||
| } | ||
|
|
||
| return objectType, relation, true | ||
| } | ||
|
|
||
| // relationForNode resolves the relation a node belongs to. | ||
| // | ||
| // A relation node is its own answer. An operator or logical node is not: its | ||
| // label holds the relation but also an operator and an index, and the grouping | ||
| // labels are built differently again. Rather than parse those forms, this reads | ||
| // the relation off an outgoing edge, which records the relation the edge was | ||
| // written for. Operator and logical nodes exist to group edges, so they always | ||
| // have one. | ||
| func relationForNode(weighted *graph.WeightedAuthorizationModelGraph, | ||
| node *graph.WeightedAuthorizationModelNode) (string, bool) { | ||
| switch node.GetNodeType() { | ||
| case graph.SpecificType, graph.SpecificTypeWildcard: | ||
| // A terminal node belongs to no relation. | ||
| return "", false | ||
| case graph.SpecificTypeAndRelation: | ||
| return node.GetUniqueLabel(), true | ||
| } | ||
|
|
||
| edges, _ := weighted.GetEdgesFromNodeID(node.GetUniqueLabel()) | ||
| for _, edge := range edges { | ||
| if definition := edge.GetRelationDefinition(); definition != "" { | ||
| return definition, true | ||
| } | ||
| } | ||
|
|
||
| return "", false | ||
| } | ||
|
|
||
| // sortedNodeIDs returns the graph's node IDs in a stable order, so the same | ||
| // model reports findings in the same order. The graph stores nodes in a map, | ||
| // which Go iterates randomly. | ||
| func sortedNodeIDs(weighted *graph.WeightedAuthorizationModelGraph) []string { | ||
| nodes := weighted.GetNodes() | ||
| ids := make([]string, 0, len(nodes)) | ||
|
|
||
| for id := range nodes { | ||
| ids = append(ids, id) | ||
| } | ||
|
|
||
| slices.Sort(ids) | ||
|
|
||
| return ids | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is already validated through the weight, or this is something different?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's something different, I think: the weights decide whether a relation is reported, and this only picks which of the two messages it gets.
define viewer: vieweranddefine viewer: viewer from parentlook the same in the weights, and only the edge type tells them apart. I'll make the comment clearer before moving it from draft to ready! Thanks