snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
goos: linux
goarch: amd64
pkg: github.com/ory/keto/internal/check
cpu: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
BenchmarkComputedUsersets/Computed_userset-8 2155 583306 ns/op 9.000 queries/op 109370 B/op 1556 allocs/op
PASS
ok github.com/ory/keto/internal/check 1.351s

View File

@@ -0,0 +1,190 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check_test
import (
"context"
_ "embed"
"fmt"
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/testhelpers"
)
func wideNamespace(width int) *namespace.Namespace {
wideNS := &namespace.Namespace{
Name: fmt.Sprintf("%d_wide", width),
Relations: []ast.Relation{{Name: "editor"}},
}
viewerRelation := &ast.Relation{
Name: "viewer",
SubjectSetRewrite: &ast.SubjectSetRewrite{
Operation: ast.OperatorOr,
Children: ast.Children{},
},
}
for i := 0; i < width; i++ {
relation := fmt.Sprintf("relation-%d", i)
viewerRelation.SubjectSetRewrite.Children = append(
viewerRelation.SubjectSetRewrite.Children,
&ast.ComputedSubjectSet{Relation: relation},
)
wideNS.Relations = append(wideNS.Relations, ast.Relation{Name: relation})
}
viewerRelation.SubjectSetRewrite.Children = append(
viewerRelation.SubjectSetRewrite.Children,
&ast.ComputedSubjectSet{Relation: "editor"},
)
wideNS.Relations = append(wideNS.Relations, *viewerRelation)
return wideNS
}
func BenchmarkCheckEngine(b *testing.B) {
ctx := context.Background()
var (
depths = []int{2, 4, 8, 16, 32}
widths = []int{10, 20, 40, 80, 100}
maxDepth = depths[len(depths)-1]
)
namespaces := []*namespace.Namespace{
{
Name: "deep",
Relations: []ast.Relation{
{Name: "owner"},
{
Name: "editor",
SubjectSetRewrite: &ast.SubjectSetRewrite{
Children: ast.Children{&ast.ComputedSubjectSet{
Relation: "owner",
}},
},
},
{
Name: "viewer",
SubjectSetRewrite: &ast.SubjectSetRewrite{
Children: ast.Children{
&ast.ComputedSubjectSet{
Relation: "editor",
},
&ast.TupleToSubjectSet{
Relation: "parent",
ComputedSubjectSetRelation: "viewer",
},
},
},
},
},
},
}
reg := driver.NewSqliteTestRegistry(b, driver.WithNamespaces(namespaces))
reg.Logger().Logger.SetLevel(logrus.InfoLevel)
tuples := []string{
"deep:deep_file#parent@deep:folder_1#...",
}
for i := 1; i < maxDepth; i++ {
tuples = append(tuples, fmt.Sprintf("deep:folder_%d#parent@deep:folder_%d#...", i, i+1))
}
for _, d := range depths {
tuples = append(tuples, fmt.Sprintf("deep:folder_%d#owner@user_%d", d, d))
}
for _, w := range widths {
namespaces = append(namespaces, wideNamespace(w))
tuples = append(tuples, fmt.Sprintf("%d-wide:wide_file#editor@user", w))
}
testhelpers.MapAndInsertTuplesFromString(b, reg, tuples)
require.NoError(b, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 100*maxDepth))
e := check.NewEngine(reg)
b.Run("case=deep tree", func(b *testing.B) {
for _, depth := range depths {
b.Run(fmt.Sprintf("depth=%03d", depth), func(b *testing.B) {
for i := 0; i < b.N; i++ {
rt := testhelpers.TupleFromString(b, fmt.Sprintf("deep:deep_file#viewer@user_%d", depth))
res := e.CheckRelationTuple(ctx, rt, 2*depth)
assert.NoError(b, res.Err)
if res.Membership != check.IsMember {
b.Error("user should be able to view 'deep_file'")
}
}
})
}
})
b.Run("case=wide tree", func(b *testing.B) {
for _, width := range widths {
b.Run(fmt.Sprintf("width=%03d", width), func(b *testing.B) {
for i := 0; i < b.N; i++ {
rt := testhelpers.TupleFromString(b, fmt.Sprintf("%d-wide:wide_file#editor@user", width))
res := e.CheckRelationTuple(ctx, rt, 2*width)
assert.NoError(b, res.Err)
if res.Membership != check.IsMember {
b.Error("user should be able to view 'wide_file'")
}
}
})
}
})
}
//go:embed testfixtures/project_opl.ts
var ProjectOPLConfig string
func BenchmarkComputedUsersets(b *testing.B) {
ctx := context.Background()
spans := tracetest.NewSpanRecorder()
tracer := trace.NewTracerProvider(trace.WithSpanProcessor(spans)).Tracer("")
reg := driver.NewSqliteTestRegistry(b, driver.WithLogLevel("debug"), driver.WithOPL(ProjectOPLConfig), driver.WithTracer(tracer), driver.WithConfig(config.KeyFeatureFlagStrictMode, true))
reg.Logger().Logger.SetLevel(logrus.DebugLevel)
testhelpers.MapAndInsertTuplesFromString(b, reg, []string{
"Project:Ory#owner@User:Admin",
"Project:Ory#developer@User:Dev",
})
e := check.NewEngine(reg)
query := testhelpers.TupleFromString(b, "Project:Ory#readProject@User:Dev")
b.Run("Computed userset", func(b *testing.B) {
initialDBSpans := dbSpans(spans)
for i := 0; i < b.N; i++ {
res := e.CheckRelationTuple(ctx, query, 0)
assert.NoError(b, res.Err)
if res.Err != nil {
b.Errorf("got unexpected error: %v", res.Err)
}
if res.Membership != check.IsMember {
b.Error("check failed")
}
}
b.ReportMetric((float64(dbSpans(spans)-initialDBSpans))/float64(b.N), "queries/op")
})
}
func dbSpans(spans *tracetest.SpanRecorder) (count int) {
for _, s := range spans.Started() {
if strings.HasPrefix(s.Name(), "sql-conn-query") {
count++
}
}
return
}

View File

@@ -0,0 +1,157 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/trace"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/schema"
)
func newRegistryForOPL(t *testing.T, opl string, opts ...driver.TestRegistryOption) *driver.RegistryDefault {
t.Helper()
parsed, errs := schema.Parse(opl)
require.Empty(t, errs)
nss := make([]*namespace.Namespace, len(parsed))
for i := range parsed {
nss[i] = &parsed[i]
}
return driver.NewSqliteTestRegistry(t, append([]driver.TestRegistryOption{driver.WithNamespaces(nss)}, opts...)...)
}
// requireCyclicError asserts err is the call-limit backstop, not just any error
// (e.g. a context cancellation), by checking its reason.
func requireCyclicError(t *testing.T, err error) {
t.Helper()
var re interface{ Reason() string }
require.ErrorAs(t, err, &re)
require.Contains(t, re.Reason(), "cyclic permit reference")
}
func TestCallLimit(t *testing.T) {
t.Parallel()
type outcome int
const (
cyclicError outcome = iota
member
depthLimited
)
for _, tc := range []struct {
name string
opl string
opts []driver.TestRegistryOption
tuples []string
check string
restDepth int
want outcome
}{
{
name: "invert cycle aborts regardless of configured depth",
opl: `
class User implements Namespace {}
class Resource implements Namespace {
permits = {
edit: (ctx: Context): boolean => !this.permits.view(ctx),
view: (ctx: Context): boolean => !this.permits.edit(ctx),
}
}`,
opts: []driver.TestRegistryOption{driver.WithConfig("limit.max_read_depth", 65535)},
check: "Resource:doc#edit@User:alice",
restDepth: 65535,
want: cyclicError,
},
{
name: "branching cycle aborts decisively",
opl: `
class User implements Namespace {}
class R implements Namespace {
permits = {
a: (ctx: Context): boolean => this.permits.b(ctx) && this.permits.c(ctx),
b: (ctx: Context): boolean => this.permits.a(ctx) && this.permits.a(ctx),
c: (ctx: Context): boolean => this.permits.a(ctx) && this.permits.a(ctx),
}
}`,
check: "R:x#a@User:u",
restDepth: 5,
want: cyclicError,
},
{
name: "self-loop is bounded by RestDepth",
opl: `
class User implements Namespace {}
class Resource implements Namespace {
permits = {
edit: (ctx: Context): boolean => this.permits.edit(ctx),
}
}`,
check: "Resource:doc#edit@User:alice",
restDepth: 5,
want: depthLimited,
},
{
name: "legit role chain resolves",
opl: `
class User implements Namespace {}
class Doc implements Namespace {
related: {
l0: User[]; l1: User[]; l2: User[]; l3: User[];
}
permits = {
p0: (ctx: Context): boolean => this.permits.p1(ctx) || this.related.l0.includes(ctx.subject),
p1: (ctx: Context): boolean => this.permits.p2(ctx) || this.related.l1.includes(ctx.subject),
p2: (ctx: Context): boolean => this.permits.p3(ctx) || this.related.l2.includes(ctx.subject),
p3: (ctx: Context): boolean => this.related.l3.includes(ctx.subject),
}
}`,
tuples: []string{"Doc:d#l3@User:alice"},
check: "Doc:d#p0@User:alice",
restDepth: 5,
want: member,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
reg := newRegistryForOPL(t, tc.opl, tc.opts...)
if len(tc.tuples) > 0 {
testhelpers.MapAndInsertTuplesFromString(t, reg, tc.tuples)
}
rt := testhelpers.TupleFromString(t, tc.check)
assertResult := func(t *testing.T, res check.Result) {
switch tc.want {
case cyclicError:
requireCyclicError(t, res.Err)
require.Equal(t, check.MembershipUnknown, res.Membership)
case member:
require.NoError(t, res.Err)
require.Equal(t, check.IsMember, res.Membership)
case depthLimited:
require.NoError(t, res.Err)
require.Equal(t, check.MembershipUnknown, res.Membership)
require.Equal(t, check.LimitationMaxDepthExceeded, res.Limitation)
}
}
// Both executors wire the middleware separately.
t.Run("check engine", func(t *testing.T) {
assertResult(t, check.NewEngine(reg).CheckRelationTuple(t.Context(), rt, tc.restDepth))
})
t.Run("trace engine", func(t *testing.T) {
res, tree := trace.NewEngine(reg).CheckRelationTupleWithTrace(t.Context(), rt, tc.restDepth)
require.NotNil(t, tree)
assertResult(t, res)
})
})
}
}

View File

@@ -0,0 +1,141 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check
import (
"context"
"github.com/ory/keto/internal/relationtuple"
)
type (
// Result holds the outcome of a single permission check.
// Limitation is non-empty only when Membership is MembershipUnknown.
Result struct {
Membership Membership
Err error
Limitation LimitationKind
}
Membership int
// LimitationKind identifies which traversal constraint was hit. A non-empty
// value on a Result means the membership answer is indeterminate.
LimitationKind string
)
//go:generate stringer -type Membership
const (
MembershipUnknown Membership = iota
IsMember
NotMember
)
const (
LimitationMaxDepthExceeded LimitationKind = "max_depth_exceeded"
LimitationMaxWidthExceeded LimitationKind = "max_width_exceeded"
)
var (
ResultUnknown = Result{Membership: MembershipUnknown}
ResultIsMember = Result{Membership: IsMember}
ResultNotMember = Result{Membership: NotMember}
)
// CheckRequest carries all inputs for a single check evaluation step.
type CheckRequest struct {
Tuple *relationtuple.RelationTuple
RestDepth int
}
func (r CheckRequest) WithDepth(d int) CheckRequest {
r.RestDepth = d
return r
}
// PlannedStep pairs a Step with the CheckRequest it should run against.
type PlannedStep struct {
Step Step
Req CheckRequest
}
// StepRunner orchestrates execution of multiple steps with short-circuit semantics.
// Typical usage:
//
// runner := ex.NewUnionRunner(ctx)
// for _, ps := range steps {
// if runner.Done() { break }
// runner.Add(ps)
// }
// return runner.Result()
type StepRunner interface {
// Add enqueues a step for execution. Returns early if Done() is true.
// No-op if the runner has already reached a decisive result or been cancelled.
Add(PlannedStep)
// Done reports whether a decisive result was reached or context cancelled.
// Use to short-circuit the enqueue loop.
Done() bool
// Ctx returns the runner's context, usable for shared state like visited tracking.
// The context is cancelled when a decisive result is reached (to signal siblings).
Ctx() context.Context
// AddLimitation records that some branches were skipped due to given [LimitationKind] constraint.
AddLimitation(LimitationKind)
// Result waits for all in-flight steps to complete and returns the aggregated result.
// Call it after the Add loop finishes (or breaks early on Done()).
Result() Result
}
// Executor coordinates permission check execution with middleware support.
// It runs individual steps (Run), sequences steps with union/intersection logic
// (RunUnion, RunIntersection), and supports custom execution strategies via
// middleware. All Executor implementations are stateless; dependencies come
// from EngineDependencies.
//
// Typical usage:
//
// result := executor.Run(ctx, DirectStep{}, req)
// unionResult := executor.RunUnion(ctx, step1, step2)
// intersectionResult := executor.RunIntersection(ctx, step1, step2)
type Executor interface {
// Run executes a single step through the middleware chain.
Run(ctx context.Context, step Step, req CheckRequest) Result
// RunUnion runs steps with OR semantics: returns IsMember on first match,
// short-circuits siblings, falls through to NotMember if none match.
RunUnion(ctx context.Context, steps ...PlannedStep) Result
// RunIntersection runs steps with AND semantics: returns NotMember or Error
// on first failure, short-circuits siblings.
RunIntersection(ctx context.Context, steps ...PlannedStep) Result
// NewUnionRunner creates a StepRunner for streaming union execution.
// It is useful when Steps are created dynamically and the full list is not known upfront.
NewUnionRunner(ctx context.Context) StepRunner
Deps() EngineDependencies
}
// Middleware intercepts a step execution within the pipeline. next continues
// down the remaining middlewares and ultimately calls step.Execute.
type Middleware func(
ctx context.Context,
step Step,
req CheckRequest,
next func(context.Context, Step, CheckRequest) Result,
) Result
// Checker performs a full permission check for a single relation tuple.
// Implemented by Executor in the step package.
type (
CheckerProvider interface {
Checker() Checker
}
Checker interface {
CheckRelationTuple(ctx context.Context, r *relationtuple.RelationTuple, restDepth int) Result
}
)

View File

