From 258501134dc52dc594b1255e58f04da5541f89d8 Mon Sep 17 00:00:00 2001 From: Justin Lindh Date: Mon, 17 Jan 2022 01:31:31 -0700 Subject: [PATCH] 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 --- ast/builtins.go | 21 ++ capabilities.json | 59 ++++++ docs/content/policy-reference.md | 26 ++- .../reachable/test-reachable-paths-0422.yaml | 190 ++++++++++++++++++ topdown/reachable.go | 73 +++++++ 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 test/cases/testdata/reachable/test-reachable-paths-0422.yaml diff --git a/ast/builtins.go b/ast/builtins.go index b6ecf00b1b..83fa6066e8 100644 --- a/ast/builtins.go +++ b/ast/builtins.go @@ -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 */ diff --git a/capabilities.json b/capabilities.json index 1e2b33a6d4..03339eb5e8 100644 --- a/capabilities.json +++ b/capabilities.json @@ -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": { diff --git a/docs/content/policy-reference.md b/docs/content/policy-reference.md index 426d343f26..2e603ec34f 100644 --- a/docs/content/policy-reference.md +++ b/docs/content/policy-reference.md @@ -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 | -| ------- |-------------|---------------| +| ------- |-------------|--------------| | ``walk(x, [path, value])`` | ``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). | ✅ | | ``output := graph.reachable(graph, initial)`` | ``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. | ✅ | +| ``output := graph.reachable_paths(graph, initial)`` | ``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 | diff --git a/test/cases/testdata/reachable/test-reachable-paths-0422.yaml b/test/cases/testdata/reachable/test-reachable-paths-0422.yaml new file mode 100644 index 0000000000..8e2d425122 --- /dev/null +++ b/test/cases/testdata/reachable/test-reachable-paths-0422.yaml @@ -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 diff --git a/topdown/reachable.go b/topdown/reachable.go index 6b73fa2c29..958ad59a99 100644 --- a/topdown/reachable.go +++ b/topdown/reachable.go @@ -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) }