diff --git a/pkg/go/graph/graph.go b/pkg/go/graph/graph.go index 257b791f..8d9754bf 100644 --- a/pkg/go/graph/graph.go +++ b/pkg/go/graph/graph.go @@ -3,7 +3,6 @@ package graph import ( "errors" "fmt" - "gonum.org/v1/gonum/graph" "gonum.org/v1/gonum/graph/encoding" "gonum.org/v1/gonum/graph/encoding/dot" @@ -35,7 +34,7 @@ func (g *AuthorizationModelGraph) GetDrawingDirection() DrawingDirection { return g.drawingDirection } -// GetNodeByLabel provides O(1) access to a node. +// GetNodeByLabel provides O(1) access to a node. If the node doesn't exist, it returns ErrQueryingGraph. func (g *AuthorizationModelGraph) GetNodeByLabel(label string) (*AuthorizationModelNode, error) { id, ok := g.ids[label] if !ok { @@ -101,6 +100,22 @@ func (g *AuthorizationModelGraph) Reversed() (*AuthorizationModelGraph, error) { return nil, fmt.Errorf("%w: could not cast to directed graph", ErrBuildingGraph) } +// PathExists returns true if both nodes exist and there is a path starting at 'fromLabel' extending to 'toLabel'. +// If either node doesn't exist, it returns false and ErrQueryingGraph. +func (g *AuthorizationModelGraph) PathExists(fromLabel, toLabel string) (bool, error) { + fromNode, err := g.GetNodeByLabel(fromLabel) + if err != nil { + return false, err + } + + toNode, err := g.GetNodeByLabel(toLabel) + if err != nil { + return false, err + } + + return topo.PathExistsIn(g.DirectedGraph, fromNode, toNode), nil +} + var _ dot.Attributers = (*AuthorizationModelGraph)(nil) func (g *AuthorizationModelGraph) DOTAttributers() (encoding.Attributer, encoding.Attributer, encoding.Attributer) { diff --git a/pkg/go/graph/graph_test.go b/pkg/go/graph/graph_test.go index fff22d41..f8370de1 100644 --- a/pkg/go/graph/graph_test.go +++ b/pkg/go/graph/graph_test.go @@ -1,6 +1,7 @@ package graph import ( + "fmt" "strconv" "testing" @@ -231,3 +232,527 @@ func TestGetNodeTypes(t *testing.T) { require.Len(t, differenceNodes, 1) require.Len(t, intersectionNodes, 1) } + +func TestPathExists(t *testing.T) { + type pathTest struct { + fromLabel string + toLabel string + expectPath bool + expectedErr error + } + tests := []struct { + name string + model string + pathTests []pathTest + }{ + { + name: "userset_computed_userset", + model: ` +model + schema 1.1 +type other +type user +type wild +type employee +type group + relations + define rootMember: [user, user:*, employee, wild:*] + define member: rootMember +type folder + relations + define viewer: [group#member] +`, + pathTests: []pathTest{ + { + fromLabel: "user", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "foo", + toLabel: "group#member", + expectPath: false, + expectedErr: ErrQueryingGraph, + }, + { + fromLabel: "user", + toLabel: "group#undefined", + expectPath: false, + expectedErr: ErrQueryingGraph, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#member", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "group#member", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "group#rootMember", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "user", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "folder#viewer", + expectPath: false, + }, + }, + }, + { + name: "nested_computed_userset", + model: ` +model + schema 1.1 +type other +type user +type employee +type wild +type group + relations + define member: [user, user:*, employee, wild:*, group#member] +type folder + relations + define viewer: [group#member] +`, + pathTests: []pathTest{ + { + fromLabel: "user", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group#member", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "group#member", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "user", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "folder#viewer", + expectPath: false, + }, + }, + }, + { + name: "union_relation", + model: ` +model + schema 1.1 +type other +type user +type employee +type wild +type group + relations + define child1: [user, user:*] + define child2: [employee] + define child3: [wild:*] + define member: child1 or child2 or child3 +type folder + relations + define viewer: [group#member] +`, + pathTests: []pathTest{ + { + fromLabel: "user", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group", + toLabel: "group#member", + expectPath: false, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#member", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "group#member", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "user", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "folder#viewer", + expectPath: false, + }, + }, + }, + { + name: "intersection_relation", + model: ` +model + schema 1.1 +type other +type user +type employee +type wild +type group + relations + define child1: [user] + define child2: [user, employee, wild:*] + define member: child1 and child2 +type folder + relations + define viewer: [group#member] +`, + pathTests: []pathTest{ + { + fromLabel: "user", + toLabel: "group#member", + expectPath: true, + }, + { + // Ideally, we will reject employee because type needs to appear + // in both child for an intersection. For now, the graph + // package is not smart enough to handle intersection as a special case. + fromLabel: "employee", + toLabel: "group#member", + expectPath: true, + }, + { + // Ideally, we will reject wild because type needs to appear + // in both child for an intersection. For now, the graph + // package is not smart enough to handle intersection as a special case. + fromLabel: "wild:*", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group", + toLabel: "group#member", + expectPath: false, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#member", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "group#member", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "user", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "folder#viewer", + expectPath: false, + }, + }, + }, + { + name: "exclusion_relation", + model: ` +model + schema 1.1 +type other +type user +type employee +type group + relations + define child1: [user] + define child2: [user, employee] + define member: child1 but not child2 +type folder + relations + define viewer: [group#member] +`, + pathTests: []pathTest{ + { + fromLabel: "user", + toLabel: "group#member", + expectPath: true, + }, + { + // Ideally, we will reject employee because type needs to appear + // in both child for exclusion. For now, the graph + // package is not smart enough to handle exclusion as a special case. + fromLabel: "employee", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group", + toLabel: "group#member", + expectPath: false, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#member", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "group#member", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "user", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "folder#viewer", + expectPath: false, + }, + }, + }, + { + name: "ttu", + model: ` +model + schema 1.1 +type other +type user +type employee +type wild +type group + relations + define rootMember: [user, user:*, employee, wild:*] + define member: rootMember +type folder + relations + define parent: [group] + define viewer: member from parent +`, + pathTests: []pathTest{ + { + fromLabel: "user", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "group#member", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "group#member", + expectPath: false, + }, + { + fromLabel: "group", + toLabel: "group#member", + expectPath: false, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#member", + toLabel: "group#member", + expectPath: true, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#member", + toLabel: "folder#viewer", + expectPath: true, + }, + { + // TODO: ideally this should be false. However, for now, this + // will return true as there is some path. + fromLabel: "group#rootMember", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "user", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "employee", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "wild:*", + toLabel: "folder#viewer", + expectPath: true, + }, + { + fromLabel: "other", + toLabel: "folder#viewer", + expectPath: false, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + model := language.MustTransformDSLToProto(test.model) + graph, err := NewAuthorizationModelGraph(model) + require.NoError(t, err) + + for _, test := range test.pathTests { + t.Run(fmt.Sprintf("%s -> %s", test.fromLabel, test.toLabel), func(t *testing.T) { + actual, err := graph.PathExists(test.fromLabel, test.toLabel) + if test.expectedErr == nil { + require.NoError(t, err) + require.Equal(t, test.expectPath, actual) + } else { + require.ErrorIs(t, err, test.expectedErr) + } + }) + + } + }) + } +}