@@ -0,0 +1,25 @@
// Code generated by "stringer -type Membership"; DO NOT EDIT.
package check
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[MembershipUnknown-0]
_ = x[IsMember-1]
_ = x[NotMember-2]
}
const _Membership_name = "MembershipUnknownIsMemberNotMember"
var _Membership_index = [...]uint8{0, 17, 25, 34}
func (i Membership) String() string {
if i < 0 || i >= Membership(len(_Membership_index)-1) {
return "Membership(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Membership_name[_Membership_index[i]:_Membership_index[i+1]]
}

127
internal/check/engine.go Normal file
View File

@@ -0,0 +1,127 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check
import (
"context"
"net/http"
"github.com/ory/herodot"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc/codes"
"github.com/ory/keto/x/events"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
)
type (
EngineProvider interface {
PermissionEngine() *Engine
}
Engine struct {
dep EngineDependencies
}
EngineDependencies interface {
relationtuple.ManagerProvider
relationtuple.MapperProvider
CheckerProvider
config.Provider
logrusx.Provider
otelx.Provider
}
// Type alias for shorter signatures.
relationTuple = relationtuple.RelationTuple
)
func errLimitationExceeded() *herodot.DefaultError {
return &herodot.DefaultError{
StatusField: http.StatusText(http.StatusUnprocessableEntity),
ErrorField: "The check could not be completed",
CodeField: http.StatusUnprocessableEntity,
GRPCCodeField: codes.FailedPrecondition,
}
}
// NewEngine creates an Engine that delegates checks to checker.
func NewEngine(d EngineDependencies) *Engine {
return &Engine{dep: d}
}
// CheckIsMember checks if the relation tuple's subject has the relation on the
// object in the namespace either directly or indirectly and returns a boolean
// result.
func (e *Engine) CheckIsMember(ctx context.Context, r *relationTuple, restDepth int) (bool, error) {
result := e.CheckRelationTuple(ctx, r, restDepth)
if result.Err != nil {
return false, result.Err
}
if result.Membership == MembershipUnknown && result.Limitation != "" && e.dep.Config(ctx).StrictMode() {
return false, errLimitationExceeded().WithReason(string(result.Limitation))
}
return result.Membership == IsMember, nil
}
// CheckRelationTuple checks if the relation tuple's subject has the relation on
// the object in the namespace either directly or indirectly and returns a check
// result.
func (e *Engine) CheckRelationTuple(ctx context.Context, r *relationTuple, restDepth int) (res Result) {
ctx, span := e.dep.Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTuple")
defer otelx.End(span, &res.Err)
txErr := e.dep.RelationTupleManager().WithLatestSnapshot(ctx, func(ctx context.Context) error {
res = e.dep.Checker().CheckRelationTuple(ctx, r, restDepth)
return res.Err
})
if txErr != nil && res.Err == nil {
res.Membership = MembershipUnknown
res.Err = herodot.ErrInternalServerError().WithWrap(txErr)
e.dep.Logger().WithError(txErr).WithField("tuple", r.String()).Error("could not open a consistent read snapshot for the check")
}
if res.Err == nil {
span.AddEvent(events.NewPermissionsChecked(ctx))
}
return res
}
// BatchCheck makes parallelized check requests for tuples. The check results
// are returned as a slice where the result index matches the input tuple index.
func (e *Engine) BatchCheck(
ctx context.Context,
tuples []*ketoapi.RelationTuple,
maxDepth int,
) ([]Result, error) {
eg := &errgroup.Group{}
eg.SetLimit(e.dep.Config(ctx).BatchCheckParallelizationLimit())
mapper := e.dep.ReadOnlyMapper()
results := make([]Result, len(tuples))
for i, tuple := range tuples {
eg.Go(func() error {
internalTuple, err := mapper.FromTuple(ctx, tuple)
if err != nil {
results[i] = Result{
Membership: MembershipUnknown,
Err: err,
}
} else {
results[i] = e.CheckRelationTuple(ctx, internalTuple[0], maxDepth)
}
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
return results, nil
}

View File

@@ -0,0 +1,845 @@
// 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)
}

526
internal/check/handler.go Normal file
View File

@@ -0,0 +1,526 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"connectrpc.com/connect"
"github.com/pkg/errors"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/ory/herodot"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x"
"github.com/ory/keto/ketoapi"
)
type (
handlerDependencies interface {
EngineProvider
relationtuple.ManagerProvider
relationtuple.MapperProvider
logrusx.Provider
httpx.WriterProvider
config.Provider
x.HandlerOptionsProvider
}
Handler struct {
relationtuplesconnect.UnimplementedCheckServiceHandler
d handlerDependencies
}
)
var (
_ relationtuplesconnect.CheckServiceHandler = (*Handler)(nil)
_ *checkPermission = nil
)
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
const (
RouteBase = "/relation-tuples/check"
OpenAPIRouteBase = RouteBase + "/openapi"
BatchRoute = "/relation-tuples/batch/check"
)
func (h *Handler) RegisterReadRoutes(r *httprouterx.RouterPublic) {
r.GET(RouteBase, h.getCheckMirrorStatus)
r.GET(OpenAPIRouteBase, h.getCheckNoStatus)
r.POST(RouteBase, h.postCheckMirrorStatus)
r.POST(OpenAPIRouteBase, h.postCheckNoStatus)
r.POST(BatchRoute, h.batchCheck)
checkServiceMethods := rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto.Services().ByName("CheckService").Methods()
checkHandler := connect.NewUnaryHandler(
relationtuplesconnect.CheckServiceCheckProcedure,
h.Check,
connect.WithSchema(checkServiceMethods.ByName("Check")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.CheckServiceCheckProcedure, checkHandler)
batchCheckHandler := connect.NewUnaryHandler(
relationtuplesconnect.CheckServiceBatchCheckProcedure,
h.BatchCheck,
connect.WithSchema(checkServiceMethods.ByName("BatchCheck")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.CheckServiceBatchCheckProcedure, batchCheckHandler)
}
func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor {
return []protoreflect.FileDescriptor{
rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto,
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
}
}
// Check Permission Result
//
// The content of the allowed field is mirrored in the HTTP status code.
//
// swagger:model checkPermissionResult
type CheckPermissionResult struct {
// whether the relation tuple is allowed
//
// required: true
Allowed bool `json:"allowed"`
}
// Check Permission Result With Error
//
// swagger:model checkPermissionResultWithError
type CheckPermissionResultWithError struct {
// whether the relation tuple is allowed
//
// required: true
Allowed bool `json:"allowed"`
// any error generated while checking the relation tuple
//
// required: false
Error string `json:"error,omitempty"`
}
// Check Permission Request Parameters
//
// swagger:parameters checkPermission
type checkPermission struct {
// in: query
MaxDepth int `json:"max-depth"`
}
// swagger:route GET /relation-tuples/check/openapi permission checkPermission
//
// # Check a permission
//
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
//
// Consumes:
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: checkPermissionResult
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-public-high
func (h *Handler) getCheckNoStatus(w http.ResponseWriter, r *http.Request) {
allowed, err := h.getCheck(r.Context(), r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
}
// Check Permission Or Error Request Parameters
//
// swagger:parameters checkPermissionOrError
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type checkPermissionOrError struct {
// in: query
MaxDepth int `json:"max-depth"`
}
// swagger:route GET /relation-tuples/check permission checkPermissionOrError
//
// # Check a permission
//
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
//
// Consumes:
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: checkPermissionResult
// 400: errorGeneric
// 403: checkPermissionResult
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-public-high
func (h *Handler) getCheckMirrorStatus(w http.ResponseWriter, r *http.Request) {
allowed, err := h.getCheck(r.Context(), r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if allowed {
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
return
}
h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed})
}
func (h *Handler) getCheck(ctx context.Context, q url.Values) (bool, error) {
maxDepth, err := x.GetMaxDepthFromQuery(q)
if err != nil {
return false, err
}
tuple, err := (&ketoapi.RelationTuple{}).FromURLQuery(q)
if err != nil {
return false, err
}
it, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple)
// herodot.ErrNotFound occurs when the namespace is unknown
if errors.Is(err, herodot.ErrNotFound()) {
return false, nil
} else if err != nil {
return false, err
}
return h.d.PermissionEngine().CheckIsMember(ctx, it[0], maxDepth)
}
// Check Permission using Post Request Parameters
//
// swagger:parameters postCheckPermission
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type postCheckPermission struct {
// in: query
MaxDepth int `json:"max-depth"`
// in: body
Payload postCheckPermissionBody
}
// Check Permission using Post Request Body
//
// swagger:model postCheckPermissionBody
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type postCheckPermissionBody struct {
ketoapi.RelationQuery
}
// swagger:route POST /relation-tuples/check/openapi permission postCheckPermission
//
// # Check a permission
//
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: checkPermissionResult
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-public-high
func (h *Handler) postCheckNoStatus(w http.ResponseWriter, r *http.Request) {
allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
}
// Post Check Permission Or Error Request Parameters
//
// swagger:parameters postCheckPermissionOrError
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type postCheckPermissionOrError struct {
// in: query
MaxDepth int `json:"max-depth"`
// in: body
Body postCheckPermissionOrErrorBody
}
// Post Check Permission Or Error Body
//
// swagger:model postCheckPermissionOrErrorBody
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type postCheckPermissionOrErrorBody struct {
ketoapi.RelationQuery
}
// swagger:route POST /relation-tuples/check permission postCheckPermissionOrError
//
// # Check a permission
//
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: checkPermissionResult
// 400: errorGeneric
// 403: checkPermissionResult
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-public-high
func (h *Handler) postCheckMirrorStatus(w http.ResponseWriter, r *http.Request) {
allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if allowed {
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
return
}
h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed})
}
func (h *Handler) postCheck(ctx context.Context, body io.Reader, query url.Values) (bool, error) {
maxDepth, err := x.GetMaxDepthFromQuery(query)
if err != nil {
return false, err
}
var tuple ketoapi.RelationTuple
if err := json.NewDecoder(body).Decode(&tuple); err != nil {
return false, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error()))
}
t, err := h.d.ReadOnlyMapper().FromTuple(ctx, &tuple)
// herodot.ErrNotFound occurs when the namespace is unknown
if errors.Is(err, herodot.ErrNotFound()) {
return false, nil
} else if err != nil {
return false, err
}
return h.d.PermissionEngine().CheckIsMember(ctx, t[0], maxDepth)
}
func (h *Handler) Check(ctx context.Context, req *connect.Request[rts.CheckRequest]) (*connect.Response[rts.CheckResponse], error) {
var src ketoapi.TupleData
if req.Msg.Tuple != nil {
src = req.Msg.Tuple
} else {
src = req.Msg
}
tuple, err := (&ketoapi.RelationTuple{}).FromDataProvider(src)
if err != nil {
return nil, err
}
internalTuple, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple)
if err != nil {
return nil, err
}
allowed, err := h.d.PermissionEngine().CheckIsMember(ctx, internalTuple[0], int(req.Msg.MaxDepth))
// TODO add content change handling
if err != nil {
return nil, err
}
return connect.NewResponse(&rts.CheckResponse{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value.
Allowed: allowed,
Snaptoken: "not yet implemented",
}), nil
}
// Batch Check Permission Request Parameters
//
// swagger:parameters batchCheckPermission
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type batchCheckPermission struct {
// in: query
MaxDepth int `json:"max-depth"`
// in: body
Body batchCheckPermissionBody
}
// Batch Check Permission Body
//
// swagger:model batchCheckPermissionBody
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type batchCheckPermissionBody struct {
Tuples []*ketoapi.RelationTuple `json:"tuples"`
}
// Batch Check Permission Result
//
// swagger:model batchCheckPermissionResult
type BatchCheckPermissionResult struct {
// An array of check results. The order aligns with the input order.
//
// required: true
Results []*CheckPermissionResultWithError `json:"results"`
}
// swagger:route POST /relation-tuples/batch/check permission batchCheckPermission
//
// # Batch check permissions
//
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: batchCheckPermissionResult
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-public-high
func (h *Handler) batchCheck(w http.ResponseWriter, r *http.Request) {
results, err := h.doBatchCheck(r.Context(), r.Body, r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, &BatchCheckPermissionResult{Results: results})
}
// doBatchCheck is the HTTP entry point for checking batches of tuples
func (h *Handler) doBatchCheck(ctx context.Context, body io.Reader, query url.Values) ([]*CheckPermissionResultWithError, error) {
maxDepth, err := x.GetMaxDepthFromQuery(query)
if err != nil {
return nil, err
}
var request batchCheckPermissionBody
if err := json.NewDecoder(body).Decode(&request); err != nil {
return nil, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error()))
}
if len(request.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() {
return nil, herodot.ErrBadRequest().WithErrorf("batch exceeds max size of %v",
h.d.Config(ctx).BatchCheckMaxBatchSize())
}
results, err := h.d.PermissionEngine().BatchCheck(ctx, request.Tuples, maxDepth)
if err != nil {
return nil, err
}
strict := h.d.Config(ctx).StrictMode()
responses := make([]*CheckPermissionResultWithError, len(request.Tuples))
for i, result := range results {
responses[i] = &CheckPermissionResultWithError{
Allowed: result.Membership == IsMember,
Error: batchResultErrMsg(result, strict),
}
}
return responses, nil
}
// BatchCheck is the gRPC entry point for checking batches of tuples
func (h *Handler) BatchCheck(ctx context.Context, req *connect.Request[rts.BatchCheckRequest]) (*connect.Response[rts.BatchCheckResponse], error) {
if len(req.Msg.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf(
"batch exceeds max size of %v", h.d.Config(ctx).BatchCheckMaxBatchSize()))
}
ketoTuples := make([]*ketoapi.RelationTuple, len(req.Msg.Tuples))
for i, tuple := range req.Msg.Tuples {
ketoTuples[i] = (&ketoapi.RelationTuple{}).FromProto(tuple)
}
results, err := h.d.PermissionEngine().BatchCheck(ctx, ketoTuples, int(req.Msg.MaxDepth))
if err != nil {
return nil, err
}
strict := h.d.Config(ctx).StrictMode()
responses := make([]*rts.CheckResponseWithError, len(results))
for i, result := range results {
responses[i] = &rts.CheckResponseWithError{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value.
Allowed: result.Membership == IsMember,
Error: batchResultErrMsg(result, strict),
Snaptoken: "not yet implemented",
}
}
return connect.NewResponse(&rts.BatchCheckResponse{
Results: responses,
}), nil
}
// batchResultErrMsg returns an error string for a single batch check result.
// In strict mode a limitation is surfaced so callers can distinguish
// "definitely not a member" from "could not determine membership".
func batchResultErrMsg(r Result, strict bool) string {
if r.Err != nil {
return r.Err.Error()
}
if r.Membership == MembershipUnknown && r.Limitation != "" && strict {
return string(r.Limitation)
}
return ""
}

View File

@@ -0,0 +1,415 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check_test
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/ory/x/httprouterx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
client "github.com/ory/keto/internal/httpclient"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/ketoapi"
)
func readBody(t *testing.T, resp *http.Response) []byte {
t.Helper()
defer func() { assert.NoError(t, resp.Body.Close()) }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return body
}
func assertAllowed(t *testing.T, resp *http.Response) {
t.Helper()
body := readBody(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
allowed := gjson.GetBytes(body, "allowed")
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
require.True(t, allowed.Bool())
}
type responseAssertion func(t *testing.T, resp *http.Response)
func baseAssertDenied(t *testing.T, resp *http.Response) {
t.Helper()
body := readBody(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s", body)
allowed := gjson.GetBytes(body, "allowed")
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
require.False(t, allowed.Bool())
}
// For OpenAPI clients, we want to always return a 200 status code even if the
// check returned "denied" to not cause exceptions etc. in the generated clients.
func openAPIAssertDenied(t *testing.T, resp *http.Response) {
t.Helper()
body := readBody(t, resp)
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
allowed := gjson.GetBytes(body, "allowed")
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
require.False(t, allowed.Bool())
}
func newTestGRPCCheckClient(t *testing.T, h *check.Handler) rts.CheckServiceClient {
t.Helper()
router := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(router)
ts := httptest.NewUnstartedServer(router)
ts.EnableHTTP2 = true
ts.StartTLS()
t.Cleanup(ts.Close)
rootCA := x509.NewCertPool()
rootCA.AddCert(ts.Certificate())
conn, err := grpc.NewClient(ts.Listener.Addr().String(),
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{RootCAs: rootCA})),
)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, conn.Close()) })
return rts.NewCheckServiceClient(conn)
}
func buildBatchURL(baseURL, maxDepth string) string {
return fmt.Sprintf("%s%s?max-depth=%s", baseURL, check.BatchRoute, maxDepth)
}
func TestCheckRESTHandler(t *testing.T) {
nspaces := []*namespace.Namespace{{
Name: "ns",
}}
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
h := check.NewHandler(reg)
r := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(r)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
for _, suite := range []struct {
name string
base string
assertDenied responseAssertion
}{
{name: "base", base: check.RouteBase, assertDenied: baseAssertDenied},
{name: "openapi", base: check.OpenAPIRouteBase, assertDenied: openAPIAssertDenied},
} {
t.Run("suite="+suite.name, func(t *testing.T) {
assertDenied := suite.assertDenied
t.Run("case=returns bad request on malformed int", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + suite.base + "?max-depth=foo")
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
})
t.Run("case=returns bad request on malformed input", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + url.Values{
"subject": {"not#a valid userset rewrite"},
}.Encode())
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("case=returns bad request on missing subject", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + suite.base)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Contains(t, string(readBody(t, resp)), "subject is not allowed to be nil")
})
t.Run("case=returns denied on unknown namespace", func(t *testing.T) {
rt := testhelpers.APITupleFromString(t, "not:o#r@s")
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
require.NoError(t, err)
assertDenied(t, resp)
})
t.Run("case=returns allowed", func(t *testing.T) {
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
testhelpers.MapAndInsertTuples(t, reg, rt)
q := rt.ToURLQuery()
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + q.Encode())
require.NoError(t, err)
assertAllowed(t, resp)
})
t.Run("case=returns denied", func(t *testing.T) {
rt := testhelpers.APITupleFromString(t, "ns:foo#r@s")
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
require.NoError(t, err)
assertDenied(t, resp)
})
})
}
}
func TestBatchCheckHandler(t *testing.T) {
nspaces := []*namespace.Namespace{{Name: "ns"}}
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
h := check.NewHandler(reg)
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
testhelpers.MapAndInsertTuples(t, reg, rt)
// REST setup.
router := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(router)
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
// gRPC setup.
checkClient := newTestGRPCCheckClient(t, h)
t.Run("case=REST: validates request format", func(t *testing.T) {
t.Run("non-int max depth", func(t *testing.T) {
resp, err := ts.Client().Post(ts.URL+check.BatchRoute+"?max-depth=foo",
"application/json", nil)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
})
t.Run("invalid JSON body", func(t *testing.T) {
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"),
"application/json", strings.NewReader("not-json"))
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Contains(t, string(readBody(t, resp)), "could not unmarshal json")
})
})
t.Run("case=validates max batch size", func(t *testing.T) {
t.Run("protocol=REST", func(t *testing.T) {
tuples := make([]client.Relationship, 11)
for i := range tuples {
tuples[i] = relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s"))
}
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: tuples})
require.NoError(t, err)
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Contains(t, string(readBody(t, resp)), "batch exceeds max size of 10")
})
t.Run("protocol=gRPC", func(t *testing.T) {
tuples := make([]*rts.RelationTuple, 11)
for i := range tuples {
tuples[i] = testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto()
}
_, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: tuples, MaxDepth: 5})
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.InvalidArgument, st.Code())
require.Equal(t, "batch exceeds max size of 10", st.Message())
})
})
t.Run("case=returns correct results per tuple", func(t *testing.T) {
// Three tuples: allowed, not-allowed, unknown namespace.
restTuples := []client.Relationship{
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s")), // allowed
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o2#r@s")), // not allowed
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "n2:o#r@s")), // unknown namespace
}
grpcTuples := []*rts.RelationTuple{
testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto(), // allowed
testhelpers.APITupleFromString(t, "ns:o2#r@s").ToProto(), // not allowed
testhelpers.APITupleFromString(t, "n2:o#r@s").ToProto(), // unknown namespace
}
t.Run("protocol=REST", func(t *testing.T) {
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: restTuples})
require.NoError(t, err)
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result client.BatchCheckPermissionResult
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
require.Equal(t, client.BatchCheckPermissionResult{
Results: []client.CheckPermissionResultWithError{
{Allowed: true},
{Allowed: false},
{Allowed: false, Error: new("The requested resource could not be found")},
},
}, result)
})
t.Run("protocol=gRPC", func(t *testing.T) {
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: grpcTuples, MaxDepth: 5})
require.NoError(t, err)
require.Len(t, resp.Results, 3)
require.True(t, resp.Results[0].Allowed)
require.Empty(t, resp.Results[0].Error)
require.False(t, resp.Results[1].Allowed)
require.Empty(t, resp.Results[1].Error)
require.False(t, resp.Results[2].Allowed)
require.Equal(t, "The requested resource could not be found", resp.Results[2].Error)
})
})
}
// relationshipFromAPITuple converts a ketoapi.RelationTuple to a client.Relationship
// for use in REST batch check requests.
func relationshipFromAPITuple(tuple *ketoapi.RelationTuple) client.Relationship {
rel := client.Relationship{
Namespace: tuple.Namespace,
Object: tuple.Object,
Relation: tuple.Relation,
}
if tuple.SubjectID != nil {
rel.SubjectId = tuple.SubjectID
} else if tuple.SubjectSet != nil {
rel.SubjectSet = &client.SubjectSet{
Namespace: tuple.SubjectSet.Namespace,
Object: tuple.SubjectSet.Object,
Relation: tuple.SubjectSet.Relation,
}
}
return rel
}
func TestCheckStrictModeLimit(t *testing.T) {
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadDepth, 2))
h := check.NewHandler(reg)
// This tuple has a depth of 3, which exceeds the configured limit of 2, causing to hit the limitation.
tupleQuery := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice").ToURLQuery()
t.Run("case=HTTP: returns 422 with limitation kind", func(t *testing.T) {
router := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(router)
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
resp, err := ts.Client().Get(ts.URL + check.RouteBase + "?max-depth=2&" + tupleQuery.Encode())
require.NoError(t, err)
body := readBody(t, resp)
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "%s", body)
require.Equal(t, "The check could not be completed", gjson.GetBytes(body, "error.message").String())
require.Contains(t, gjson.GetBytes(body, "error.reason").String(), string(check.LimitationMaxDepthExceeded))
})
t.Run("case=gRPC: returns FailedPrecondition with limitation kind", func(t *testing.T) {
checkClient := newTestGRPCCheckClient(t, h)
tuple := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice")
_, err := checkClient.Check(t.Context(), &rts.CheckRequest{
Tuple: tuple.ToProto(),
MaxDepth: 2,
})
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.FailedPrecondition, st.Code())
require.Equal(t, "The check could not be completed", st.Message())
var ei *errdetails.ErrorInfo
for _, d := range st.Details() {
if info, ok := d.(*errdetails.ErrorInfo); ok {
ei = info
break
}
}
require.NotNilf(t, ei, "%+v", st.Details())
require.Contains(t, ei.Reason, string(check.LimitationMaxDepthExceeded))
})
})
}
func TestBatchCheckLimitHandler(t *testing.T) {
for _, scenario := range []testhelpers.Scenario{
widthLimitScenario,
widthLimitScenario.WithStrict(true),
} {
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadWidth, 1))
h := check.NewHandler(reg)
strict := scenario.Strict
// Bob is only in the truncated branch → MembershipUnknown at width=1.
tuple := testhelpers.APITupleFromString(t, "Resource:doc#readers@User:Bob")
t.Run("case=REST: strict mode surfaces limitation in error field", func(t *testing.T) {
router := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(router)
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{
Tuples: []client.Relationship{relationshipFromAPITuple(tuple)},
})
require.NoError(t, err)
resp, err := ts.Client().Post(
fmt.Sprintf("%s%s?max-depth=10", ts.URL, check.BatchRoute),
"application/json", bytes.NewReader(bodyBytes))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var result client.BatchCheckPermissionResult
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
require.Len(t, result.Results, 1)
require.False(t, result.Results[0].Allowed)
if strict {
require.NotNil(t, result.Results[0].Error)
require.Contains(t, *result.Results[0].Error, string(check.LimitationMaxWidthExceeded))
} else {
require.Empty(t, result.Results[0].Error)
}
})
t.Run("case=gRPC: strict mode surfaces limitation in error field", func(t *testing.T) {
checkClient := newTestGRPCCheckClient(t, h)
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{
Tuples: []*rts.RelationTuple{tuple.ToProto()},
MaxDepth: 10,
})
require.NoError(t, err)
require.Len(t, resp.Results, 1)
require.False(t, resp.Results[0].Allowed)
if strict {
require.Contains(t, resp.Results[0].Error, string(check.LimitationMaxWidthExceeded))
} else {
require.Empty(t, resp.Results[0].Error)
}
})
})
}
}

