snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

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

View File

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

View File

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

View File

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

View File

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

View File

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