mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
built-ins: add graph.reachable_paths (#4205)
This new built-in functionality allows callers to find all reachable paths in a graph based on an array or set of root nodes. See the updates to policy-reference.md for more details and usage information. Signed-off-by: Justin Lindh <justin.lindh@webfilings.com>
This commit is contained in:
@@ -211,6 +211,7 @@ var DefaultBuiltins = [...]*Builtin{
|
||||
// Graphs
|
||||
WalkBuiltin,
|
||||
ReachableBuiltin,
|
||||
ReachablePathsBuiltin,
|
||||
|
||||
// Sort
|
||||
Sort,
|
||||
@@ -2007,6 +2008,26 @@ var ReachableBuiltin = &Builtin{
|
||||
),
|
||||
}
|
||||
|
||||
// ReachablePathsBuiltin computes the set of reachable paths in the graph from a set
|
||||
// of starting nodes.
|
||||
var ReachablePathsBuiltin = &Builtin{
|
||||
Name: "graph.reachable_paths",
|
||||
Decl: types.NewFunction(
|
||||
types.Args(
|
||||
types.NewObject(
|
||||
nil,
|
||||
types.NewDynamicProperty(
|
||||
types.A,
|
||||
types.NewAny(
|
||||
types.NewSet(types.A),
|
||||
types.NewArray(nil, types.A)),
|
||||
)),
|
||||
types.NewAny(types.NewSet(types.A), types.NewArray(nil, types.A)),
|
||||
),
|
||||
types.NewSet(types.NewArray(nil, types.A)),
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorting
|
||||
*/
|
||||
|
||||
@@ -979,6 +979,65 @@
|
||||
"type": "function"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graph.reachable_paths",
|
||||
"decl": {
|
||||
"args": [
|
||||
{
|
||||
"dynamic": {
|
||||
"key": {
|
||||
"type": "any"
|
||||
},
|
||||
"value": {
|
||||
"of": [
|
||||
{
|
||||
"dynamic": {
|
||||
"type": "any"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"of": {
|
||||
"type": "any"
|
||||
},
|
||||
"type": "set"
|
||||
}
|
||||
],
|
||||
"type": "any"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"of": [
|
||||
{
|
||||
"dynamic": {
|
||||
"type": "any"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"of": {
|
||||
"type": "any"
|
||||
},
|
||||
"type": "set"
|
||||
}
|
||||
],
|
||||
"type": "any"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"of": {
|
||||
"dynamic": {
|
||||
"type": "any"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": "set"
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gt",
|
||||
"decl": {
|
||||
|
||||
@@ -842,9 +842,10 @@ Note that the opa executable will need access to the timezone files in the envir
|
||||
### Graphs
|
||||
|
||||
| Built-in | Description | Wasm Support |
|
||||
| ------- |-------------|---------------|
|
||||
| ------- |-------------|--------------|
|
||||
| <span class="opa-keep-it-together">``walk(x, [path, value])``</span> | ``walk`` is a relation that produces ``path`` and ``value`` pairs for documents under ``x``. ``path`` is ``array`` representing a pointer to ``value`` in ``x``. Queries can use ``walk`` to traverse documents nested under ``x`` (recursively). | ✅ |
|
||||
| <span class="opa-keep-it-together">``output := graph.reachable(graph, initial)``</span> | ``output`` is the set of vertices [reachable](https://en.wikipedia.org/wiki/Reachability) from the ``initial`` vertices in the directed ``graph``. ``initial`` is a set or array of vertices, and ``graph`` is an object containing a set or array of neighboring vertices. | ✅ |
|
||||
| <span class="opa-keep-it-together">``output := graph.reachable_paths(graph, initial)``</span> | ``output`` is the set of arrays of paths reachable from the ``initial`` vertices in the directed ``graph``. ``initial`` is a set or array of paths, and ``graph`` is an object containing a set or array of root vertices. | `SDK-dependent` |
|
||||
|
||||
A common class of recursive rules can be reduced to a graph reachability
|
||||
problem, so `graph.reachable` is useful for more than just graph analysis.
|
||||
@@ -878,6 +879,29 @@ org_chart_permissions[entity_name]
|
||||
```live:graph/reachable/example:output
|
||||
```
|
||||
|
||||
It may be useful to find all reachable paths from a root element. `graph.reachable_paths` can be used for this. Note that cyclical paths will terminate on the repeated node. If an element references a nonexistent element, the path will be terminated, and excludes the nonexistent node.
|
||||
|
||||
```live:graph/reachable_paths/example:module
|
||||
package graph_reachable_paths_example
|
||||
|
||||
path_data = {
|
||||
"aTop": [],
|
||||
"cMiddle": ["aTop"],
|
||||
"bBottom": ["cMiddle"],
|
||||
"dIgnored": []
|
||||
}
|
||||
|
||||
all_paths[root] = paths {
|
||||
path_data[root]
|
||||
paths := graph.reachable_paths(path_data, {root})
|
||||
}
|
||||
```
|
||||
```live:graph/reachable_paths/example:query
|
||||
all_paths[entity_name]
|
||||
```
|
||||
```live:graph/reachable_paths/example:output
|
||||
```
|
||||
|
||||
### HTTP
|
||||
|
||||
| Built-in | Description | Wasm Support |
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
cases:
|
||||
- data: {}
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths({}, {"a"}, result)
|
||||
}
|
||||
note: reachable_paths/empty
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x: []
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": {"b"},
|
||||
"b": {"c"},
|
||||
"c": {"a"},
|
||||
},
|
||||
"initial": {"a"}
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/cycle
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x:
|
||||
- - a
|
||||
- b
|
||||
- c
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": {"b", "c"},
|
||||
"b": {"d"},
|
||||
"c": {"d"},
|
||||
"d": set(),
|
||||
"e": {"f"},
|
||||
"f": {"e"},
|
||||
"x": {"x"},
|
||||
},
|
||||
"initial": {
|
||||
"b", "e"
|
||||
}
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/components
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x:
|
||||
- - b
|
||||
- d
|
||||
- - e
|
||||
- f
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": ["b"],
|
||||
"b": ["c"],
|
||||
"c": ["a"],
|
||||
},
|
||||
"initial": ["a"]
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/arrays
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x:
|
||||
- - a
|
||||
- b
|
||||
- c
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": 1,
|
||||
"initial": [1]
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/malformed 1
|
||||
query: data.reachable.p = x
|
||||
want_error_code: eval_type_error
|
||||
strict_error: true
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": null
|
||||
},
|
||||
"initial": ["a"]
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/malformed 2
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x:
|
||||
- - a
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": []
|
||||
},
|
||||
"initial": "a"
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/malformed 3
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x: []
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": ["b", "c"],
|
||||
"b": ["c"],
|
||||
"c": [],
|
||||
},
|
||||
"initial": {"a"}
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/multiple_paths
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x:
|
||||
- - a
|
||||
- b
|
||||
- c
|
||||
- - a
|
||||
- c
|
||||
|
||||
- data: {}
|
||||
input_term: '{
|
||||
"graph": {
|
||||
"a": ["b"],
|
||||
"b": ["nonexistent"],
|
||||
},
|
||||
"initial": {"a", "b"}
|
||||
}'
|
||||
modules:
|
||||
- |
|
||||
package reachable
|
||||
|
||||
p = result {
|
||||
graph.reachable_paths(input.graph, input.initial, result)
|
||||
}
|
||||
note: reachable_paths/invalid_end
|
||||
query: data.reachable.p = x
|
||||
want_result:
|
||||
- x:
|
||||
- - a
|
||||
- b
|
||||
- - b
|
||||
@@ -6,6 +6,7 @@ package topdown
|
||||
|
||||
import (
|
||||
"github.com/open-policy-agent/opa/ast"
|
||||
"github.com/open-policy-agent/opa/topdown/builtins"
|
||||
)
|
||||
|
||||
// Helper: sets of vertices can be represented as Arrays or Sets.
|
||||
@@ -18,6 +19,18 @@ func foreachVertex(collection *ast.Term, f func(*ast.Term)) {
|
||||
}
|
||||
}
|
||||
|
||||
// numberOfEdges returns the number of elements of an array or a set (of edges)
|
||||
func numberOfEdges(collection *ast.Term) int {
|
||||
switch v := collection.Value.(type) {
|
||||
case ast.Set:
|
||||
return v.Len()
|
||||
case *ast.Array:
|
||||
return v.Len()
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func builtinReachable(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term) error) error {
|
||||
// Return the empty set if the first argument is not an object.
|
||||
graph, ok := args[0].Value.(ast.Object)
|
||||
@@ -56,6 +69,66 @@ func builtinReachable(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term
|
||||
return iter(ast.NewTerm(reached))
|
||||
}
|
||||
|
||||
// pathBuilder is called recursively to build an array of paths that are reachable from the root
|
||||
func pathBuilder(graph ast.Object, root *ast.Term, path []*ast.Term, paths []*ast.Term, reached ast.Set) []*ast.Term {
|
||||
if edges := graph.Get(root); edges != nil {
|
||||
path = append(path, root)
|
||||
|
||||
if numberOfEdges(edges) >= 1 {
|
||||
foreachVertex(edges, func(neighbor *ast.Term) {
|
||||
if reached.Contains(neighbor) {
|
||||
// If we've already reached this node, return current path (avoid infinite recursion)
|
||||
paths = append(paths, path...)
|
||||
} else {
|
||||
reached.Add(root)
|
||||
paths = pathBuilder(graph, neighbor, path, paths, reached)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
paths = append(paths, path...)
|
||||
}
|
||||
} else {
|
||||
// Node is nonexistent (not in graph). Commit the current path (without adding this root)
|
||||
paths = append(paths, path...)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
func builtinReachablePaths(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term) error) error {
|
||||
// Return an error if the first argument is not an object.
|
||||
graph, err := builtins.ObjectOperand(args[0].Value, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// This is a queue that holds all nodes we still need to visit. It is
|
||||
// initialised to the initial set of nodes we start out with.
|
||||
var queue []*ast.Term
|
||||
foreachVertex(args[1], func(t *ast.Term) {
|
||||
queue = append(queue, t)
|
||||
})
|
||||
|
||||
results := ast.NewSet()
|
||||
|
||||
for _, node := range queue {
|
||||
// Find reachable paths from edges in root node in queue and append arrays to the results set
|
||||
if edges := graph.Get(node); edges != nil {
|
||||
if numberOfEdges(edges) >= 1 {
|
||||
foreachVertex(edges, func(neighbor *ast.Term) {
|
||||
paths := pathBuilder(graph, neighbor, []*ast.Term{node}, []*ast.Term{}, ast.NewSet(node))
|
||||
results.Add(ast.ArrayTerm(paths...))
|
||||
})
|
||||
} else {
|
||||
results.Add(ast.ArrayTerm(node))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter(ast.NewTerm(results))
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterBuiltinFunc(ast.ReachableBuiltin.Name, builtinReachable)
|
||||
RegisterBuiltinFunc(ast.ReachablePathsBuiltin.Name, builtinReachablePaths)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user