View File

@@ -0,0 +1,284 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/trace"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/testhelpers"
)
var rewrites = testhelpers.Scenario{
Name: "userset rewrites",
Opl: `
class User implements Namespace {}
class Doc implements Namespace {
related: {
owner: User[]
parent: Doc[]
}
permits = {
editor: (ctx: Context) => this.related.owner.includes(ctx.subject),
viewer: (ctx: Context) => this.permits.editor(ctx) ||
this.related.parent.traverse(p => p.permits.viewer(ctx))
}
}
class Group implements Namespace {
related: {
member: User[]
}
}
class Level implements Namespace {
related: {
member: User[]
}
}
class Resource implements Namespace {
related: {
level: Level[]
owner: Group[]
}
permits = {
viewer: (ctx: Context) => this.related.owner.traverse(g => g.related.member.includes(ctx.subject)),
isOwner: (ctx: Context) => this.related.owner.traverse(g => g.related.member.includes(ctx.subject)),
read: (ctx: Context) => this.permits.viewer(ctx) || this.permits.isOwner(ctx),
update: (ctx: Context) => this.permits.isOwner(ctx),
delete: (ctx: Context) => this.permits.isOwner(ctx) && this.related.level.traverse(l => l.related.member.includes(ctx.subject)),
}
}
class Acl implements Namespace {
related: {
allow: User[]
deny: User[]
}
permits = {
access: (ctx: Context) => this.related.allow.includes(ctx.subject) && !this.related.deny.includes(ctx.subject)
}
}
`,
InputTuples: []string{
// Direct ownership — plain and namespace-qualified subjects both work.
"Doc:document#owner@plain_user",
"Doc:document#owner@User:user",
// Single-level parent: doc_in_folder inherits viewer from its parent folder.
"Doc:folder#owner@plain_user",
"Doc:folder#owner@User:user",
"Doc:doc_in_folder#parent@Doc:folder",
// Deep hierarchy: user owns folder_a; viewer propagates down 4 levels to file.
"Doc:folder_a#owner@user",
"Doc:folder_b#parent@Doc:folder_a",
"Doc:folder_c#parent@Doc:folder_b",
"Doc:file#parent@Doc:folder_c",
// Group + level gating: delete requires owner-group membership AND correct level.
"Group:editors#member@mark",
"Group:editors#member@mike",
"Level:superadmin#member@mark",
"Level:superadmin#member@sandy",
"Resource:topsecret#owner@Group:editors#",
"Resource:topsecret#level@Level:superadmin#",
// Allow/deny ACL: deny overrides allow (mallory is on both lists).
"Acl:document#allow@alice",
"Acl:document#allow@bob",
"Acl:document#allow@mallory",
"Acl:document#deny@mallory",
},
}
type path []string
func TestUsersetRewrites(t *testing.T) {
testCases := []struct {
query string
expected check.Result
expectedPaths []path
}{
{
// direct
query: "Doc:document#owner@User:user",
expected: check.ResultIsMember,
},
{
// userset rewrite
query: "Doc:document#editor@User:user",
expected: check.ResultIsMember,
},
{
// userset rewrite
query: "Doc:document#editor@plain_user",
expected: check.ResultIsMember,
},
{
// transitive userset rewrite
query: "Doc:document#viewer@User:user",
expected: check.ResultIsMember,
},
{
query: "Doc:document#editor@nobody",
expected: check.ResultNotMember,
},
{
query: "Doc:folder#viewer@User:user",
expected: check.ResultIsMember,
},
{
// tuple to userset
query: "Doc:doc_in_folder#viewer@User:user",
expected: check.ResultIsMember,
},
{
// tuple to userset
query: "Doc:doc_in_folder#viewer@plain_user",
expected: check.ResultIsMember,
},
{
// tuple to userset
query: "Doc:doc_in_folder#viewer@nobody",
expected: check.ResultNotMember,
},
{
// tuple to userset
query: "Doc:another_doc#viewer@user",
expected: check.ResultNotMember,
},
{
query: "Doc:file#viewer@user",
expected: check.ResultIsMember,
},
{
query: "Level:superadmin#member@mark",
expected: check.ResultIsMember, // mark is both editor and has correct level
},
{
query: "Resource:topsecret#isOwner@mark",
expected: check.ResultIsMember, // mark is both editor and has correct level
},
{
query: "Resource:topsecret#delete@mark",
expected: check.ResultIsMember, // mark is both editor and has correct level
expectedPaths: []path{
{"*", "Resource:topsecret#delete@mark", "*", "Level:superadmin#member@mark"},
{"*", "Resource:topsecret#delete@mark", "Resource:topsecret#isOwner@mark", "*", "*", "*", "Group:editors#member@mark"},
},
},
{
query: "Resource:topsecret#update@mike",
expected: check.ResultIsMember, // mike owns the resource
},
{
query: "Level:superadmin#member@mike",
expected: check.ResultNotMember, // mike does not have correct level
},
{
query: "Resource:topsecret#delete@mike",
expected: check.ResultNotMember, // mike does not have correct level
},
{
query: "Resource:topsecret#delete@sandy",
expected: check.ResultNotMember, // sandy is not in the editor Group
},
{
query: "Acl:document#access@alice",
expected: check.ResultIsMember,
expectedPaths: []path{{"*", "Acl:document#access@alice", "Acl:document#allow@alice"}},
},
{
query: "Acl:document#access@bob",
expected: check.ResultIsMember,
},
{
query: "Acl:document#allow@mallory",
expected: check.ResultIsMember,
},
{
query: "Acl:document#access@mallory",
expected: check.ResultNotMember, // mallory is also on deny-list
},
}
rewrites.Run(t, func(t *testing.T, reg driver.Registry) {
e := trace.NewEngine(reg)
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
t.Run("suite=testcases", func(t *testing.T) {
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
for _, tc := range testCases {
t.Run("case="+tc.query, func(t *testing.T) {
rt := testhelpers.TupleFromString(t, tc.query)
res, tree := e.CheckRelationTupleWithTrace(t.Context(), rt, 100)
require.NoError(t, res.Err)
assert.Equal(t, tc.expected.Membership.String(), res.Membership.String())
if len(tc.expectedPaths) > 0 {
for _, path := range tc.expectedPaths {
assertPath(t, path, tree)
}
}
})
}
})
t.Run("suite=one worker", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
e := trace.NewEngine(reg)
rt := testhelpers.TupleFromString(t, "Doc:file#viewer@user")
res, _ := e.CheckRelationTupleWithTrace(ctx, rt, 100)
require.NoError(t, res.Err)
assert.Equal(t, check.ResultIsMember.Membership, res.Membership)
})
})
}
// assertPath asserts that the given path can be found in the tree.
func assertPath(t *testing.T, path path, tree *trace.Node) {
require.NotNil(t, tree)
assert.True(t, hasPath(t, path, tree), "could not find path %s in tree:\n%s", path, tree)
}
func hasPath(t *testing.T, path path, tree *trace.Node) bool {
if len(path) == 0 {
return true
}
treeLabel := tree.Tuple.String()
if path[0] != "*" {
// use testhelpers.TupleFromString to compare against paths with UUIDs.
tuple := testhelpers.TupleFromString(t, path[0])
tupleStr := tuple.String()
if tupleStr != treeLabel {
return false
}
}
if len(path) == 1 {
return true
}
for _, child := range tree.Children {
if hasPath(t, path[1:], child) {
return true
}
}
return false
}

32
internal/check/step.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check
import (
"context"
)
// StepKind identifies the execution model type of a Step.
type StepKind string
const (
StepIsAllowed StepKind = "is_allowed"
StepDirect StepKind = "direct"
StepDirectMulti StepKind = "direct_multi"
StepExpand StepKind = "expand_subject"
StepComputed StepKind = "computed"
StepTraverse StepKind = "traverse"
StepRewrite StepKind = "rewrite"
StepInvert StepKind = "invert"
)
// Step is a single unit of execution within the permission check engine.
type Step interface {
// Kind returns the execution-model kind of this step.
Kind() StepKind
// Execute performs the step's work and returns the membership result.
// Child steps must be dispatched through ex, not called directly.
Execute(ctx context.Context, req CheckRequest, ex Executor) Result
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"net/http"
"github.com/ory/herodot"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"github.com/ory/keto/internal/check"
)
type callBudgetKeyType struct{}
var callBudgetKey callBudgetKeyType
// maxCallSteps bounds the number of steps along a single resolution path. Most
// recursion is already bounded by RestDepth, but same-object computed hops
// (ComputedUsersetStep, InvertStep) don't decrement it, so a cyclic config
// (e.g. two permits that reference each other) would otherwise recurse until
// the stack overflows and crashes the process. This is a blanket per-path
// ceiling, well above any real config and well below a stack overflow.
const maxCallSteps = 1000
func errCallBudgetExceeded() error {
return errors.WithStack(&herodot.DefaultError{
StatusField: http.StatusText(http.StatusUnprocessableEntity),
ErrorField: "The check could not be completed",
ReasonField: "The check was aborted after too many resolution steps. This usually indicates a cyclic permit reference in the namespace configuration.",
CodeField: http.StatusUnprocessableEntity,
GRPCCodeField: codes.FailedPrecondition,
})
}
// CallLimitMiddleware aborts a check once any single resolution path exceeds
// maxCallSteps. It returns a decisive error so the whole check stops
// immediately, rather than a limitation (which is non-decisive and would let a
// cyclic config keep exploring exponentially many branches). The budget is
// carried per-path in the context and seeded lazily.
func CallLimitMiddleware() check.Middleware {
return func(ctx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
remaining, ok := ctx.Value(callBudgetKey).(int)
if !ok {
remaining = maxCallSteps
}
if remaining <= 0 {
return check.Result{Err: errCallBudgetExceeded()}
}
return next(context.WithValue(ctx, callBudgetKey, remaining-1), s, req)
}
}

View File

@@ -0,0 +1,37 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/relationtuple"
)
// ComputedUsersetStep rewrites the tuple's relation to the computed relation
// and delegates to IsAllowedStep.
type ComputedUsersetStep struct {
Relation string
}
func (ComputedUsersetStep) Kind() check.StepKind { return check.StepComputed }
func (s ComputedUsersetStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
WithField("computed subjectSet relation", s.Relation).
Trace("check computed subjectSet")
childReq := check.CheckRequest{
Tuple: &relationtuple.RelationTuple{
Namespace: req.Tuple.Namespace,
Object: req.Tuple.Object,
Relation: s.Relation,
Subject: req.Tuple.Subject,
},
RestDepth: req.RestDepth,
}
return ex.Run(ctx, IsAllowedStep{}, childReq)
}

View File

@@ -0,0 +1,41 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/pkg/errors"
"github.com/ory/keto/internal/check"
)
// DirectStep checks whether the exact relation tuple exists in the database.
type DirectStep struct{}
func (DirectStep) Kind() check.StepKind { return check.StepDirect }
func (DirectStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
if req.RestDepth <= 0 {
return maxDepthReached(ex, req)
}
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
Trace("check direct")
found, err := ex.Deps().RelationTupleManager().ExistsRelationTuples(ctx, req.Tuple.ToQuery())
switch {
case err != nil:
ex.Deps().Logger().
WithField("method", "DirectStep").
WithError(err).
Error("failed to look up direct access in db")
return check.Result{Err: errors.WithStack(err)}
case found:
return check.ResultIsMember
default:
return check.ResultNotMember
}
}

View File

@@ -0,0 +1,40 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/pkg/errors"
"github.com/ory/keto/internal/check"
)
// DirectMultiStep checks whether any of the given relations exist directly in
// the database for the tuple.
type DirectMultiStep struct {
Relations []string
}
func (DirectMultiStep) Kind() check.StepKind { return check.StepDirectMulti }
func (s DirectMultiStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
if req.RestDepth <= 0 {
return maxDepthReached(ex, req)
}
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
WithField("relations", s.Relations).
Trace("check direct multi")
res, err := ex.Deps().RelationTupleManager().FindTupleWithRelations(ctx, req.Tuple, s.Relations)
if err != nil {
return check.Result{Err: errors.WithStack(err)}
}
if res != nil {
return check.ResultIsMember
}
return check.ResultNotMember
}

View File

