Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
1378 lines
39 KiB
Go
1378 lines
39 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package expand_test
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
|
|
|
|
"github.com/ory/keto/internal/driver"
|
|
"github.com/ory/keto/internal/driver/config"
|
|
"github.com/ory/keto/internal/expand"
|
|
"github.com/ory/keto/internal/namespace"
|
|
"github.com/ory/keto/internal/relationtuple"
|
|
"github.com/ory/keto/internal/testhelpers"
|
|
"github.com/ory/keto/internal/x"
|
|
"github.com/ory/keto/ketoapi"
|
|
"github.com/ory/keto/schema"
|
|
)
|
|
|
|
func TestEngine(t *testing.T) {
|
|
emptyNs := driver.WithNamespaces([]*namespace.Namespace{{}})
|
|
|
|
t.Run("case=returns SubjectID on expand", func(t *testing.T) {
|
|
groupObj := uuid.Must(uuid.NewV4())
|
|
tuple := &relationtuple.RelationTuple{
|
|
Namespace: "Group",
|
|
Object: groupObj,
|
|
Relation: "rel",
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
}
|
|
|
|
reg := driver.NewSqliteTestRegistry(t)
|
|
e := expand.NewEngine(reg)
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), tuple))
|
|
|
|
t.Run(`tuple does not exist, but ns:obj exists`, func(t *testing.T) {
|
|
tree, err := e.BuildTree(context.Background(), &relationtuple.SubjectSet{
|
|
Namespace: "Group",
|
|
Object: groupObj,
|
|
Relation: "rel2",
|
|
}, 100)
|
|
require.NoError(t, err)
|
|
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Namespace: "Group",
|
|
Object: groupObj,
|
|
Relation: "rel2",
|
|
},
|
|
Children: nil,
|
|
}
|
|
assert.Equal(t, expectedTree, tree)
|
|
})
|
|
|
|
t.Run(`no such ns:obj exists at all`, func(t *testing.T) {
|
|
tree, err := e.BuildTree(context.Background(), &relationtuple.SubjectSet{
|
|
Namespace: "Folder",
|
|
Object: groupObj,
|
|
Relation: "rel",
|
|
}, 100)
|
|
require.NoError(t, err)
|
|
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Namespace: "Folder",
|
|
Object: groupObj,
|
|
Relation: "rel",
|
|
},
|
|
Children: nil,
|
|
}
|
|
assert.Equal(t, expectedTree, tree)
|
|
})
|
|
})
|
|
|
|
t.Run("case=expands one level", func(t *testing.T) {
|
|
tommy := &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())}
|
|
paul := &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())}
|
|
boulderGroup := uuid.Must(uuid.NewV4())
|
|
bouldererUserSet := &relationtuple.SubjectSet{
|
|
Relation: "member",
|
|
Object: boulderGroup,
|
|
}
|
|
boulderers := []*relationtuple.RelationTuple{
|
|
{
|
|
Relation: bouldererUserSet.Relation,
|
|
Object: boulderGroup,
|
|
Subject: tommy,
|
|
},
|
|
{
|
|
Relation: bouldererUserSet.Relation,
|
|
Object: boulderGroup,
|
|
Subject: paul,
|
|
},
|
|
}
|
|
reg := driver.NewSqliteTestRegistry(t, emptyNs)
|
|
e := expand.NewEngine(reg)
|
|
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), boulderers...))
|
|
|
|
tree, err := e.BuildTree(context.Background(), bouldererUserSet, 100)
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, expand.AssertInternalTreesAreEqual(t, &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: bouldererUserSet,
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: paul,
|
|
},
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: tommy,
|
|
},
|
|
},
|
|
}, tree))
|
|
})
|
|
|
|
t.Run("case=expands two levels", func(t *testing.T) {
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: uuid.Must(uuid.NewV4()),
|
|
Relation: "transitive member",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: uuid.Must(uuid.NewV4()),
|
|
Relation: "member",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
},
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
},
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: uuid.Must(uuid.NewV4()),
|
|
Relation: "member",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
},
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
},
|
|
{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: uuid.Must(uuid.NewV4())},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
reg := driver.NewSqliteTestRegistry(t, emptyNs)
|
|
e := expand.NewEngine(reg)
|
|
|
|
for _, group := range expectedTree.Children {
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &relationtuple.RelationTuple{
|
|
Object: expectedTree.Subject.(*relationtuple.SubjectSet).Object,
|
|
Relation: "transitive member",
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: group.Subject.(*relationtuple.SubjectSet).Object,
|
|
Relation: "member",
|
|
},
|
|
}))
|
|
|
|
for _, user := range group.Children {
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &relationtuple.RelationTuple{
|
|
Object: group.Subject.(*relationtuple.SubjectSet).Object,
|
|
Relation: "member",
|
|
Subject: user.Subject.(*relationtuple.SubjectID),
|
|
}))
|
|
}
|
|
}
|
|
|
|
actualTree, err := e.BuildTree(context.Background(), expectedTree.Subject.(*relationtuple.SubjectSet), 100)
|
|
require.NoError(t, err)
|
|
require.True(t, expand.AssertInternalTreesAreEqual(t, expectedTree, actualTree))
|
|
})
|
|
|
|
t.Run("case=respects max depth", func(t *testing.T) {
|
|
reg := driver.NewSqliteTestRegistry(t, emptyNs)
|
|
e := expand.NewEngine(reg)
|
|
|
|
ids := x.UUIDs(5)
|
|
for i := 1; i < len(ids); i++ {
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &relationtuple.RelationTuple{
|
|
Object: ids[i-1],
|
|
Relation: "child",
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: ids[i],
|
|
Relation: "child",
|
|
},
|
|
}))
|
|
}
|
|
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: ids[0],
|
|
Relation: "child",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: ids[1],
|
|
Relation: "child",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: ids[2],
|
|
Relation: "child",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: ids[3],
|
|
Relation: "child",
|
|
},
|
|
Truncation: &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonDepthLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindDirect,
|
|
SubjectSet: &relationtuple.SubjectSet{
|
|
Object: ids[3],
|
|
Relation: "child",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
actualTree, err := e.BuildTree(context.Background(), expectedTree.Subject.(*relationtuple.SubjectSet), 4)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, expectedTree, actualTree)
|
|
})
|
|
|
|
t.Run("case=paginates", func(t *testing.T) {
|
|
innerReg := driver.NewSqliteTestRegistry(t, emptyNs)
|
|
reg, mw := testhelpers.RegistryWithManagerWrapper(t, innerReg, keysetpagination.WithSize(2))
|
|
|
|
e := expand.NewEngine(reg)
|
|
root := uuid.Must(uuid.NewV4())
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{Object: root, Relation: "access"},
|
|
}
|
|
|
|
for _, user := range x.UUIDs(4) {
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &relationtuple.RelationTuple{
|
|
Object: root,
|
|
Relation: "access",
|
|
Subject: &relationtuple.SubjectID{ID: user},
|
|
}))
|
|
expectedTree.Children = append(expectedTree.Children, &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeLeaf,
|
|
Subject: &relationtuple.SubjectID{ID: user},
|
|
})
|
|
}
|
|
|
|
tree, err := e.BuildTree(context.Background(), &relationtuple.SubjectSet{
|
|
Object: root,
|
|
Relation: "access",
|
|
}, 10)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, expand.AssertInternalTreesAreEqual(t, expectedTree, tree))
|
|
assert.Len(t, mw.RequestedPages, 2)
|
|
})
|
|
|
|
t.Run("case=handles subject sets as leaf", func(t *testing.T) {
|
|
reg := driver.NewSqliteTestRegistry(t, emptyNs)
|
|
e := expand.NewEngine(reg)
|
|
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: uuid.Must(uuid.NewV4()),
|
|
Relation: "rel",
|
|
},
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: &relationtuple.SubjectSet{
|
|
Object: uuid.Must(uuid.NewV4()),
|
|
Relation: "sr",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), &relationtuple.RelationTuple{
|
|
Object: expectedTree.Subject.(*relationtuple.SubjectSet).Object,
|
|
Relation: expectedTree.Subject.(*relationtuple.SubjectSet).Relation,
|
|
Subject: expectedTree.Children[0].Subject,
|
|
}))
|
|
|
|
tree, err := e.BuildTree(context.Background(), expectedTree.Subject.(*relationtuple.SubjectSet), 100)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedTree, tree)
|
|
})
|
|
|
|
t.Run("case=circular tuples", func(t *testing.T) {
|
|
sendlingerTor, odeonsplatz, centralStation, connected, namesp := uuid.NewV5(uuid.Nil, "Sendlinger Tor"), uuid.NewV5(uuid.Nil, "Odeonsplatz"), uuid.NewV5(uuid.Nil, "Central Station"), "connected", "92384"
|
|
|
|
sendlingerTorSS, odeonsplatzSS, centralStationSS := &relationtuple.SubjectSet{
|
|
Namespace: namesp,
|
|
Object: sendlingerTor,
|
|
Relation: connected,
|
|
}, &relationtuple.SubjectSet{
|
|
Namespace: namesp,
|
|
Object: odeonsplatz,
|
|
Relation: connected,
|
|
}, &relationtuple.SubjectSet{
|
|
Namespace: namesp,
|
|
Object: centralStation,
|
|
Relation: connected,
|
|
}
|
|
|
|
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces([]*namespace.Namespace{{Name: namesp}}))
|
|
e := expand.NewEngine(reg)
|
|
|
|
expectedTree := &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: sendlingerTorSS,
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: odeonsplatzSS,
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: centralStationSS,
|
|
Children: []*relationtuple.Tree{
|
|
{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: sendlingerTorSS,
|
|
Truncation: &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonCycle,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(context.Background(), []*relationtuple.RelationTuple{
|
|
{
|
|
Namespace: namesp,
|
|
Object: sendlingerTor,
|
|
Relation: connected,
|
|
Subject: odeonsplatzSS,
|
|
},
|
|
{
|
|
Namespace: namesp,
|
|
Object: odeonsplatz,
|
|
Relation: connected,
|
|
Subject: centralStationSS,
|
|
},
|
|
{
|
|
Namespace: namesp,
|
|
Object: centralStation,
|
|
Relation: connected,
|
|
Subject: sendlingerTorSS,
|
|
},
|
|
}...))
|
|
|
|
tree, err := e.BuildTree(context.Background(), &relationtuple.SubjectSet{
|
|
Namespace: namesp,
|
|
Object: sendlingerTor,
|
|
Relation: connected,
|
|
}, 100)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedTree, tree)
|
|
})
|
|
|
|
t.Run("case=returns result on unknown subject", func(t *testing.T) {
|
|
reg := driver.NewSqliteTestRegistry(t, emptyNs)
|
|
e := expand.NewEngine(reg)
|
|
ss := &relationtuple.SubjectSet{
|
|
Namespace: "unknown",
|
|
Object: uuid.Must(uuid.NewV4()),
|
|
Relation: "rel",
|
|
}
|
|
tree, err := e.BuildTree(context.Background(), ss, 100)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, &relationtuple.Tree{
|
|
Type: ketoapi.TreeNodeUnion,
|
|
Subject: ss,
|
|
}, tree)
|
|
})
|
|
}
|
|
|
|
func TestEngineOpl(t *testing.T) {
|
|
type expandTest struct {
|
|
name string
|
|
opl string
|
|
inputTuples []string
|
|
expandInput string
|
|
expected *relationtuple.Tree
|
|
strict bool
|
|
maxNodes int
|
|
only bool
|
|
}
|
|
tests := []expandTest{
|
|
{
|
|
name: "case=Multile levels of union and exclusion",
|
|
strict: true,
|
|
opl: `
|
|
class User implements Namespace {}
|
|
class Corporate implements Namespace {
|
|
related: {
|
|
releasers: User[]
|
|
}
|
|
}
|
|
class UserGroup implements Namespace {
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class AccountGroup implements Namespace {
|
|
related: {
|
|
managers: SubjectSet<UserGroup, "members">[]
|
|
}
|
|
}
|
|
class Account implements Namespace {
|
|
related: {
|
|
managers: SubjectSet<AccountGroup, "managers">[]
|
|
}
|
|
}
|
|
class Task implements Namespace {
|
|
related: {
|
|
managers: (SubjectSet<Account, "managers"> | SubjectSet<Corporate, "releasers">)[]
|
|
|
|
blocklist: SubjectSet<UserGroup, "members">[]
|
|
}
|
|
permits = {
|
|
viewer: (ctx: Context) => this.related.managers.includes(ctx.subject) && !this.related.blocklist.includes(ctx.subject)
|
|
}
|
|
}
|
|
`,
|
|
|
|
inputTuples: []string{
|
|
"UserGroup:UG1#members@User:1",
|
|
"UserGroup:UG1#members@User:2",
|
|
"UserGroup:UG2#members@User:2",
|
|
"UserGroup:UG2#members@User:3",
|
|
"UserGroup:UG2#members@User:4",
|
|
"UserGroup:UG3#members@User:4",
|
|
"UserGroup:UG3#members@User:5",
|
|
|
|
"AccountGroup:AG1#managers@UserGroup:UG1#members",
|
|
"AccountGroup:AG1#managers@UserGroup:UG2#members",
|
|
"AccountGroup:AG2#managers@UserGroup:UG3#members",
|
|
|
|
"Account:A1#managers@AccountGroup:AG1#managers",
|
|
"Account:A1#managers@AccountGroup:AG2#managers",
|
|
|
|
"Task:T1#managers@Account:A1#managers",
|
|
|
|
// thru corporate
|
|
"Task:T1#managers@Corporate:C1#releasers",
|
|
|
|
// blocklist
|
|
"Task:T1#blocklist@UserGroup:UG-1#members",
|
|
"UserGroup:UG-1#members@User:5",
|
|
},
|
|
expandInput: "Task:T1#viewer",
|
|
maxNodes: 20,
|
|
expected: treeNode(t, intersection, "Task:T1#viewer", nil,
|
|
treeNode(t, union, "Task:T1#managers", nil,
|
|
treeNode(t, union, "Account:A1#managers", nil,
|
|
treeNode(t, union, "AccountGroup:AG1#managers", nil,
|
|
treeNode(t, union, "UserGroup:UG1#members", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
),
|
|
treeNode(t, union, "UserGroup:UG2#members", nil,
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
),
|
|
treeNode(t, union, "AccountGroup:AG2#managers", nil,
|
|
treeNode(t, union, "UserGroup:UG3#members", nil,
|
|
treeNode(t, leaf, "User:4", nil),
|
|
treeNode(t, leaf, "User:5", nil),
|
|
),
|
|
),
|
|
),
|
|
treeNode(t, union, "Corporate:C1#releasers", nil),
|
|
),
|
|
treeNode(t, exclusion, "", nil,
|
|
treeNode(t, union, "Task:T1#blocklist", nil,
|
|
treeNode(t, union, "UserGroup:UG-1#members", nil,
|
|
treeNode(t, leaf, "User:5", nil),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands 1 level without OPL",
|
|
opl: "",
|
|
inputTuples: []string{
|
|
"Group:parent#users@User:1",
|
|
"Group:parent#users@User:2",
|
|
"Group:parent#users@User:3",
|
|
"Group:parent#users@User4",
|
|
"Group:parent#users@ApiKey:x",
|
|
},
|
|
expandInput: "Group:parent#users",
|
|
expected: treeNode(t, union, "Group:parent#users", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
treeNode(t, leaf, "User4", nil),
|
|
treeNode(t, leaf, "ApiKey:x", nil),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands simple ComputedSubjectSet level with OPL",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.viewers.includes(ctx.subject)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands simple Inverted ComputedSubjectSet level with OPL",
|
|
strict: true,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => !this.related.viewers.includes(ctx.subject)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, exclusion, "", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands blocklistexample",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
blocklist: User[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) && !this.related.blocklist.includes(ctx.subject)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#blocklist@User:1",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, intersection, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
),
|
|
treeNode(t, exclusion, "", nil,
|
|
treeNode(t, union, "File:f1#blocklist", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands 2 blocklists example",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
blocklist1: User[]
|
|
blocklist2: User[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) =>
|
|
this.related.viewers.includes(ctx.subject) &&
|
|
!(
|
|
this.related.blocklist1.includes(ctx.subject) &&
|
|
this.related.blocklist2.includes(ctx.subject)
|
|
)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#blocklist1@User:1",
|
|
"File:f1#blocklist2@User:2",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, intersection, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
),
|
|
treeNode(t, exclusion, "", nil,
|
|
treeNode(t, intersection, "", nil,
|
|
treeNode(t, union, "File:f1#blocklist2", nil,
|
|
treeNode(t, leaf, "User:2", nil),
|
|
),
|
|
treeNode(t, union, "File:f1#blocklist1", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands (includes or traverse)",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
viewerGroups: Group[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
|
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject))
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
|
|
"Group:g1#members@User:4",
|
|
"File:f1#viewerGroups@Group:g1",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
treeNode(t, union, "File:f1#viewerGroups", nil,
|
|
treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands (includes or traverse) - traverse group has no member tuples",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
viewerGroups: Group[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
|
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject))
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
|
|
"File:f1#viewerGroups@Group:g1",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
treeNode(t, union, "File:f1#viewerGroups", nil,
|
|
treeNode(t, union, "Group:g1#members", nil),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands (includes and traverse) - traverse has no tuples",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
viewerGroups: Group[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) &&
|
|
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject))
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, intersection, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
treeNode(t, union, "File:f1#viewerGroups", nil),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands multiple child permits",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
viewerGroups: Group[]
|
|
}
|
|
permits = {
|
|
viewUser: (ctx: Context) => this.related.viewers.includes(ctx.subject),
|
|
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
|
|
|
view: (ctx: Context) => this.permits.viewUser(ctx) && this.permits.viewGroup(ctx)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
|
|
"Group:g1#members@User:4",
|
|
"File:f1#viewerGroups@Group:g1",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, intersection, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewUser", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
treeNode(t, union, "File:f1#viewGroup", nil,
|
|
treeNode(t, union, "File:f1#viewerGroups", nil,
|
|
treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands compound relations",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
editors: User[]
|
|
owners: User[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) =>
|
|
(
|
|
this.related.viewers.includes(ctx.subject) ||
|
|
this.related.editors.includes(ctx.subject)
|
|
) &&
|
|
this.related.owners.includes(ctx.subject)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#editors@User:2",
|
|
"File:f1#editors@User:3",
|
|
"File:f1#owners@User:3",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, intersection, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#owners", nil,
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
treeNode(t, union, "", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
),
|
|
treeNode(t, union, "File:f1#editors", nil,
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands repeated child permit calls",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
viewerGroups: Group[]
|
|
}
|
|
permits = {
|
|
viewUser: (ctx: Context) => this.related.viewers.includes(ctx.subject),
|
|
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
|
|
|
view: (ctx: Context) => (
|
|
( this.permits.viewUser(ctx) && this.permits.viewGroup(ctx) )
|
|
||
|
|
( this.permits.viewUser(ctx) || this.permits.viewGroup(ctx) )
|
|
)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
|
|
"Group:g1#members@User:4",
|
|
"File:f1#viewerGroups@Group:g1",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, intersection, "", nil,
|
|
treeNode(t, union, "File:f1#viewUser", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
treeNode(t, union, "File:f1#viewGroup", nil,
|
|
treeNode(t, union, "File:f1#viewerGroups", nil,
|
|
treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
treeNode(t, union, "File:f1#viewUser", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
treeNode(t, union, "File:f1#viewGroup", nil,
|
|
treeNode(t, union, "File:f1#viewerGroups", nil,
|
|
treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=expands recursive groups should detect circular references",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
parent: Group[]
|
|
}
|
|
permits = {
|
|
isMember: (ctx: Context) => this.related.members.includes(ctx.subject) ||
|
|
this.related.parent.traverse(g => g.permits.isMember(ctx))
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"Group:g1#members@User:4",
|
|
"Group:g1#parent@Group:g2",
|
|
"Group:g2#members@User:5",
|
|
"Group:g2#parent@Group:g1",
|
|
},
|
|
expandInput: "Group:g1#isMember",
|
|
expected: treeNode(t, union, "Group:g1#isMember", nil,
|
|
treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
treeNode(t, union, "Group:g1#parent", nil,
|
|
treeNode(t, union, "Group:g2#isMember", nil,
|
|
treeNode(t, union, "Group:g2#members", nil,
|
|
treeNode(t, leaf, "User:5", nil),
|
|
),
|
|
treeNode(t, union, "Group:g2#parent", nil,
|
|
treeNode(t, union, "Group:g1#isMember", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonCycle,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
// node 1.= Group:g2#members
|
|
// node 2 = Group:g3#members
|
|
// rest is truncated.
|
|
name: "case=node-limit is counted correctly for SubjectSet relationships",
|
|
maxNodes: 2,
|
|
inputTuples: []string{
|
|
"Group:g1#members@Group:g2#members",
|
|
"Group:g2#members@Group:g3#members",
|
|
"Group:g3#members@User:1",
|
|
},
|
|
expandInput: "Group:g1#members",
|
|
expected: treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, union, "Group:g2#members", nil,
|
|
treeNode(t, union, "Group:g3#members", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindDirect,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "Group:g3#members"),
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=stops at empty SubjectSet",
|
|
inputTuples: []string{
|
|
"Group:g1#members@Group:g2#members",
|
|
},
|
|
expandInput: "Group:g1#members",
|
|
expected: treeNode(t, union, "Group:g1#members", nil,
|
|
treeNode(t, union, "Group:g2#members", nil),
|
|
),
|
|
},
|
|
{
|
|
name: "case=node limit does not block OPL structural traversal when there's data",
|
|
maxNodes: 3,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
viewerGroups: Group[]
|
|
}
|
|
permits = {
|
|
viewUser: (ctx: Context) => this.related.viewers.includes(ctx.subject),
|
|
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
|
view: (ctx: Context) => this.permits.viewUser(ctx) || this.permits.viewGroup(ctx)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#viewers@User:1",
|
|
"File:f1#viewers@User:2",
|
|
"File:f1#viewers@User:3",
|
|
|
|
"File:f1#viewerGroups@Group:g1",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#viewUser", nil,
|
|
treeNode(t, union, "File:f1#viewers", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
treeNode(t, leaf, "User:3", nil),
|
|
),
|
|
),
|
|
treeNode(t, union, "File:f1#viewGroup", nil,
|
|
treeNode(t, union, "File:f1#viewerGroups", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindTTU,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "File:f1#viewerGroups"),
|
|
TraverseRelation: new("members"),
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=unknown 2nd relationship on strict mode",
|
|
strict: true,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
groupViewers: SubjectSet<Group, "viewers">[]
|
|
groups: Group[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.groupViewers.includes(ctx.subject) ||
|
|
|| this.related.groups.traverse(g => g.related.viewers.includes(ctx.subject))
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"Group:g1#editors@User:1", // "editor" relationship does not exist on Group
|
|
"Group:g2#editors@User:2",
|
|
|
|
"File:f1#groupViewers@Group:g1#readers", // "reader" relationship does not exist on Group
|
|
"File:f1#groups@Group:g2",
|
|
},
|
|
expandInput: "File:f1#view",
|
|
expected: treeNode(t, union, "File:f1#view", nil,
|
|
treeNode(t, union, "File:f1#groupViewers", nil),
|
|
treeNode(t, union, "File:f1#groups", nil,
|
|
treeNode(t, union, "Group:g2#viewers", nil),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "case=unknown relationship on strict mode",
|
|
maxNodes: 3,
|
|
strict: true,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#undefined@User:1",
|
|
},
|
|
expandInput: "File:f1#undefined",
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "case=unknown namespace on strict mode",
|
|
maxNodes: 3,
|
|
strict: true,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#undefined@User:1",
|
|
},
|
|
expandInput: "File:f1#undefined",
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "case=unknown relationship on non-strict mode",
|
|
maxNodes: 3,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"File:f1#undefined@User:1",
|
|
},
|
|
expandInput: "File:f1#undefined",
|
|
expected: treeNode(t, union, "File:f1#undefined", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
),
|
|
},
|
|
{
|
|
name: "case=unknown namespace on non-strict mode",
|
|
maxNodes: 3,
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class File implements Namespace{
|
|
related: {
|
|
viewers: User[]
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{},
|
|
expandInput: "X:f1#undefined",
|
|
expected: treeNode(t, union, "X:f1#undefined", nil),
|
|
},
|
|
{
|
|
name: "case=limit should be applied and prevent nesting",
|
|
strict: true,
|
|
opl: `
|
|
class File implements Namespace{}
|
|
class Folder implements Namespace{
|
|
related: {
|
|
members: (File | SubjectSet<Folder, "members">)[]
|
|
}
|
|
}
|
|
`,
|
|
// the 1st digit refers to the depth, 2nd digit refers to the position in that depth.
|
|
inputTuples: []string{
|
|
"Folder:11#members@Folder:21#members",
|
|
"Folder:11#members@Folder:22#members",
|
|
|
|
"Folder:21#members@Folder:31#members",
|
|
"Folder:21#members@Folder:32#members",
|
|
"Folder:21#members@Folder:33#members",
|
|
|
|
// Folder:22 has no members
|
|
|
|
"Folder:31#members@File:41", "Folder:31#members@File:42",
|
|
"Folder:32#members@File:43", "Folder:32#members@File:44",
|
|
},
|
|
maxNodes: 4,
|
|
expandInput: "Folder:11#members",
|
|
expected: treeNode(t, union, "Folder:11#members", nil,
|
|
treeNode(t, union, "Folder:21#members", nil,
|
|
treeNode(t, union, "Folder:31#members", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindDirect,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "Folder:31#members"),
|
|
},
|
|
}),
|
|
treeNode(t, union, "Folder:32#members", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindDirect,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "Folder:32#members"),
|
|
},
|
|
}),
|
|
treeNode(t, union, "Folder:33#members", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindDirect,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "Folder:33#members"),
|
|
},
|
|
}),
|
|
),
|
|
treeNode(t, union, "Folder:22#members", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindDirect,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "Folder:22#members"),
|
|
},
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "case=direct relations should be prioritized over rewrites",
|
|
opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: User[]
|
|
parent: Group[]
|
|
hiddenMembers: User[]
|
|
}
|
|
permits = {
|
|
isMember: (ctx: Context) => this.related.members.includes(ctx.subject)
|
|
|| this.related.parent.traverse(p => p.permits.isMember(ctx))
|
|
|| this.related.hiddenMembers.includes(ctx.subject)
|
|
}
|
|
}
|
|
`,
|
|
inputTuples: []string{
|
|
"Group:Sales#parent@Group:All",
|
|
|
|
"Group:Sales#members@User:1",
|
|
"Group:Sales#members@User:2",
|
|
|
|
"Group:Sales#hiddenMembers@User:3",
|
|
"Group:Sales#hiddenMembers@User:4",
|
|
|
|
"Group:All#members@User:5",
|
|
"Group:All#hiddenMembers@User:6",
|
|
},
|
|
maxNodes: 4,
|
|
expandInput: "Group:Sales#isMember",
|
|
expected: treeNode(t, union, "Group:Sales#isMember", nil,
|
|
treeNode(t, union, "Group:Sales#members", nil,
|
|
treeNode(t, leaf, "User:1", nil),
|
|
treeNode(t, leaf, "User:2", nil),
|
|
),
|
|
treeNode(t, union, "Group:Sales#hiddenMembers", nil,
|
|
treeNode(t, leaf, "User:3", nil),
|
|
treeNode(t, leaf, "User:4", nil),
|
|
),
|
|
treeNode(t, union, "Group:Sales#parent", &relationtuple.Truncation{
|
|
Reason: relationtuple.TruncationReasonTupleLimit,
|
|
Cursor: &relationtuple.ExpandCursor{
|
|
Kind: relationtuple.ExpandCursorKindTTU,
|
|
SubjectSet: testhelpers.SubjectSetFromString(t, "Group:Sales#parent"),
|
|
TraverseRelation: new("isMember"),
|
|
},
|
|
}),
|
|
),
|
|
},
|
|
}
|
|
|
|
t.Run("loop tests", func(t *testing.T) {
|
|
focused := slices.ContainsFunc(tests, func(tt expandTest) bool { return tt.only })
|
|
for _, tt := range tests {
|
|
if focused && !tt.only {
|
|
continue
|
|
}
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.opl != "" {
|
|
_, errs := schema.Parse(tt.opl)
|
|
require.Empty(t, errs)
|
|
}
|
|
opts := []driver.TestRegistryOption{
|
|
driver.WithConfig(config.KeyFeatureFlagExpandRewrites, true),
|
|
driver.WithConfig(config.KeyLimitMaxReadDepth, 50),
|
|
}
|
|
|
|
if tt.opl != "" {
|
|
opts = append(opts, driver.WithOPL(tt.opl))
|
|
} else {
|
|
opts = append(opts, driver.WithConfig(config.KeyNamespaces, []*namespace.Namespace{}))
|
|
}
|
|
if tt.maxNodes > 0 {
|
|
opts = append(opts, driver.WithConfig(config.KeyLimitExpandMaxSize, tt.maxNodes))
|
|
}
|
|
if tt.strict {
|
|
opts = append(opts, driver.WithConfig(config.KeyFeatureFlagStrictMode, tt.strict))
|
|
}
|
|
|
|
reg := driver.NewSqliteTestRegistry(t, opts...)
|
|
|
|
e := expand.NewEngine(reg)
|
|
|
|
ctx := context.Background()
|
|
testhelpers.MapAndInsertTuplesFromString(t, reg, tt.inputTuples)
|
|
|
|
tree, err := e.BuildTree(ctx, testhelpers.SubjectSetFromString(t, tt.expandInput), 100)
|
|
require.NoError(t, err)
|
|
|
|
expand.RequireInternalTreesAreEqual(t, tt.expected, tree)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
var (
|
|
union = ketoapi.TreeNodeUnion
|
|
leaf = ketoapi.TreeNodeLeaf
|
|
intersection = ketoapi.TreeNodeIntersection
|
|
exclusion = ketoapi.TreeNodeExclusion
|
|
)
|
|
|
|
// treeNode builds a *relationtuple.Tree for use in test expectations.
|
|
// Pass subject="" to leave Subject nil (needed for Exclusion/Intersection
|
|
// nodes that have no subject). Omit children to leave Children nil.
|
|
func treeNode(t testing.TB, nodeType ketoapi.TreeNodeType, subject string, truncation *relationtuple.Truncation, children ...*relationtuple.Tree) *relationtuple.Tree {
|
|
t.Helper()
|
|
node := &relationtuple.Tree{
|
|
Type: nodeType,
|
|
Truncation: truncation,
|
|
Children: children,
|
|
}
|
|
if subject != "" {
|
|
node.Subject = testhelpers.SubjectFromString(t, subject)
|
|
}
|
|
if len(children) == 0 {
|
|
node.Children = nil
|
|
}
|
|
return node
|
|
}
|