Files
upstream-ory-keto/internal/check/engine_test.go
upstream-mirror 9456a4e922
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
snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:08:34 +05:00

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)
}