@@ -0,0 +1,91 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/ory/keto/internal/check"
)
// IsAllowedStep is the central semantic dispatcher. It reads the AST rule for
// the tuple and determines what checks are applicable via RunUnion.
type IsAllowedStep struct {
skipDirect bool
}
func (IsAllowedStep) Kind() check.StepKind { return check.StepIsAllowed }
func (s IsAllowedStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
if req.RestDepth <= 0 {
return maxDepthReached(ex, req)
}
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
Trace("check is allowed")
nm, err := ex.Deps().Config(ctx).NamespaceManager()
if err != nil {
return check.Result{Err: err}
}
relation, err := astRelationFor(ctx, nm, req.Tuple)
if err != nil {
return check.Result{Err: err}
}
hasRewrite := relation != nil && relation.SubjectSetRewrite != nil
strictMode := ex.Deps().Config(ctx).StrictMode()
// In strict mode, only run steps that OPL explicitly permits.
// In non-strict mode, run all applicable steps for backwards compatibility.
canHaveDirect := !strictMode || AllowsDirectMember(relation, req.Tuple.Subject)
steps := make([]check.PlannedStep, 0, 3)
if hasRewrite {
steps = append(steps, check.PlannedStep{
Step: RewriteStep{Rewrite: relation.SubjectSetRewrite},
Req: req,
})
}
if canHaveDirect && !s.skipDirect {
steps = append(steps, check.PlannedStep{
Step: DirectStep{},
Req: req,
})
}
// In strict mode, only expand when OPL explicitly declares subject-set types, and
// filter expansion to only those declared types.
// In non-strict mode, always expand to preserve backwards compatibility.
if strictMode {
subjectSetTypes, err := subjectSetTypesFor(ctx, nm, req.Tuple.Subject, relation)
if err != nil {
return check.Result{Err: err}
}
if len(subjectSetTypes) > 0 {
steps = append(steps, check.PlannedStep{
Step: ExpandSubjectStep{SubjectSetTypes: subjectSetTypes},
Req: req,
})
}
} else {
steps = append(steps, check.PlannedStep{
Step: ExpandSubjectStep{},
Req: req,
})
}
switch len(steps) {
case 0:
return check.ResultNotMember
case 1:
return ex.Run(ctx, steps[0].Step, steps[0].Req)
default:
return ex.RunUnion(ctx, steps...)
}
}

View File

@@ -0,0 +1,277 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"sync"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/relationtuple"
)
type (
relationTuple = relationtuple.RelationTuple
query = relationtuple.RelationQuery
)
// runMode defines the decisiveness and default result semantics for an execution mode.
//
// IsDecisive determines whether a step result ends execution early (short-circuit).
// - Union: IsMember or any Error is decisive.
// - Intersection: NotMember or any Error is decisive.
//
// NoDecisionResult is returned when all steps ran without triggering a short-circuit.
// - Union: NotMember (no member found among alternatives).
// - Intersection: IsMember (all conditions met).
//
// NoStepsResult is returned when no steps were added (empty input slice).
// - Both: NotMember (no tuples to check).
type runMode interface {
IsDecisive(check.Result) bool
NoDecisionResult() check.Result
NoStepsResult() check.Result
}
// unionRunMode implements OR semantics: first IsMember or Error wins.
type unionRunMode struct{}
func (unionRunMode) IsDecisive(r check.Result) bool {
return r.Membership == check.IsMember || r.Err != nil
}
func (unionRunMode) NoDecisionResult() check.Result { return check.ResultNotMember }
func (unionRunMode) NoStepsResult() check.Result { return check.ResultNotMember }
// intersectionRunMode implements AND semantics: first NotMember or Error wins.
type intersectionRunMode struct{}
func (intersectionRunMode) IsDecisive(r check.Result) bool {
return r.Membership == check.NotMember || r.Err != nil
}
func (intersectionRunMode) NoDecisionResult() check.Result { return check.ResultIsMember }
func (intersectionRunMode) NoStepsResult() check.Result { return check.ResultNotMember }
var (
unionMode runMode = unionRunMode{}
intersectionMode runMode = intersectionRunMode{}
)
// Executor implements check.Executor and runs Steps with short-circuit
// union/intersection semantics and a middleware chain.
type Executor struct {
dep check.EngineDependencies
middlewares []check.Middleware
}
func NewExecutor(dep check.EngineDependencies, middlewares ...check.Middleware) *Executor {
return &Executor{dep: dep, middlewares: middlewares}
}
func (e *Executor) Deps() check.EngineDependencies { return e.dep }
func (e *Executor) Run(ctx context.Context, s check.Step, req check.CheckRequest) check.Result {
return e.runAt(ctx, s, req, 0)
}
// runAt executes the middleware chain at position i, then the step's Execute method.
// It implements a functional middleware chain: each middleware receives a next() function
// that continues the chain.
func (e *Executor) runAt(ctx context.Context, s check.Step, req check.CheckRequest, i int) check.Result {
if i >= len(e.middlewares) {
return s.Execute(ctx, req, e)
}
return e.middlewares[i](ctx, s, req, func(ctx context.Context, s check.Step, req check.CheckRequest) check.Result {
return e.runAt(ctx, s, req, i+1)
})
}
func (e *Executor) RunUnion(ctx context.Context, steps ...check.PlannedStep) check.Result {
return runAll(ctx, e, unionMode, 1, steps...)
}
func (e *Executor) RunIntersection(ctx context.Context, steps ...check.PlannedStep) check.Result {
return runAll(ctx, e, intersectionMode, 1, steps...)
}
func (e *Executor) NewUnionRunner(ctx context.Context) check.StepRunner {
return newStepGroup(ctx, 1, unionMode, e)
}
// CheckRelationTuple checks if the tuple's subject has the relation on the object.
func (e *Executor) CheckRelationTuple(ctx context.Context, r *relationTuple, restDepth int) check.Result {
restDepth = clampDepth(ctx, e.dep, restDepth)
return e.Run(ctx, IsAllowedStep{}, check.CheckRequest{
Tuple: r,
RestDepth: restDepth,
})
}
func clampDepth(ctx context.Context, c config.Provider, restDepth int) int {
if globalMaxDepth := c.Config(ctx).MaxReadDepth(); restDepth <= 0 || globalMaxDepth < restDepth {
return globalMaxDepth
}
return restDepth
}
func runAll(ctx context.Context, ex check.Executor, mode runMode, limit int, steps ...check.PlannedStep) check.Result {
if len(steps) == 0 {
return mode.NoStepsResult()
}
g := newStepGroup(ctx, limit, mode, ex)
for _, ps := range steps {
if g.Done() {
break
}
g.Add(ps)
}
return g.Result()
}
// stepGroup runs sub-checks with short-circuit semantics. When sequential (limit=1)
// steps run inline with no goroutine overhead. When limit>1 steps run
// concurrently, cancelling siblings on the first decisive result.
// stepGroup implements check.StepRunner.
type stepGroup struct {
ctx context.Context
cancel context.CancelFunc
ex check.Executor
sequential bool
sem chan struct{}
wg sync.WaitGroup
mu sync.Mutex
result check.Result
set bool
limitation check.LimitationKind
mode runMode
}
// newStepGroup creates a StepGroup that runs steps with the given concurrencyLimit and mode.
// If concurrencyLimit < 1, it's clamped to 1 (sequential). Sequential execution (concurrencyLimit=1)
// runs steps inline without goroutines; concurrent execution spawns workers up to
// concurrencyLimit. The returned context is derived from ctx via WithCancel and is cancelled
// when a decisive result is reached (triggering sibling cancellation).
func newStepGroup(ctx context.Context, concurrencyLimit int, mode runMode, ex check.Executor) *stepGroup {
if concurrencyLimit < 1 {
concurrencyLimit = 1
}
g := &stepGroup{
ex: ex,
sequential: concurrencyLimit == 1,
mode: mode,
}
g.ctx, g.cancel = context.WithCancel(ctx)
if !g.sequential {
g.sem = make(chan struct{}, concurrencyLimit)
}
return g
}
// Add submits ps for execution. No-op if Done returns true.
func (g *stepGroup) Add(ps check.PlannedStep) {
g.add(func(ctx context.Context) check.Result {
return g.ex.Run(ctx, ps.Step, ps.Req)
})
}
func (g *stepGroup) add(f func(context.Context) check.Result) {
if g.sequential {
if g.Done() {
return
}
g.handle(f(g.ctx))
return
}
// currently dead path as we always use concurrentLimit = 1
select {
case g.sem <- struct{}{}:
case <-g.ctx.Done():
return
}
if g.Done() {
<-g.sem
return
}
g.wg.Add(1)
go func() {
defer g.wg.Done()
defer func() { <-g.sem }()
g.handle(f(g.ctx))
}()
}
func (g *stepGroup) AddLimitation(l check.LimitationKind) {
g.mu.Lock()
if !g.set && g.limitation == "" {
g.limitation = l
}
g.mu.Unlock()
}
func (g *stepGroup) handle(r check.Result) {
if g.mode.IsDecisive(r) && g.trySetResult(r) {
g.cancel()
return
}
if r.Membership == check.MembershipUnknown && r.Limitation != "" {
g.AddLimitation(r.Limitation)
}
}
// Done reports whether a decisive result has been reached.
func (g *stepGroup) Done() bool {
select {
case <-g.ctx.Done():
return true
default:
return false
}
}
// Ctx returns the group's context.
func (g *stepGroup) Ctx() context.Context { return g.ctx }
// Result waits for all in-flight steps to complete and returns the aggregated result.
// Safe to call multiple times; subsequent calls return the same value.
func (g *stepGroup) Result() check.Result {
g.wg.Wait()
// Capture ctx.Err() before g.cancel() to distinguish an external cancellation
// (deadline exceeded, parent cancelled) from the internal cancel used to signal siblings.
ctxErr := g.ctx.Err()
g.cancel()
g.mu.Lock()
defer g.mu.Unlock()
// No decisive result was set. Return in priority order:
// 1. Context cancelled by parent or sibling — propagate the error.
// 2. A limitation was recorded — result is indeterminate.
// 3. Neither — no step was decisive, so fall back to the mode default.
if !g.set {
if ctxErr != nil {
g.result = check.Result{Membership: check.MembershipUnknown, Err: ctxErr}
} else if g.limitation != "" {
g.result = check.Result{Membership: check.MembershipUnknown, Limitation: g.limitation}
} else {
g.result = g.mode.NoDecisionResult()
}
g.set = true
}
return g.result
}
func (g *stepGroup) trySetResult(r check.Result) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.set {
return false
}
g.set = true
g.result = r
return true
}

View File

