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
846 lines
28 KiB
Go
846 lines
28 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package check_test
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/ory/herodot"
|
|
|
|
"github.com/ory/keto/internal/check"
|
|
"github.com/ory/keto/internal/check/step"
|
|
"github.com/ory/keto/internal/driver"
|
|
"github.com/ory/keto/internal/driver/config"
|
|
"github.com/ory/keto/internal/testhelpers"
|
|
"github.com/ory/keto/internal/x/dbx"
|
|
"github.com/ory/keto/ketoapi"
|
|
)
|
|
|
|
// ExpansionRecursive is a three-level group hierarchy where engineering branches
|
|
// into two sibling sub-groups. Safe to run with WithStrict(true).
|
|
//
|
|
// company ⊃ engineering ⊃ (frontend, backend)
|
|
// company ⊃ User:cto
|
|
// engineering ⊃ User:manager
|
|
// backend ⊃ User:Alice, Alice
|
|
// frontend ⊃ User:Bob, Bob
|
|
var ExpansionRecursive = testhelpers.Scenario{
|
|
Name: "Expansion Recursive",
|
|
Opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
members: (User | SubjectSet<Group, "members">)[]
|
|
}
|
|
}
|
|
`,
|
|
InputTuples: []string{
|
|
"Group:company#members@Group:engineering#members",
|
|
|
|
"Group:company#members@User:cto",
|
|
"Group:engineering#members@User:manager",
|
|
|
|
"Group:engineering#members@Group:frontend#members",
|
|
"Group:engineering#members@Group:backend#members",
|
|
|
|
"Group:backend#members@User:Alice",
|
|
"Group:frontend#members@User:Bob",
|
|
|
|
"Group:backend#members@Alice", // SubjectID
|
|
"Group:frontend#members@Bob", // SubjectID
|
|
},
|
|
}
|
|
|
|
var ExpansionRecursiveNamespaceOnly = testhelpers.Scenario{
|
|
Name: ExpansionRecursive.Name + " (namespaces only)",
|
|
Opl: `class User implements Namespace{} class Group implements Namespace{}`,
|
|
InputTuples: ExpansionRecursive.InputTuples,
|
|
}
|
|
|
|
// SubjectSetExpansion3Hop combines cross-namespace and within-namespace SubjectSet expansion.
|
|
//
|
|
// File:team-report#viewers ⊃ File:team-report#editors ⊃ Group:finance#member → (User:Alice, User:Bob).
|
|
// File:personal-note#viewers ⊃ User:Alice, User:Bob
|
|
var SubjectSetExpansion3Hop = testhelpers.Scenario{
|
|
Name: "SubjectSet Expansion 3 Hops",
|
|
Opl: `
|
|
class User implements Namespace{}
|
|
class Group implements Namespace{
|
|
related: {
|
|
member: User[]
|
|
}
|
|
}
|
|
class File implements Namespace{
|
|
related: {
|
|
editors: (User | SubjectSet<Group, "member">)[]
|
|
viewers: (User | SubjectSet<File, "editors">)[]
|
|
}
|
|
}
|
|
`,
|
|
InputTuples: []string{
|
|
"Group:finance#member@User:Alice",
|
|
"Group:finance#member@User:Bob",
|
|
|
|
"File:team-report#editors@Group:finance#member", // group members are editors
|
|
"File:team-report#viewers@File:team-report#editors", // editors are also viewers
|
|
|
|
"File:personal-note#viewers@User:Alice",
|
|
"File:personal-note#viewers@User:Bob",
|
|
},
|
|
}
|
|
|
|
var SubjectSetExpansion3HopNamespacesOnly = testhelpers.Scenario{
|
|
Name: SubjectSetExpansion3Hop.Name + " (namespaces only)",
|
|
Opl: `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`,
|
|
InputTuples: SubjectSetExpansion3Hop.InputTuples,
|
|
}
|
|
|
|
// CircularGraphNamespacesOnly covers a fully circular SubjectSet graph with no leaf subjects.
|
|
//
|
|
// A → B → C → A.
|
|
var CircularGraphNamespacesOnly = testhelpers.Scenario{
|
|
Name: "Circular Graph (namespaces only)",
|
|
Opl: `
|
|
class Folder implements Namespace{}
|
|
`,
|
|
InputTuples: []string{
|
|
"Folder:a#parent@Folder:b#parent",
|
|
"Folder:b#parent@Folder:c#parent",
|
|
"Folder:c#parent@Folder:a#parent",
|
|
},
|
|
}
|
|
|
|
// FileWithNonConformingTuples covers a schema where a client incorrectly stores
|
|
// tuples directly under permit names (canEdit, canView) instead of the backing
|
|
// related fields (editors, viewers).
|
|
//
|
|
// In strict mode the engine ignores those tuples and resolves access only via
|
|
// OPL rewrites. In non-strict mode the direct tuples are visible alongside the
|
|
// rewrites.
|
|
var FileWithNonConformingTuples = testhelpers.Scenario{
|
|
Name: "File With Non-Conforming Tuples",
|
|
Opl: `
|
|
class User implements Namespace {}
|
|
class File implements Namespace {
|
|
related: {
|
|
editors: User[]
|
|
viewers: User[]
|
|
}
|
|
permits = {
|
|
canEdit: (ctx: Context) => this.related.editors.includes(ctx.subject),
|
|
canView: (ctx: Context) => this.permits.canEdit(ctx) || this.related.viewers.includes(ctx.subject),
|
|
}
|
|
}
|
|
`,
|
|
InputTuples: []string{
|
|
"File:secret-doc#editors@User:Alice",
|
|
"File:secret-doc#viewers@User:Bob",
|
|
|
|
// Bad client data: permit names used as direct relation targets.
|
|
"File:secret-doc#canEdit@User:Eve",
|
|
"File:secret-doc#canEdit@Eve", // subjectID
|
|
},
|
|
}
|
|
|
|
// NotionPage models a simplified Notion-like workspace with nested pages.
|
|
//
|
|
// canView requires direct or parent access.
|
|
// canEdit requires direct editor access AND workspace membership.
|
|
var NotionPage = testhelpers.Scenario{
|
|
Name: "Notion Page",
|
|
Opl: `
|
|
class User implements Namespace {}
|
|
class Workspace implements Namespace {
|
|
related: {
|
|
member: User[]
|
|
}
|
|
}
|
|
class Page implements Namespace {
|
|
related: {
|
|
workspace: Workspace[]
|
|
parent: Page[]
|
|
viewer: User[]
|
|
editor: User[]
|
|
}
|
|
permits = {
|
|
canView: (ctx: Context) =>
|
|
this.related.viewer.includes(ctx.subject) ||
|
|
this.related.editor.includes(ctx.subject) ||
|
|
this.related.parent.traverse(p => p.permits.canView(ctx)),
|
|
canEdit: (ctx: Context) =>
|
|
this.related.editor.includes(ctx.subject) &&
|
|
this.related.workspace.traverse(w => w.related.member.includes(ctx.subject)),
|
|
}
|
|
}
|
|
`,
|
|
InputTuples: []string{
|
|
"Workspace:acme#member@User:Alice",
|
|
"Workspace:acme#member@User:Bob",
|
|
|
|
"Page:home#workspace@Workspace:acme",
|
|
"Page:home#viewer@User:Alice",
|
|
"Page:home#editor@User:Bob",
|
|
"Page:home#editor@User:Eve", // Eve is not workspace member
|
|
|
|
"Page:intro#workspace@Workspace:acme",
|
|
"Page:intro#parent@Page:home", // intro inherits canView from home
|
|
"Page:intro#editor@User:Alice",
|
|
},
|
|
}
|
|
|
|
var testcases = []struct {
|
|
scenarios []testhelpers.Scenario
|
|
expectedMembers []string
|
|
expectedNonMembers []string
|
|
}{
|
|
{
|
|
scenarios: []testhelpers.Scenario{ExpansionRecursive, ExpansionRecursiveNamespaceOnly},
|
|
expectedMembers: []string{
|
|
// direct: SubjectSet and SubjectID members
|
|
"Group:backend#members@User:Alice", "Group:backend#members@Alice",
|
|
"Group:frontend#members@User:Bob", "Group:frontend#members@Bob",
|
|
|
|
// engineering: own direct member plus frontend and backend
|
|
"Group:engineering#members@User:manager",
|
|
"Group:engineering#members@User:Alice", "Group:engineering#members@Alice",
|
|
"Group:engineering#members@User:Bob", "Group:engineering#members@Bob",
|
|
|
|
// company: own direct member plus everything from engineering
|
|
"Group:company#members@User:cto",
|
|
"Group:company#members@User:manager",
|
|
"Group:company#members@User:Alice", "Group:company#members@Alice",
|
|
"Group:company#members@User:Bob", "Group:company#members@Bob",
|
|
},
|
|
expectedNonMembers: []string{
|
|
// frontend and backend members do not cross.
|
|
"Group:frontend#members@User:Alice",
|
|
"Group:backend#members@User:Bob",
|
|
|
|
// Membership flows upward only.
|
|
"Group:backend#members@User:manager", "Group:backend#members@User:cto",
|
|
"Group:frontend#members@User:manager", "Group:frontend#members@User:cto",
|
|
"Group:engineering#members@User:cto",
|
|
},
|
|
},
|
|
{
|
|
scenarios: []testhelpers.Scenario{SubjectSetExpansion3Hop, SubjectSetExpansion3HopNamespacesOnly},
|
|
expectedMembers: []string{
|
|
// direct
|
|
"Group:finance#member@User:Alice", "Group:finance#member@User:Bob",
|
|
|
|
// via group expansion
|
|
"File:team-report#editors@User:Alice", "File:team-report#editors@User:Bob",
|
|
|
|
// via editors are viewers
|
|
"File:team-report#viewers@User:Alice", "File:team-report#viewers@User:Bob",
|
|
|
|
// direct viewers on personal note
|
|
"File:personal-note#viewers@User:Alice", "File:personal-note#viewers@User:Bob",
|
|
},
|
|
expectedNonMembers: []string{
|
|
// File:personal-note is missing subject-set relationship;
|
|
// Therefore editors are not viewers;
|
|
"File:personal-note#editors@User:Alice",
|
|
"File:personal-note#editors@User:Bob",
|
|
},
|
|
},
|
|
{
|
|
scenarios: []testhelpers.Scenario{CircularGraphNamespacesOnly},
|
|
expectedMembers: []string{
|
|
"Folder:a#parent@Folder:a#parent",
|
|
"Folder:a#parent@Folder:b#parent",
|
|
"Folder:a#parent@Folder:c#parent",
|
|
|
|
"Folder:b#parent@Folder:a#parent",
|
|
"Folder:b#parent@Folder:b#parent",
|
|
"Folder:b#parent@Folder:c#parent",
|
|
|
|
"Folder:c#parent@Folder:a#parent",
|
|
"Folder:c#parent@Folder:b#parent",
|
|
"Folder:c#parent@Folder:c#parent",
|
|
},
|
|
},
|
|
{
|
|
scenarios: []testhelpers.Scenario{{
|
|
Name: "rejects transitive relation",
|
|
Opl: `
|
|
class User implements Namespace{}
|
|
class Directory implements Namespace{}
|
|
class File implements Namespace{}
|
|
`,
|
|
InputTuples: []string{
|
|
"File:report#parent@Directory:docs",
|
|
"Directory:docs#access@User:Alice",
|
|
},
|
|
}},
|
|
expectedNonMembers: []string{
|
|
"File:report#access@User:Alice",
|
|
},
|
|
},
|
|
{
|
|
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples.WithStrict(true)},
|
|
expectedMembers: []string{
|
|
"File:secret-doc#viewers@User:Bob",
|
|
"File:secret-doc#editors@User:Alice",
|
|
|
|
// via OPL
|
|
"File:secret-doc#canView@User:Bob",
|
|
"File:secret-doc#canEdit@User:Alice",
|
|
"File:secret-doc#canView@User:Alice",
|
|
},
|
|
expectedNonMembers: []string{
|
|
"File:secret-doc#canEdit@User:Eve",
|
|
"File:secret-doc#canView@User:Eve",
|
|
|
|
"File:secret-doc#canEdit@Eve",
|
|
"File:secret-doc#canView@Eve",
|
|
},
|
|
},
|
|
{
|
|
scenarios: []testhelpers.Scenario{NotionPage},
|
|
expectedMembers: []string{
|
|
// direct viewer/editor on home
|
|
"Page:home#canView@User:Alice",
|
|
"Page:home#canView@User:Bob", // editor also satisfies canView
|
|
"Page:home#canView@User:Eve", // stale editor tuple still satisfies canView (no workspace check)
|
|
// canEdit requires editor AND workspace member
|
|
"Page:home#canEdit@User:Bob",
|
|
// intro: direct editor + workspace member
|
|
"Page:intro#canEdit@User:Alice",
|
|
// intro canView via TTU: intro → parent → home → Alice/Bob/Eve
|
|
"Page:intro#canView@User:Alice",
|
|
"Page:intro#canView@User:Bob",
|
|
"Page:intro#canView@User:Eve",
|
|
},
|
|
expectedNonMembers: []string{
|
|
// Eve has editor tuple but is not a workspace member — AND fails
|
|
"Page:home#canEdit@User:Eve",
|
|
// Bob is editor of home, not intro — canEdit requires explicit editor tuple
|
|
"Page:intro#canEdit@User:Bob",
|
|
// Alice is only a viewer on home, not an editor
|
|
"Page:home#canEdit@User:Alice",
|
|
},
|
|
},
|
|
{
|
|
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples},
|
|
expectedMembers: []string{
|
|
"File:secret-doc#viewers@User:Bob",
|
|
"File:secret-doc#editors@User:Alice",
|
|
|
|
// via OPL
|
|
"File:secret-doc#canView@User:Bob",
|
|
"File:secret-doc#canEdit@User:Alice",
|
|
"File:secret-doc#canView@User:Alice",
|
|
|
|
// canEdit is a direct tuple, visible in non-strict mode
|
|
"File:secret-doc#canEdit@User:Eve",
|
|
// canView is visible via OPL rewrite in non-strict mode
|
|
"File:secret-doc#canView@User:Eve",
|
|
},
|
|
expectedNonMembers: []string{},
|
|
},
|
|
}
|
|
|
|
func TestEngine(t *testing.T) {
|
|
t.Run(`membership tests`, func(t *testing.T) {
|
|
for _, tc := range testcases {
|
|
for _, scenario := range tc.scenarios {
|
|
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
|
e := check.NewEngine(reg)
|
|
ctx := t.Context()
|
|
|
|
for _, tuple := range tc.expectedMembers {
|
|
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
|
|
require.NoError(t, err)
|
|
assert.Truef(t, res, "expected %q to be a member", tuple)
|
|
}
|
|
|
|
for _, tuple := range tc.expectedNonMembers {
|
|
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
|
|
require.NoError(t, err)
|
|
assert.Falsef(t, res, "expected %q to not be a member", tuple)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("respects max-depth", func(t *testing.T) {
|
|
ExpansionRecursive.Run(t, func(t *testing.T, reg driver.Registry) {
|
|
e := check.NewEngine(reg)
|
|
ctx := t.Context()
|
|
|
|
// company → engineering → backend → User:Alice requires exactly 2 hops.
|
|
tuple := testhelpers.TupleFromString(t, "Group:company#members@User:Alice")
|
|
|
|
assert.Equal(t, 5, reg.Config(ctx).MaxReadDepth())
|
|
|
|
res, err := e.CheckIsMember(ctx, tuple, 1)
|
|
require.NoError(t, err)
|
|
assert.False(t, res, "depth=1 is not enough for a 2-hop chain")
|
|
|
|
res, err = e.CheckIsMember(ctx, tuple, 2)
|
|
require.NoError(t, err)
|
|
assert.True(t, res, "depth=2 is enough for a 2-hop chain")
|
|
|
|
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 1))
|
|
res, err = e.CheckIsMember(ctx, tuple, 5)
|
|
require.NoError(t, err)
|
|
assert.False(t, res, "global max-depth=1 overrides request depth=5")
|
|
|
|
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 3))
|
|
res, err = e.CheckIsMember(ctx, tuple, 0)
|
|
require.NoError(t, err)
|
|
assert.True(t, res, "global max-depth=3 is enough")
|
|
})
|
|
})
|
|
|
|
t.Run("batch check", func(t *testing.T) {
|
|
SubjectSetExpansion3Hop.Run(t, func(t *testing.T, reg driver.Registry) {
|
|
e := check.NewEngine(reg)
|
|
ctx := t.Context()
|
|
|
|
targetTuples := []*ketoapi.RelationTuple{
|
|
testhelpers.APITupleFromString(t, "Group:finance#member@User:Alice"), // direct
|
|
testhelpers.APITupleFromString(t, "File:team-report#editors@User:Alice"), // 1-hop
|
|
testhelpers.APITupleFromString(t, "File:team-report#viewers@User:Alice"), // 2-hop
|
|
testhelpers.APITupleFromString(t, "NonExistent:x#rel@User:Alice"), // non-existent namespace
|
|
testhelpers.APITupleFromString(t, "Group:finance#member@User:Eve"), // unknown subject
|
|
testhelpers.APITupleFromString(t, "File:team-report#viewers@File:team-report#editors"), // via subject set
|
|
}
|
|
|
|
// At depth=1, the 2-hop viewers chain is not resolved.
|
|
results, err := e.BatchCheck(ctx, targetTuples, 1)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, check.IsMember, results[0].Membership)
|
|
require.Equal(t, check.IsMember, results[1].Membership)
|
|
require.Equal(t, check.MembershipUnknown, results[2].Membership)
|
|
require.Equal(t, check.LimitationMaxDepthExceeded, results[2].Limitation)
|
|
require.Equal(t, check.MembershipUnknown, results[3].Membership)
|
|
require.EqualError(t, results[3].Err, herodot.ErrNotFound().Error())
|
|
require.Equal(t, check.MembershipUnknown, results[4].Membership)
|
|
require.Equal(t, check.LimitationMaxDepthExceeded, results[4].Limitation)
|
|
require.Equal(t, check.IsMember, results[5].Membership)
|
|
|
|
// At depth=2, the 2-hop viewers chain resolves
|
|
results, err = e.BatchCheck(ctx, targetTuples, 3)
|
|
require.NoError(t, err)
|
|
require.Equal(t, check.IsMember, results[2].Membership)
|
|
})
|
|
})
|
|
}
|
|
|
|
// widthLimitScenario fans out Resource:doc#readers into three groups.
|
|
// With width=1 only Group:alpha is explored; the rest are truncated.
|
|
var widthLimitScenario = testhelpers.Scenario{
|
|
Name: "Width Limit",
|
|
OrderedShardID: true,
|
|
Opl: `
|
|
class User implements Namespace {}
|
|
class Group implements Namespace {
|
|
related: {
|
|
member: (User | SubjectSet<Group, "member">)[]
|
|
}
|
|
}
|
|
class Resource implements Namespace {
|
|
related: {
|
|
readers: (User | SubjectSet<Group, "member">)[]
|
|
}
|
|
}
|
|
`,
|
|
// Resource:doc#readers
|
|
// ├── Group:alpha#member ──► Group:alpha-sub#member ──► User:Alice (explored first at width=1)
|
|
// ├── Group:beta#member ──► Group:beta-sub#member ──► User:Bob (truncated at width=1)
|
|
// └── Group:gamma#member ──► Group:gamma-sub#member (truncated at width=1)
|
|
InputTuples: []string{
|
|
// Insertion order determines DB return order for expand
|
|
"Resource:doc#readers@Group:alpha#member",
|
|
"Resource:doc#readers@Group:beta#member",
|
|
"Resource:doc#readers@Group:gamma#member",
|
|
|
|
"Group:alpha#member@Group:alpha-sub#member",
|
|
"Group:beta#member@Group:beta-sub#member",
|
|
"Group:gamma#member@Group:gamma-sub#member",
|
|
|
|
"Group:alpha-sub#member@User:Alice",
|
|
"Group:beta-sub#member@User:Bob",
|
|
// Eve is in none of the groups.
|
|
},
|
|
}
|
|
|
|
func TestWidthLimit(t *testing.T) {
|
|
type tc struct {
|
|
name string
|
|
subject string
|
|
width int // 0 means default 100
|
|
expectedMembership check.Membership
|
|
expectedLimitation check.LimitationKind
|
|
expectedIsMember bool
|
|
}
|
|
|
|
tests := []tc{
|
|
{
|
|
name: "no limit: Alice is IsMember",
|
|
subject: "User:Alice",
|
|
expectedMembership: check.IsMember,
|
|
expectedIsMember: true,
|
|
},
|
|
{
|
|
name: "no limit: Eve is NotMember",
|
|
subject: "User:Eve",
|
|
expectedMembership: check.NotMember,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "width=1: Eve is indeterminate",
|
|
subject: "User:Eve",
|
|
width: 1,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxWidthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
// Bob is only in beta-sub, which is always truncated at width=1.
|
|
name: "width=1: member only in truncated branch is indeterminate",
|
|
subject: "User:Bob",
|
|
width: 1,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxWidthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
widthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
|
ctx := t.Context()
|
|
e := check.NewEngine(reg)
|
|
if tc.width > 0 {
|
|
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
|
|
}
|
|
|
|
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
|
require.NoError(t, res.Err)
|
|
assert.Equal(t, tc.expectedMembership, res.Membership)
|
|
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
|
|
|
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedIsMember, isMember)
|
|
})
|
|
|
|
// in strict mode, expect Error when limitation is hit
|
|
widthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
|
ctx := t.Context()
|
|
e := check.NewEngine(reg)
|
|
if tc.width > 0 {
|
|
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
|
|
}
|
|
|
|
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
|
require.NoError(t, res.Err)
|
|
assert.Equal(t, tc.expectedMembership, res.Membership)
|
|
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
|
|
|
if tc.expectedLimitation != "" {
|
|
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
|
require.Error(t, err)
|
|
var he *herodot.DefaultError
|
|
require.ErrorAs(t, err, &he)
|
|
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
|
|
} else {
|
|
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedIsMember, isMember)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// Alice is in both deep and short chains.
|
|
// Depth of 3 is needed to reach Alice via shortRel.
|
|
// Depth of 5 is needed to reach Alice via deepRel.
|
|
var depthLimitScenario = testhelpers.Scenario{
|
|
Name: "Depth Limit",
|
|
Opl: `
|
|
class User implements Namespace {}
|
|
class Group implements Namespace {
|
|
related: {
|
|
members: (User | SubjectSet<Group, "members">)[]
|
|
}
|
|
}
|
|
class Resource implements Namespace {
|
|
related: {
|
|
deepRel: (User | SubjectSet<Group, "members">)[]
|
|
shortRel: (User | SubjectSet<Group, "members">)[]
|
|
}
|
|
permits = {
|
|
union: (ctx:Context) =>
|
|
this.related.deepRel.includes(ctx.subject)
|
|
|| this.related.shortRel.includes(ctx.subject),
|
|
intersection: (ctx:Context) =>
|
|
this.related.deepRel.includes(ctx.subject)
|
|
&& this.related.shortRel.includes(ctx.subject),
|
|
shortButNotDeep: (ctx:Context) => this.related.shortRel.includes(ctx.subject)
|
|
&& !this.related.deepRel.includes(ctx.subject),
|
|
|
|
// notEither simulates a client that authorizes users when Keto returns NotMember
|
|
notEither: (ctx:Context) => !this.permits.union(ctx),
|
|
}
|
|
}
|
|
`,
|
|
InputTuples: []string{
|
|
"Resource:doc#deepRel@Group:deep#members",
|
|
"Resource:doc#shortRel@Group:short#members",
|
|
|
|
"Group:deep#members@Group:deep1#members",
|
|
"Group:deep1#members@Group:deep2#members",
|
|
"Group:deep2#members@Group:deep3#members",
|
|
"Group:deep3#members@Group:deep4#members",
|
|
"Group:deep4#members@User:Alice",
|
|
|
|
"Group:short#members@Group:short1#members",
|
|
"Group:short1#members@Group:short2#members",
|
|
"Group:short2#members@User:Alice",
|
|
"Group:short2#members@User:Charlie",
|
|
},
|
|
}
|
|
|
|
func TestDepthLimit(t *testing.T) {
|
|
type tc struct {
|
|
name string
|
|
tuple string
|
|
depth int
|
|
expectedMembership check.Membership
|
|
expectedLimitation check.LimitationKind
|
|
expectedIsMember bool
|
|
}
|
|
|
|
tests := []tc{
|
|
{
|
|
name: "union: too shallow depth for both branches yields NotMember",
|
|
tuple: "Resource:doc#union@User:Alice",
|
|
depth: 2,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "union: enough depth for 1 Member branch yields Member",
|
|
tuple: "Resource:doc#union@User:Alice",
|
|
depth: 4,
|
|
expectedMembership: check.IsMember,
|
|
expectedIsMember: true,
|
|
},
|
|
{
|
|
name: "union: enough depth for 1 NotMember branch yields Unknown",
|
|
tuple: "Resource:doc#union@User:Bob", // Bob is in no branch
|
|
depth: 4,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "intersection: too shallow depth for both branches yields Unknown",
|
|
tuple: "Resource:doc#intersection@User:Alice",
|
|
depth: 2,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "intersection: enough depth for only 1 branch yields Unknown",
|
|
tuple: "Resource:doc#intersection@User:Alice",
|
|
depth: 4,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "intersection: enough depth for both branches yields Member",
|
|
tuple: "Resource:doc#intersection@User:Alice",
|
|
depth: 5,
|
|
expectedMembership: check.IsMember,
|
|
expectedIsMember: true,
|
|
},
|
|
{
|
|
name: "shortButNotDeep: too shallow depth for both branches yields Unknown",
|
|
tuple: "Resource:doc#shortButNotDeep@User:Alice",
|
|
depth: 2,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "shortButNotDeep: enough depth for only 1 branch yields Unknown",
|
|
tuple: "Resource:doc#shortButNotDeep@User:Alice",
|
|
depth: 4,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
{
|
|
name: "shortButNotDeep: user only in shortRel yields Member at sufficient depth",
|
|
tuple: "Resource:doc#shortButNotDeep@User:Charlie",
|
|
depth: 7,
|
|
expectedMembership: check.IsMember,
|
|
expectedIsMember: true,
|
|
},
|
|
{
|
|
name: "notEither: not enough depth for any branch yields Unknown",
|
|
tuple: "Resource:doc#notEither@User:Alice",
|
|
depth: 2,
|
|
expectedMembership: check.MembershipUnknown,
|
|
expectedLimitation: check.LimitationMaxDepthExceeded,
|
|
expectedIsMember: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
depthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
|
ctx := t.Context()
|
|
e := check.NewEngine(reg)
|
|
if tc.depth > 0 {
|
|
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
|
|
}
|
|
|
|
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
|
require.NoError(t, res.Err)
|
|
assert.Equal(t, tc.expectedMembership, res.Membership)
|
|
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
|
|
|
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedIsMember, isMember)
|
|
})
|
|
|
|
// in strict mode, expect Error when limitation is hit
|
|
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
|
ctx := t.Context()
|
|
e := check.NewEngine(reg)
|
|
if tc.depth > 0 {
|
|
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
|
|
}
|
|
|
|
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
|
require.NoError(t, res.Err)
|
|
assert.Equal(t, tc.expectedMembership, res.Membership)
|
|
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
|
|
|
if tc.expectedLimitation != "" {
|
|
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
|
require.Error(t, err)
|
|
var he *herodot.DefaultError
|
|
require.ErrorAs(t, err, &he)
|
|
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
|
|
} else {
|
|
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedIsMember, isMember)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckSnapshotIsolation verifies that a concurrent write committed between
|
|
// two SQL queries within the same check does not affect the result.
|
|
func TestCheckSnapshotIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const opl = `
|
|
class User implements Namespace {}
|
|
class Folder implements Namespace {
|
|
related: {
|
|
members: User[]
|
|
}
|
|
permits = {
|
|
isMember: (ctx: Context) => this.related.members.includes(ctx.subject),
|
|
}
|
|
}
|
|
class File implements Namespace {
|
|
related: {
|
|
parent: Folder[]
|
|
}
|
|
permits = {
|
|
view: (ctx: Context) => this.related.parent.traverse(p => p.permits.isMember(ctx)),
|
|
}
|
|
}
|
|
`
|
|
inputTuples := []string{
|
|
"File:readme#parent@Folder:docs",
|
|
"Folder:docs#members@User:u1", // this tuple will be deleted during check execution
|
|
}
|
|
|
|
var snapshotDSNs []*dbx.DsnT
|
|
for _, dsn := range dbx.GetDSNs(t) {
|
|
switch dsn.Name {
|
|
case "cockroach", "mysql", "postgres":
|
|
snapshotDSNs = append(snapshotDSNs, dsn)
|
|
}
|
|
}
|
|
if len(snapshotDSNs) == 0 {
|
|
// SQLite is excluded. pop serialises all SQLite transactions with a mutex,
|
|
// so the middleware's concurrent delete gets deadlocked against the outer check
|
|
// transaction
|
|
t.Skip("sqlite is excluded from snapshot isolation test; run without -short to start CockroachDB/MySQL/PostgreSQL")
|
|
}
|
|
|
|
for _, dsn := range snapshotDSNs {
|
|
t.Run("dsn="+dsn.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reg := driver.NewTestRegistry(t, dsn,
|
|
driver.WithOPL(opl),
|
|
driver.WithMapperNamespace(testhelpers.CustomMapperNamespace),
|
|
driver.WithConfig(config.KeyFeatureFlagStrictMode, true),
|
|
)
|
|
testhelpers.MapAndInsertTuplesFromString(t, reg, inputTuples)
|
|
|
|
ctx := t.Context()
|
|
|
|
// The delete must happen after the first SQL query in the transaction so
|
|
// the snapshot is already established in PostgreSQL/MySQL REPEATABLE READ
|
|
// (which snapshots at first statement, not at BEGIN).
|
|
var once sync.Once
|
|
middleware := func(mCtx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
|
if s.Kind() == check.StepIsAllowed && req.Tuple.Namespace == "Folder" && req.Tuple.Relation == "isMember" {
|
|
once.Do(func() {
|
|
assert.NoError(t, reg.RelationTupleManager().DeleteRelationTuples(t.Context(),
|
|
testhelpers.TupleFromString(t, "Folder:docs#members@User:u1"),
|
|
))
|
|
})
|
|
}
|
|
return next(mCtx, s, req)
|
|
}
|
|
|
|
engine := check.NewEngine(&checkerOverrideRegistry{Registry: reg, mw: middleware})
|
|
|
|
result := engine.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "File:readme#view@User:u1"), 5)
|
|
require.NoError(t, result.Err)
|
|
assert.Equal(t, check.IsMember, result.Membership,
|
|
"check must reflect the database state at the start of the evaluation")
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkerOverrideRegistry wraps a Registry and replaces its Checker.
|
|
// mw can be used to inject a middleware to the Executor.
|
|
type checkerOverrideRegistry struct {
|
|
driver.Registry
|
|
mw check.Middleware
|
|
}
|
|
|
|
func (r *checkerOverrideRegistry) Checker() check.Checker {
|
|
return step.NewExecutor(r.Registry, r.mw)
|
|
}
|