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