@@ -0,0 +1,587 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/check"
)
func TestBaseExecutorRunSequential(t *testing.T) {
t.Parallel()
sentinel := errors.New("planted error")
unknownErr := check.Result{Membership: check.MembershipUnknown, Err: sentinel}
unknownLimit := check.Result{Membership: check.MembershipUnknown, Limitation: check.LimitationMaxDepthExceeded}
t.Run("RunUnion", func(t *testing.T) {
t.Parallel()
t.Run("NotMember when there is no step", func(t *testing.T) {
t.Parallel()
result := NewExecutor(nil).RunUnion(t.Context())
assert.Equal(t, check.ResultNotMember, result)
})
t.Run("return error when called after context is canceled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
calls := make([]int, 0, 1)
steps := plannedSteps([]check.Result{check.ResultIsMember}, &calls)
result := NewExecutor(nil).RunUnion(ctx, steps...)
// context is cancelled, therefore steps did not run, and the result Unknown
assert.Equal(t, check.MembershipUnknown, result.Membership)
assert.ErrorIs(t, result.Err, context.Canceled)
})
t.Run("table", func(t *testing.T) {
t.Parallel()
type testCase struct {
name string
results []check.Result
expectedResult check.Result
expectedErr error
expectedCalls []int
}
for _, tc := range []testCase{
{
name: "runs all steps when none is member",
results: []check.Result{check.ResultUnknown, check.ResultNotMember, check.ResultUnknown, check.ResultNotMember, check.ResultNotMember},
// todo: ideally, this should be Unknown
expectedResult: check.ResultNotMember,
expectedCalls: []int{0, 1, 2, 3, 4},
},
{
name: "stops on first Member",
results: []check.Result{check.ResultNotMember, check.ResultIsMember, check.ResultNotMember, check.ResultNotMember},
expectedResult: check.ResultIsMember,
expectedCalls: []int{0, 1},
},
{
name: "stops on first Member; last step is Member",
results: []check.Result{check.ResultNotMember, check.ResultNotMember, check.ResultNotMember, check.ResultIsMember},
expectedResult: check.ResultIsMember,
expectedCalls: []int{0, 1, 2, 3},
},
{
name: "stops on first Member; therefore doesn't run error after Member",
results: []check.Result{check.ResultNotMember, check.ResultIsMember, {Err: sentinel}, check.ResultNotMember},
expectedResult: check.ResultIsMember,
expectedCalls: []int{0, 1},
},
{
name: "error short-circuits",
results: []check.Result{check.ResultNotMember, {Err: sentinel}, check.ResultNotMember, check.ResultNotMember},
expectedResult: check.ResultUnknown,
expectedErr: sentinel,
expectedCalls: []int{0, 1},
},
{
name: "NotMember + Error = Unknown+error",
results: []check.Result{check.ResultNotMember, unknownErr},
expectedResult: check.ResultUnknown,
expectedErr: sentinel,
expectedCalls: []int{0, 1},
},
{
name: "should stop on first error",
results: []check.Result{unknownErr, check.ResultIsMember},
expectedResult: check.ResultUnknown,
expectedErr: sentinel,
expectedCalls: []int{0},
},
{
name: "NotMember + limit = Unknown",
results: []check.Result{check.ResultNotMember, unknownLimit},
expectedResult: unknownLimit,
expectedCalls: []int{0, 1},
},
{
name: "Limit + NotMember = Unknown",
results: []check.Result{unknownLimit, check.ResultNotMember},
expectedResult: unknownLimit,
expectedCalls: []int{0, 1},
},
{
name: "Limit + Member = Member",
results: []check.Result{unknownLimit, check.ResultIsMember},
expectedResult: check.ResultIsMember,
expectedCalls: []int{0, 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
calls := make([]int, 0, len(tc.results))
steps := plannedSteps(tc.results, &calls)
result := NewExecutor(nil).RunUnion(t.Context(), steps...)
assert.Equal(t, tc.expectedResult.Membership, result.Membership)
assert.ErrorIs(t, result.Err, tc.expectedErr)
assert.Equal(t, tc.expectedCalls, calls)
})
}
})
})
t.Run("RunIntersection", func(t *testing.T) {
t.Parallel()
t.Run("NotMember when there is no step", func(t *testing.T) {
t.Parallel()
result := NewExecutor(nil).RunIntersection(t.Context())
assert.Equal(t, check.ResultNotMember, result)
})
t.Run("return error when called after context is canceled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
calls := make([]int, 0, 1)
steps := plannedSteps([]check.Result{check.ResultIsMember}, &calls)
result := NewExecutor(nil).RunIntersection(ctx, steps...)
// context is cancelled, therefore steps did not run, and the result Unknown
assert.Equal(t, check.MembershipUnknown, result.Membership)
assert.ErrorIs(t, result.Err, context.Canceled)
})
t.Run("table", func(t *testing.T) {
t.Parallel()
type testCase struct {
name string
results []check.Result
expectedResult check.Result
expectedErr error
expectedCalls []int
}
for _, tc := range []testCase{
{
name: "runs all steps when no NotMember",
results: []check.Result{check.ResultIsMember, check.ResultIsMember, check.ResultIsMember},
expectedResult: check.ResultIsMember,
expectedCalls: []int{0, 1, 2},
},
{
name: "stops on first NotMember",
results: []check.Result{check.ResultIsMember, check.ResultNotMember, check.ResultIsMember, check.ResultIsMember},
expectedResult: check.ResultNotMember,
expectedCalls: []int{0, 1},
},
{
name: "stops on first NotMember; last step is NotMember",
results: []check.Result{check.ResultIsMember, check.ResultIsMember, check.ResultIsMember, check.ResultNotMember},
expectedResult: check.ResultNotMember,
expectedCalls: []int{0, 1, 2, 3},
},
{
name: "error short-circuits",
results: []check.Result{check.ResultIsMember, {Err: sentinel}, check.ResultIsMember, check.ResultIsMember},
expectedResult: check.ResultUnknown,
expectedErr: sentinel,
expectedCalls: []int{0, 1},
},
{
name: "Member + Error = Unknown+error",
results: []check.Result{check.ResultIsMember, unknownErr},
expectedResult: check.ResultUnknown,
expectedErr: sentinel,
expectedCalls: []int{0, 1},
},
{
name: "should stop on first error",
results: []check.Result{unknownErr, check.ResultIsMember},
expectedResult: check.ResultUnknown,
expectedErr: sentinel,
expectedCalls: []int{0},
},
{
name: "Member + limit = Unknown",
results: []check.Result{check.ResultIsMember, unknownLimit},
expectedResult: unknownLimit,
expectedCalls: []int{0, 1},
},
{
name: "Limit + Member = Unknown",
results: []check.Result{unknownLimit, check.ResultIsMember},
expectedResult: unknownLimit,
expectedCalls: []int{0, 1},
},
{
name: "Limit + NotMember = NotMember",
results: []check.Result{unknownLimit, check.ResultNotMember},
expectedResult: check.ResultNotMember,
expectedCalls: []int{0, 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
calls := make([]int, 0, len(tc.results))
steps := plannedSteps(tc.results, &calls)
result := NewExecutor(nil).RunIntersection(t.Context(), steps...)
assert.Equal(t, tc.expectedResult.Membership, result.Membership)
assert.Equal(t, tc.expectedResult.Limitation, result.Limitation)
assert.ErrorIs(t, result.Err, tc.expectedErr)
assert.Equal(t, tc.expectedCalls, calls)
})
}
})
})
}
func TestGroupSequential(t *testing.T) {
t.Parallel()
t.Run("Add after decisive result is no-op", func(t *testing.T) {
t.Parallel()
calls := make([]int, 0, 3)
runner := newStepGroup(t.Context(), 1, unionMode, NewExecutor(nil))
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls = append(calls, 0)
return check.ResultNotMember
})})
require.False(t, runner.Done())
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls = append(calls, 1)
return check.ResultIsMember
})})
require.True(t, runner.Done())
// this step is no-op because the runner is already done with a decisive result
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls = append(calls, 2)
return check.ResultIsMember
})})
result := runner.Result()
require.Equal(t, check.IsMember, result.Membership)
assert.Equal(t, []int{0, 1}, calls)
})
t.Run("Add after cancelled context does not execute the step", func(t *testing.T) {
t.Parallel()
calls := 0
ctx, cancel := context.WithCancel(t.Context())
cancel()
runner := newStepGroup(ctx, 1, unionMode, NewExecutor(nil))
require.True(t, runner.Done())
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls++
return check.ResultIsMember
})})
assert.Equal(t, 0, calls)
})
t.Run("Result after cancelled context returns MembershipUnknown and context.Canceled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
runner := newStepGroup(ctx, 1, unionMode, NewExecutor(nil))
result := runner.Result()
assert.Equal(t, check.MembershipUnknown, result.Membership)
assert.ErrorIs(t, result.Err, context.Canceled)
})
t.Run("limit lower than one is clamped to sequential", func(t *testing.T) {
t.Parallel()
for _, limit := range []int{0, -1} {
t.Run("limit", func(t *testing.T) {
t.Parallel()
calls := make([]int, 0, 3)
runner := newStepGroup(t.Context(), limit, unionMode, NewExecutor(nil))
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls = append(calls, 0)
return check.ResultNotMember
})})
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls = append(calls, 1)
return check.ResultIsMember
})})
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls = append(calls, 2)
return check.ResultNotMember
})})
result := runner.Result()
require.Equal(t, check.IsMember, result.Membership)
assert.Equal(t, []int{0, 1}, calls)
})
}
})
t.Run("Result on empty group uses mode default", func(t *testing.T) {
t.Parallel()
unionRunner := newStepGroup(t.Context(), 1, unionMode, NewExecutor(nil))
assert.Equal(t, check.ResultNotMember, unionRunner.Result())
intersectionRunner := newStepGroup(t.Context(), 1, intersectionMode, NewExecutor(nil))
assert.Equal(t, check.ResultIsMember, intersectionRunner.Result())
})
t.Run("Result called twice returns the same value", func(t *testing.T) {
t.Parallel()
t.Run("decisive result", func(t *testing.T) {
t.Parallel()
runner := newStepGroup(t.Context(), 1, unionMode, NewExecutor(nil))
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
return check.ResultIsMember
})})
assert.Equal(t, check.IsMember, runner.Result().Membership)
assert.Equal(t, check.IsMember, runner.Result().Membership)
})
t.Run("no decisive result", func(t *testing.T) {
t.Parallel()
// When g.set is false after all steps complete, Result() commits the final value.
// A second call must not return context.Canceled due to the internal g.cancel() from the first call.
runner := newStepGroup(t.Context(), 1, intersectionMode, NewExecutor(nil))
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
return check.ResultIsMember
})})
assert.Equal(t, check.IsMember, runner.Result().Membership)
assert.Equal(t, check.IsMember, runner.Result().Membership)
})
t.Run("cancelled context", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
runner := newStepGroup(ctx, 1, unionMode, NewExecutor(nil))
assert.ErrorIs(t, runner.Result().Err, context.Canceled)
assert.ErrorIs(t, runner.Result().Err, context.Canceled)
})
})
}
func TestGroupConcurrent(t *testing.T) {
t.Parallel()
t.Run("union", func(t *testing.T) {
t.Parallel()
t.Run("siblings gets context cancellation when one sibling returns a decisive result", func(t *testing.T) {
t.Parallel()
var ran [3]atomic.Bool
var blockedStepCancelled atomic.Bool
runner := newStepGroup(t.Context(), 2, unionMode, NewExecutor(nil))
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
ran[0].Store(true)
<-ctx.Done()
blockedStepCancelled.Store(true)
return check.ResultNotMember
})})
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
ran[1].Store(true)
return check.ResultIsMember
})})
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
ran[2].Store(true)
return check.ResultNotMember
})})
result := runner.Result()
require.Equal(t, check.IsMember, result.Membership)
assert.True(t, ran[0].Load())
assert.True(t, ran[1].Load())
assert.True(t, blockedStepCancelled.Load())
assert.False(t, ran[2].Load())
})
t.Run("internal sibling cancellation does not leak as error when decisive result exists", func(t *testing.T) {
t.Parallel()
runner := newStepGroup(t.Context(), 2, unionMode, NewExecutor(nil))
// Step 1 propagates its context error on cancellation.
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
<-ctx.Done()
return check.Result{Err: ctx.Err()}
})})
// Step 2 returns a decisive result, which internally cancels step 1.
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
return check.ResultIsMember
})})
result := runner.Result()
assert.Equal(t, check.IsMember, result.Membership)
assert.NoError(t, result.Err)
})
t.Run("Result blocks until all in-flight goroutines finish", func(t *testing.T) {
t.Parallel()
var stepCompleted atomic.Bool
runner := newStepGroup(t.Context(), 2, unionMode, NewExecutor(nil))
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
<-ctx.Done()
stepCompleted.Store(true)
return check.ResultNotMember
})})
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
return check.ResultIsMember
})})
result := runner.Result()
require.Equal(t, check.IsMember, result.Membership)
assert.True(t, stepCompleted.Load(), "Result() returned before the in-flight goroutine finished")
})
t.Run("Add after cancelled context does not execute the step", func(t *testing.T) {
t.Parallel()
var calls atomic.Int64
ctx, cancel := context.WithCancel(t.Context())
cancel()
runner := newStepGroup(ctx, 2, unionMode, NewExecutor(nil))
require.True(t, runner.Done())
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
calls.Add(1)
return check.ResultIsMember
})})
assert.Equal(t, int64(0), calls.Load())
})
t.Run("Result after cancelled context returns MembershipUnknown and context.Canceled", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
runner := newStepGroup(ctx, 2, unionMode, NewExecutor(nil))
result := runner.Result()
assert.Equal(t, check.MembershipUnknown, result.Membership)
assert.ErrorIs(t, result.Err, context.Canceled)
})
t.Run("context cancelled mid-step: error propagates to Result", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
runner := newStepGroup(ctx, 2, unionMode, NewExecutor(nil))
// Step 1 blocks until the context is cancelled, then returns the cancellation error.
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
<-ctx.Done()
return check.Result{Err: ctx.Err()}
})})
cancel()
// Step 2 is a no-op: g.ctx is already cancelled so Add returns immediately without running it.
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
return check.ResultNotMember
})})
result := runner.Result()
assert.ErrorIs(t, result.Err, context.Canceled)
assert.Equal(t, check.MembershipUnknown, result.Membership)
})
t.Run("returns NotMember when no step is decisive", func(t *testing.T) {
t.Parallel()
runner := newStepGroup(t.Context(), 3, unionMode, NewExecutor(nil))
for range 3 {
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
return check.ResultNotMember
})})
}
result := runner.Result()
assert.Equal(t, check.NotMember, result.Membership)
assert.NoError(t, result.Err)
})
})
t.Run("intersection", func(t *testing.T) {
t.Parallel()
t.Run("all steps IsMember returns IsMember without error", func(t *testing.T) {
t.Parallel()
// For intersection, IsMember is not decisive so g.set stays false throughout.
// Result() relies on ctxErr being nil (no internal g.cancel() fires) to reach NoDecisionResult().
// This test documents that the success path produces no spurious error.
runner := newStepGroup(t.Context(), 3, intersectionMode, NewExecutor(nil))
for range 3 {
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
return check.ResultIsMember
})})
}
result := runner.Result()
assert.Equal(t, check.IsMember, result.Membership)
assert.NoError(t, result.Err)
})
})
}
// plannedSteps is a test helper that creates a slice of PlannedStep from a slice of Results.
// Each step records its index in calls when executed, allowing tests to verify execution order and count.
func plannedSteps(results []check.Result, calls *[]int) []check.PlannedStep {
var mu sync.Mutex
steps := make([]check.PlannedStep, 0, len(results))
for i, result := range results {
steps = append(steps, check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
mu.Lock()
*calls = append(*calls, i)
mu.Unlock()
return result
})})
}
return steps
}
// stepFunc is a Step whose Execute calls f directly, ignoring req and executor.
type stepFunc func(ctx context.Context) check.Result
func (stepFunc) Kind() check.StepKind { return check.StepIsAllowed }
func (f stepFunc) Execute(ctx context.Context, _ check.CheckRequest, _ check.Executor) check.Result {
return f(ctx)
}

View File

@@ -0,0 +1,100 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/ory/herodot"
"github.com/pkg/errors"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x/graph"
)
// ExpandSubjectStep expands subject-set pointers under the tuple's relation and recursively checks each one.
type ExpandSubjectStep struct {
// SubjectSetTypes are the OPL-declared subject-set types for this relation.
// When empty, all subject-set pointers are expanded.
// When non-empty, these types can be used to narrow down the expansion.
SubjectSetTypes []relationtuple.SubjectSetType
}
func (ExpandSubjectStep) Kind() check.StepKind { return check.StepExpand }
func (s ExpandSubjectStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
if req.RestDepth <= 0 {
return maxDepthReached(ex, req)
}
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
Trace("check expand subject")
results, err := ex.Deps().RelationTupleManager().TraverseSubjectSetExpansion(ctx, req.Tuple, s.SubjectSetTypes)
if errors.Is(err, herodot.ErrNotFound()) {
return check.ResultNotMember
} else if err != nil {
return check.Result{Err: errors.WithStack(err)}
}
check.ReportTuplesLoaded(ctx, len(results))
// Check whether the current hop already resolved the query.
for _, result := range results {
if result.Found {
check.ReportSubjectFound(ctx, result.To)
return check.ResultIsMember
}
}
if len(results) == 0 {
return check.ResultNotMember
}
// If we have results, but no direct hits, we need to check the next level of expansion.
// We check the depth here to prevent planning a large number of child checks when they will already be at max depth.
if req.RestDepth <= 1 {
return maxDepthReached(ex, req)
}
// Build the visited-tracking context from the current ctx so that child
// checks share the same visited set and prevent infinite loops.
var visited bool
visitCtx := graph.InitVisited(ctx)
runner := ex.NewUnionRunner(visitCtx)
if maxWidth := ex.Deps().Config(ctx).MaxReadWidth(); len(results) > maxWidth {
ex.Deps().Logger().
WithField("method", "ExpandSubjectStep").
WithField("request", req.Tuple.String()).
WithField("max_width", maxWidth).
WithField("results", len(results)).
Debug("too many results, truncating to width limit")
results = results[:maxWidth]
runner.AddLimitation(check.LimitationMaxWidthExceeded)
}
for _, result := range results {
if runner.Done() {
break
}
sub := &relationtuple.SubjectSet{
Namespace: result.To.Namespace,
Object: result.To.Object,
Relation: result.To.Relation,
}
visitCtx, visited = graph.CheckAndAddVisited(visitCtx, sub)
if visited {
continue
}
runner.Add(check.PlannedStep{
Step: IsAllowedStep{skipDirect: true},
Req: check.CheckRequest{Tuple: result.To, RestDepth: req.RestDepth - 1},
})
}
return runner.Result()
}

View File

