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:
7
internal/check/2023-01-09-benchtest.txt
Normal file
7
internal/check/2023-01-09-benchtest.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/ory/keto/internal/check
|
||||
cpu: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
|
||||
BenchmarkComputedUsersets/Computed_userset-8 2155 583306 ns/op 9.000 queries/op 109370 B/op 1556 allocs/op
|
||||
PASS
|
||||
ok github.com/ory/keto/internal/check 1.351s
|
||||
190
internal/check/bench_test.go
Normal file
190
internal/check/bench_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
func wideNamespace(width int) *namespace.Namespace {
|
||||
wideNS := &namespace.Namespace{
|
||||
Name: fmt.Sprintf("%d_wide", width),
|
||||
Relations: []ast.Relation{{Name: "editor"}},
|
||||
}
|
||||
viewerRelation := &ast.Relation{
|
||||
Name: "viewer",
|
||||
SubjectSetRewrite: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{},
|
||||
},
|
||||
}
|
||||
for i := 0; i < width; i++ {
|
||||
relation := fmt.Sprintf("relation-%d", i)
|
||||
viewerRelation.SubjectSetRewrite.Children = append(
|
||||
viewerRelation.SubjectSetRewrite.Children,
|
||||
&ast.ComputedSubjectSet{Relation: relation},
|
||||
)
|
||||
wideNS.Relations = append(wideNS.Relations, ast.Relation{Name: relation})
|
||||
}
|
||||
viewerRelation.SubjectSetRewrite.Children = append(
|
||||
viewerRelation.SubjectSetRewrite.Children,
|
||||
&ast.ComputedSubjectSet{Relation: "editor"},
|
||||
)
|
||||
wideNS.Relations = append(wideNS.Relations, *viewerRelation)
|
||||
|
||||
return wideNS
|
||||
}
|
||||
|
||||
func BenchmarkCheckEngine(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
var (
|
||||
depths = []int{2, 4, 8, 16, 32}
|
||||
widths = []int{10, 20, 40, 80, 100}
|
||||
maxDepth = depths[len(depths)-1]
|
||||
)
|
||||
|
||||
namespaces := []*namespace.Namespace{
|
||||
{
|
||||
Name: "deep",
|
||||
Relations: []ast.Relation{
|
||||
{Name: "owner"},
|
||||
{
|
||||
Name: "editor",
|
||||
SubjectSetRewrite: &ast.SubjectSetRewrite{
|
||||
Children: ast.Children{&ast.ComputedSubjectSet{
|
||||
Relation: "owner",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "viewer",
|
||||
SubjectSetRewrite: &ast.SubjectSetRewrite{
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{
|
||||
Relation: "editor",
|
||||
},
|
||||
&ast.TupleToSubjectSet{
|
||||
Relation: "parent",
|
||||
ComputedSubjectSetRelation: "viewer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(b, driver.WithNamespaces(namespaces))
|
||||
reg.Logger().Logger.SetLevel(logrus.InfoLevel)
|
||||
|
||||
tuples := []string{
|
||||
"deep:deep_file#parent@deep:folder_1#...",
|
||||
}
|
||||
for i := 1; i < maxDepth; i++ {
|
||||
tuples = append(tuples, fmt.Sprintf("deep:folder_%d#parent@deep:folder_%d#...", i, i+1))
|
||||
}
|
||||
for _, d := range depths {
|
||||
tuples = append(tuples, fmt.Sprintf("deep:folder_%d#owner@user_%d", d, d))
|
||||
}
|
||||
for _, w := range widths {
|
||||
namespaces = append(namespaces, wideNamespace(w))
|
||||
tuples = append(tuples, fmt.Sprintf("%d-wide:wide_file#editor@user", w))
|
||||
}
|
||||
testhelpers.MapAndInsertTuplesFromString(b, reg, tuples)
|
||||
|
||||
require.NoError(b, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 100*maxDepth))
|
||||
e := check.NewEngine(reg)
|
||||
|
||||
b.Run("case=deep tree", func(b *testing.B) {
|
||||
for _, depth := range depths {
|
||||
b.Run(fmt.Sprintf("depth=%03d", depth), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
rt := testhelpers.TupleFromString(b, fmt.Sprintf("deep:deep_file#viewer@user_%d", depth))
|
||||
res := e.CheckRelationTuple(ctx, rt, 2*depth)
|
||||
assert.NoError(b, res.Err)
|
||||
if res.Membership != check.IsMember {
|
||||
b.Error("user should be able to view 'deep_file'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("case=wide tree", func(b *testing.B) {
|
||||
for _, width := range widths {
|
||||
b.Run(fmt.Sprintf("width=%03d", width), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
rt := testhelpers.TupleFromString(b, fmt.Sprintf("%d-wide:wide_file#editor@user", width))
|
||||
res := e.CheckRelationTuple(ctx, rt, 2*width)
|
||||
assert.NoError(b, res.Err)
|
||||
if res.Membership != check.IsMember {
|
||||
b.Error("user should be able to view 'wide_file'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//go:embed testfixtures/project_opl.ts
|
||||
var ProjectOPLConfig string
|
||||
|
||||
func BenchmarkComputedUsersets(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
spans := tracetest.NewSpanRecorder()
|
||||
tracer := trace.NewTracerProvider(trace.WithSpanProcessor(spans)).Tracer("")
|
||||
reg := driver.NewSqliteTestRegistry(b, driver.WithLogLevel("debug"), driver.WithOPL(ProjectOPLConfig), driver.WithTracer(tracer), driver.WithConfig(config.KeyFeatureFlagStrictMode, true))
|
||||
reg.Logger().Logger.SetLevel(logrus.DebugLevel)
|
||||
|
||||
testhelpers.MapAndInsertTuplesFromString(b, reg, []string{
|
||||
"Project:Ory#owner@User:Admin",
|
||||
"Project:Ory#developer@User:Dev",
|
||||
})
|
||||
|
||||
e := check.NewEngine(reg)
|
||||
|
||||
query := testhelpers.TupleFromString(b, "Project:Ory#readProject@User:Dev")
|
||||
|
||||
b.Run("Computed userset", func(b *testing.B) {
|
||||
initialDBSpans := dbSpans(spans)
|
||||
for i := 0; i < b.N; i++ {
|
||||
res := e.CheckRelationTuple(ctx, query, 0)
|
||||
assert.NoError(b, res.Err)
|
||||
if res.Err != nil {
|
||||
b.Errorf("got unexpected error: %v", res.Err)
|
||||
}
|
||||
if res.Membership != check.IsMember {
|
||||
b.Error("check failed")
|
||||
}
|
||||
}
|
||||
b.ReportMetric((float64(dbSpans(spans)-initialDBSpans))/float64(b.N), "queries/op")
|
||||
})
|
||||
}
|
||||
|
||||
func dbSpans(spans *tracetest.SpanRecorder) (count int) {
|
||||
for _, s := range spans.Started() {
|
||||
if strings.HasPrefix(s.Name(), "sql-conn-query") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
157
internal/check/call_limit_test.go
Normal file
157
internal/check/call_limit_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/schema"
|
||||
)
|
||||
|
||||
func newRegistryForOPL(t *testing.T, opl string, opts ...driver.TestRegistryOption) *driver.RegistryDefault {
|
||||
t.Helper()
|
||||
parsed, errs := schema.Parse(opl)
|
||||
require.Empty(t, errs)
|
||||
nss := make([]*namespace.Namespace, len(parsed))
|
||||
for i := range parsed {
|
||||
nss[i] = &parsed[i]
|
||||
}
|
||||
return driver.NewSqliteTestRegistry(t, append([]driver.TestRegistryOption{driver.WithNamespaces(nss)}, opts...)...)
|
||||
}
|
||||
|
||||
// requireCyclicError asserts err is the call-limit backstop, not just any error
|
||||
// (e.g. a context cancellation), by checking its reason.
|
||||
func requireCyclicError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var re interface{ Reason() string }
|
||||
require.ErrorAs(t, err, &re)
|
||||
require.Contains(t, re.Reason(), "cyclic permit reference")
|
||||
}
|
||||
|
||||
func TestCallLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type outcome int
|
||||
const (
|
||||
cyclicError outcome = iota
|
||||
member
|
||||
depthLimited
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
opl string
|
||||
opts []driver.TestRegistryOption
|
||||
tuples []string
|
||||
check string
|
||||
restDepth int
|
||||
want outcome
|
||||
}{
|
||||
{
|
||||
name: "invert cycle aborts regardless of configured depth",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class Resource implements Namespace {
|
||||
permits = {
|
||||
edit: (ctx: Context): boolean => !this.permits.view(ctx),
|
||||
view: (ctx: Context): boolean => !this.permits.edit(ctx),
|
||||
}
|
||||
}`,
|
||||
opts: []driver.TestRegistryOption{driver.WithConfig("limit.max_read_depth", 65535)},
|
||||
check: "Resource:doc#edit@User:alice",
|
||||
restDepth: 65535,
|
||||
want: cyclicError,
|
||||
},
|
||||
{
|
||||
name: "branching cycle aborts decisively",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class R implements Namespace {
|
||||
permits = {
|
||||
a: (ctx: Context): boolean => this.permits.b(ctx) && this.permits.c(ctx),
|
||||
b: (ctx: Context): boolean => this.permits.a(ctx) && this.permits.a(ctx),
|
||||
c: (ctx: Context): boolean => this.permits.a(ctx) && this.permits.a(ctx),
|
||||
}
|
||||
}`,
|
||||
check: "R:x#a@User:u",
|
||||
restDepth: 5,
|
||||
want: cyclicError,
|
||||
},
|
||||
{
|
||||
name: "self-loop is bounded by RestDepth",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class Resource implements Namespace {
|
||||
permits = {
|
||||
edit: (ctx: Context): boolean => this.permits.edit(ctx),
|
||||
}
|
||||
}`,
|
||||
check: "Resource:doc#edit@User:alice",
|
||||
restDepth: 5,
|
||||
want: depthLimited,
|
||||
},
|
||||
{
|
||||
name: "legit role chain resolves",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class Doc implements Namespace {
|
||||
related: {
|
||||
l0: User[]; l1: User[]; l2: User[]; l3: User[];
|
||||
}
|
||||
permits = {
|
||||
p0: (ctx: Context): boolean => this.permits.p1(ctx) || this.related.l0.includes(ctx.subject),
|
||||
p1: (ctx: Context): boolean => this.permits.p2(ctx) || this.related.l1.includes(ctx.subject),
|
||||
p2: (ctx: Context): boolean => this.permits.p3(ctx) || this.related.l2.includes(ctx.subject),
|
||||
p3: (ctx: Context): boolean => this.related.l3.includes(ctx.subject),
|
||||
}
|
||||
}`,
|
||||
tuples: []string{"Doc:d#l3@User:alice"},
|
||||
check: "Doc:d#p0@User:alice",
|
||||
restDepth: 5,
|
||||
want: member,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := newRegistryForOPL(t, tc.opl, tc.opts...)
|
||||
if len(tc.tuples) > 0 {
|
||||
testhelpers.MapAndInsertTuplesFromString(t, reg, tc.tuples)
|
||||
}
|
||||
rt := testhelpers.TupleFromString(t, tc.check)
|
||||
|
||||
assertResult := func(t *testing.T, res check.Result) {
|
||||
switch tc.want {
|
||||
case cyclicError:
|
||||
requireCyclicError(t, res.Err)
|
||||
require.Equal(t, check.MembershipUnknown, res.Membership)
|
||||
case member:
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, check.IsMember, res.Membership)
|
||||
case depthLimited:
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, check.MembershipUnknown, res.Membership)
|
||||
require.Equal(t, check.LimitationMaxDepthExceeded, res.Limitation)
|
||||
}
|
||||
}
|
||||
|
||||
// Both executors wire the middleware separately.
|
||||
t.Run("check engine", func(t *testing.T) {
|
||||
assertResult(t, check.NewEngine(reg).CheckRelationTuple(t.Context(), rt, tc.restDepth))
|
||||
})
|
||||
t.Run("trace engine", func(t *testing.T) {
|
||||
res, tree := trace.NewEngine(reg).CheckRelationTupleWithTrace(t.Context(), rt, tc.restDepth)
|
||||
require.NotNil(t, tree)
|
||||
assertResult(t, res)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
141
internal/check/definitions.go
Normal file
141
internal/check/definitions.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type (
|
||||
// Result holds the outcome of a single permission check.
|
||||
// Limitation is non-empty only when Membership is MembershipUnknown.
|
||||
Result struct {
|
||||
Membership Membership
|
||||
Err error
|
||||
Limitation LimitationKind
|
||||
}
|
||||
|
||||
Membership int
|
||||
|
||||
// LimitationKind identifies which traversal constraint was hit. A non-empty
|
||||
// value on a Result means the membership answer is indeterminate.
|
||||
LimitationKind string
|
||||
)
|
||||
|
||||
//go:generate stringer -type Membership
|
||||
const (
|
||||
MembershipUnknown Membership = iota
|
||||
IsMember
|
||||
NotMember
|
||||
)
|
||||
|
||||
const (
|
||||
LimitationMaxDepthExceeded LimitationKind = "max_depth_exceeded"
|
||||
LimitationMaxWidthExceeded LimitationKind = "max_width_exceeded"
|
||||
)
|
||||
|
||||
var (
|
||||
ResultUnknown = Result{Membership: MembershipUnknown}
|
||||
ResultIsMember = Result{Membership: IsMember}
|
||||
ResultNotMember = Result{Membership: NotMember}
|
||||
)
|
||||
|
||||
// CheckRequest carries all inputs for a single check evaluation step.
|
||||
type CheckRequest struct {
|
||||
Tuple *relationtuple.RelationTuple
|
||||
RestDepth int
|
||||
}
|
||||
|
||||
func (r CheckRequest) WithDepth(d int) CheckRequest {
|
||||
r.RestDepth = d
|
||||
return r
|
||||
}
|
||||
|
||||
// PlannedStep pairs a Step with the CheckRequest it should run against.
|
||||
type PlannedStep struct {
|
||||
Step Step
|
||||
Req CheckRequest
|
||||
}
|
||||
|
||||
// StepRunner orchestrates execution of multiple steps with short-circuit semantics.
|
||||
// Typical usage:
|
||||
//
|
||||
// runner := ex.NewUnionRunner(ctx)
|
||||
// for _, ps := range steps {
|
||||
// if runner.Done() { break }
|
||||
// runner.Add(ps)
|
||||
// }
|
||||
// return runner.Result()
|
||||
type StepRunner interface {
|
||||
// Add enqueues a step for execution. Returns early if Done() is true.
|
||||
// No-op if the runner has already reached a decisive result or been cancelled.
|
||||
Add(PlannedStep)
|
||||
|
||||
// Done reports whether a decisive result was reached or context cancelled.
|
||||
// Use to short-circuit the enqueue loop.
|
||||
Done() bool
|
||||
|
||||
// Ctx returns the runner's context, usable for shared state like visited tracking.
|
||||
// The context is cancelled when a decisive result is reached (to signal siblings).
|
||||
Ctx() context.Context
|
||||
|
||||
// AddLimitation records that some branches were skipped due to given [LimitationKind] constraint.
|
||||
AddLimitation(LimitationKind)
|
||||
|
||||
// Result waits for all in-flight steps to complete and returns the aggregated result.
|
||||
// Call it after the Add loop finishes (or breaks early on Done()).
|
||||
Result() Result
|
||||
}
|
||||
|
||||
// Executor coordinates permission check execution with middleware support.
|
||||
// It runs individual steps (Run), sequences steps with union/intersection logic
|
||||
// (RunUnion, RunIntersection), and supports custom execution strategies via
|
||||
// middleware. All Executor implementations are stateless; dependencies come
|
||||
// from EngineDependencies.
|
||||
//
|
||||
// Typical usage:
|
||||
//
|
||||
// result := executor.Run(ctx, DirectStep{}, req)
|
||||
// unionResult := executor.RunUnion(ctx, step1, step2)
|
||||
// intersectionResult := executor.RunIntersection(ctx, step1, step2)
|
||||
type Executor interface {
|
||||
// Run executes a single step through the middleware chain.
|
||||
Run(ctx context.Context, step Step, req CheckRequest) Result
|
||||
|
||||
// RunUnion runs steps with OR semantics: returns IsMember on first match,
|
||||
// short-circuits siblings, falls through to NotMember if none match.
|
||||
RunUnion(ctx context.Context, steps ...PlannedStep) Result
|
||||
|
||||
// RunIntersection runs steps with AND semantics: returns NotMember or Error
|
||||
// on first failure, short-circuits siblings.
|
||||
RunIntersection(ctx context.Context, steps ...PlannedStep) Result
|
||||
|
||||
// NewUnionRunner creates a StepRunner for streaming union execution.
|
||||
// It is useful when Steps are created dynamically and the full list is not known upfront.
|
||||
NewUnionRunner(ctx context.Context) StepRunner
|
||||
|
||||
Deps() EngineDependencies
|
||||
}
|
||||
|
||||
// Middleware intercepts a step execution within the pipeline. next continues
|
||||
// down the remaining middlewares and ultimately calls step.Execute.
|
||||
type Middleware func(
|
||||
ctx context.Context,
|
||||
step Step,
|
||||
req CheckRequest,
|
||||
next func(context.Context, Step, CheckRequest) Result,
|
||||
) Result
|
||||
|
||||
// Checker performs a full permission check for a single relation tuple.
|
||||
// Implemented by Executor in the step package.
|
||||
type (
|
||||
CheckerProvider interface {
|
||||
Checker() Checker
|
||||
}
|
||||
Checker interface {
|
||||
CheckRelationTuple(ctx context.Context, r *relationtuple.RelationTuple, restDepth int) Result
|
||||
}
|
||||
)
|
||||
25
internal/check/definitions_string.go
Normal file
25
internal/check/definitions_string.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Code generated by "stringer -type Membership"; DO NOT EDIT.
|
||||
|
||||
package check
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[MembershipUnknown-0]
|
||||
_ = x[IsMember-1]
|
||||
_ = x[NotMember-2]
|
||||
}
|
||||
|
||||
const _Membership_name = "MembershipUnknownIsMemberNotMember"
|
||||
|
||||
var _Membership_index = [...]uint8{0, 17, 25, 34}
|
||||
|
||||
func (i Membership) String() string {
|
||||
if i < 0 || i >= Membership(len(_Membership_index)-1) {
|
||||
return "Membership(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _Membership_name[_Membership_index[i]:_Membership_index[i+1]]
|
||||
}
|
||||
127
internal/check/engine.go
Normal file
127
internal/check/engine.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
||||
"github.com/ory/keto/x/events"
|
||||
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
EngineProvider interface {
|
||||
PermissionEngine() *Engine
|
||||
}
|
||||
|
||||
Engine struct {
|
||||
dep EngineDependencies
|
||||
}
|
||||
|
||||
EngineDependencies interface {
|
||||
relationtuple.ManagerProvider
|
||||
relationtuple.MapperProvider
|
||||
CheckerProvider
|
||||
config.Provider
|
||||
logrusx.Provider
|
||||
otelx.Provider
|
||||
}
|
||||
|
||||
// Type alias for shorter signatures.
|
||||
relationTuple = relationtuple.RelationTuple
|
||||
)
|
||||
|
||||
func errLimitationExceeded() *herodot.DefaultError {
|
||||
return &herodot.DefaultError{
|
||||
StatusField: http.StatusText(http.StatusUnprocessableEntity),
|
||||
ErrorField: "The check could not be completed",
|
||||
CodeField: http.StatusUnprocessableEntity,
|
||||
GRPCCodeField: codes.FailedPrecondition,
|
||||
}
|
||||
}
|
||||
|
||||
// NewEngine creates an Engine that delegates checks to checker.
|
||||
func NewEngine(d EngineDependencies) *Engine {
|
||||
return &Engine{dep: d}
|
||||
}
|
||||
|
||||
// CheckIsMember checks if the relation tuple's subject has the relation on the
|
||||
// object in the namespace either directly or indirectly and returns a boolean
|
||||
// result.
|
||||
func (e *Engine) CheckIsMember(ctx context.Context, r *relationTuple, restDepth int) (bool, error) {
|
||||
result := e.CheckRelationTuple(ctx, r, restDepth)
|
||||
if result.Err != nil {
|
||||
return false, result.Err
|
||||
}
|
||||
if result.Membership == MembershipUnknown && result.Limitation != "" && e.dep.Config(ctx).StrictMode() {
|
||||
return false, errLimitationExceeded().WithReason(string(result.Limitation))
|
||||
}
|
||||
return result.Membership == IsMember, nil
|
||||
}
|
||||
|
||||
// CheckRelationTuple checks if the relation tuple's subject has the relation on
|
||||
// the object in the namespace either directly or indirectly and returns a check
|
||||
// result.
|
||||
func (e *Engine) CheckRelationTuple(ctx context.Context, r *relationTuple, restDepth int) (res Result) {
|
||||
ctx, span := e.dep.Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTuple")
|
||||
defer otelx.End(span, &res.Err)
|
||||
|
||||
txErr := e.dep.RelationTupleManager().WithLatestSnapshot(ctx, func(ctx context.Context) error {
|
||||
res = e.dep.Checker().CheckRelationTuple(ctx, r, restDepth)
|
||||
return res.Err
|
||||
})
|
||||
if txErr != nil && res.Err == nil {
|
||||
res.Membership = MembershipUnknown
|
||||
res.Err = herodot.ErrInternalServerError().WithWrap(txErr)
|
||||
|
||||
e.dep.Logger().WithError(txErr).WithField("tuple", r.String()).Error("could not open a consistent read snapshot for the check")
|
||||
}
|
||||
if res.Err == nil {
|
||||
span.AddEvent(events.NewPermissionsChecked(ctx))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// BatchCheck makes parallelized check requests for tuples. The check results
|
||||
// are returned as a slice where the result index matches the input tuple index.
|
||||
func (e *Engine) BatchCheck(
|
||||
ctx context.Context,
|
||||
tuples []*ketoapi.RelationTuple,
|
||||
maxDepth int,
|
||||
) ([]Result, error) {
|
||||
eg := &errgroup.Group{}
|
||||
eg.SetLimit(e.dep.Config(ctx).BatchCheckParallelizationLimit())
|
||||
|
||||
mapper := e.dep.ReadOnlyMapper()
|
||||
results := make([]Result, len(tuples))
|
||||
for i, tuple := range tuples {
|
||||
eg.Go(func() error {
|
||||
internalTuple, err := mapper.FromTuple(ctx, tuple)
|
||||
if err != nil {
|
||||
results[i] = Result{
|
||||
Membership: MembershipUnknown,
|
||||
Err: err,
|
||||
}
|
||||
} else {
|
||||
results[i] = e.CheckRelationTuple(ctx, internalTuple[0], maxDepth)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
845
internal/check/engine_test.go
Normal file
845
internal/check/engine_test.go
Normal file
@@ -0,0 +1,845 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/internal/x/dbx"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
// ExpansionRecursive is a three-level group hierarchy where engineering branches
|
||||
// into two sibling sub-groups. Safe to run with WithStrict(true).
|
||||
//
|
||||
// company ⊃ engineering ⊃ (frontend, backend)
|
||||
// company ⊃ User:cto
|
||||
// engineering ⊃ User:manager
|
||||
// backend ⊃ User:Alice, Alice
|
||||
// frontend ⊃ User:Bob, Bob
|
||||
var ExpansionRecursive = testhelpers.Scenario{
|
||||
Name: "Expansion Recursive",
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Group:company#members@Group:engineering#members",
|
||||
|
||||
"Group:company#members@User:cto",
|
||||
"Group:engineering#members@User:manager",
|
||||
|
||||
"Group:engineering#members@Group:frontend#members",
|
||||
"Group:engineering#members@Group:backend#members",
|
||||
|
||||
"Group:backend#members@User:Alice",
|
||||
"Group:frontend#members@User:Bob",
|
||||
|
||||
"Group:backend#members@Alice", // SubjectID
|
||||
"Group:frontend#members@Bob", // SubjectID
|
||||
},
|
||||
}
|
||||
|
||||
var ExpansionRecursiveNamespaceOnly = testhelpers.Scenario{
|
||||
Name: ExpansionRecursive.Name + " (namespaces only)",
|
||||
Opl: `class User implements Namespace{} class Group implements Namespace{}`,
|
||||
InputTuples: ExpansionRecursive.InputTuples,
|
||||
}
|
||||
|
||||
// SubjectSetExpansion3Hop combines cross-namespace and within-namespace SubjectSet expansion.
|
||||
//
|
||||
// File:team-report#viewers ⊃ File:team-report#editors ⊃ Group:finance#member → (User:Alice, User:Bob).
|
||||
// File:personal-note#viewers ⊃ User:Alice, User:Bob
|
||||
var SubjectSetExpansion3Hop = testhelpers.Scenario{
|
||||
Name: "SubjectSet Expansion 3 Hops",
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
editors: (User | SubjectSet<Group, "member">)[]
|
||||
viewers: (User | SubjectSet<File, "editors">)[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Group:finance#member@User:Alice",
|
||||
"Group:finance#member@User:Bob",
|
||||
|
||||
"File:team-report#editors@Group:finance#member", // group members are editors
|
||||
"File:team-report#viewers@File:team-report#editors", // editors are also viewers
|
||||
|
||||
"File:personal-note#viewers@User:Alice",
|
||||
"File:personal-note#viewers@User:Bob",
|
||||
},
|
||||
}
|
||||
|
||||
var SubjectSetExpansion3HopNamespacesOnly = testhelpers.Scenario{
|
||||
Name: SubjectSetExpansion3Hop.Name + " (namespaces only)",
|
||||
Opl: `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`,
|
||||
InputTuples: SubjectSetExpansion3Hop.InputTuples,
|
||||
}
|
||||
|
||||
// CircularGraphNamespacesOnly covers a fully circular SubjectSet graph with no leaf subjects.
|
||||
//
|
||||
// A → B → C → A.
|
||||
var CircularGraphNamespacesOnly = testhelpers.Scenario{
|
||||
Name: "Circular Graph (namespaces only)",
|
||||
Opl: `
|
||||
class Folder implements Namespace{}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Folder:a#parent@Folder:b#parent",
|
||||
"Folder:b#parent@Folder:c#parent",
|
||||
"Folder:c#parent@Folder:a#parent",
|
||||
},
|
||||
}
|
||||
|
||||
// FileWithNonConformingTuples covers a schema where a client incorrectly stores
|
||||
// tuples directly under permit names (canEdit, canView) instead of the backing
|
||||
// related fields (editors, viewers).
|
||||
//
|
||||
// In strict mode the engine ignores those tuples and resolves access only via
|
||||
// OPL rewrites. In non-strict mode the direct tuples are visible alongside the
|
||||
// rewrites.
|
||||
var FileWithNonConformingTuples = testhelpers.Scenario{
|
||||
Name: "File With Non-Conforming Tuples",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
editors: User[]
|
||||
viewers: User[]
|
||||
}
|
||||
permits = {
|
||||
canEdit: (ctx: Context) => this.related.editors.includes(ctx.subject),
|
||||
canView: (ctx: Context) => this.permits.canEdit(ctx) || this.related.viewers.includes(ctx.subject),
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"File:secret-doc#editors@User:Alice",
|
||||
"File:secret-doc#viewers@User:Bob",
|
||||
|
||||
// Bad client data: permit names used as direct relation targets.
|
||||
"File:secret-doc#canEdit@User:Eve",
|
||||
"File:secret-doc#canEdit@Eve", // subjectID
|
||||
},
|
||||
}
|
||||
|
||||
// NotionPage models a simplified Notion-like workspace with nested pages.
|
||||
//
|
||||
// canView requires direct or parent access.
|
||||
// canEdit requires direct editor access AND workspace membership.
|
||||
var NotionPage = testhelpers.Scenario{
|
||||
Name: "Notion Page",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class Workspace implements Namespace {
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
class Page implements Namespace {
|
||||
related: {
|
||||
workspace: Workspace[]
|
||||
parent: Page[]
|
||||
viewer: User[]
|
||||
editor: User[]
|
||||
}
|
||||
permits = {
|
||||
canView: (ctx: Context) =>
|
||||
this.related.viewer.includes(ctx.subject) ||
|
||||
this.related.editor.includes(ctx.subject) ||
|
||||
this.related.parent.traverse(p => p.permits.canView(ctx)),
|
||||
canEdit: (ctx: Context) =>
|
||||
this.related.editor.includes(ctx.subject) &&
|
||||
this.related.workspace.traverse(w => w.related.member.includes(ctx.subject)),
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Workspace:acme#member@User:Alice",
|
||||
"Workspace:acme#member@User:Bob",
|
||||
|
||||
"Page:home#workspace@Workspace:acme",
|
||||
"Page:home#viewer@User:Alice",
|
||||
"Page:home#editor@User:Bob",
|
||||
"Page:home#editor@User:Eve", // Eve is not workspace member
|
||||
|
||||
"Page:intro#workspace@Workspace:acme",
|
||||
"Page:intro#parent@Page:home", // intro inherits canView from home
|
||||
"Page:intro#editor@User:Alice",
|
||||
},
|
||||
}
|
||||
|
||||
var testcases = []struct {
|
||||
scenarios []testhelpers.Scenario
|
||||
expectedMembers []string
|
||||
expectedNonMembers []string
|
||||
}{
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{ExpansionRecursive, ExpansionRecursiveNamespaceOnly},
|
||||
expectedMembers: []string{
|
||||
// direct: SubjectSet and SubjectID members
|
||||
"Group:backend#members@User:Alice", "Group:backend#members@Alice",
|
||||
"Group:frontend#members@User:Bob", "Group:frontend#members@Bob",
|
||||
|
||||
// engineering: own direct member plus frontend and backend
|
||||
"Group:engineering#members@User:manager",
|
||||
"Group:engineering#members@User:Alice", "Group:engineering#members@Alice",
|
||||
"Group:engineering#members@User:Bob", "Group:engineering#members@Bob",
|
||||
|
||||
// company: own direct member plus everything from engineering
|
||||
"Group:company#members@User:cto",
|
||||
"Group:company#members@User:manager",
|
||||
"Group:company#members@User:Alice", "Group:company#members@Alice",
|
||||
"Group:company#members@User:Bob", "Group:company#members@Bob",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
// frontend and backend members do not cross.
|
||||
"Group:frontend#members@User:Alice",
|
||||
"Group:backend#members@User:Bob",
|
||||
|
||||
// Membership flows upward only.
|
||||
"Group:backend#members@User:manager", "Group:backend#members@User:cto",
|
||||
"Group:frontend#members@User:manager", "Group:frontend#members@User:cto",
|
||||
"Group:engineering#members@User:cto",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{SubjectSetExpansion3Hop, SubjectSetExpansion3HopNamespacesOnly},
|
||||
expectedMembers: []string{
|
||||
// direct
|
||||
"Group:finance#member@User:Alice", "Group:finance#member@User:Bob",
|
||||
|
||||
// via group expansion
|
||||
"File:team-report#editors@User:Alice", "File:team-report#editors@User:Bob",
|
||||
|
||||
// via editors are viewers
|
||||
"File:team-report#viewers@User:Alice", "File:team-report#viewers@User:Bob",
|
||||
|
||||
// direct viewers on personal note
|
||||
"File:personal-note#viewers@User:Alice", "File:personal-note#viewers@User:Bob",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
// File:personal-note is missing subject-set relationship;
|
||||
// Therefore editors are not viewers;
|
||||
"File:personal-note#editors@User:Alice",
|
||||
"File:personal-note#editors@User:Bob",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{CircularGraphNamespacesOnly},
|
||||
expectedMembers: []string{
|
||||
"Folder:a#parent@Folder:a#parent",
|
||||
"Folder:a#parent@Folder:b#parent",
|
||||
"Folder:a#parent@Folder:c#parent",
|
||||
|
||||
"Folder:b#parent@Folder:a#parent",
|
||||
"Folder:b#parent@Folder:b#parent",
|
||||
"Folder:b#parent@Folder:c#parent",
|
||||
|
||||
"Folder:c#parent@Folder:a#parent",
|
||||
"Folder:c#parent@Folder:b#parent",
|
||||
"Folder:c#parent@Folder:c#parent",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{{
|
||||
Name: "rejects transitive relation",
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
class Directory implements Namespace{}
|
||||
class File implements Namespace{}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"File:report#parent@Directory:docs",
|
||||
"Directory:docs#access@User:Alice",
|
||||
},
|
||||
}},
|
||||
expectedNonMembers: []string{
|
||||
"File:report#access@User:Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples.WithStrict(true)},
|
||||
expectedMembers: []string{
|
||||
"File:secret-doc#viewers@User:Bob",
|
||||
"File:secret-doc#editors@User:Alice",
|
||||
|
||||
// via OPL
|
||||
"File:secret-doc#canView@User:Bob",
|
||||
"File:secret-doc#canEdit@User:Alice",
|
||||
"File:secret-doc#canView@User:Alice",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
"File:secret-doc#canEdit@User:Eve",
|
||||
"File:secret-doc#canView@User:Eve",
|
||||
|
||||
"File:secret-doc#canEdit@Eve",
|
||||
"File:secret-doc#canView@Eve",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{NotionPage},
|
||||
expectedMembers: []string{
|
||||
// direct viewer/editor on home
|
||||
"Page:home#canView@User:Alice",
|
||||
"Page:home#canView@User:Bob", // editor also satisfies canView
|
||||
"Page:home#canView@User:Eve", // stale editor tuple still satisfies canView (no workspace check)
|
||||
// canEdit requires editor AND workspace member
|
||||
"Page:home#canEdit@User:Bob",
|
||||
// intro: direct editor + workspace member
|
||||
"Page:intro#canEdit@User:Alice",
|
||||
// intro canView via TTU: intro → parent → home → Alice/Bob/Eve
|
||||
"Page:intro#canView@User:Alice",
|
||||
"Page:intro#canView@User:Bob",
|
||||
"Page:intro#canView@User:Eve",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
// Eve has editor tuple but is not a workspace member — AND fails
|
||||
"Page:home#canEdit@User:Eve",
|
||||
// Bob is editor of home, not intro — canEdit requires explicit editor tuple
|
||||
"Page:intro#canEdit@User:Bob",
|
||||
// Alice is only a viewer on home, not an editor
|
||||
"Page:home#canEdit@User:Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples},
|
||||
expectedMembers: []string{
|
||||
"File:secret-doc#viewers@User:Bob",
|
||||
"File:secret-doc#editors@User:Alice",
|
||||
|
||||
// via OPL
|
||||
"File:secret-doc#canView@User:Bob",
|
||||
"File:secret-doc#canEdit@User:Alice",
|
||||
"File:secret-doc#canView@User:Alice",
|
||||
|
||||
// canEdit is a direct tuple, visible in non-strict mode
|
||||
"File:secret-doc#canEdit@User:Eve",
|
||||
// canView is visible via OPL rewrite in non-strict mode
|
||||
"File:secret-doc#canView@User:Eve",
|
||||
},
|
||||
expectedNonMembers: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
t.Run(`membership tests`, func(t *testing.T) {
|
||||
for _, tc := range testcases {
|
||||
for _, scenario := range tc.scenarios {
|
||||
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := check.NewEngine(reg)
|
||||
ctx := t.Context()
|
||||
|
||||
for _, tuple := range tc.expectedMembers {
|
||||
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
|
||||
require.NoError(t, err)
|
||||
assert.Truef(t, res, "expected %q to be a member", tuple)
|
||||
}
|
||||
|
||||
for _, tuple := range tc.expectedNonMembers {
|
||||
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
|
||||
require.NoError(t, err)
|
||||
assert.Falsef(t, res, "expected %q to not be a member", tuple)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects max-depth", func(t *testing.T) {
|
||||
ExpansionRecursive.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := check.NewEngine(reg)
|
||||
ctx := t.Context()
|
||||
|
||||
// company → engineering → backend → User:Alice requires exactly 2 hops.
|
||||
tuple := testhelpers.TupleFromString(t, "Group:company#members@User:Alice")
|
||||
|
||||
assert.Equal(t, 5, reg.Config(ctx).MaxReadDepth())
|
||||
|
||||
res, err := e.CheckIsMember(ctx, tuple, 1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res, "depth=1 is not enough for a 2-hop chain")
|
||||
|
||||
res, err = e.CheckIsMember(ctx, tuple, 2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res, "depth=2 is enough for a 2-hop chain")
|
||||
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 1))
|
||||
res, err = e.CheckIsMember(ctx, tuple, 5)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res, "global max-depth=1 overrides request depth=5")
|
||||
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 3))
|
||||
res, err = e.CheckIsMember(ctx, tuple, 0)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res, "global max-depth=3 is enough")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("batch check", func(t *testing.T) {
|
||||
SubjectSetExpansion3Hop.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := check.NewEngine(reg)
|
||||
ctx := t.Context()
|
||||
|
||||
targetTuples := []*ketoapi.RelationTuple{
|
||||
testhelpers.APITupleFromString(t, "Group:finance#member@User:Alice"), // direct
|
||||
testhelpers.APITupleFromString(t, "File:team-report#editors@User:Alice"), // 1-hop
|
||||
testhelpers.APITupleFromString(t, "File:team-report#viewers@User:Alice"), // 2-hop
|
||||
testhelpers.APITupleFromString(t, "NonExistent:x#rel@User:Alice"), // non-existent namespace
|
||||
testhelpers.APITupleFromString(t, "Group:finance#member@User:Eve"), // unknown subject
|
||||
testhelpers.APITupleFromString(t, "File:team-report#viewers@File:team-report#editors"), // via subject set
|
||||
}
|
||||
|
||||
// At depth=1, the 2-hop viewers chain is not resolved.
|
||||
results, err := e.BatchCheck(ctx, targetTuples, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, check.IsMember, results[0].Membership)
|
||||
require.Equal(t, check.IsMember, results[1].Membership)
|
||||
require.Equal(t, check.MembershipUnknown, results[2].Membership)
|
||||
require.Equal(t, check.LimitationMaxDepthExceeded, results[2].Limitation)
|
||||
require.Equal(t, check.MembershipUnknown, results[3].Membership)
|
||||
require.EqualError(t, results[3].Err, herodot.ErrNotFound().Error())
|
||||
require.Equal(t, check.MembershipUnknown, results[4].Membership)
|
||||
require.Equal(t, check.LimitationMaxDepthExceeded, results[4].Limitation)
|
||||
require.Equal(t, check.IsMember, results[5].Membership)
|
||||
|
||||
// At depth=2, the 2-hop viewers chain resolves
|
||||
results, err = e.BatchCheck(ctx, targetTuples, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, check.IsMember, results[2].Membership)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// widthLimitScenario fans out Resource:doc#readers into three groups.
|
||||
// With width=1 only Group:alpha is explored; the rest are truncated.
|
||||
var widthLimitScenario = testhelpers.Scenario{
|
||||
Name: "Width Limit",
|
||||
OrderedShardID: true,
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
member: (User | SubjectSet<Group, "member">)[]
|
||||
}
|
||||
}
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
readers: (User | SubjectSet<Group, "member">)[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
// Resource:doc#readers
|
||||
// ├── Group:alpha#member ──► Group:alpha-sub#member ──► User:Alice (explored first at width=1)
|
||||
// ├── Group:beta#member ──► Group:beta-sub#member ──► User:Bob (truncated at width=1)
|
||||
// └── Group:gamma#member ──► Group:gamma-sub#member (truncated at width=1)
|
||||
InputTuples: []string{
|
||||
// Insertion order determines DB return order for expand
|
||||
"Resource:doc#readers@Group:alpha#member",
|
||||
"Resource:doc#readers@Group:beta#member",
|
||||
"Resource:doc#readers@Group:gamma#member",
|
||||
|
||||
"Group:alpha#member@Group:alpha-sub#member",
|
||||
"Group:beta#member@Group:beta-sub#member",
|
||||
"Group:gamma#member@Group:gamma-sub#member",
|
||||
|
||||
"Group:alpha-sub#member@User:Alice",
|
||||
"Group:beta-sub#member@User:Bob",
|
||||
// Eve is in none of the groups.
|
||||
},
|
||||
}
|
||||
|
||||
func TestWidthLimit(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
subject string
|
||||
width int // 0 means default 100
|
||||
expectedMembership check.Membership
|
||||
expectedLimitation check.LimitationKind
|
||||
expectedIsMember bool
|
||||
}
|
||||
|
||||
tests := []tc{
|
||||
{
|
||||
name: "no limit: Alice is IsMember",
|
||||
subject: "User:Alice",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "no limit: Eve is NotMember",
|
||||
subject: "User:Eve",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "width=1: Eve is indeterminate",
|
||||
subject: "User:Eve",
|
||||
width: 1,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxWidthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
// Bob is only in beta-sub, which is always truncated at width=1.
|
||||
name: "width=1: member only in truncated branch is indeterminate",
|
||||
subject: "User:Bob",
|
||||
width: 1,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxWidthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
widthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.width > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
})
|
||||
|
||||
// in strict mode, expect Error when limitation is hit
|
||||
widthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.width > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
if tc.expectedLimitation != "" {
|
||||
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.Error(t, err)
|
||||
var he *herodot.DefaultError
|
||||
require.ErrorAs(t, err, &he)
|
||||
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
|
||||
} else {
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Alice is in both deep and short chains.
|
||||
// Depth of 3 is needed to reach Alice via shortRel.
|
||||
// Depth of 5 is needed to reach Alice via deepRel.
|
||||
var depthLimitScenario = testhelpers.Scenario{
|
||||
Name: "Depth Limit",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
deepRel: (User | SubjectSet<Group, "members">)[]
|
||||
shortRel: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
permits = {
|
||||
union: (ctx:Context) =>
|
||||
this.related.deepRel.includes(ctx.subject)
|
||||
|| this.related.shortRel.includes(ctx.subject),
|
||||
intersection: (ctx:Context) =>
|
||||
this.related.deepRel.includes(ctx.subject)
|
||||
&& this.related.shortRel.includes(ctx.subject),
|
||||
shortButNotDeep: (ctx:Context) => this.related.shortRel.includes(ctx.subject)
|
||||
&& !this.related.deepRel.includes(ctx.subject),
|
||||
|
||||
// notEither simulates a client that authorizes users when Keto returns NotMember
|
||||
notEither: (ctx:Context) => !this.permits.union(ctx),
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Resource:doc#deepRel@Group:deep#members",
|
||||
"Resource:doc#shortRel@Group:short#members",
|
||||
|
||||
"Group:deep#members@Group:deep1#members",
|
||||
"Group:deep1#members@Group:deep2#members",
|
||||
"Group:deep2#members@Group:deep3#members",
|
||||
"Group:deep3#members@Group:deep4#members",
|
||||
"Group:deep4#members@User:Alice",
|
||||
|
||||
"Group:short#members@Group:short1#members",
|
||||
"Group:short1#members@Group:short2#members",
|
||||
"Group:short2#members@User:Alice",
|
||||
"Group:short2#members@User:Charlie",
|
||||
},
|
||||
}
|
||||
|
||||
func TestDepthLimit(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
tuple string
|
||||
depth int
|
||||
expectedMembership check.Membership
|
||||
expectedLimitation check.LimitationKind
|
||||
expectedIsMember bool
|
||||
}
|
||||
|
||||
tests := []tc{
|
||||
{
|
||||
name: "union: too shallow depth for both branches yields NotMember",
|
||||
tuple: "Resource:doc#union@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "union: enough depth for 1 Member branch yields Member",
|
||||
tuple: "Resource:doc#union@User:Alice",
|
||||
depth: 4,
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "union: enough depth for 1 NotMember branch yields Unknown",
|
||||
tuple: "Resource:doc#union@User:Bob", // Bob is in no branch
|
||||
depth: 4,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "intersection: too shallow depth for both branches yields Unknown",
|
||||
tuple: "Resource:doc#intersection@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "intersection: enough depth for only 1 branch yields Unknown",
|
||||
tuple: "Resource:doc#intersection@User:Alice",
|
||||
depth: 4,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "intersection: enough depth for both branches yields Member",
|
||||
tuple: "Resource:doc#intersection@User:Alice",
|
||||
depth: 5,
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "shortButNotDeep: too shallow depth for both branches yields Unknown",
|
||||
tuple: "Resource:doc#shortButNotDeep@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "shortButNotDeep: enough depth for only 1 branch yields Unknown",
|
||||
tuple: "Resource:doc#shortButNotDeep@User:Alice",
|
||||
depth: 4,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "shortButNotDeep: user only in shortRel yields Member at sufficient depth",
|
||||
tuple: "Resource:doc#shortButNotDeep@User:Charlie",
|
||||
depth: 7,
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "notEither: not enough depth for any branch yields Unknown",
|
||||
tuple: "Resource:doc#notEither@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
depthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.depth > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
})
|
||||
|
||||
// in strict mode, expect Error when limitation is hit
|
||||
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.depth > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
if tc.expectedLimitation != "" {
|
||||
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.Error(t, err)
|
||||
var he *herodot.DefaultError
|
||||
require.ErrorAs(t, err, &he)
|
||||
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
|
||||
} else {
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSnapshotIsolation verifies that a concurrent write committed between
|
||||
// two SQL queries within the same check does not affect the result.
|
||||
func TestCheckSnapshotIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opl = `
|
||||
class User implements Namespace {}
|
||||
class Folder implements Namespace {
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
permits = {
|
||||
isMember: (ctx: Context) => this.related.members.includes(ctx.subject),
|
||||
}
|
||||
}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
parent: Folder[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.parent.traverse(p => p.permits.isMember(ctx)),
|
||||
}
|
||||
}
|
||||
`
|
||||
inputTuples := []string{
|
||||
"File:readme#parent@Folder:docs",
|
||||
"Folder:docs#members@User:u1", // this tuple will be deleted during check execution
|
||||
}
|
||||
|
||||
var snapshotDSNs []*dbx.DsnT
|
||||
for _, dsn := range dbx.GetDSNs(t) {
|
||||
switch dsn.Name {
|
||||
case "cockroach", "mysql", "postgres":
|
||||
snapshotDSNs = append(snapshotDSNs, dsn)
|
||||
}
|
||||
}
|
||||
if len(snapshotDSNs) == 0 {
|
||||
// SQLite is excluded. pop serialises all SQLite transactions with a mutex,
|
||||
// so the middleware's concurrent delete gets deadlocked against the outer check
|
||||
// transaction
|
||||
t.Skip("sqlite is excluded from snapshot isolation test; run without -short to start CockroachDB/MySQL/PostgreSQL")
|
||||
}
|
||||
|
||||
for _, dsn := range snapshotDSNs {
|
||||
t.Run("dsn="+dsn.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := driver.NewTestRegistry(t, dsn,
|
||||
driver.WithOPL(opl),
|
||||
driver.WithMapperNamespace(testhelpers.CustomMapperNamespace),
|
||||
driver.WithConfig(config.KeyFeatureFlagStrictMode, true),
|
||||
)
|
||||
testhelpers.MapAndInsertTuplesFromString(t, reg, inputTuples)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// The delete must happen after the first SQL query in the transaction so
|
||||
// the snapshot is already established in PostgreSQL/MySQL REPEATABLE READ
|
||||
// (which snapshots at first statement, not at BEGIN).
|
||||
var once sync.Once
|
||||
middleware := func(mCtx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
||||
if s.Kind() == check.StepIsAllowed && req.Tuple.Namespace == "Folder" && req.Tuple.Relation == "isMember" {
|
||||
once.Do(func() {
|
||||
assert.NoError(t, reg.RelationTupleManager().DeleteRelationTuples(t.Context(),
|
||||
testhelpers.TupleFromString(t, "Folder:docs#members@User:u1"),
|
||||
))
|
||||
})
|
||||
}
|
||||
return next(mCtx, s, req)
|
||||
}
|
||||
|
||||
engine := check.NewEngine(&checkerOverrideRegistry{Registry: reg, mw: middleware})
|
||||
|
||||
result := engine.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "File:readme#view@User:u1"), 5)
|
||||
require.NoError(t, result.Err)
|
||||
assert.Equal(t, check.IsMember, result.Membership,
|
||||
"check must reflect the database state at the start of the evaluation")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// checkerOverrideRegistry wraps a Registry and replaces its Checker.
|
||||
// mw can be used to inject a middleware to the Executor.
|
||||
type checkerOverrideRegistry struct {
|
||||
driver.Registry
|
||||
mw check.Middleware
|
||||
}
|
||||
|
||||
func (r *checkerOverrideRegistry) Checker() check.Checker {
|
||||
return step.NewExecutor(r.Registry, r.mw)
|
||||
}
|
||||
526
internal/check/handler.go
Normal file
526
internal/check/handler.go
Normal file
@@ -0,0 +1,526 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/internal/x"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
handlerDependencies interface {
|
||||
EngineProvider
|
||||
relationtuple.ManagerProvider
|
||||
relationtuple.MapperProvider
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
config.Provider
|
||||
x.HandlerOptionsProvider
|
||||
}
|
||||
Handler struct {
|
||||
relationtuplesconnect.UnimplementedCheckServiceHandler
|
||||
d handlerDependencies
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ relationtuplesconnect.CheckServiceHandler = (*Handler)(nil)
|
||||
_ *checkPermission = nil
|
||||
)
|
||||
|
||||
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
|
||||
|
||||
const (
|
||||
RouteBase = "/relation-tuples/check"
|
||||
OpenAPIRouteBase = RouteBase + "/openapi"
|
||||
BatchRoute = "/relation-tuples/batch/check"
|
||||
)
|
||||
|
||||
func (h *Handler) RegisterReadRoutes(r *httprouterx.RouterPublic) {
|
||||
r.GET(RouteBase, h.getCheckMirrorStatus)
|
||||
r.GET(OpenAPIRouteBase, h.getCheckNoStatus)
|
||||
r.POST(RouteBase, h.postCheckMirrorStatus)
|
||||
r.POST(OpenAPIRouteBase, h.postCheckNoStatus)
|
||||
r.POST(BatchRoute, h.batchCheck)
|
||||
|
||||
checkServiceMethods := rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto.Services().ByName("CheckService").Methods()
|
||||
|
||||
checkHandler := connect.NewUnaryHandler(
|
||||
relationtuplesconnect.CheckServiceCheckProcedure,
|
||||
h.Check,
|
||||
connect.WithSchema(checkServiceMethods.ByName("Check")),
|
||||
connect.WithHandlerOptions(h.d.HandlerOptions()...),
|
||||
)
|
||||
r.Handle(relationtuplesconnect.CheckServiceCheckProcedure, checkHandler)
|
||||
|
||||
batchCheckHandler := connect.NewUnaryHandler(
|
||||
relationtuplesconnect.CheckServiceBatchCheckProcedure,
|
||||
h.BatchCheck,
|
||||
connect.WithSchema(checkServiceMethods.ByName("BatchCheck")),
|
||||
connect.WithHandlerOptions(h.d.HandlerOptions()...),
|
||||
)
|
||||
r.Handle(relationtuplesconnect.CheckServiceBatchCheckProcedure, batchCheckHandler)
|
||||
}
|
||||
|
||||
func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor {
|
||||
return []protoreflect.FileDescriptor{
|
||||
rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto,
|
||||
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
|
||||
}
|
||||
}
|
||||
|
||||
// Check Permission Result
|
||||
//
|
||||
// The content of the allowed field is mirrored in the HTTP status code.
|
||||
//
|
||||
// swagger:model checkPermissionResult
|
||||
type CheckPermissionResult struct {
|
||||
// whether the relation tuple is allowed
|
||||
//
|
||||
// required: true
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Check Permission Result With Error
|
||||
//
|
||||
// swagger:model checkPermissionResultWithError
|
||||
type CheckPermissionResultWithError struct {
|
||||
// whether the relation tuple is allowed
|
||||
//
|
||||
// required: true
|
||||
Allowed bool `json:"allowed"`
|
||||
// any error generated while checking the relation tuple
|
||||
//
|
||||
// required: false
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Check Permission Request Parameters
|
||||
//
|
||||
// swagger:parameters checkPermission
|
||||
type checkPermission struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
}
|
||||
|
||||
// swagger:route GET /relation-tuples/check/openapi permission checkPermission
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) getCheckNoStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.getCheck(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
// Check Permission Or Error Request Parameters
|
||||
//
|
||||
// swagger:parameters checkPermissionOrError
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type checkPermissionOrError struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
}
|
||||
|
||||
// swagger:route GET /relation-tuples/check permission checkPermissionOrError
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// 403: checkPermissionResult
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) getCheckMirrorStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.getCheck(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if allowed {
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
func (h *Handler) getCheck(ctx context.Context, q url.Values) (bool, error) {
|
||||
maxDepth, err := x.GetMaxDepthFromQuery(q)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
tuple, err := (&ketoapi.RelationTuple{}).FromURLQuery(q)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
it, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple)
|
||||
// herodot.ErrNotFound occurs when the namespace is unknown
|
||||
if errors.Is(err, herodot.ErrNotFound()) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return h.d.PermissionEngine().CheckIsMember(ctx, it[0], maxDepth)
|
||||
}
|
||||
|
||||
// Check Permission using Post Request Parameters
|
||||
//
|
||||
// swagger:parameters postCheckPermission
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermission struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
|
||||
// in: body
|
||||
Payload postCheckPermissionBody
|
||||
}
|
||||
|
||||
// Check Permission using Post Request Body
|
||||
//
|
||||
// swagger:model postCheckPermissionBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermissionBody struct {
|
||||
ketoapi.RelationQuery
|
||||
}
|
||||
|
||||
// swagger:route POST /relation-tuples/check/openapi permission postCheckPermission
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) postCheckNoStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
// Post Check Permission Or Error Request Parameters
|
||||
//
|
||||
// swagger:parameters postCheckPermissionOrError
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermissionOrError struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
|
||||
// in: body
|
||||
Body postCheckPermissionOrErrorBody
|
||||
}
|
||||
|
||||
// Post Check Permission Or Error Body
|
||||
//
|
||||
// swagger:model postCheckPermissionOrErrorBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermissionOrErrorBody struct {
|
||||
ketoapi.RelationQuery
|
||||
}
|
||||
|
||||
// swagger:route POST /relation-tuples/check permission postCheckPermissionOrError
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// 403: checkPermissionResult
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) postCheckMirrorStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if allowed {
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
func (h *Handler) postCheck(ctx context.Context, body io.Reader, query url.Values) (bool, error) {
|
||||
maxDepth, err := x.GetMaxDepthFromQuery(query)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var tuple ketoapi.RelationTuple
|
||||
if err := json.NewDecoder(body).Decode(&tuple); err != nil {
|
||||
return false, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error()))
|
||||
}
|
||||
t, err := h.d.ReadOnlyMapper().FromTuple(ctx, &tuple)
|
||||
// herodot.ErrNotFound occurs when the namespace is unknown
|
||||
if errors.Is(err, herodot.ErrNotFound()) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return h.d.PermissionEngine().CheckIsMember(ctx, t[0], maxDepth)
|
||||
}
|
||||
|
||||
func (h *Handler) Check(ctx context.Context, req *connect.Request[rts.CheckRequest]) (*connect.Response[rts.CheckResponse], error) {
|
||||
var src ketoapi.TupleData
|
||||
if req.Msg.Tuple != nil {
|
||||
src = req.Msg.Tuple
|
||||
} else {
|
||||
src = req.Msg
|
||||
}
|
||||
|
||||
tuple, err := (&ketoapi.RelationTuple{}).FromDataProvider(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
internalTuple, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed, err := h.d.PermissionEngine().CheckIsMember(ctx, internalTuple[0], int(req.Msg.MaxDepth))
|
||||
// TODO add content change handling
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rts.CheckResponse{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value.
|
||||
Allowed: allowed,
|
||||
Snaptoken: "not yet implemented",
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Batch Check Permission Request Parameters
|
||||
//
|
||||
// swagger:parameters batchCheckPermission
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type batchCheckPermission struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
|
||||
// in: body
|
||||
Body batchCheckPermissionBody
|
||||
}
|
||||
|
||||
// Batch Check Permission Body
|
||||
//
|
||||
// swagger:model batchCheckPermissionBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type batchCheckPermissionBody struct {
|
||||
Tuples []*ketoapi.RelationTuple `json:"tuples"`
|
||||
}
|
||||
|
||||
// Batch Check Permission Result
|
||||
//
|
||||
// swagger:model batchCheckPermissionResult
|
||||
type BatchCheckPermissionResult struct {
|
||||
// An array of check results. The order aligns with the input order.
|
||||
//
|
||||
// required: true
|
||||
Results []*CheckPermissionResultWithError `json:"results"`
|
||||
}
|
||||
|
||||
// swagger:route POST /relation-tuples/batch/check permission batchCheckPermission
|
||||
//
|
||||
// # Batch check permissions
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: batchCheckPermissionResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) batchCheck(w http.ResponseWriter, r *http.Request) {
|
||||
results, err := h.doBatchCheck(r.Context(), r.Body, r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, &BatchCheckPermissionResult{Results: results})
|
||||
}
|
||||
|
||||
// doBatchCheck is the HTTP entry point for checking batches of tuples
|
||||
func (h *Handler) doBatchCheck(ctx context.Context, body io.Reader, query url.Values) ([]*CheckPermissionResultWithError, error) {
|
||||
maxDepth, err := x.GetMaxDepthFromQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var request batchCheckPermissionBody
|
||||
if err := json.NewDecoder(body).Decode(&request); err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error()))
|
||||
}
|
||||
|
||||
if len(request.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() {
|
||||
return nil, herodot.ErrBadRequest().WithErrorf("batch exceeds max size of %v",
|
||||
h.d.Config(ctx).BatchCheckMaxBatchSize())
|
||||
}
|
||||
|
||||
results, err := h.d.PermissionEngine().BatchCheck(ctx, request.Tuples, maxDepth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strict := h.d.Config(ctx).StrictMode()
|
||||
responses := make([]*CheckPermissionResultWithError, len(request.Tuples))
|
||||
for i, result := range results {
|
||||
responses[i] = &CheckPermissionResultWithError{
|
||||
Allowed: result.Membership == IsMember,
|
||||
Error: batchResultErrMsg(result, strict),
|
||||
}
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// BatchCheck is the gRPC entry point for checking batches of tuples
|
||||
func (h *Handler) BatchCheck(ctx context.Context, req *connect.Request[rts.BatchCheckRequest]) (*connect.Response[rts.BatchCheckResponse], error) {
|
||||
if len(req.Msg.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf(
|
||||
"batch exceeds max size of %v", h.d.Config(ctx).BatchCheckMaxBatchSize()))
|
||||
}
|
||||
|
||||
ketoTuples := make([]*ketoapi.RelationTuple, len(req.Msg.Tuples))
|
||||
for i, tuple := range req.Msg.Tuples {
|
||||
ketoTuples[i] = (&ketoapi.RelationTuple{}).FromProto(tuple)
|
||||
}
|
||||
|
||||
results, err := h.d.PermissionEngine().BatchCheck(ctx, ketoTuples, int(req.Msg.MaxDepth))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strict := h.d.Config(ctx).StrictMode()
|
||||
responses := make([]*rts.CheckResponseWithError, len(results))
|
||||
for i, result := range results {
|
||||
responses[i] = &rts.CheckResponseWithError{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value.
|
||||
Allowed: result.Membership == IsMember,
|
||||
Error: batchResultErrMsg(result, strict),
|
||||
Snaptoken: "not yet implemented",
|
||||
}
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rts.BatchCheckResponse{
|
||||
Results: responses,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// batchResultErrMsg returns an error string for a single batch check result.
|
||||
// In strict mode a limitation is surfaced so callers can distinguish
|
||||
// "definitely not a member" from "could not determine membership".
|
||||
func batchResultErrMsg(r Result, strict bool) string {
|
||||
if r.Err != nil {
|
||||
return r.Err.Error()
|
||||
}
|
||||
if r.Membership == MembershipUnknown && r.Limitation != "" && strict {
|
||||
return string(r.Limitation)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
415
internal/check/handler_test.go
Normal file
415
internal/check/handler_test.go
Normal file
@@ -0,0 +1,415 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
client "github.com/ory/keto/internal/httpclient"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
defer func() { assert.NoError(t, resp.Body.Close()) }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
func assertAllowed(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
body := readBody(t, resp)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
|
||||
allowed := gjson.GetBytes(body, "allowed")
|
||||
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
||||
require.True(t, allowed.Bool())
|
||||
}
|
||||
|
||||
type responseAssertion func(t *testing.T, resp *http.Response)
|
||||
|
||||
func baseAssertDenied(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
body := readBody(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s", body)
|
||||
allowed := gjson.GetBytes(body, "allowed")
|
||||
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
||||
require.False(t, allowed.Bool())
|
||||
}
|
||||
|
||||
// For OpenAPI clients, we want to always return a 200 status code even if the
|
||||
// check returned "denied" to not cause exceptions etc. in the generated clients.
|
||||
func openAPIAssertDenied(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
body := readBody(t, resp)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
|
||||
allowed := gjson.GetBytes(body, "allowed")
|
||||
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
||||
require.False(t, allowed.Bool())
|
||||
}
|
||||
|
||||
func newTestGRPCCheckClient(t *testing.T, h *check.Handler) rts.CheckServiceClient {
|
||||
t.Helper()
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewUnstartedServer(router)
|
||||
ts.EnableHTTP2 = true
|
||||
ts.StartTLS()
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
rootCA := x509.NewCertPool()
|
||||
rootCA.AddCert(ts.Certificate())
|
||||
|
||||
conn, err := grpc.NewClient(ts.Listener.Addr().String(),
|
||||
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{RootCAs: rootCA})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
||||
return rts.NewCheckServiceClient(conn)
|
||||
}
|
||||
|
||||
func buildBatchURL(baseURL, maxDepth string) string {
|
||||
return fmt.Sprintf("%s%s?max-depth=%s", baseURL, check.BatchRoute, maxDepth)
|
||||
}
|
||||
|
||||
func TestCheckRESTHandler(t *testing.T) {
|
||||
nspaces := []*namespace.Namespace{{
|
||||
Name: "ns",
|
||||
}}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
|
||||
h := check.NewHandler(reg)
|
||||
r := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(r)
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
for _, suite := range []struct {
|
||||
name string
|
||||
base string
|
||||
assertDenied responseAssertion
|
||||
}{
|
||||
{name: "base", base: check.RouteBase, assertDenied: baseAssertDenied},
|
||||
{name: "openapi", base: check.OpenAPIRouteBase, assertDenied: openAPIAssertDenied},
|
||||
} {
|
||||
t.Run("suite="+suite.name, func(t *testing.T) {
|
||||
assertDenied := suite.assertDenied
|
||||
|
||||
t.Run("case=returns bad request on malformed int", func(t *testing.T) {
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?max-depth=foo")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
|
||||
})
|
||||
|
||||
t.Run("case=returns bad request on malformed input", func(t *testing.T) {
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + url.Values{
|
||||
"subject": {"not#a valid userset rewrite"},
|
||||
}.Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=returns bad request on missing subject", func(t *testing.T) {
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "subject is not allowed to be nil")
|
||||
})
|
||||
|
||||
t.Run("case=returns denied on unknown namespace", func(t *testing.T) {
|
||||
rt := testhelpers.APITupleFromString(t, "not:o#r@s")
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertDenied(t, resp)
|
||||
})
|
||||
|
||||
t.Run("case=returns allowed", func(t *testing.T) {
|
||||
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
|
||||
testhelpers.MapAndInsertTuples(t, reg, rt)
|
||||
|
||||
q := rt.ToURLQuery()
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + q.Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertAllowed(t, resp)
|
||||
})
|
||||
|
||||
t.Run("case=returns denied", func(t *testing.T) {
|
||||
rt := testhelpers.APITupleFromString(t, "ns:foo#r@s")
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertDenied(t, resp)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCheckHandler(t *testing.T) {
|
||||
nspaces := []*namespace.Namespace{{Name: "ns"}}
|
||||
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
|
||||
h := check.NewHandler(reg)
|
||||
|
||||
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
|
||||
testhelpers.MapAndInsertTuples(t, reg, rt)
|
||||
|
||||
// REST setup.
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// gRPC setup.
|
||||
checkClient := newTestGRPCCheckClient(t, h)
|
||||
|
||||
t.Run("case=REST: validates request format", func(t *testing.T) {
|
||||
t.Run("non-int max depth", func(t *testing.T) {
|
||||
resp, err := ts.Client().Post(ts.URL+check.BatchRoute+"?max-depth=foo",
|
||||
"application/json", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON body", func(t *testing.T) {
|
||||
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"),
|
||||
"application/json", strings.NewReader("not-json"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "could not unmarshal json")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=validates max batch size", func(t *testing.T) {
|
||||
t.Run("protocol=REST", func(t *testing.T) {
|
||||
tuples := make([]client.Relationship, 11)
|
||||
for i := range tuples {
|
||||
tuples[i] = relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s"))
|
||||
}
|
||||
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: tuples})
|
||||
require.NoError(t, err)
|
||||
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "batch exceeds max size of 10")
|
||||
})
|
||||
|
||||
t.Run("protocol=gRPC", func(t *testing.T) {
|
||||
tuples := make([]*rts.RelationTuple, 11)
|
||||
for i := range tuples {
|
||||
tuples[i] = testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto()
|
||||
}
|
||||
_, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: tuples, MaxDepth: 5})
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.InvalidArgument, st.Code())
|
||||
require.Equal(t, "batch exceeds max size of 10", st.Message())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=returns correct results per tuple", func(t *testing.T) {
|
||||
// Three tuples: allowed, not-allowed, unknown namespace.
|
||||
restTuples := []client.Relationship{
|
||||
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s")), // allowed
|
||||
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o2#r@s")), // not allowed
|
||||
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "n2:o#r@s")), // unknown namespace
|
||||
}
|
||||
grpcTuples := []*rts.RelationTuple{
|
||||
testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto(), // allowed
|
||||
testhelpers.APITupleFromString(t, "ns:o2#r@s").ToProto(), // not allowed
|
||||
testhelpers.APITupleFromString(t, "n2:o#r@s").ToProto(), // unknown namespace
|
||||
}
|
||||
|
||||
t.Run("protocol=REST", func(t *testing.T) {
|
||||
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: restTuples})
|
||||
require.NoError(t, err)
|
||||
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result client.BatchCheckPermissionResult
|
||||
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
|
||||
require.Equal(t, client.BatchCheckPermissionResult{
|
||||
Results: []client.CheckPermissionResultWithError{
|
||||
{Allowed: true},
|
||||
{Allowed: false},
|
||||
{Allowed: false, Error: new("The requested resource could not be found")},
|
||||
},
|
||||
}, result)
|
||||
})
|
||||
|
||||
t.Run("protocol=gRPC", func(t *testing.T) {
|
||||
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: grpcTuples, MaxDepth: 5})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Results, 3)
|
||||
require.True(t, resp.Results[0].Allowed)
|
||||
require.Empty(t, resp.Results[0].Error)
|
||||
require.False(t, resp.Results[1].Allowed)
|
||||
require.Empty(t, resp.Results[1].Error)
|
||||
require.False(t, resp.Results[2].Allowed)
|
||||
require.Equal(t, "The requested resource could not be found", resp.Results[2].Error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// relationshipFromAPITuple converts a ketoapi.RelationTuple to a client.Relationship
|
||||
// for use in REST batch check requests.
|
||||
func relationshipFromAPITuple(tuple *ketoapi.RelationTuple) client.Relationship {
|
||||
rel := client.Relationship{
|
||||
Namespace: tuple.Namespace,
|
||||
Object: tuple.Object,
|
||||
Relation: tuple.Relation,
|
||||
}
|
||||
if tuple.SubjectID != nil {
|
||||
rel.SubjectId = tuple.SubjectID
|
||||
} else if tuple.SubjectSet != nil {
|
||||
rel.SubjectSet = &client.SubjectSet{
|
||||
Namespace: tuple.SubjectSet.Namespace,
|
||||
Object: tuple.SubjectSet.Object,
|
||||
Relation: tuple.SubjectSet.Relation,
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func TestCheckStrictModeLimit(t *testing.T) {
|
||||
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadDepth, 2))
|
||||
h := check.NewHandler(reg)
|
||||
|
||||
// This tuple has a depth of 3, which exceeds the configured limit of 2, causing to hit the limitation.
|
||||
tupleQuery := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice").ToURLQuery()
|
||||
|
||||
t.Run("case=HTTP: returns 422 with limitation kind", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
resp, err := ts.Client().Get(ts.URL + check.RouteBase + "?max-depth=2&" + tupleQuery.Encode())
|
||||
require.NoError(t, err)
|
||||
body := readBody(t, resp)
|
||||
|
||||
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "%s", body)
|
||||
require.Equal(t, "The check could not be completed", gjson.GetBytes(body, "error.message").String())
|
||||
require.Contains(t, gjson.GetBytes(body, "error.reason").String(), string(check.LimitationMaxDepthExceeded))
|
||||
})
|
||||
|
||||
t.Run("case=gRPC: returns FailedPrecondition with limitation kind", func(t *testing.T) {
|
||||
checkClient := newTestGRPCCheckClient(t, h)
|
||||
tuple := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice")
|
||||
_, err := checkClient.Check(t.Context(), &rts.CheckRequest{
|
||||
Tuple: tuple.ToProto(),
|
||||
MaxDepth: 2,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.FailedPrecondition, st.Code())
|
||||
require.Equal(t, "The check could not be completed", st.Message())
|
||||
|
||||
var ei *errdetails.ErrorInfo
|
||||
for _, d := range st.Details() {
|
||||
if info, ok := d.(*errdetails.ErrorInfo); ok {
|
||||
ei = info
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNilf(t, ei, "%+v", st.Details())
|
||||
require.Contains(t, ei.Reason, string(check.LimitationMaxDepthExceeded))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchCheckLimitHandler(t *testing.T) {
|
||||
for _, scenario := range []testhelpers.Scenario{
|
||||
widthLimitScenario,
|
||||
widthLimitScenario.WithStrict(true),
|
||||
} {
|
||||
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadWidth, 1))
|
||||
h := check.NewHandler(reg)
|
||||
strict := scenario.Strict
|
||||
|
||||
// Bob is only in the truncated branch → MembershipUnknown at width=1.
|
||||
tuple := testhelpers.APITupleFromString(t, "Resource:doc#readers@User:Bob")
|
||||
|
||||
t.Run("case=REST: strict mode surfaces limitation in error field", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{
|
||||
Tuples: []client.Relationship{relationshipFromAPITuple(tuple)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := ts.Client().Post(
|
||||
fmt.Sprintf("%s%s?max-depth=10", ts.URL, check.BatchRoute),
|
||||
"application/json", bytes.NewReader(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result client.BatchCheckPermissionResult
|
||||
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
|
||||
require.Len(t, result.Results, 1)
|
||||
require.False(t, result.Results[0].Allowed)
|
||||
if strict {
|
||||
require.NotNil(t, result.Results[0].Error)
|
||||
require.Contains(t, *result.Results[0].Error, string(check.LimitationMaxWidthExceeded))
|
||||
} else {
|
||||
require.Empty(t, result.Results[0].Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=gRPC: strict mode surfaces limitation in error field", func(t *testing.T) {
|
||||
checkClient := newTestGRPCCheckClient(t, h)
|
||||
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{
|
||||
Tuples: []*rts.RelationTuple{tuple.ToProto()},
|
||||
MaxDepth: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Results, 1)
|
||||
require.False(t, resp.Results[0].Allowed)
|
||||
if strict {
|
||||
require.Contains(t, resp.Results[0].Error, string(check.LimitationMaxWidthExceeded))
|
||||
} else {
|
||||
require.Empty(t, resp.Results[0].Error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
284
internal/check/rewrites_test.go
Normal file
284
internal/check/rewrites_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
var rewrites = testhelpers.Scenario{
|
||||
Name: "userset rewrites",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
|
||||
class Doc implements Namespace {
|
||||
related: {
|
||||
owner: User[]
|
||||
parent: Doc[]
|
||||
}
|
||||
permits = {
|
||||
editor: (ctx: Context) => this.related.owner.includes(ctx.subject),
|
||||
viewer: (ctx: Context) => this.permits.editor(ctx) ||
|
||||
this.related.parent.traverse(p => p.permits.viewer(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class Level implements Namespace {
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
level: Level[]
|
||||
owner: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewer: (ctx: Context) => this.related.owner.traverse(g => g.related.member.includes(ctx.subject)),
|
||||
isOwner: (ctx: Context) => this.related.owner.traverse(g => g.related.member.includes(ctx.subject)),
|
||||
|
||||
read: (ctx: Context) => this.permits.viewer(ctx) || this.permits.isOwner(ctx),
|
||||
update: (ctx: Context) => this.permits.isOwner(ctx),
|
||||
delete: (ctx: Context) => this.permits.isOwner(ctx) && this.related.level.traverse(l => l.related.member.includes(ctx.subject)),
|
||||
}
|
||||
}
|
||||
|
||||
class Acl implements Namespace {
|
||||
related: {
|
||||
allow: User[]
|
||||
deny: User[]
|
||||
}
|
||||
permits = {
|
||||
access: (ctx: Context) => this.related.allow.includes(ctx.subject) && !this.related.deny.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
// Direct ownership — plain and namespace-qualified subjects both work.
|
||||
"Doc:document#owner@plain_user",
|
||||
"Doc:document#owner@User:user",
|
||||
|
||||
// Single-level parent: doc_in_folder inherits viewer from its parent folder.
|
||||
"Doc:folder#owner@plain_user",
|
||||
"Doc:folder#owner@User:user",
|
||||
"Doc:doc_in_folder#parent@Doc:folder",
|
||||
|
||||
// Deep hierarchy: user owns folder_a; viewer propagates down 4 levels to file.
|
||||
"Doc:folder_a#owner@user",
|
||||
"Doc:folder_b#parent@Doc:folder_a",
|
||||
"Doc:folder_c#parent@Doc:folder_b",
|
||||
"Doc:file#parent@Doc:folder_c",
|
||||
|
||||
// Group + level gating: delete requires owner-group membership AND correct level.
|
||||
"Group:editors#member@mark",
|
||||
"Group:editors#member@mike",
|
||||
"Level:superadmin#member@mark",
|
||||
"Level:superadmin#member@sandy",
|
||||
"Resource:topsecret#owner@Group:editors#",
|
||||
"Resource:topsecret#level@Level:superadmin#",
|
||||
|
||||
// Allow/deny ACL: deny overrides allow (mallory is on both lists).
|
||||
"Acl:document#allow@alice",
|
||||
"Acl:document#allow@bob",
|
||||
"Acl:document#allow@mallory",
|
||||
"Acl:document#deny@mallory",
|
||||
},
|
||||
}
|
||||
|
||||
type path []string
|
||||
|
||||
func TestUsersetRewrites(t *testing.T) {
|
||||
testCases := []struct {
|
||||
query string
|
||||
expected check.Result
|
||||
expectedPaths []path
|
||||
}{
|
||||
{
|
||||
// direct
|
||||
query: "Doc:document#owner@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// userset rewrite
|
||||
query: "Doc:document#editor@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// userset rewrite
|
||||
query: "Doc:document#editor@plain_user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// transitive userset rewrite
|
||||
query: "Doc:document#viewer@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Doc:document#editor@nobody",
|
||||
expected: check.ResultNotMember,
|
||||
},
|
||||
{
|
||||
query: "Doc:folder#viewer@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:doc_in_folder#viewer@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:doc_in_folder#viewer@plain_user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:doc_in_folder#viewer@nobody",
|
||||
expected: check.ResultNotMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:another_doc#viewer@user",
|
||||
expected: check.ResultNotMember,
|
||||
},
|
||||
{
|
||||
query: "Doc:file#viewer@user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Level:superadmin#member@mark",
|
||||
expected: check.ResultIsMember, // mark is both editor and has correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#isOwner@mark",
|
||||
expected: check.ResultIsMember, // mark is both editor and has correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#delete@mark",
|
||||
expected: check.ResultIsMember, // mark is both editor and has correct level
|
||||
expectedPaths: []path{
|
||||
{"*", "Resource:topsecret#delete@mark", "*", "Level:superadmin#member@mark"},
|
||||
{"*", "Resource:topsecret#delete@mark", "Resource:topsecret#isOwner@mark", "*", "*", "*", "Group:editors#member@mark"},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#update@mike",
|
||||
expected: check.ResultIsMember, // mike owns the resource
|
||||
},
|
||||
{
|
||||
query: "Level:superadmin#member@mike",
|
||||
expected: check.ResultNotMember, // mike does not have correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#delete@mike",
|
||||
expected: check.ResultNotMember, // mike does not have correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#delete@sandy",
|
||||
expected: check.ResultNotMember, // sandy is not in the editor Group
|
||||
},
|
||||
{
|
||||
query: "Acl:document#access@alice",
|
||||
expected: check.ResultIsMember,
|
||||
expectedPaths: []path{{"*", "Acl:document#access@alice", "Acl:document#allow@alice"}},
|
||||
},
|
||||
{
|
||||
query: "Acl:document#access@bob",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Acl:document#allow@mallory",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Acl:document#access@mallory",
|
||||
expected: check.ResultNotMember, // mallory is also on deny-list
|
||||
},
|
||||
}
|
||||
|
||||
rewrites.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := trace.NewEngine(reg)
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
||||
|
||||
t.Run("suite=testcases", func(t *testing.T) {
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run("case="+tc.query, func(t *testing.T) {
|
||||
rt := testhelpers.TupleFromString(t, tc.query)
|
||||
|
||||
res, tree := e.CheckRelationTupleWithTrace(t.Context(), rt, 100)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expected.Membership.String(), res.Membership.String())
|
||||
|
||||
if len(tc.expectedPaths) > 0 {
|
||||
for _, path := range tc.expectedPaths {
|
||||
assertPath(t, path, tree)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Run("suite=one worker", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
e := trace.NewEngine(reg)
|
||||
|
||||
rt := testhelpers.TupleFromString(t, "Doc:file#viewer@user")
|
||||
res, _ := e.CheckRelationTupleWithTrace(ctx, rt, 100)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, check.ResultIsMember.Membership, res.Membership)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// assertPath asserts that the given path can be found in the tree.
|
||||
func assertPath(t *testing.T, path path, tree *trace.Node) {
|
||||
require.NotNil(t, tree)
|
||||
assert.True(t, hasPath(t, path, tree), "could not find path %s in tree:\n%s", path, tree)
|
||||
}
|
||||
|
||||
func hasPath(t *testing.T, path path, tree *trace.Node) bool {
|
||||
if len(path) == 0 {
|
||||
return true
|
||||
}
|
||||
treeLabel := tree.Tuple.String()
|
||||
if path[0] != "*" {
|
||||
// use testhelpers.TupleFromString to compare against paths with UUIDs.
|
||||
tuple := testhelpers.TupleFromString(t, path[0])
|
||||
tupleStr := tuple.String()
|
||||
if tupleStr != treeLabel {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(path) == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, child := range tree.Children {
|
||||
if hasPath(t, path[1:], child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
32
internal/check/step.go
Normal file
32
internal/check/step.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// StepKind identifies the execution model type of a Step.
|
||||
type StepKind string
|
||||
|
||||
const (
|
||||
StepIsAllowed StepKind = "is_allowed"
|
||||
StepDirect StepKind = "direct"
|
||||
StepDirectMulti StepKind = "direct_multi"
|
||||
StepExpand StepKind = "expand_subject"
|
||||
StepComputed StepKind = "computed"
|
||||
StepTraverse StepKind = "traverse"
|
||||
StepRewrite StepKind = "rewrite"
|
||||
StepInvert StepKind = "invert"
|
||||
)
|
||||
|
||||
// Step is a single unit of execution within the permission check engine.
|
||||
type Step interface {
|
||||
// Kind returns the execution-model kind of this step.
|
||||
Kind() StepKind
|
||||
|
||||
// Execute performs the step's work and returns the membership result.
|
||||
// Child steps must be dispatched through ex, not called directly.
|
||||
Execute(ctx context.Context, req CheckRequest, ex Executor) Result
|
||||
}
|
||||
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()
|
||||
}
|
||||
29
internal/check/testfixtures/project_opl.ts
Normal file
29
internal/check/testfixtures/project_opl.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {Namespace, Context} from '@ory/keto-namespace-types'
|
||||
|
||||
class User implements Namespace {}
|
||||
|
||||
class Project implements Namespace {
|
||||
related: {
|
||||
owner: User[]
|
||||
developer: User[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
isOwner: (ctx: Context) => this.related.owner.includes(ctx.subject),
|
||||
isOwnerOrDeveloper: (ctx: Context) =>
|
||||
this.related.owner.includes(ctx.subject) ||
|
||||
this.related.developer.includes(ctx.subject),
|
||||
writeCollaborator: (ctx: Context) =>
|
||||
this.permits.isOwner(ctx),
|
||||
readCollaborator: (ctx: Context) =>
|
||||
this.permits.isOwnerOrDeveloper(ctx),
|
||||
deleteProject: (ctx: Context) => this.permits.isOwner(ctx),
|
||||
writeProject: (ctx: Context) =>
|
||||
this.permits.isOwnerOrDeveloper(ctx),
|
||||
readProject: (ctx: Context) =>
|
||||
this.permits.isOwnerOrDeveloper(ctx),
|
||||
}
|
||||
}
|
||||
20
internal/check/testmain_test.go
Normal file
20
internal/check/testmain_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*defaultPolicy[...]).processItems"),
|
||||
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*Cache[...]).processItems"),
|
||||
goleak.IgnoreTopFunction("net/http.(*persistConn).readLoop"),
|
||||
goleak.IgnoreTopFunction("net/http.(*persistConn).writeLoop"),
|
||||
goleak.IgnoreTopFunction("database/sql.(*DB).connectionOpener"),
|
||||
)
|
||||
}
|
||||
50
internal/check/trace/engine.go
Normal file
50
internal/check/trace/engine.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/x/events"
|
||||
)
|
||||
|
||||
// Engine builds a runtime with tracing enabled and returns the debug Node
|
||||
// tree alongside the check result.
|
||||
type Engine struct {
|
||||
deps check.EngineDependencies
|
||||
}
|
||||
|
||||
func NewEngine(d check.EngineDependencies) *Engine {
|
||||
return &Engine{deps: d}
|
||||
}
|
||||
|
||||
func (e *Engine) d() check.EngineDependencies { return e.deps }
|
||||
|
||||
// CheckIsMemberWithTrace is like Engine.CheckIsMember but also returns a debug
|
||||
// tree that traces every sub-check performed.
|
||||
func (e *Engine) CheckIsMemberWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (bool, *Node, error) {
|
||||
res, tree := e.CheckRelationTupleWithTrace(ctx, tuple, restDepth)
|
||||
return res.Membership == check.IsMember, tree, res.Err
|
||||
}
|
||||
|
||||
// CheckRelationTupleWithTrace returns the check result and the debug tree.
|
||||
func (e *Engine) CheckRelationTupleWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (res check.Result, tree *Node) {
|
||||
ctx, span := e.d().Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTupleWithTrace")
|
||||
defer otelx.End(span, &res.Err)
|
||||
|
||||
session := NewSession()
|
||||
executor := step.NewExecutor(e.deps, session.Middleware(), step.CallLimitMiddleware())
|
||||
|
||||
res = executor.CheckRelationTuple(ctx, tuple, restDepth)
|
||||
session.Wait()
|
||||
if res.Err == nil {
|
||||
span.AddEvent(events.NewPermissionsChecked(ctx))
|
||||
}
|
||||
return res, session.Root()
|
||||
}
|
||||
607
internal/check/trace/engine_test.go
Normal file
607
internal/check/trace/engine_test.go
Normal file
@@ -0,0 +1,607 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/schema"
|
||||
)
|
||||
|
||||
// # Non-determinism constraint
|
||||
//
|
||||
// Expand and traverse (tuple-to-subject-set) children are ordered by
|
||||
// shard_id (random UUIDv4) and require sortNode normalization.
|
||||
//
|
||||
// As a result, we cannot write tests where multiple Expand or Traverse siblings
|
||||
// compete to produce member; which one wins first is non-deterministic, as the
|
||||
// child tuples are not executed in the order we create them.
|
||||
// Therefore:
|
||||
// - sortNode is used to sort the children of an Expand or Traverse nodes
|
||||
// - Tests where all branches return not-member are always safe: every branch
|
||||
// runs to completion before not-member is returned.
|
||||
// - Do not write tests where Expand or Traverse has multiple children, and one child
|
||||
// returns member, the other one notMember; which one runs first is non-deterministic;
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
opl string
|
||||
inputTuples []string
|
||||
maxDepth int
|
||||
|
||||
checkInput string
|
||||
expected func(t testing.TB) *trace.Node
|
||||
strict bool
|
||||
only bool
|
||||
}
|
||||
|
||||
func TestTraceEngine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []testcase{
|
||||
// ── no-OPL mode ──────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// Engine finds the tuple immediately via direct lookup.
|
||||
// Expand is registered eagerly, but the Executor already has a decisive
|
||||
// result from the direct match, so the expansion work is skipped.
|
||||
name: "no-OPL: direct hit",
|
||||
inputTuples: []string{
|
||||
"Group:eng#members@User:1",
|
||||
},
|
||||
checkInput: "Group:eng#members@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "Group:eng#members@User:1",
|
||||
directNode(t, member, "Group:eng#members@User:1"),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Engine follows a three-hop SubjectSet chain to find a member.
|
||||
name: "no-OPL: multi-hop SubjectSet expansion",
|
||||
inputTuples: []string{
|
||||
"Group:a#members@User:1",
|
||||
"Group:b#members@Group:a#members",
|
||||
"Group:c#members@Group:b#members",
|
||||
},
|
||||
checkInput: "Group:c#members@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "Group:c#members@User:1",
|
||||
directNode(t, notMember, "Group:c#members@User:1"),
|
||||
expandNode(t, member, "Group:c#members@User:1", 1,
|
||||
checkNode(t, member, "Group:b#members@User:1",
|
||||
expandNode(t, member, "Group:b#members@User:1", 1,
|
||||
foundNode(t, "Group:a#members@User:1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Engine hits the depth cap at restDepth=0 and returns unknown.
|
||||
name: "no-OPL: depth limit stops expansion",
|
||||
maxDepth: 2,
|
||||
inputTuples: []string{
|
||||
"Group:a#members@User:1",
|
||||
"Group:b1#members@Group:a#members",
|
||||
"Group:c#members@Group:b1#members",
|
||||
},
|
||||
checkInput: "Group:c#members@User:99",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, unknown, "Group:c#members@User:99",
|
||||
directNode(t, notMember, "Group:c#members@User:99"),
|
||||
expandNode(t, unknown, "Group:c#members@User:99", 1,
|
||||
checkNode(t, unknown, "Group:b1#members@User:99",
|
||||
expandNode(t, unknown, "Group:b1#members@User:99", 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── strict OPL mode ───────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// In strict mode the only type-safe checks are fired;
|
||||
// no useless direct/expand on relations that are "permit";
|
||||
// Subject-set fires only when OPL field declares a SubjectSet<..>;
|
||||
name: "strict OPL: SubjectSet expand fires only for declared types",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
viewers: SubjectSet<Group, "members">[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
},
|
||||
checkInput: "File:f1#view@User:u1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:u1",
|
||||
unionNode(t, notMember, "File:f1#view@User:u1",
|
||||
// viewers only declares SubjectSet<Group,"members">, so User:u1 can
|
||||
// never be a direct match, so no direct or multi-direct check runs.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:u1",
|
||||
// expand fires because viewers declares SubjectSet<Group,"members">.
|
||||
// expand found 1 node, which is Group:g1#members, which is not a direct hit → not-member.
|
||||
expandNode(t, notMember, "File:f1#viewers@User:u1", 1,
|
||||
// skipDirect=true + no SubjectSet types on members → empty check
|
||||
checkNode(t, notMember, "Group:g1#members@User:u1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Strict OR with multiple computed-usersets and a TTU.
|
||||
// The OR shortcut batches all plain computed-usersets into a single multiDirect check.
|
||||
// Plain related fields (viewers, viewers2) with no SubjectSet types produce
|
||||
// empty checks because multiDirect already covers them, and sets skipDirect=true.
|
||||
name: "strict OPL: OR batches computed-usersets, suppresses spurious permit checks",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewers2: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)) ||
|
||||
this.related.viewers2.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
|
||||
"File:f1#viewers2@User:10",
|
||||
"File:f1#viewers2@User:11",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
},
|
||||
checkInput: "File:f1#view@User:100",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:100",
|
||||
unionNode(t, notMember, "File:f1#view@User:100",
|
||||
// OR shortcut batches viewers + viewers2 into one multi-relation direct check.
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewers2"}),
|
||||
// viewers and viewers2 are plain User[] fields: empty checks in strict mode.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:100"),
|
||||
checkNode(t, notMember, "File:f1#viewers2@User:100"),
|
||||
// TTU traverses viewerGroups; Group:g1 has no User:100 → not-member.
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 1,
|
||||
checkNode(t, notMember, "Group:g1#members@User:100",
|
||||
directNode(t, notMember, "Group:g1#members@User:100"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Strict OR where the TTU branch finds a member.
|
||||
name: "strict OPL: OR with TTU finds member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject))
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"Group:g1#members@User:4",
|
||||
},
|
||||
checkInput: "File:f1#view@User:4",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "File:f1#view@User:4",
|
||||
unionNode(t, member, "File:f1#view@User:4",
|
||||
// multiDirect for viewers: User:4 is not a direct viewer.
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:4", []string{"viewers"}),
|
||||
// viewers is User[]: empty check in strict+skipDirect.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:4"),
|
||||
// TTU loads Group:g1; User:4 is a direct member → member.
|
||||
// strict: members is User[] (no SubjectSet types) → no expand registered.
|
||||
traverseNode(t, member, "File:f1#viewerGroups@User:4", 1,
|
||||
checkNode(t, member, "Group:g1#members@User:4",
|
||||
directNode(t, member, "Group:g1#members@User:4"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// NOT inverts an inner member result to not-member.
|
||||
name: "strict OPL: NOT inverts member to not-member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
blocklist: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#blocklist@User:blocked",
|
||||
},
|
||||
checkInput: "File:f1#view@User:blocked",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:blocked",
|
||||
unionNode(t, notMember, "File:f1#view@User:blocked",
|
||||
invertNode(t, notMember, "File:f1#view@User:blocked",
|
||||
computedNode(t, member, "File:f1#blocklist@User:blocked",
|
||||
checkNode(t, member, "File:f1#blocklist@User:blocked",
|
||||
directNode(t, member, "File:f1#blocklist@User:blocked"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// NOT inverts an inner not-member result to member.
|
||||
name: "strict OPL: NOT inverts not-member to member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
blocklist: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#blocklist@User:blocked",
|
||||
},
|
||||
checkInput: "File:f1#view@User:free",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "File:f1#view@User:free",
|
||||
unionNode(t, member, "File:f1#view@User:free",
|
||||
invertNode(t, member, "File:f1#view@User:free",
|
||||
computedNode(t, notMember, "File:f1#blocklist@User:free",
|
||||
checkNode(t, notMember, "File:f1#blocklist@User:free",
|
||||
directNode(t, notMember, "File:f1#blocklist@User:free"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strict OPL: TTU with permit call inside traverse",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
permits = {
|
||||
isMember: (ctx: Context) => this.related.members.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewerGroups.traverse(g => g.permits.isMember(ctx))
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"Group:g1#members@User:1",
|
||||
"Group:g1#members@User:2",
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
},
|
||||
checkInput: "File:f1#view@User:99",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:99",
|
||||
unionNode(t, notMember, "File:f1#view@User:99",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:99", 1,
|
||||
checkNode(t, notMember, "Group:g1#isMember@User:99",
|
||||
unionNode(t, notMember, "Group:g1#isMember@User:99",
|
||||
multiDirectNode(t, notMember, "Group:g1#members@User:99", []string{"members"}),
|
||||
checkNode(t, notMember, "Group:g1#members@User:99"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── non-strict OPL mode ───────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// Non-strict mode fires the rewrite, a direct check, AND a subject-set
|
||||
// expand concurrently even for a permit relation (view).
|
||||
name: "non-strict OPL: OR on permit reference emits spurious direct and expand",
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) || this.permits.viewGroup(ctx)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
"Group:g2#members@User:6",
|
||||
"Group:g2#members@User:7",
|
||||
"Group:g3#members@User:8",
|
||||
"Group:g3#members@User:9",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"File:f1#viewerGroups@Group:g2",
|
||||
"File:f1#viewerGroups@Group:g3",
|
||||
},
|
||||
checkInput: "File:f1#view@User:100",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
leafCheck := func(s string) *trace.Node {
|
||||
return checkNode(t, notMember, s,
|
||||
expandNode(t, notMember, s, 0),
|
||||
)
|
||||
}
|
||||
groupCheck := func(g string, users ...string) *trace.Node {
|
||||
members := make([]*trace.Node, len(users))
|
||||
for i, u := range users {
|
||||
members[i] = leafCheck("User:" + u + "#@User:100")
|
||||
}
|
||||
return checkNode(t, notMember, "Group:"+g+"#members@User:100",
|
||||
directNode(t, notMember, "Group:"+g+"#members@User:100"),
|
||||
expandNode(t, notMember, "Group:"+g+"#members@User:100", len(users), members...),
|
||||
)
|
||||
}
|
||||
return checkNode(t, notMember, "File:f1#view@User:100",
|
||||
unionNode(t, notMember, "File:f1#view@User:100",
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewGroup"}),
|
||||
checkNode(t, notMember, "File:f1#viewers@User:100",
|
||||
expandNode(t, notMember, "File:f1#viewers@User:100", 3,
|
||||
leafCheck("User:1#@User:100"),
|
||||
leafCheck("User:2#@User:100"),
|
||||
leafCheck("User:3#@User:100"),
|
||||
),
|
||||
),
|
||||
checkNode(t, notMember, "File:f1#viewGroup@User:100",
|
||||
unionNode(t, notMember, "File:f1#viewGroup@User:100",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 3,
|
||||
groupCheck("g1", "4", "5"),
|
||||
groupCheck("g2", "6", "7"),
|
||||
groupCheck("g3", "8", "9"),
|
||||
),
|
||||
),
|
||||
expandNode(t, notMember, "File:f1#viewGroup@User:100", 0),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#view@User:100"),
|
||||
expandNode(t, notMember, "File:f1#view@User:100", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// AND: both arms must complete before the result is known, so
|
||||
// the full tree is always explored regardless of intermediate results.
|
||||
name: "non-strict OPL: AND requires both arms; permit-calls-permit",
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
||||
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) &&
|
||||
this.permits.viewGroup(ctx)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
"Group:g2#members@User:6",
|
||||
"Group:g2#members@User:7",
|
||||
"Group:g3#members@User:8",
|
||||
"Group:g3#members@User:9",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"File:f1#viewerGroups@Group:g2",
|
||||
"File:f1#viewerGroups@Group:g3",
|
||||
},
|
||||
checkInput: "File:f1#view@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
leafCheck := func(s string) *trace.Node {
|
||||
return checkNode(t, notMember, s,
|
||||
expandNode(t, notMember, s, 0),
|
||||
)
|
||||
}
|
||||
groupCheck := func(g string, users ...string) *trace.Node {
|
||||
members := make([]*trace.Node, len(users))
|
||||
for i, u := range users {
|
||||
members[i] = leafCheck("User:" + u + "#@User:1")
|
||||
}
|
||||
|
||||
return checkNode(t, notMember, "Group:"+g+"#members@User:1",
|
||||
directNode(t, notMember, "Group:"+g+"#members@User:1"),
|
||||
expandNode(t, notMember, "Group:"+g+"#members@User:1", len(users), members...),
|
||||
)
|
||||
}
|
||||
return checkNode(t, notMember, "File:f1#view@User:1",
|
||||
intersectionNode(t, notMember, "File:f1#view@User:1",
|
||||
computedNode(t, member, "File:f1#viewers@User:1",
|
||||
checkNode(t, member, "File:f1#viewers@User:1",
|
||||
directNode(t, member, "File:f1#viewers@User:1"),
|
||||
),
|
||||
),
|
||||
computedNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
checkNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
unionNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:1", 3,
|
||||
groupCheck("g1", "4", "5"),
|
||||
groupCheck("g2", "6", "7"),
|
||||
groupCheck("g3", "8", "9"),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#viewGroup@User:1"),
|
||||
expandNode(t, notMember, "File:f1#viewGroup@User:1", 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#view@User:1"),
|
||||
expandNode(t, notMember, "File:f1#view@User:1", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
focused := slices.ContainsFunc(tests, func(tt testcase) bool { return tt.only })
|
||||
for _, tt := range tests {
|
||||
if focused && !tt.only {
|
||||
continue
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts := []driver.TestRegistryOption{driver.WithLogLevel("info")}
|
||||
|
||||
if tt.opl != "" {
|
||||
_, errs := schema.Parse(tt.opl)
|
||||
require.Empty(t, errs, "invalid OPL in test case: %v", errs)
|
||||
}
|
||||
|
||||
if tt.opl != "" {
|
||||
opts = append(opts, driver.WithOPL(tt.opl))
|
||||
} else {
|
||||
opts = append(opts, driver.WithConfig(config.KeyNamespaces, []*namespace.Namespace{}))
|
||||
}
|
||||
|
||||
if tt.maxDepth != 0 {
|
||||
opts = append(opts, driver.WithConfig(config.KeyLimitMaxReadDepth, tt.maxDepth))
|
||||
}
|
||||
|
||||
if tt.strict {
|
||||
opts = append(opts, driver.WithConfig(config.KeyFeatureFlagStrictMode, tt.strict))
|
||||
}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(t, opts...)
|
||||
e := trace.NewEngine(reg)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, row := range tt.inputTuples {
|
||||
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(ctx, testhelpers.TupleFromString(t, row)))
|
||||
}
|
||||
|
||||
_, tree, err := e.CheckIsMemberWithTrace(ctx, testhelpers.TupleFromString(t, tt.checkInput), 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := trace.SortNode(tt.expected(t))
|
||||
actual := trace.SortNode(trace.StripTiming(tree))
|
||||
require.Equal(t, expected.String(), actual.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// result helpers — used as the first argument to every node builder.
|
||||
const (
|
||||
member = trace.ResultMember
|
||||
notMember = trace.ResultNotMember
|
||||
unknown = trace.ResultUnknown
|
||||
)
|
||||
|
||||
// Local aliases for the exported helpers in trace so test cases stay concise.
|
||||
var (
|
||||
checkNode = trace.CheckNode
|
||||
directNode = trace.DirectNode
|
||||
expandNode = trace.ExpandNode
|
||||
foundNode = trace.FoundNode
|
||||
unionNode = trace.UnionNode
|
||||
intersectionNode = trace.IntersectionNode
|
||||
invertNode = trace.InvertNode
|
||||
computedNode = trace.ComputedNode
|
||||
traverseNode = trace.TraverseNode
|
||||
multiDirectNode = trace.MultiDirectNode
|
||||
)
|
||||
199
internal/check/trace/node.go
Normal file
199
internal/check/trace/node.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type (
|
||||
NodeKind string
|
||||
NodeResult string
|
||||
)
|
||||
|
||||
const (
|
||||
NodeIsAllowed NodeKind = "is_allowed"
|
||||
NodeDirect NodeKind = "direct"
|
||||
NodeMultiDirect NodeKind = "direct_multi"
|
||||
NodeExpandSubject NodeKind = "expand_subject"
|
||||
NodeFoundSubject NodeKind = "expand_found"
|
||||
NodeComputed NodeKind = "computed"
|
||||
NodeTraverse NodeKind = "traverse"
|
||||
NodeUnion NodeKind = "union"
|
||||
NodeIntersection NodeKind = "intersection"
|
||||
NodeInvert NodeKind = "invert"
|
||||
)
|
||||
|
||||
func (k NodeKind) String() string { return string(k) }
|
||||
|
||||
const (
|
||||
// ResultMember indicates the subject is a member.
|
||||
ResultMember NodeResult = "member"
|
||||
// ResultNotMember indicates the subject is not a member.
|
||||
ResultNotMember NodeResult = "not_member"
|
||||
// ResultError indicates an error occurred during the check.
|
||||
ResultError NodeResult = "error"
|
||||
// ResultSkipped indicates the check was registered but never executed
|
||||
// because a sibling check resolved the parent before this one ran.
|
||||
ResultSkipped NodeResult = "skipped"
|
||||
// ResultCancelled indicates the check was cancelled mid-execution
|
||||
// because a sibling check resolved.
|
||||
ResultCancelled NodeResult = "cancelled"
|
||||
// ResultUnknown indicates the check was cut short because restDepth
|
||||
// reached zero; membership could not be determined.
|
||||
ResultUnknown NodeResult = "unknown"
|
||||
)
|
||||
|
||||
func (r NodeResult) prunable() bool {
|
||||
return r == ResultNotMember || r == ResultSkipped
|
||||
}
|
||||
|
||||
// MaxExpandChildren is the maximum number of non-significant children kept on
|
||||
// expand_subject and traverse nodes. Children whose result is member, error,
|
||||
// or cancelled are always kept regardless of this limit.
|
||||
const MaxExpandChildren = 5
|
||||
|
||||
// Node is one step in the debug tree produced by a permission check. It
|
||||
// records what kind of operation was performed, what the input tuple was, what
|
||||
// the result was, how many database records were fetched, and any sub-steps
|
||||
// that were spawned.
|
||||
type Node struct {
|
||||
Kind NodeKind
|
||||
Tuple *relationtuple.RelationTuple
|
||||
Result NodeResult
|
||||
|
||||
// Relations holds the batched relation names for NodeMultiDirect nodes.
|
||||
Relations []string
|
||||
|
||||
// TuplesLoaded is the number of database records fetched by this node.
|
||||
TuplesLoaded int
|
||||
|
||||
// TruncatedChildren is the number of children that were dropped to keep
|
||||
// the tree size manageable.
|
||||
TruncatedChildren int
|
||||
|
||||
Children []*Node
|
||||
|
||||
// Duration is the wall-clock time spent in this node (including children).
|
||||
Duration time.Duration
|
||||
|
||||
// parent is set when this node is appended to a parent, so that
|
||||
// dropIfOverLimit can remove it after the result is known.
|
||||
parent *Node
|
||||
|
||||
// mu protects Children, TruncatedChildren, TuplesLoaded, and prunableCount.
|
||||
mu sync.Mutex
|
||||
|
||||
// prunableCount counts how many children have a prunable result.
|
||||
prunableCount int
|
||||
}
|
||||
|
||||
func (n *Node) setResult(result check.Result) {
|
||||
if result.Err != nil {
|
||||
n.Result = ResultError
|
||||
if errors.Is(result.Err, context.Canceled) {
|
||||
n.Result = ResultCancelled
|
||||
}
|
||||
return
|
||||
}
|
||||
switch result.Membership {
|
||||
case check.IsMember:
|
||||
n.Result = ResultMember
|
||||
case check.NotMember:
|
||||
n.Result = ResultNotMember
|
||||
default:
|
||||
n.Result = ResultUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// dropIfOverLimit removes this node from its parent when the parent is an
|
||||
// expand or TTU node that already has MaxExpandChildren prunable children.
|
||||
func (n *Node) dropIfOverLimit() {
|
||||
parent := n.parent
|
||||
if parent == nil {
|
||||
return
|
||||
}
|
||||
if parent.Kind != NodeExpandSubject && parent.Kind != NodeTraverse {
|
||||
return
|
||||
}
|
||||
if !n.Result.prunable() {
|
||||
return
|
||||
}
|
||||
parent.mu.Lock()
|
||||
defer parent.mu.Unlock()
|
||||
parent.prunableCount++
|
||||
if parent.prunableCount <= MaxExpandChildren {
|
||||
return
|
||||
}
|
||||
for i, c := range parent.Children {
|
||||
if c == n {
|
||||
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
parent.TruncatedChildren++
|
||||
}
|
||||
|
||||
func (n *Node) recordTuplesLoaded(count int) {
|
||||
n.mu.Lock()
|
||||
n.TuplesLoaded += count
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Node) recordFoundSubject(tuple *relationtuple.RelationTuple) {
|
||||
child := &Node{Kind: NodeFoundSubject, Tuple: tuple, Result: ResultMember}
|
||||
n.mu.Lock()
|
||||
n.Children = append(n.Children, child)
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// String is a human-friendly representation of the debug tree, useful during testing.
|
||||
func (n *Node) String() string {
|
||||
if n == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
var walk func(cur *Node, depth int)
|
||||
walk = func(cur *Node, depth int) {
|
||||
for range depth {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
|
||||
input := ""
|
||||
if cur.Tuple != nil {
|
||||
input = cur.Tuple.String()
|
||||
if len(cur.Relations) > 0 {
|
||||
input = strings.Replace(input, "#"+cur.Tuple.Relation+"@", "#{"+strings.Join(cur.Relations, ",")+"}"+"@", 1)
|
||||
}
|
||||
}
|
||||
b.WriteString(string(cur.Kind))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.Quote(input))
|
||||
b.WriteString(" -> ")
|
||||
b.WriteString(string(cur.Result))
|
||||
|
||||
if cur.TuplesLoaded > 0 {
|
||||
b.WriteString(" tuples_loaded=")
|
||||
b.WriteString(strconv.Itoa(cur.TuplesLoaded))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
|
||||
for _, child := range cur.Children {
|
||||
walk(child, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(n, 0)
|
||||
return strings.TrimSuffix(b.String(), "\n")
|
||||
}
|
||||
147
internal/check/trace/session.go
Normal file
147
internal/check/trace/session.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// traceNodeKey is the context key used to store and retrieve the current
|
||||
// trace node as execution descends through the step tree.
|
||||
type traceNodeKey struct{}
|
||||
|
||||
func nodeFromCtx(ctx context.Context) *Node {
|
||||
n, _ := ctx.Value(traceNodeKey{}).(*Node)
|
||||
return n
|
||||
}
|
||||
|
||||
func withTraceNode(ctx context.Context, n *Node) context.Context {
|
||||
return context.WithValue(ctx, traceNodeKey{}, n)
|
||||
}
|
||||
|
||||
// Session collects the debug tree for a single runtime execution.
|
||||
type Session struct {
|
||||
mu sync.Mutex
|
||||
root *Node
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewSession() *Session {
|
||||
return &Session{}
|
||||
}
|
||||
|
||||
func (t *Session) Root() *Node {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.root
|
||||
}
|
||||
|
||||
func (t *Session) Wait() {
|
||||
t.wg.Wait()
|
||||
}
|
||||
|
||||
// nodeKindForStep maps a Step's execution-model kind to the trace-model NodeKind.
|
||||
func nodeKindForStep(s check.Step) NodeKind {
|
||||
if s, ok := s.(step.RewriteStep); ok {
|
||||
if s.Rewrite.Operation == ast.OperatorOr {
|
||||
return NodeUnion
|
||||
}
|
||||
return NodeIntersection
|
||||
}
|
||||
switch s.Kind() {
|
||||
case check.StepIsAllowed:
|
||||
return NodeIsAllowed
|
||||
case check.StepDirect:
|
||||
return NodeDirect
|
||||
case check.StepDirectMulti:
|
||||
return NodeMultiDirect
|
||||
case check.StepExpand:
|
||||
return NodeExpandSubject
|
||||
case check.StepComputed:
|
||||
return NodeComputed
|
||||
case check.StepTraverse:
|
||||
return NodeTraverse
|
||||
case check.StepInvert:
|
||||
return NodeInvert
|
||||
default:
|
||||
return NodeKind(s.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns a Middleware that records a Node for every step execution,
|
||||
// building the debug trace tree as execution unfolds.
|
||||
func (t *Session) Middleware() check.Middleware {
|
||||
return func(ctx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
||||
t.wg.Add(1)
|
||||
defer t.wg.Done()
|
||||
|
||||
tuple := req.Tuple
|
||||
var relations []string
|
||||
|
||||
switch s := s.(type) {
|
||||
case step.ComputedUsersetStep:
|
||||
clone := *req.Tuple
|
||||
clone.Relation = s.Relation
|
||||
tuple = &clone
|
||||
case step.TraverseStep:
|
||||
clone := *req.Tuple
|
||||
clone.Relation = s.TTU.Relation
|
||||
tuple = &clone
|
||||
case step.DirectMultiStep:
|
||||
relations = s.Relations
|
||||
}
|
||||
|
||||
node, ctx := t.newTraceNode(ctx, nodeKindForStep(s), tuple, relations...)
|
||||
switch s.Kind() {
|
||||
case check.StepExpand:
|
||||
ctx = check.WithTraceHooks(ctx, check.TraceHooks{
|
||||
TuplesLoaded: node.recordTuplesLoaded,
|
||||
SubjectFound: node.recordFoundSubject,
|
||||
})
|
||||
case check.StepTraverse:
|
||||
ctx = check.WithTraceHooks(ctx, check.TraceHooks{TuplesLoaded: node.recordTuplesLoaded})
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := next(ctx, s, req)
|
||||
node.Duration = time.Since(start)
|
||||
node.setResult(result)
|
||||
node.dropIfOverLimit()
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// newTraceNode creates a Node, links it as a child of the current parent node
|
||||
// in ctx (or sets it as the root if there is none), and returns both the node
|
||||
// and a derived context with the new node set as the current parent.
|
||||
func (t *Session) newTraceNode(ctx context.Context, kind NodeKind, tuple *relationtuple.RelationTuple, relations ...string) (*Node, context.Context) {
|
||||
node := &Node{
|
||||
Kind: kind,
|
||||
Tuple: tuple,
|
||||
Relations: relations,
|
||||
|
||||
// Initial Result is Skipped, as it's possible that the step won't run at all
|
||||
// due to other sibling branch finishing with success.
|
||||
Result: ResultSkipped,
|
||||
}
|
||||
if parent := nodeFromCtx(ctx); parent != nil {
|
||||
node.parent = parent
|
||||
parent.mu.Lock()
|
||||
parent.Children = append(parent.Children, node)
|
||||
parent.mu.Unlock()
|
||||
} else {
|
||||
t.mu.Lock()
|
||||
t.root = node
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return node, withTraceNode(ctx, node)
|
||||
}
|
||||
115
internal/check/trace/testhelpers.go
Normal file
115
internal/check/trace/testhelpers.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
// SortNode sorts children of NodeExpandSubject and NodeTraverse nodes by
|
||||
// Tuple.String() recursively. Both node kinds have non-deterministic child
|
||||
// order because the underlying queries order by shard_id (a random UUIDv4
|
||||
// assigned at write time). All other sibling orderings are engine-controlled
|
||||
// and must be asserted as-is.
|
||||
func SortNode(n *Node) *Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
if n.Kind == NodeExpandSubject || n.Kind == NodeTraverse {
|
||||
slices.SortFunc(n.Children, func(a, b *Node) int {
|
||||
as, bs := "", ""
|
||||
if a.Tuple != nil {
|
||||
as = a.Tuple.String()
|
||||
}
|
||||
if b.Tuple != nil {
|
||||
bs = b.Tuple.String()
|
||||
}
|
||||
if c := strings.Compare(as, bs); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(string(a.Kind), string(b.Kind))
|
||||
})
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
SortNode(child)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// StripTiming zeroes out Duration on every node in the tree so that test
|
||||
// assertions are not sensitive to wall-clock time.
|
||||
func StripTiming(n *Node) *Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
n.Duration = 0
|
||||
for _, child := range n.Children {
|
||||
StripTiming(child)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CheckNode builds a NodeIsAllowed node.
|
||||
func CheckNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeIsAllowed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// DirectNode builds a NodeDirect leaf (no children).
|
||||
func DirectNode(t testing.TB, result NodeResult, tuple string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeDirect, Tuple: testhelpers.TupleFromString(t, tuple), Result: result}
|
||||
}
|
||||
|
||||
// ExpandNode builds a NodeExpandSubject node.
|
||||
func ExpandNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeExpandSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
|
||||
}
|
||||
|
||||
// FoundNode builds a NodeFoundSubject leaf (no children).
|
||||
func FoundNode(t testing.TB, tuple string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeFoundSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: ResultMember}
|
||||
}
|
||||
|
||||
// UnionNode builds a NodeUnion node.
|
||||
func UnionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeUnion, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// IntersectionNode builds a NodeIntersection node.
|
||||
func IntersectionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeIntersection, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// InvertNode builds a NodeInvert node.
|
||||
func InvertNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeInvert, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// ComputedNode builds a NodeComputed node.
|
||||
func ComputedNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeComputed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// TraverseNode builds a NodeTraverse node.
|
||||
func TraverseNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeTraverse, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
|
||||
}
|
||||
|
||||
// MultiDirectNode builds a NodeMultiDirect leaf (no children).
|
||||
func MultiDirectNode(t testing.TB, result NodeResult, tuple string, relations []string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeMultiDirect, Tuple: testhelpers.TupleFromString(t, tuple), Relations: relations, Result: result}
|
||||
}
|
||||
44
internal/check/trace_hook.go
Normal file
44
internal/check/trace_hook.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type traceHooksKey struct{}
|
||||
|
||||
// TraceHooks carries optional instrumentation callbacks injected into context
|
||||
// by tracing middleware. Step implementations can emit events through helpers
|
||||
// in this file without importing the trace package.
|
||||
type TraceHooks struct {
|
||||
TuplesLoaded func(n int)
|
||||
SubjectFound func(tuple *relationtuple.RelationTuple)
|
||||
}
|
||||
|
||||
// WithTraceHooks returns a context that carries the given trace hooks.
|
||||
func WithTraceHooks(ctx context.Context, hooks TraceHooks) context.Context {
|
||||
return context.WithValue(ctx, traceHooksKey{}, hooks)
|
||||
}
|
||||
|
||||
func traceHooksFromContext(ctx context.Context) (TraceHooks, bool) {
|
||||
hooks, ok := ctx.Value(traceHooksKey{}).(TraceHooks)
|
||||
return hooks, ok
|
||||
}
|
||||
|
||||
// ReportTuplesLoaded calls the tuples-loaded hook stored in ctx, if any.
|
||||
func ReportTuplesLoaded(ctx context.Context, n int) {
|
||||
if hooks, ok := traceHooksFromContext(ctx); ok && hooks.TuplesLoaded != nil {
|
||||
hooks.TuplesLoaded(n)
|
||||
}
|
||||
}
|
||||
|
||||
// ReportSubjectFound calls the subject-found hook stored in ctx, if any.
|
||||
func ReportSubjectFound(ctx context.Context, tuple *relationtuple.RelationTuple) {
|
||||
if hooks, ok := traceHooksFromContext(ctx); ok && hooks.SubjectFound != nil {
|
||||
hooks.SubjectFound(tuple)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user