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
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:
55
internal/check/step/call_limit.go
Normal file
55
internal/check/step/call_limit.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
internal/check/step/computed.go
Normal file
37
internal/check/step/computed.go
Normal 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)
|
||||
}
|
||||
41
internal/check/step/direct.go
Normal file
41
internal/check/step/direct.go
Normal 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
|
||||
}
|
||||
}
|
||||
40
internal/check/step/direct_multi.go
Normal file
40
internal/check/step/direct_multi.go
Normal 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
|
||||
}
|
||||
91
internal/check/step/dispatch.go
Normal file
91
internal/check/step/dispatch.go
Normal 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...)
|
||||
}
|
||||
}
|
||||
277
internal/check/step/executor.go
Normal file
277
internal/check/step/executor.go
Normal 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
|
||||
}
|
||||
587
internal/check/step/executor_test.go
Normal file
587
internal/check/step/executor_test.go
Normal 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)
|
||||
}
|
||||
100
internal/check/step/expand.go
Normal file
100
internal/check/step/expand.go
Normal 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()
|
||||
}
|
||||
479
internal/check/step/expand_test.go
Normal file
479
internal/check/step/expand_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
78
internal/check/step/helpers.go
Normal file
78
internal/check/step/helpers.go
Normal 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
|
||||
}
|
||||
107
internal/check/step/helpers_test.go
Normal file
107
internal/check/step/helpers_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
40
internal/check/step/invert.go
Normal file
40
internal/check/step/invert.go
Normal 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
|
||||
}
|
||||
148
internal/check/step/rewrite.go
Normal file
148
internal/check/step/rewrite.go
Normal 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")
|
||||
}
|
||||
}
|
||||
17
internal/check/step/testmain_test.go
Normal file
17
internal/check/step/testmain_test.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
84
internal/check/step/traverse.go
Normal file
84
internal/check/step/traverse.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user