@@ -0,0 +1,479 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/trace"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/testhelpers"
)
// TestExpandSubjectStep_MemberFound verifies that ExpandSubjectStep produces
// the correct short-circuit trace when the subject is found via subject-set expansion.
func TestExpandSubjectStep_MemberFound(t *testing.T) {
t.Parallel()
namespaceOnlyOPL := `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`
tests := []struct {
scenario testhelpers.Scenario
checkInput string
expectedMembership check.Membership
expectedTrace func(t testing.TB) *trace.Node
}{
{
scenario: testhelpers.Scenario{
Name: "1-hop subject-set hit resolves without recursive sub-check",
Opl: namespaceOnlyOPL,
InputTuples: []string{"File:f1#viewers@Group:g1#members", "Group:g1#members@User:Alice"},
},
checkInput: "File:f1#viewers@User:Alice",
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Alice",
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice"),
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Alice", 1,
trace.FoundNode(t, "Group:g1#members@User:Alice"),
),
)
},
},
{
scenario: testhelpers.Scenario{
Name: "2-hop subject-set hit resolves without recursive sub-check",
Opl: namespaceOnlyOPL,
InputTuples: []string{
"File:f1#viewers@Group:g1#members",
"Group:g1#members@Group:g2#members",
"Group:g2#members@User:Alice",
},
},
checkInput: "File:f1#viewers@User:Alice",
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Alice",
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice"),
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Alice", 1,
trace.CheckNode(t, trace.ResultMember, "Group:g1#members@User:Alice",
trace.ExpandNode(t, trace.ResultMember, "Group:g1#members@User:Alice", 1,
trace.FoundNode(t, "Group:g2#members@User:Alice"),
),
),
),
)
},
},
}
for _, tt := range tests {
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
t.Parallel()
e := trace.NewEngine(reg)
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, tt.checkInput), 100)
require.Equal(t, tt.expectedMembership, res.Membership)
require.NoError(t, res.Err)
require.Equal(t, trace.SortNode(tt.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
})
}
}
// TestExpandSubjectStep_NotMember documents the not-member traces across the three
// OPL modes (namespace-only; typed OPL; typed OPL with strict mode).
//
// Without typed OPL, the engine follows every subject-set pointer in the DB, including
// leaf subjects stored as subject-sets with an empty relation. With typed OPL the engine
// filters expansion to only the subject-set types declared in the schema, skipping leaf
// subjects that are not valid subject-set types for the relation.
func TestExpandSubjectStep_NotMember(t *testing.T) {
t.Parallel()
typedOPL := `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: (User|SubjectSet<Group, "members">)[]
}
}
class File implements Namespace{
related: {
viewers: SubjectSet<Group, "members">[]
}
}
`
inputTuples := []string{
"File:f1#viewers@Group:g1#members",
"Group:g1#members@User:Alice",
}
tests := []struct {
scenario testhelpers.Scenario
checkInput string
expectedMembership check.Membership
expectedTrace func(t testing.TB) *trace.Node
}{
{
// The engine follows every subject-set pointer including leaf subjects stored as SubjectSets.
// In only-namespaces setup, everything in DB is treated as a valid.
scenario: testhelpers.Scenario{
Name: "namespace-only-OPL: no filter available, recurses into leaf subject-sets",
Opl: `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`,
InputTuples: inputTuples,
},
checkInput: "File:f1#viewers@User:Bob",
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob",
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob"),
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob", 1,
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob",
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob", 1,
// Redundant: the engine doesn't know Alice is a leaf subject,
// so it creates a sub-check that always returns not_member.
trace.CheckNode(t, trace.ResultNotMember, "User:Alice#@User:Bob",
trace.ExpandNode(t, trace.ResultNotMember, "User:Alice#@User:Bob", 0),
),
),
),
),
)
},
},
{
// In non-strict mode the OPL filter is not applied, so the engine recurses
// into every subject-set pointer including leaf Users stored as empty-relation
// SubjectSets. This matches the namespace-only behavior.
scenario: testhelpers.Scenario{
Name: "valid-OPL: without filter, recurses into leaf subject-sets",
Opl: typedOPL,
InputTuples: inputTuples,
},
checkInput: "File:f1#viewers@User:Bob",
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob",
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob"),
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob", 1,
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob",
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob", 1,
trace.CheckNode(t, trace.ResultNotMember, "User:Alice#@User:Bob",
trace.ExpandNode(t, trace.ResultNotMember, "User:Alice#@User:Bob", 0),
),
),
),
),
)
},
},
{
scenario: testhelpers.Scenario{
Name: "valid-OPL-strict: with filter, skips leaf subject-sets",
Strict: true,
Opl: typedOPL,
InputTuples: inputTuples,
},
checkInput: "File:f1#viewers@User:Bob",
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
// In strict mode, File#viewers only accepts SubjectSet<Group, "members">.
// User:Bob# (namespace=User) is not a declared direct type for "viewers" relation,
// so the direct check is skipped entirely. Only the subject-set expansion runs.
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob",
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob", 1,
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob",
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob", 0),
),
),
)
},
},
}
for _, tt := range tests {
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
t.Parallel()
e := trace.NewEngine(reg)
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, tt.checkInput), 100)
require.Equal(t, tt.expectedMembership, res.Membership)
require.NoError(t, res.Err)
require.Equal(t, trace.SortNode(tt.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
})
}
}
// TestExpandSubjectStep_StaleSubjectSet shows how stale subject-set tuples behave
// differently in strict vs non-strict mode.
//
// Scenario: OPL declares viewers: SubjectSet<Group, "admins">[], but the DB still
// contains a tuple for the old "members" relation. In strict mode the OPL filter
// blocks the stale pointer and access is denied. In non-strict mode no filter is
// applied, so the stale pointer is followed and access is incorrectly granted.
// Clients should migrate to strict mode to prevent stale tuples from granting access.
func TestExpandSubjectStep_StaleSubjectSet(t *testing.T) {
t.Parallel()
opl := `
class User implements Namespace{}
class Group implements Namespace{
related: {
admins: User[]
}
}
class File implements Namespace{
related: {
viewers: SubjectSet<Group, "admins">[]
}
}
`
inputTuples := []string{
// "members" was removed from OPL but the tuple was not cleaned up in the DB.
"File:f1#viewers@Group:g1#members",
"Group:g1#members@User:Alice",
}
tests := []struct {
scenario testhelpers.Scenario
expectedMembership check.Membership
expectedTrace func(t testing.TB) *trace.Node
}{
{
// Without the OPL filter, the stale "members" pointer is followed and
// Alice is found as a direct member of Group:g1#members.
scenario: testhelpers.Scenario{
Name: "valid-OPL: stale subject-set tuples are followed without OPL filter",
Opl: opl,
InputTuples: inputTuples,
},
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Alice",
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice"),
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Alice", 1,
trace.FoundNode(t, "Group:g1#members@User:Alice"),
),
)
},
},
{
// In strict mode the OPL filter is applied: only SubjectSet<Group,"admins">
// pointers are expanded. The stale "members" tuple is filtered out.
// The direct step is also skipped because File#viewers does not declare
// User as a direct type — only SubjectSet<Group,"admins"> is allowed.
scenario: testhelpers.Scenario{
Name: "valid-OPL-strict: stale subject-set tuples are filtered by OPL",
Strict: true,
Opl: opl,
InputTuples: inputTuples,
},
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice",
// tuples_loaded=0: expand found no valid subject-set tuples with "admins" relation.
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice", 0),
)
},
},
}
for _, tt := range tests {
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
t.Parallel()
e := trace.NewEngine(reg)
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, "File:f1#viewers@User:Alice"), 100)
require.Equal(t, tt.expectedMembership, res.Membership)
require.NoError(t, res.Err)
require.Equal(t, trace.SortNode(tt.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
})
}
}
func TestSubjectSetExpansionOPLFilter(t *testing.T) {
t.Parallel()
tests := []struct {
scenario testhelpers.Scenario
checks []struct {
name string
input string
expectedMembership check.Membership
expectedTrace func(t testing.TB) *trace.Node
}
}{
{
scenario: testhelpers.Scenario{
Name: "nested SubjectSet chain with Direct User at the very end",
Strict: true,
Opl: `
class User implements Namespace{}
class Subgroup implements Namespace{
related: {
members: User[]
}
}
class Group implements Namespace{
related: {
members: SubjectSet<Subgroup, "members">[]
}
}
class File implements Namespace{
related: {
viewers: SubjectSet<Group, "members">[]
}
}
`,
// According to OPL, the only valid viewer is User:Bob, as it follows
// the subjectset chain declared in OPL.
InputTuples: []string{
// tuple exists even though "viewers" cannot accept direct-User by OPL
"File:f1#viewers@User:Alice",
// File:f1#viewers c Group:g1#members c Subgroup:sg1#members c User:Bob
"File:f1#viewers@Group:g1#members",
"Group:g1#members@Subgroup:sg1#members",
"Subgroup:sg1#members@User:Bob",
// Group:g1#members c User:Eve
"Group:g1#members@User:Eve",
// SubjectID: File:f2#viewers c Group:g2#members c Alice
"File:f2#viewers@Group:g2#members",
"Group:g2#members@Alice",
},
},
checks: []struct {
name string
input string
expectedMembership check.Membership
expectedTrace func(t testing.TB) *trace.Node
}{
{
name: "User:Alice is not a viewer (no path through subject-set chain)",
input: "File:f1#viewers@User:Alice",
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice",
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice", 1,
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Alice",
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Alice", 1,
trace.CheckNode(t, trace.ResultNotMember, "Subgroup:sg1#members@User:Alice"),
),
),
),
)
},
},
{
// Eve is stored as a direct member of Group:g1#members, but OPL declares
// Group#members as SubjectSet<Subgroup,"members"> only — no direct subjects.
name: "User:Eve stored directly in Group but OPL forbids direct subjects there",
input: "File:f1#viewers@User:Eve",
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Eve",
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Eve", 1,
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Eve",
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Eve", 1,
trace.CheckNode(t, trace.ResultNotMember, "Subgroup:sg1#members@User:Eve"),
),
),
),
)
},
},
{
// Subgroup#members declares User[] only. SubjectSet with a non-empty relation
// must not satisfy that direct-type check, so no steps are taken.
name: "SubjectSet pointer rejected by direct-only User[] declaration",
input: "Subgroup:sg1#members@Subgroup:sg2#members",
expectedMembership: check.NotMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultNotMember, "Subgroup:sg1#members@Subgroup:sg2#members")
},
},
{
name: "User:Bob is a viewer via nested subject-set chain",
input: "File:f1#viewers@User:Bob",
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Bob",
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Bob", 1,
trace.CheckNode(t, trace.ResultMember, "Group:g1#members@User:Bob",
trace.ExpandNode(t, trace.ResultMember, "Group:g1#members@User:Bob", 1,
trace.FoundNode(t, "Subgroup:sg1#members@User:Bob"),
),
),
),
)
},
},
{
name: "Group:g1#members is a viewer via direct tuple",
input: "File:f1#viewers@Group:g1#members",
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@Group:g1#members",
trace.DirectNode(t, trace.ResultMember, "File:f1#viewers@Group:g1#members"),
)
},
},
{
name: "Subgroup:sg1#members is a viewer via Group:g1#members pointer",
input: "File:f1#viewers@Subgroup:sg1#members",
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@Subgroup:sg1#members",
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@Subgroup:sg1#members", 1,
trace.FoundNode(t, "Group:g1#members@Subgroup:sg1#members"),
),
)
},
},
{
// SubjectID has no namespace, so OPL type constraints cannot apply to it
name: "SubjectID Alice bypasses OPL type constraints and is member",
input: "File:f2#viewers@Alice",
expectedMembership: check.IsMember,
expectedTrace: func(t testing.TB) *trace.Node {
return trace.CheckNode(t, trace.ResultMember, "File:f2#viewers@Alice",
// in non-strict mode, the engine would follow the direct SubjectID pointer here and
// incorrectly find Alice as a member of Group:g1#members, even though OPL only allows
// SubjectSet<Team,"members">.
// We don't expect SubjectID to be used in strict mode, but if it is, it should be allowed to bypass
// OPL for backward compatibility.
trace.DirectNode(t, trace.ResultNotMember, "File:f2#viewers@Alice"),
trace.ExpandNode(t, trace.ResultMember, "File:f2#viewers@Alice", 1,
trace.FoundNode(t, "Group:g2#members@Alice"),
),
)
},
},
},
},
}
for _, tt := range tests {
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
for _, c := range tt.checks {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
e := trace.NewEngine(reg)
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, c.input), 100)
require.Equal(t, c.expectedMembership, res.Membership)
require.NoError(t, res.Err)
require.Equal(t, trace.SortNode(c.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
})
}
})
}
}

View File

@@ -0,0 +1,78 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/relationtuple"
)
// astRelationFor looks up the AST Relation for the given tuple's namespace and
// relation. Returns nil (not an error) when no namespace config is present.
func astRelationFor(ctx context.Context, nm namespace.Manager, r *relationTuple) (*ast.Relation, error) {
return namespace.ASTRelationFor(ctx, nm, r.Namespace, r.Relation)
}
// maxDepthReached logs a debug message and returns MembershipUnknown.
// Call it when RestDepth is exhausted and the check cannot be expanded further.
func maxDepthReached(ex check.Executor, req check.CheckRequest) check.Result {
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
Debug("reached max-depth, therefore this query will not be further expanded")
return check.Result{Membership: check.MembershipUnknown, Limitation: check.LimitationMaxDepthExceeded}
}
// subjectSetTypesFor returns the OPL-declared subject-set types for the relation.
// It looks up the AST for each next-level (namespace, relation) to determine
// whether the Subject can be direct-match there.
func subjectSetTypesFor(ctx context.Context, nm namespace.Manager, subject relationtuple.Subject, relation *ast.Relation) ([]relationtuple.SubjectSetType, error) {
if relation == nil {
return nil, nil
}
var types []relationtuple.SubjectSetType
for _, t := range relation.Types {
// empty relation means direct membership, so not a SubjectSet<> declaration
if t.Relation == "" {
continue
}
nextRel, err := namespace.ASTRelationFor(ctx, nm, t.Namespace, t.Relation)
if err != nil {
return nil, err
}
types = append(types, relationtuple.SubjectSetType{
Namespace: t.Namespace,
Relation: t.Relation,
AllowsDirect: AllowsDirectMember(nextRel, subject),
})
}
return types, nil
}
// AllowsDirectMember reports whether OPL allows a direct subject match for the given relation.
// A relation with a SubjectSetRewrite is computed, so no direct tuple conforms to OPL,
// regardless of the subject kind.
// SubjectID subjects have no namespace, so declared types cannot constrain them and true
// is returned conservatively.
// For SubjectSet subjects, both namespace and relation must match a declared type exactly.
func AllowsDirectMember(relation *ast.Relation, subject relationtuple.Subject) bool {
s, isSubjectSet := subject.(*relationtuple.SubjectSet)
if !isSubjectSet {
// For SubjectID, allow direct when the relation is not computed via a rewrite.
return relation == nil || relation.SubjectSetRewrite == nil
}
if relation == nil || relation.SubjectSetRewrite != nil {
return false
}
for _, t := range relation.Types {
if t.Namespace == s.Namespace && t.Relation == s.Relation {
return true
}
}
return false
}

View File

@@ -0,0 +1,107 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/keto/internal/check/step"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/testhelpers"
)
func TestAllowsDirectMember(t *testing.T) {
t.Parallel()
var (
rewriteRel = &ast.Relation{SubjectSetRewrite: &ast.SubjectSetRewrite{}}
directRel = &ast.Relation{Name: "users", Types: []ast.RelationType{{Namespace: "User"}}} // users: User[]
anotherRel = &ast.Relation{Name: "keys", Types: []ast.RelationType{{Namespace: "ApiKey"}}} // keys: ApiKey[]
subjectSetRel = &ast.Relation{Name: "members", Types: []ast.RelationType{{Namespace: "Group", Relation: "members"}}} // members: SubjectSet<Group,"members">
)
tests := []struct {
name string
relation *ast.Relation
subjectStr string
want bool
}{
{
name: "SubjectID always returns true (conservative)",
relation: directRel,
subjectStr: "fixedID",
want: true,
},
{
name: "SubjectID with nil relation returns true (conservative)",
relation: nil,
subjectStr: "fixedID",
want: true,
},
{
name: "SubjectID with SubjectSetRewrite returns false (computed relation has no direct tuples)",
relation: rewriteRel,
subjectStr: "fixedID",
want: false,
},
{
name: "SubjectSet with nil relation returns false",
relation: nil,
subjectStr: "User:u1",
want: false,
},
{
name: "SubjectSet with SubjectSetRewrite returns false",
relation: rewriteRel,
subjectStr: "User:u1",
want: false,
},
{
name: "SubjectSet namespace matches direct type",
relation: directRel,
subjectStr: "User:u1",
want: true,
},
{
name: "subject namespace does not match any direct type",
relation: anotherRel,
subjectStr: "User:u1",
want: false,
},
{
name: "relation doesn't even have any User type",
relation: subjectSetRel,
subjectStr: "User:u1",
want: false,
},
{
name: "subject is a single object, but relation is a SubjectSet<Group,'members'>, so no direct match allowed",
relation: subjectSetRel,
subjectStr: "Group:g1",
want: false,
},
{
name: "subject is group#member, and relation is also SubjectSet<Group,'members'>, so direct match is allowed",
relation: subjectSetRel,
subjectStr: "Group:g1#members",
want: true,
},
{
name: "subject is group#member, but relation is a Direct",
relation: &ast.Relation{Name: "groups", Types: []ast.RelationType{{Namespace: "Group"}}},
subjectStr: "Group:g1#members",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
subject := testhelpers.SubjectFromString(t, tc.subjectStr)
assert.Equal(t, tc.want, step.AllowsDirectMember(tc.relation, subject))
})
}
}

View File

@@ -0,0 +1,40 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/namespace/ast"
)
// InvertStep evaluates its child and inverts the membership result.
// IsMember becomes NotMember and vice versa; Unknown and Error pass through.
type InvertStep struct {
Child ast.Child
}
func (InvertStep) Kind() check.StepKind { return check.StepInvert }
func (s InvertStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
Trace("invert check")
childStep, err := rewriteChildToStep(s.Child)
if err != nil {
return check.Result{Err: err}
}
result := ex.Run(ctx, childStep, req)
switch result.Membership {
case check.IsMember:
result.Membership = check.NotMember
case check.NotMember:
result.Membership = check.IsMember
}
return result
}

View File

@@ -0,0 +1,148 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/pkg/errors"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/relationtuple"
)
// RewriteStep evaluates a SubjectSetRewrite node (OR or AND).
// It translates the rewrite's children into PlannedSteps via planRewrite and
// then delegates to RunUnion (OR) or RunIntersection (AND).
type RewriteStep struct {
Rewrite *ast.SubjectSetRewrite
}
func (RewriteStep) Kind() check.StepKind { return check.StepRewrite }
func (s RewriteStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
if req.RestDepth <= 0 {
return maxDepthReached(ex, req)
}
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
Trace("check subject-set rewrite")
steps, err := planRewrite(ctx, ex, req, s.Rewrite)
if err != nil {
return check.Result{Err: err}
}
if s.Rewrite.Operation == ast.OperatorAnd {
return ex.RunIntersection(ctx, steps...)
}
return ex.RunUnion(ctx, steps...)
}
// planRewrite translates a SubjectSetRewrite AST node into a flat list of
// PlannedSteps ready for execution. It applies the OR-shortcut optimization:
// ComputedSubjectSet children in an OR are batched into a single
// DirectMultiStep followed by individual IsAllowedStep calls.
func planRewrite(ctx context.Context, ex check.Executor, req check.CheckRequest, rewrite *ast.SubjectSetRewrite) ([]check.PlannedStep, error) {
var steps []check.PlannedStep
handled := make(map[int]struct{}, len(rewrite.Children))
// OR shortcut: batch all ComputedSubjectSet children into one DirectMulti
// check plus individual IsAllowed calls (matching the current engine's
// OR-of-computed-usersets optimization).
if rewrite.Operation == ast.OperatorOr {
var computedRelations []string
for i, child := range rewrite.Children {
if c, ok := child.(*ast.ComputedSubjectSet); ok {
handled[i] = struct{}{}
computedRelations = append(computedRelations, c.Relation)
}
}
if len(computedRelations) > 0 {
namespaceManager, err := ex.Deps().Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
// In strict mode, skip direct checks for relations where OPL does not
// allow a direct subject match, mirroring the DirectStep gate in
// IsAllowedStep so nested and top-level checks agree.
var directRelations []string
isStrictMode := ex.Deps().Config(ctx).StrictMode()
for _, rel := range computedRelations {
astRel, _ := namespace.ASTRelationFor(ctx, namespaceManager, req.Tuple.Namespace, rel)
if isStrictMode && !AllowsDirectMember(astRel, req.Tuple.Subject) {
continue
}
directRelations = append(directRelations, rel)
}
if len(directRelations) > 0 {
steps = append(steps, check.PlannedStep{
Step: DirectMultiStep{Relations: directRelations},
Req: req,
})
}
// Each computed relation also gets a full IsAllowed check
// (skipDirect=true since DirectMulti already covers direct hits).
for _, rel := range computedRelations {
steps = append(steps, check.PlannedStep{
Step: IsAllowedStep{skipDirect: true},
Req: check.CheckRequest{
Tuple: &relationtuple.RelationTuple{
Namespace: req.Tuple.Namespace,
Object: req.Tuple.Object,
Relation: rel,
Subject: req.Tuple.Subject,
},
RestDepth: req.RestDepth - 1,
},
})
}
}
}
// Process remaining (non-shortcut) children.
for i, child := range rewrite.Children {
if _, ok := handled[i]; ok {
continue
}
step, err := rewriteChildToStep(child)
if err != nil {
return nil, err
}
childReq := req
switch child.(type) {
case *ast.SubjectSetRewrite:
childReq = req.WithDepth(req.RestDepth - 1)
}
steps = append(steps, check.PlannedStep{Step: step, Req: childReq})
}
return steps, nil
}
// rewriteChildToStep converts a single rewrite AST child node into an
// executable Step. Returns an error for unrecognised node types.
func rewriteChildToStep(child ast.Child) (check.Step, error) {
switch c := child.(type) {
case *ast.TupleToSubjectSet:
return TraverseStep{TTU: c}, nil
case *ast.ComputedSubjectSet:
return ComputedUsersetStep{Relation: c.Relation}, nil
case *ast.SubjectSetRewrite:
return RewriteStep{Rewrite: c}, nil
case *ast.InvertResult:
return InvertStep{Child: c.Child}, nil
default:
return nil, errors.New("not implemented: unknown rewrite child type")
}
}

View File

@@ -0,0 +1,17 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*defaultPolicy[...]).processItems"),
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*Cache[...]).processItems"),
)
}

View File

@@ -0,0 +1,84 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package step
import (
"context"
"github.com/ory/x/otelx"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/relationtuple"
)
// TraverseStep implements the tuple-to-subject-set (TTU) rewrite.
// It loads all tuples matching the TTU relation on the request object, then
// for each subject-set subject checks membership under the computed relation.
type TraverseStep struct {
TTU *ast.TupleToSubjectSet
}
func (TraverseStep) Kind() check.StepKind { return check.StepTraverse }
func (s TraverseStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) (res check.Result) {
ctx, span := ex.Deps().Tracer(ctx).Tracer().Start(ctx, "check.step.TraverseStep")
var rowCount int
defer func() {
span.SetAttributes(attribute.Int("tuples_loaded", rowCount))
otelx.End(span, &res.Err)
}()
if req.RestDepth <= 0 {
return maxDepthReached(ex, req)
}
ex.Deps().Logger().
WithField("request", req.Tuple.String()).
WithField("tuple to subject-set relation", s.TTU.Relation).
WithField("tuple to subject-set computed", s.TTU.ComputedSubjectSetRelation).
Trace("check tuple to subjectSet")
runner := ex.NewUnionRunner(ctx)
for nextPage, _ := keysetpagination.NewPaginator(); !nextPage.IsLast() && !runner.Done(); {
var tuples []*relationtuple.RelationTuple
var err error
tuples, nextPage, err = ex.Deps().RelationTupleManager().GetRelationTuples(runner.Ctx(), &query{
Namespace: &req.Tuple.Namespace,
Object: &req.Tuple.Object,
Relation: &s.TTU.Relation,
}, nextPage.ToOptions()...)
if err != nil {
return check.Result{Err: errors.WithStack(err)}
}
rowCount += len(tuples)
check.ReportTuplesLoaded(runner.Ctx(), len(tuples))
for _, t := range tuples {
subSet, ok := t.Subject.(*relationtuple.SubjectSet)
if !ok {
continue
}
runner.Add(check.PlannedStep{
Step: IsAllowedStep{},
Req: check.CheckRequest{
Tuple: &relationtuple.RelationTuple{
Namespace: subSet.Namespace,
Object: subSet.Object,
Relation: s.TTU.ComputedSubjectSetRelation,
Subject: req.Tuple.Subject,
},
RestDepth: req.RestDepth - 1,
},
})
if runner.Done() {
break
}
}
}
return runner.Result()
}

View File

@@ -0,0 +1,29 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import {Namespace, Context} from '@ory/keto-namespace-types'
class User implements Namespace {}
class Project implements Namespace {
related: {
owner: User[]
developer: User[]
}
permits = {
isOwner: (ctx: Context) => this.related.owner.includes(ctx.subject),
isOwnerOrDeveloper: (ctx: Context) =>
this.related.owner.includes(ctx.subject) ||
this.related.developer.includes(ctx.subject),
writeCollaborator: (ctx: Context) =>
this.permits.isOwner(ctx),
readCollaborator: (ctx: Context) =>
this.permits.isOwnerOrDeveloper(ctx),
deleteProject: (ctx: Context) => this.permits.isOwner(ctx),
writeProject: (ctx: Context) =>
this.permits.isOwnerOrDeveloper(ctx),
readProject: (ctx: Context) =>
this.permits.isOwnerOrDeveloper(ctx),
}
}

View File

@@ -0,0 +1,20 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check_test
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*defaultPolicy[...]).processItems"),
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*Cache[...]).processItems"),
goleak.IgnoreTopFunction("net/http.(*persistConn).readLoop"),
goleak.IgnoreTopFunction("net/http.(*persistConn).writeLoop"),
goleak.IgnoreTopFunction("database/sql.(*DB).connectionOpener"),
)
}

View File

@@ -0,0 +1,50 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package trace
import (
"context"
"github.com/ory/x/otelx"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/step"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/x/events"
)
// Engine builds a runtime with tracing enabled and returns the debug Node
// tree alongside the check result.
type Engine struct {
deps check.EngineDependencies
}
func NewEngine(d check.EngineDependencies) *Engine {
return &Engine{deps: d}
}
func (e *Engine) d() check.EngineDependencies { return e.deps }
// CheckIsMemberWithTrace is like Engine.CheckIsMember but also returns a debug
// tree that traces every sub-check performed.
func (e *Engine) CheckIsMemberWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (bool, *Node, error) {
res, tree := e.CheckRelationTupleWithTrace(ctx, tuple, restDepth)
return res.Membership == check.IsMember, tree, res.Err
}
// CheckRelationTupleWithTrace returns the check result and the debug tree.
func (e *Engine) CheckRelationTupleWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (res check.Result, tree *Node) {
ctx, span := e.d().Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTupleWithTrace")
defer otelx.End(span, &res.Err)
session := NewSession()
executor := step.NewExecutor(e.deps, session.Middleware(), step.CallLimitMiddleware())
res = executor.CheckRelationTuple(ctx, tuple, restDepth)
session.Wait()
if res.Err == nil {
span.AddEvent(events.NewPermissionsChecked(ctx))
}
return res, session.Root()
}

View File

@@ -0,0 +1,607 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package trace_test
import (
"context"
"slices"
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/check/trace"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/schema"
)
// # Non-determinism constraint
//
// Expand and traverse (tuple-to-subject-set) children are ordered by
// shard_id (random UUIDv4) and require sortNode normalization.
//
// As a result, we cannot write tests where multiple Expand or Traverse siblings
// compete to produce member; which one wins first is non-deterministic, as the
// child tuples are not executed in the order we create them.
// Therefore:
// - sortNode is used to sort the children of an Expand or Traverse nodes
// - Tests where all branches return not-member are always safe: every branch
// runs to completion before not-member is returned.
// - Do not write tests where Expand or Traverse has multiple children, and one child
// returns member, the other one notMember; which one runs first is non-deterministic;
type testcase struct {
name string
opl string
inputTuples []string
maxDepth int
checkInput string
expected func(t testing.TB) *trace.Node
strict bool
only bool
}
func TestTraceEngine(t *testing.T) {
t.Parallel()
tests := []testcase{
// ── no-OPL mode ──────────────────────────────────────────────────────────
{
// Engine finds the tuple immediately via direct lookup.
// Expand is registered eagerly, but the Executor already has a decisive
// result from the direct match, so the expansion work is skipped.
name: "no-OPL: direct hit",
inputTuples: []string{
"Group:eng#members@User:1",
},
checkInput: "Group:eng#members@User:1",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, member, "Group:eng#members@User:1",
directNode(t, member, "Group:eng#members@User:1"),
)
},
},
{
// Engine follows a three-hop SubjectSet chain to find a member.
name: "no-OPL: multi-hop SubjectSet expansion",
inputTuples: []string{
"Group:a#members@User:1",
"Group:b#members@Group:a#members",
"Group:c#members@Group:b#members",
},
checkInput: "Group:c#members@User:1",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, member, "Group:c#members@User:1",
directNode(t, notMember, "Group:c#members@User:1"),
expandNode(t, member, "Group:c#members@User:1", 1,
checkNode(t, member, "Group:b#members@User:1",
expandNode(t, member, "Group:b#members@User:1", 1,
foundNode(t, "Group:a#members@User:1"),
),
),
),
)
},
},
{
// Engine hits the depth cap at restDepth=0 and returns unknown.
name: "no-OPL: depth limit stops expansion",
maxDepth: 2,
inputTuples: []string{
"Group:a#members@User:1",
"Group:b1#members@Group:a#members",
"Group:c#members@Group:b1#members",
},
checkInput: "Group:c#members@User:99",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, unknown, "Group:c#members@User:99",
directNode(t, notMember, "Group:c#members@User:99"),
expandNode(t, unknown, "Group:c#members@User:99", 1,
checkNode(t, unknown, "Group:b1#members@User:99",
expandNode(t, unknown, "Group:b1#members@User:99", 1),
),
),
)
},
},
// ── strict OPL mode ───────────────────────────────────────────────────────
{
// In strict mode the only type-safe checks are fired;
// no useless direct/expand on relations that are "permit";
// Subject-set fires only when OPL field declares a SubjectSet<..>;
name: "strict OPL: SubjectSet expand fires only for declared types",
strict: true,
opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: User[]
}
}
class File implements Namespace {
related: {
viewers: SubjectSet<Group, "members">[]
}
permits = {
view: (ctx: Context) => this.related.viewers.includes(ctx.subject)
}
}
`,
inputTuples: []string{
"File:f1#viewers@Group:g1#members",
},
checkInput: "File:f1#view@User:u1",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, notMember, "File:f1#view@User:u1",
unionNode(t, notMember, "File:f1#view@User:u1",
// viewers only declares SubjectSet<Group,"members">, so User:u1 can
// never be a direct match, so no direct or multi-direct check runs.
checkNode(t, notMember, "File:f1#viewers@User:u1",
// expand fires because viewers declares SubjectSet<Group,"members">.
// expand found 1 node, which is Group:g1#members, which is not a direct hit → not-member.
expandNode(t, notMember, "File:f1#viewers@User:u1", 1,
// skipDirect=true + no SubjectSet types on members → empty check
checkNode(t, notMember, "Group:g1#members@User:u1"),
),
),
),
)
},
},
{
// Strict OR with multiple computed-usersets and a TTU.
// The OR shortcut batches all plain computed-usersets into a single multiDirect check.
// Plain related fields (viewers, viewers2) with no SubjectSet types produce
// empty checks because multiDirect already covers them, and sets skipDirect=true.
name: "strict OPL: OR batches computed-usersets, suppresses spurious permit checks",
strict: true,
opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: User[]
}
}
class File implements Namespace{
related: {
viewers: User[]
viewers2: User[]
viewerGroups: Group[]
}
permits = {
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)) ||
this.related.viewers2.includes(ctx.subject)
}
}
`,
inputTuples: []string{
"File:f1#viewers@User:1",
"File:f1#viewers@User:2",
"File:f1#viewers2@User:10",
"File:f1#viewers2@User:11",
"File:f1#viewerGroups@Group:g1",
"Group:g1#members@User:4",
"Group:g1#members@User:5",
},
checkInput: "File:f1#view@User:100",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, notMember, "File:f1#view@User:100",
unionNode(t, notMember, "File:f1#view@User:100",
// OR shortcut batches viewers + viewers2 into one multi-relation direct check.
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewers2"}),
// viewers and viewers2 are plain User[] fields: empty checks in strict mode.
checkNode(t, notMember, "File:f1#viewers@User:100"),
checkNode(t, notMember, "File:f1#viewers2@User:100"),
// TTU traverses viewerGroups; Group:g1 has no User:100 → not-member.
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 1,
checkNode(t, notMember, "Group:g1#members@User:100",
directNode(t, notMember, "Group:g1#members@User:100"),
),
),
),
)
},
},
{
// Strict OR where the TTU branch finds a member.
name: "strict OPL: OR with TTU finds member",
strict: true,
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",
"Group:g1#members@User:4",
},
checkInput: "File:f1#view@User:4",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, member, "File:f1#view@User:4",
unionNode(t, member, "File:f1#view@User:4",
// multiDirect for viewers: User:4 is not a direct viewer.
multiDirectNode(t, notMember, "File:f1#view@User:4", []string{"viewers"}),
// viewers is User[]: empty check in strict+skipDirect.
checkNode(t, notMember, "File:f1#viewers@User:4"),
// TTU loads Group:g1; User:4 is a direct member → member.
// strict: members is User[] (no SubjectSet types) → no expand registered.
traverseNode(t, member, "File:f1#viewerGroups@User:4", 1,
checkNode(t, member, "Group:g1#members@User:4",
directNode(t, member, "Group:g1#members@User:4"),
),
),
),
)
},
},
{
// NOT inverts an inner member result to not-member.
name: "strict OPL: NOT inverts member to not-member",
strict: true,
opl: `
class User implements Namespace{}
class File implements Namespace{
related: {
blocklist: User[]
}
permits = {
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
}
}
`,
inputTuples: []string{
"File:f1#blocklist@User:blocked",
},
checkInput: "File:f1#view@User:blocked",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, notMember, "File:f1#view@User:blocked",
unionNode(t, notMember, "File:f1#view@User:blocked",
invertNode(t, notMember, "File:f1#view@User:blocked",
computedNode(t, member, "File:f1#blocklist@User:blocked",
checkNode(t, member, "File:f1#blocklist@User:blocked",
directNode(t, member, "File:f1#blocklist@User:blocked"),
),
),
),
),
)
},
},
{
// NOT inverts an inner not-member result to member.
name: "strict OPL: NOT inverts not-member to member",
strict: true,
opl: `
class User implements Namespace{}
class File implements Namespace{
related: {
blocklist: User[]
}
permits = {
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
}
}
`,
inputTuples: []string{
"File:f1#blocklist@User:blocked",
},
checkInput: "File:f1#view@User:free",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, member, "File:f1#view@User:free",
unionNode(t, member, "File:f1#view@User:free",
invertNode(t, member, "File:f1#view@User:free",
computedNode(t, notMember, "File:f1#blocklist@User:free",
checkNode(t, notMember, "File:f1#blocklist@User:free",
directNode(t, notMember, "File:f1#blocklist@User:free"),
),
),
),
),
)
},
},
{
name: "strict OPL: TTU with permit call inside traverse",
strict: true,
opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: User[]
}
permits = {
isMember: (ctx: Context) => this.related.members.includes(ctx.subject)
}
}
class File implements Namespace{
related: {
viewerGroups: Group[]
}
permits = {
view: (ctx: Context) => this.related.viewerGroups.traverse(g => g.permits.isMember(ctx))
}
}
`,
inputTuples: []string{
"Group:g1#members@User:1",
"Group:g1#members@User:2",
"File:f1#viewerGroups@Group:g1",
},
checkInput: "File:f1#view@User:99",
expected: func(t testing.TB) *trace.Node {
return checkNode(t, notMember, "File:f1#view@User:99",
unionNode(t, notMember, "File:f1#view@User:99",
traverseNode(t, notMember, "File:f1#viewerGroups@User:99", 1,
checkNode(t, notMember, "Group:g1#isMember@User:99",
unionNode(t, notMember, "Group:g1#isMember@User:99",
multiDirectNode(t, notMember, "Group:g1#members@User:99", []string{"members"}),
checkNode(t, notMember, "Group:g1#members@User:99"),
),
),
),
),
)
},
},
// ── non-strict OPL mode ───────────────────────────────────────────────────
{
// Non-strict mode fires the rewrite, a direct check, AND a subject-set
// expand concurrently even for a permit relation (view).
name: "non-strict OPL: OR on permit reference emits spurious direct and expand",
opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: User[]
}
}
class File implements Namespace{
related: {
viewers: User[]
viewerGroups: Group[]
}
permits = {
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) || 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",
"Group:g1#members@User:5",
"Group:g2#members@User:6",
"Group:g2#members@User:7",
"Group:g3#members@User:8",
"Group:g3#members@User:9",
"File:f1#viewerGroups@Group:g1",
"File:f1#viewerGroups@Group:g2",
"File:f1#viewerGroups@Group:g3",
},
checkInput: "File:f1#view@User:100",
expected: func(t testing.TB) *trace.Node {
leafCheck := func(s string) *trace.Node {
return checkNode(t, notMember, s,
expandNode(t, notMember, s, 0),
)
}
groupCheck := func(g string, users ...string) *trace.Node {
members := make([]*trace.Node, len(users))
for i, u := range users {
members[i] = leafCheck("User:" + u + "#@User:100")
}
return checkNode(t, notMember, "Group:"+g+"#members@User:100",
directNode(t, notMember, "Group:"+g+"#members@User:100"),
expandNode(t, notMember, "Group:"+g+"#members@User:100", len(users), members...),
)
}
return checkNode(t, notMember, "File:f1#view@User:100",
unionNode(t, notMember, "File:f1#view@User:100",
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewGroup"}),
checkNode(t, notMember, "File:f1#viewers@User:100",
expandNode(t, notMember, "File:f1#viewers@User:100", 3,
leafCheck("User:1#@User:100"),
leafCheck("User:2#@User:100"),
leafCheck("User:3#@User:100"),
),
),
checkNode(t, notMember, "File:f1#viewGroup@User:100",
unionNode(t, notMember, "File:f1#viewGroup@User:100",
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 3,
groupCheck("g1", "4", "5"),
groupCheck("g2", "6", "7"),
groupCheck("g3", "8", "9"),
),
),
expandNode(t, notMember, "File:f1#viewGroup@User:100", 0),
),
),
directNode(t, notMember, "File:f1#view@User:100"),
expandNode(t, notMember, "File:f1#view@User:100", 0),
)
},
},
{
// AND: both arms must complete before the result is known, so
// the full tree is always explored regardless of intermediate results.
name: "non-strict OPL: AND requires both arms; permit-calls-permit",
opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: User[]
}
}
class File implements Namespace{
related: {
viewers: User[]
viewerGroups: Group[]
}
permits = {
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) &&
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",
"Group:g1#members@User:5",
"Group:g2#members@User:6",
"Group:g2#members@User:7",
"Group:g3#members@User:8",
"Group:g3#members@User:9",
"File:f1#viewerGroups@Group:g1",
"File:f1#viewerGroups@Group:g2",
"File:f1#viewerGroups@Group:g3",
},
checkInput: "File:f1#view@User:1",
expected: func(t testing.TB) *trace.Node {
leafCheck := func(s string) *trace.Node {
return checkNode(t, notMember, s,
expandNode(t, notMember, s, 0),
)
}
groupCheck := func(g string, users ...string) *trace.Node {
members := make([]*trace.Node, len(users))
for i, u := range users {
members[i] = leafCheck("User:" + u + "#@User:1")
}
return checkNode(t, notMember, "Group:"+g+"#members@User:1",
directNode(t, notMember, "Group:"+g+"#members@User:1"),
expandNode(t, notMember, "Group:"+g+"#members@User:1", len(users), members...),
)
}
return checkNode(t, notMember, "File:f1#view@User:1",
intersectionNode(t, notMember, "File:f1#view@User:1",
computedNode(t, member, "File:f1#viewers@User:1",
checkNode(t, member, "File:f1#viewers@User:1",
directNode(t, member, "File:f1#viewers@User:1"),
),
),
computedNode(t, notMember, "File:f1#viewGroup@User:1",
checkNode(t, notMember, "File:f1#viewGroup@User:1",
unionNode(t, notMember, "File:f1#viewGroup@User:1",
traverseNode(t, notMember, "File:f1#viewerGroups@User:1", 3,
groupCheck("g1", "4", "5"),
groupCheck("g2", "6", "7"),
groupCheck("g3", "8", "9"),
),
),
directNode(t, notMember, "File:f1#viewGroup@User:1"),
expandNode(t, notMember, "File:f1#viewGroup@User:1", 0),
),
),
),
directNode(t, notMember, "File:f1#view@User:1"),
expandNode(t, notMember, "File:f1#view@User:1", 0),
)
},
},
}
focused := slices.ContainsFunc(tests, func(tt testcase) bool { return tt.only })
for _, tt := range tests {
if focused && !tt.only {
continue
}
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
opts := []driver.TestRegistryOption{driver.WithLogLevel("info")}
if tt.opl != "" {
_, errs := schema.Parse(tt.opl)
require.Empty(t, errs, "invalid OPL in test case: %v", errs)
}
if tt.opl != "" {
opts = append(opts, driver.WithOPL(tt.opl))
} else {
opts = append(opts, driver.WithConfig(config.KeyNamespaces, []*namespace.Namespace{}))
}
if tt.maxDepth != 0 {
opts = append(opts, driver.WithConfig(config.KeyLimitMaxReadDepth, tt.maxDepth))
}
if tt.strict {
opts = append(opts, driver.WithConfig(config.KeyFeatureFlagStrictMode, tt.strict))
}
reg := driver.NewSqliteTestRegistry(t, opts...)
e := trace.NewEngine(reg)
ctx := context.Background()
for _, row := range tt.inputTuples {
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(ctx, testhelpers.TupleFromString(t, row)))
}
_, tree, err := e.CheckIsMemberWithTrace(ctx, testhelpers.TupleFromString(t, tt.checkInput), 100)
require.NoError(t, err)
expected := trace.SortNode(tt.expected(t))
actual := trace.SortNode(trace.StripTiming(tree))
require.Equal(t, expected.String(), actual.String())
})
}
}
// result helpers — used as the first argument to every node builder.
const (
member = trace.ResultMember
notMember = trace.ResultNotMember
unknown = trace.ResultUnknown
)
// Local aliases for the exported helpers in trace so test cases stay concise.
var (
checkNode = trace.CheckNode
directNode = trace.DirectNode
expandNode = trace.ExpandNode
foundNode = trace.FoundNode
unionNode = trace.UnionNode
intersectionNode = trace.IntersectionNode
invertNode = trace.InvertNode
computedNode = trace.ComputedNode
traverseNode = trace.TraverseNode
multiDirectNode = trace.MultiDirectNode
)

View File

@@ -0,0 +1,199 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package trace
import (
"context"
"errors"
"strconv"
"strings"
"sync"
"time"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/relationtuple"
)
type (
NodeKind string
NodeResult string
)
const (
NodeIsAllowed NodeKind = "is_allowed"
NodeDirect NodeKind = "direct"
NodeMultiDirect NodeKind = "direct_multi"
NodeExpandSubject NodeKind = "expand_subject"
NodeFoundSubject NodeKind = "expand_found"
NodeComputed NodeKind = "computed"
NodeTraverse NodeKind = "traverse"
NodeUnion NodeKind = "union"
NodeIntersection NodeKind = "intersection"
NodeInvert NodeKind = "invert"
)
func (k NodeKind) String() string { return string(k) }
const (
// ResultMember indicates the subject is a member.
ResultMember NodeResult = "member"
// ResultNotMember indicates the subject is not a member.
ResultNotMember NodeResult = "not_member"
// ResultError indicates an error occurred during the check.
ResultError NodeResult = "error"
// ResultSkipped indicates the check was registered but never executed
// because a sibling check resolved the parent before this one ran.
ResultSkipped NodeResult = "skipped"
// ResultCancelled indicates the check was cancelled mid-execution
// because a sibling check resolved.
ResultCancelled NodeResult = "cancelled"
// ResultUnknown indicates the check was cut short because restDepth
// reached zero; membership could not be determined.
ResultUnknown NodeResult = "unknown"
)
func (r NodeResult) prunable() bool {
return r == ResultNotMember || r == ResultSkipped
}
// MaxExpandChildren is the maximum number of non-significant children kept on
// expand_subject and traverse nodes. Children whose result is member, error,
// or cancelled are always kept regardless of this limit.
const MaxExpandChildren = 5
// Node is one step in the debug tree produced by a permission check. It
// records what kind of operation was performed, what the input tuple was, what
// the result was, how many database records were fetched, and any sub-steps
// that were spawned.
type Node struct {
Kind NodeKind
Tuple *relationtuple.RelationTuple
Result NodeResult
// Relations holds the batched relation names for NodeMultiDirect nodes.
Relations []string
// TuplesLoaded is the number of database records fetched by this node.
TuplesLoaded int
// TruncatedChildren is the number of children that were dropped to keep
// the tree size manageable.
TruncatedChildren int
Children []*Node
// Duration is the wall-clock time spent in this node (including children).
Duration time.Duration
// parent is set when this node is appended to a parent, so that
// dropIfOverLimit can remove it after the result is known.
parent *Node
// mu protects Children, TruncatedChildren, TuplesLoaded, and prunableCount.
mu sync.Mutex
// prunableCount counts how many children have a prunable result.
prunableCount int
}
func (n *Node) setResult(result check.Result) {
if result.Err != nil {
n.Result = ResultError
if errors.Is(result.Err, context.Canceled) {
n.Result = ResultCancelled
}
return
}
switch result.Membership {
case check.IsMember:
n.Result = ResultMember
case check.NotMember:
n.Result = ResultNotMember
default:
n.Result = ResultUnknown
}
}
// dropIfOverLimit removes this node from its parent when the parent is an
// expand or TTU node that already has MaxExpandChildren prunable children.
func (n *Node) dropIfOverLimit() {
parent := n.parent
if parent == nil {
return
}
if parent.Kind != NodeExpandSubject && parent.Kind != NodeTraverse {
return
}
if !n.Result.prunable() {
return
}
parent.mu.Lock()
defer parent.mu.Unlock()
parent.prunableCount++
if parent.prunableCount <= MaxExpandChildren {
return
}
for i, c := range parent.Children {
if c == n {
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
break
}
}
parent.TruncatedChildren++
}
func (n *Node) recordTuplesLoaded(count int) {
n.mu.Lock()
n.TuplesLoaded += count
n.mu.Unlock()
}
func (n *Node) recordFoundSubject(tuple *relationtuple.RelationTuple) {
child := &Node{Kind: NodeFoundSubject, Tuple: tuple, Result: ResultMember}
n.mu.Lock()
n.Children = append(n.Children, child)
n.mu.Unlock()
}
// String is a human-friendly representation of the debug tree, useful during testing.
func (n *Node) String() string {
if n == nil {
return "<nil>"
}
var b strings.Builder
var walk func(cur *Node, depth int)
walk = func(cur *Node, depth int) {
for range depth {
b.WriteString(" ")
}
input := ""
if cur.Tuple != nil {
input = cur.Tuple.String()
if len(cur.Relations) > 0 {
input = strings.Replace(input, "#"+cur.Tuple.Relation+"@", "#{"+strings.Join(cur.Relations, ",")+"}"+"@", 1)
}
}
b.WriteString(string(cur.Kind))
b.WriteByte(' ')
b.WriteString(strconv.Quote(input))
b.WriteString(" -> ")
b.WriteString(string(cur.Result))
if cur.TuplesLoaded > 0 {
b.WriteString(" tuples_loaded=")
b.WriteString(strconv.Itoa(cur.TuplesLoaded))
}
b.WriteByte('\n')
for _, child := range cur.Children {
walk(child, depth+1)
}
}
walk(n, 0)
return strings.TrimSuffix(b.String(), "\n")
}

View File

@@ -0,0 +1,147 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package trace
import (
"context"
"sync"
"time"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/step"
"github.com/ory/keto/internal/namespace/ast"
"github.com/ory/keto/internal/relationtuple"
)
// traceNodeKey is the context key used to store and retrieve the current
// trace node as execution descends through the step tree.
type traceNodeKey struct{}
func nodeFromCtx(ctx context.Context) *Node {
n, _ := ctx.Value(traceNodeKey{}).(*Node)
return n
}
func withTraceNode(ctx context.Context, n *Node) context.Context {
return context.WithValue(ctx, traceNodeKey{}, n)
}
// Session collects the debug tree for a single runtime execution.
type Session struct {
mu sync.Mutex
root *Node
wg sync.WaitGroup
}
func NewSession() *Session {
return &Session{}
}
func (t *Session) Root() *Node {
t.mu.Lock()
defer t.mu.Unlock()
return t.root
}
func (t *Session) Wait() {
t.wg.Wait()
}
// nodeKindForStep maps a Step's execution-model kind to the trace-model NodeKind.
func nodeKindForStep(s check.Step) NodeKind {
if s, ok := s.(step.RewriteStep); ok {
if s.Rewrite.Operation == ast.OperatorOr {
return NodeUnion
}
return NodeIntersection
}
switch s.Kind() {
case check.StepIsAllowed:
return NodeIsAllowed
case check.StepDirect:
return NodeDirect
case check.StepDirectMulti:
return NodeMultiDirect
case check.StepExpand:
return NodeExpandSubject
case check.StepComputed:
return NodeComputed
case check.StepTraverse:
return NodeTraverse
case check.StepInvert:
return NodeInvert
default:
return NodeKind(s.Kind())
}
}
// Middleware returns a Middleware that records a Node for every step execution,
// building the debug trace tree as execution unfolds.
func (t *Session) Middleware() check.Middleware {
return func(ctx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
t.wg.Add(1)
defer t.wg.Done()
tuple := req.Tuple
var relations []string
switch s := s.(type) {
case step.ComputedUsersetStep:
clone := *req.Tuple
clone.Relation = s.Relation
tuple = &clone
case step.TraverseStep:
clone := *req.Tuple
clone.Relation = s.TTU.Relation
tuple = &clone
case step.DirectMultiStep:
relations = s.Relations
}
node, ctx := t.newTraceNode(ctx, nodeKindForStep(s), tuple, relations...)
switch s.Kind() {
case check.StepExpand:
ctx = check.WithTraceHooks(ctx, check.TraceHooks{
TuplesLoaded: node.recordTuplesLoaded,
SubjectFound: node.recordFoundSubject,
})
case check.StepTraverse:
ctx = check.WithTraceHooks(ctx, check.TraceHooks{TuplesLoaded: node.recordTuplesLoaded})
}
start := time.Now()
result := next(ctx, s, req)
node.Duration = time.Since(start)
node.setResult(result)
node.dropIfOverLimit()
return result
}
}
// newTraceNode creates a Node, links it as a child of the current parent node
// in ctx (or sets it as the root if there is none), and returns both the node
// and a derived context with the new node set as the current parent.
func (t *Session) newTraceNode(ctx context.Context, kind NodeKind, tuple *relationtuple.RelationTuple, relations ...string) (*Node, context.Context) {
node := &Node{
Kind: kind,
Tuple: tuple,
Relations: relations,
// Initial Result is Skipped, as it's possible that the step won't run at all
// due to other sibling branch finishing with success.
Result: ResultSkipped,
}
if parent := nodeFromCtx(ctx); parent != nil {
node.parent = parent
parent.mu.Lock()
parent.Children = append(parent.Children, node)
parent.mu.Unlock()
} else {
t.mu.Lock()
t.root = node
t.mu.Unlock()
}
return node, withTraceNode(ctx, node)
}

View File

@@ -0,0 +1,115 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package trace
import (
"slices"
"strings"
"testing"
"github.com/ory/keto/internal/testhelpers"
)
// SortNode sorts children of NodeExpandSubject and NodeTraverse nodes by
// Tuple.String() recursively. Both node kinds have non-deterministic child
// order because the underlying queries order by shard_id (a random UUIDv4
// assigned at write time). All other sibling orderings are engine-controlled
// and must be asserted as-is.
func SortNode(n *Node) *Node {
if n == nil {
return nil
}
if n.Kind == NodeExpandSubject || n.Kind == NodeTraverse {
slices.SortFunc(n.Children, func(a, b *Node) int {
as, bs := "", ""
if a.Tuple != nil {
as = a.Tuple.String()
}
if b.Tuple != nil {
bs = b.Tuple.String()
}
if c := strings.Compare(as, bs); c != 0 {
return c
}
return strings.Compare(string(a.Kind), string(b.Kind))
})
}
for _, child := range n.Children {
SortNode(child)
}
return n
}
// StripTiming zeroes out Duration on every node in the tree so that test
// assertions are not sensitive to wall-clock time.
func StripTiming(n *Node) *Node {
if n == nil {
return nil
}
n.Duration = 0
for _, child := range n.Children {
StripTiming(child)
}
return n
}
// CheckNode builds a NodeIsAllowed node.
func CheckNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeIsAllowed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
}
// DirectNode builds a NodeDirect leaf (no children).
func DirectNode(t testing.TB, result NodeResult, tuple string) *Node {
t.Helper()
return &Node{Kind: NodeDirect, Tuple: testhelpers.TupleFromString(t, tuple), Result: result}
}
// ExpandNode builds a NodeExpandSubject node.
func ExpandNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeExpandSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
}
// FoundNode builds a NodeFoundSubject leaf (no children).
func FoundNode(t testing.TB, tuple string) *Node {
t.Helper()
return &Node{Kind: NodeFoundSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: ResultMember}
}
// UnionNode builds a NodeUnion node.
func UnionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeUnion, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
}
// IntersectionNode builds a NodeIntersection node.
func IntersectionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeIntersection, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
}
// InvertNode builds a NodeInvert node.
func InvertNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeInvert, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
}
// ComputedNode builds a NodeComputed node.
func ComputedNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeComputed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
}
// TraverseNode builds a NodeTraverse node.
func TraverseNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
t.Helper()
return &Node{Kind: NodeTraverse, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
}
// MultiDirectNode builds a NodeMultiDirect leaf (no children).
func MultiDirectNode(t testing.TB, result NodeResult, tuple string, relations []string) *Node {
t.Helper()
return &Node{Kind: NodeMultiDirect, Tuple: testhelpers.TupleFromString(t, tuple), Relations: relations, Result: result}
}

View File

@@ -0,0 +1,44 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check
import (
"context"
"github.com/ory/keto/internal/relationtuple"
)
type traceHooksKey struct{}
// TraceHooks carries optional instrumentation callbacks injected into context
// by tracing middleware. Step implementations can emit events through helpers
// in this file without importing the trace package.
type TraceHooks struct {
TuplesLoaded func(n int)
SubjectFound func(tuple *relationtuple.RelationTuple)
}
// WithTraceHooks returns a context that carries the given trace hooks.
func WithTraceHooks(ctx context.Context, hooks TraceHooks) context.Context {
return context.WithValue(ctx, traceHooksKey{}, hooks)
}
func traceHooksFromContext(ctx context.Context) (TraceHooks, bool) {
hooks, ok := ctx.Value(traceHooksKey{}).(TraceHooks)
return hooks, ok
}
// ReportTuplesLoaded calls the tuples-loaded hook stored in ctx, if any.
func ReportTuplesLoaded(ctx context.Context, n int) {
if hooks, ok := traceHooksFromContext(ctx); ok && hooks.TuplesLoaded != nil {
hooks.TuplesLoaded(n)
}
}
// ReportSubjectFound calls the subject-found hook stored in ctx, if any.
func ReportSubjectFound(ctx context.Context, tuple *relationtuple.RelationTuple) {
if hooks, ok := traceHooksFromContext(ctx); ok && hooks.SubjectFound != nil {
hooks.SubjectFound(tuple)
}
}