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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,845 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package check_test
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/step"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/internal/x/dbx"
"github.com/ory/keto/ketoapi"
)
// ExpansionRecursive is a three-level group hierarchy where engineering branches
// into two sibling sub-groups. Safe to run with WithStrict(true).
//
// company ⊃ engineering ⊃ (frontend, backend)
// company ⊃ User:cto
// engineering ⊃ User:manager
// backend ⊃ User:Alice, Alice
// frontend ⊃ User:Bob, Bob
var ExpansionRecursive = testhelpers.Scenario{
Name: "Expansion Recursive",
Opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
members: (User | SubjectSet<Group, "members">)[]
}
}
`,
InputTuples: []string{
"Group:company#members@Group:engineering#members",
"Group:company#members@User:cto",
"Group:engineering#members@User:manager",
"Group:engineering#members@Group:frontend#members",
"Group:engineering#members@Group:backend#members",
"Group:backend#members@User:Alice",
"Group:frontend#members@User:Bob",
"Group:backend#members@Alice", // SubjectID
"Group:frontend#members@Bob", // SubjectID
},
}
var ExpansionRecursiveNamespaceOnly = testhelpers.Scenario{
Name: ExpansionRecursive.Name + " (namespaces only)",
Opl: `class User implements Namespace{} class Group implements Namespace{}`,
InputTuples: ExpansionRecursive.InputTuples,
}
// SubjectSetExpansion3Hop combines cross-namespace and within-namespace SubjectSet expansion.
//
// File:team-report#viewers ⊃ File:team-report#editors ⊃ Group:finance#member → (User:Alice, User:Bob).
// File:personal-note#viewers ⊃ User:Alice, User:Bob
var SubjectSetExpansion3Hop = testhelpers.Scenario{
Name: "SubjectSet Expansion 3 Hops",
Opl: `
class User implements Namespace{}
class Group implements Namespace{
related: {
member: User[]
}
}
class File implements Namespace{
related: {
editors: (User | SubjectSet<Group, "member">)[]
viewers: (User | SubjectSet<File, "editors">)[]
}
}
`,
InputTuples: []string{
"Group:finance#member@User:Alice",
"Group:finance#member@User:Bob",
"File:team-report#editors@Group:finance#member", // group members are editors
"File:team-report#viewers@File:team-report#editors", // editors are also viewers
"File:personal-note#viewers@User:Alice",
"File:personal-note#viewers@User:Bob",
},
}
var SubjectSetExpansion3HopNamespacesOnly = testhelpers.Scenario{
Name: SubjectSetExpansion3Hop.Name + " (namespaces only)",
Opl: `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`,
InputTuples: SubjectSetExpansion3Hop.InputTuples,
}
// CircularGraphNamespacesOnly covers a fully circular SubjectSet graph with no leaf subjects.
//
// A → B → C → A.
var CircularGraphNamespacesOnly = testhelpers.Scenario{
Name: "Circular Graph (namespaces only)",
Opl: `
class Folder implements Namespace{}
`,
InputTuples: []string{
"Folder:a#parent@Folder:b#parent",
"Folder:b#parent@Folder:c#parent",
"Folder:c#parent@Folder:a#parent",
},
}
// FileWithNonConformingTuples covers a schema where a client incorrectly stores
// tuples directly under permit names (canEdit, canView) instead of the backing
// related fields (editors, viewers).
//
// In strict mode the engine ignores those tuples and resolves access only via
// OPL rewrites. In non-strict mode the direct tuples are visible alongside the
// rewrites.
var FileWithNonConformingTuples = testhelpers.Scenario{
Name: "File With Non-Conforming Tuples",
Opl: `
class User implements Namespace {}
class File implements Namespace {
related: {
editors: User[]
viewers: User[]
}
permits = {
canEdit: (ctx: Context) => this.related.editors.includes(ctx.subject),
canView: (ctx: Context) => this.permits.canEdit(ctx) || this.related.viewers.includes(ctx.subject),
}
}
`,
InputTuples: []string{
"File:secret-doc#editors@User:Alice",
"File:secret-doc#viewers@User:Bob",
// Bad client data: permit names used as direct relation targets.
"File:secret-doc#canEdit@User:Eve",
"File:secret-doc#canEdit@Eve", // subjectID
},
}
// NotionPage models a simplified Notion-like workspace with nested pages.
//
// canView requires direct or parent access.
// canEdit requires direct editor access AND workspace membership.
var NotionPage = testhelpers.Scenario{
Name: "Notion Page",
Opl: `
class User implements Namespace {}
class Workspace implements Namespace {
related: {
member: User[]
}
}
class Page implements Namespace {
related: {
workspace: Workspace[]
parent: Page[]
viewer: User[]
editor: User[]
}
permits = {
canView: (ctx: Context) =>
this.related.viewer.includes(ctx.subject) ||
this.related.editor.includes(ctx.subject) ||
this.related.parent.traverse(p => p.permits.canView(ctx)),
canEdit: (ctx: Context) =>
this.related.editor.includes(ctx.subject) &&
this.related.workspace.traverse(w => w.related.member.includes(ctx.subject)),
}
}
`,
InputTuples: []string{
"Workspace:acme#member@User:Alice",
"Workspace:acme#member@User:Bob",
"Page:home#workspace@Workspace:acme",
"Page:home#viewer@User:Alice",
"Page:home#editor@User:Bob",
"Page:home#editor@User:Eve", // Eve is not workspace member
"Page:intro#workspace@Workspace:acme",
"Page:intro#parent@Page:home", // intro inherits canView from home
"Page:intro#editor@User:Alice",
},
}
var testcases = []struct {
scenarios []testhelpers.Scenario
expectedMembers []string
expectedNonMembers []string
}{
{
scenarios: []testhelpers.Scenario{ExpansionRecursive, ExpansionRecursiveNamespaceOnly},
expectedMembers: []string{
// direct: SubjectSet and SubjectID members
"Group:backend#members@User:Alice", "Group:backend#members@Alice",
"Group:frontend#members@User:Bob", "Group:frontend#members@Bob",
// engineering: own direct member plus frontend and backend
"Group:engineering#members@User:manager",
"Group:engineering#members@User:Alice", "Group:engineering#members@Alice",
"Group:engineering#members@User:Bob", "Group:engineering#members@Bob",
// company: own direct member plus everything from engineering
"Group:company#members@User:cto",
"Group:company#members@User:manager",
"Group:company#members@User:Alice", "Group:company#members@Alice",
"Group:company#members@User:Bob", "Group:company#members@Bob",
},
expectedNonMembers: []string{
// frontend and backend members do not cross.
"Group:frontend#members@User:Alice",
"Group:backend#members@User:Bob",
// Membership flows upward only.
"Group:backend#members@User:manager", "Group:backend#members@User:cto",
"Group:frontend#members@User:manager", "Group:frontend#members@User:cto",
"Group:engineering#members@User:cto",
},
},
{
scenarios: []testhelpers.Scenario{SubjectSetExpansion3Hop, SubjectSetExpansion3HopNamespacesOnly},
expectedMembers: []string{
// direct
"Group:finance#member@User:Alice", "Group:finance#member@User:Bob",
// via group expansion
"File:team-report#editors@User:Alice", "File:team-report#editors@User:Bob",
// via editors are viewers
"File:team-report#viewers@User:Alice", "File:team-report#viewers@User:Bob",
// direct viewers on personal note
"File:personal-note#viewers@User:Alice", "File:personal-note#viewers@User:Bob",
},
expectedNonMembers: []string{
// File:personal-note is missing subject-set relationship;
// Therefore editors are not viewers;
"File:personal-note#editors@User:Alice",
"File:personal-note#editors@User:Bob",
},
},
{
scenarios: []testhelpers.Scenario{CircularGraphNamespacesOnly},
expectedMembers: []string{
"Folder:a#parent@Folder:a#parent",
"Folder:a#parent@Folder:b#parent",
"Folder:a#parent@Folder:c#parent",
"Folder:b#parent@Folder:a#parent",
"Folder:b#parent@Folder:b#parent",
"Folder:b#parent@Folder:c#parent",
"Folder:c#parent@Folder:a#parent",
"Folder:c#parent@Folder:b#parent",
"Folder:c#parent@Folder:c#parent",
},
},
{
scenarios: []testhelpers.Scenario{{
Name: "rejects transitive relation",
Opl: `
class User implements Namespace{}
class Directory implements Namespace{}
class File implements Namespace{}
`,
InputTuples: []string{
"File:report#parent@Directory:docs",
"Directory:docs#access@User:Alice",
},
}},
expectedNonMembers: []string{
"File:report#access@User:Alice",
},
},
{
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples.WithStrict(true)},
expectedMembers: []string{
"File:secret-doc#viewers@User:Bob",
"File:secret-doc#editors@User:Alice",
// via OPL
"File:secret-doc#canView@User:Bob",
"File:secret-doc#canEdit@User:Alice",
"File:secret-doc#canView@User:Alice",
},
expectedNonMembers: []string{
"File:secret-doc#canEdit@User:Eve",
"File:secret-doc#canView@User:Eve",
"File:secret-doc#canEdit@Eve",
"File:secret-doc#canView@Eve",
},
},
{
scenarios: []testhelpers.Scenario{NotionPage},
expectedMembers: []string{
// direct viewer/editor on home
"Page:home#canView@User:Alice",
"Page:home#canView@User:Bob", // editor also satisfies canView
"Page:home#canView@User:Eve", // stale editor tuple still satisfies canView (no workspace check)
// canEdit requires editor AND workspace member
"Page:home#canEdit@User:Bob",
// intro: direct editor + workspace member
"Page:intro#canEdit@User:Alice",
// intro canView via TTU: intro → parent → home → Alice/Bob/Eve
"Page:intro#canView@User:Alice",
"Page:intro#canView@User:Bob",
"Page:intro#canView@User:Eve",
},
expectedNonMembers: []string{
// Eve has editor tuple but is not a workspace member — AND fails
"Page:home#canEdit@User:Eve",
// Bob is editor of home, not intro — canEdit requires explicit editor tuple
"Page:intro#canEdit@User:Bob",
// Alice is only a viewer on home, not an editor
"Page:home#canEdit@User:Alice",
},
},
{
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples},
expectedMembers: []string{
"File:secret-doc#viewers@User:Bob",
"File:secret-doc#editors@User:Alice",
// via OPL
"File:secret-doc#canView@User:Bob",
"File:secret-doc#canEdit@User:Alice",
"File:secret-doc#canView@User:Alice",
// canEdit is a direct tuple, visible in non-strict mode
"File:secret-doc#canEdit@User:Eve",
// canView is visible via OPL rewrite in non-strict mode
"File:secret-doc#canView@User:Eve",
},
expectedNonMembers: []string{},
},
}
func TestEngine(t *testing.T) {
t.Run(`membership tests`, func(t *testing.T) {
for _, tc := range testcases {
for _, scenario := range tc.scenarios {
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
e := check.NewEngine(reg)
ctx := t.Context()
for _, tuple := range tc.expectedMembers {
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
require.NoError(t, err)
assert.Truef(t, res, "expected %q to be a member", tuple)
}
for _, tuple := range tc.expectedNonMembers {
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
require.NoError(t, err)
assert.Falsef(t, res, "expected %q to not be a member", tuple)
}
})
}
}
})
t.Run("respects max-depth", func(t *testing.T) {
ExpansionRecursive.Run(t, func(t *testing.T, reg driver.Registry) {
e := check.NewEngine(reg)
ctx := t.Context()
// company → engineering → backend → User:Alice requires exactly 2 hops.
tuple := testhelpers.TupleFromString(t, "Group:company#members@User:Alice")
assert.Equal(t, 5, reg.Config(ctx).MaxReadDepth())
res, err := e.CheckIsMember(ctx, tuple, 1)
require.NoError(t, err)
assert.False(t, res, "depth=1 is not enough for a 2-hop chain")
res, err = e.CheckIsMember(ctx, tuple, 2)
require.NoError(t, err)
assert.True(t, res, "depth=2 is enough for a 2-hop chain")
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 1))
res, err = e.CheckIsMember(ctx, tuple, 5)
require.NoError(t, err)
assert.False(t, res, "global max-depth=1 overrides request depth=5")
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 3))
res, err = e.CheckIsMember(ctx, tuple, 0)
require.NoError(t, err)
assert.True(t, res, "global max-depth=3 is enough")
})
})
t.Run("batch check", func(t *testing.T) {
SubjectSetExpansion3Hop.Run(t, func(t *testing.T, reg driver.Registry) {
e := check.NewEngine(reg)
ctx := t.Context()
targetTuples := []*ketoapi.RelationTuple{
testhelpers.APITupleFromString(t, "Group:finance#member@User:Alice"), // direct
testhelpers.APITupleFromString(t, "File:team-report#editors@User:Alice"), // 1-hop
testhelpers.APITupleFromString(t, "File:team-report#viewers@User:Alice"), // 2-hop
testhelpers.APITupleFromString(t, "NonExistent:x#rel@User:Alice"), // non-existent namespace
testhelpers.APITupleFromString(t, "Group:finance#member@User:Eve"), // unknown subject
testhelpers.APITupleFromString(t, "File:team-report#viewers@File:team-report#editors"), // via subject set
}
// At depth=1, the 2-hop viewers chain is not resolved.
results, err := e.BatchCheck(ctx, targetTuples, 1)
require.NoError(t, err)
require.Equal(t, check.IsMember, results[0].Membership)
require.Equal(t, check.IsMember, results[1].Membership)
require.Equal(t, check.MembershipUnknown, results[2].Membership)
require.Equal(t, check.LimitationMaxDepthExceeded, results[2].Limitation)
require.Equal(t, check.MembershipUnknown, results[3].Membership)
require.EqualError(t, results[3].Err, herodot.ErrNotFound().Error())
require.Equal(t, check.MembershipUnknown, results[4].Membership)
require.Equal(t, check.LimitationMaxDepthExceeded, results[4].Limitation)
require.Equal(t, check.IsMember, results[5].Membership)
// At depth=2, the 2-hop viewers chain resolves
results, err = e.BatchCheck(ctx, targetTuples, 3)
require.NoError(t, err)
require.Equal(t, check.IsMember, results[2].Membership)
})
})
}
// widthLimitScenario fans out Resource:doc#readers into three groups.
// With width=1 only Group:alpha is explored; the rest are truncated.
var widthLimitScenario = testhelpers.Scenario{
Name: "Width Limit",
OrderedShardID: true,
Opl: `
class User implements Namespace {}
class Group implements Namespace {
related: {
member: (User | SubjectSet<Group, "member">)[]
}
}
class Resource implements Namespace {
related: {
readers: (User | SubjectSet<Group, "member">)[]
}
}
`,
// Resource:doc#readers
// ├── Group:alpha#member ──► Group:alpha-sub#member ──► User:Alice (explored first at width=1)
// ├── Group:beta#member ──► Group:beta-sub#member ──► User:Bob (truncated at width=1)
// └── Group:gamma#member ──► Group:gamma-sub#member (truncated at width=1)
InputTuples: []string{
// Insertion order determines DB return order for expand
"Resource:doc#readers@Group:alpha#member",
"Resource:doc#readers@Group:beta#member",
"Resource:doc#readers@Group:gamma#member",
"Group:alpha#member@Group:alpha-sub#member",
"Group:beta#member@Group:beta-sub#member",
"Group:gamma#member@Group:gamma-sub#member",
"Group:alpha-sub#member@User:Alice",
"Group:beta-sub#member@User:Bob",
// Eve is in none of the groups.
},
}
func TestWidthLimit(t *testing.T) {
type tc struct {
name string
subject string
width int // 0 means default 100
expectedMembership check.Membership
expectedLimitation check.LimitationKind
expectedIsMember bool
}
tests := []tc{
{
name: "no limit: Alice is IsMember",
subject: "User:Alice",
expectedMembership: check.IsMember,
expectedIsMember: true,
},
{
name: "no limit: Eve is NotMember",
subject: "User:Eve",
expectedMembership: check.NotMember,
expectedIsMember: false,
},
{
name: "width=1: Eve is indeterminate",
subject: "User:Eve",
width: 1,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxWidthExceeded,
expectedIsMember: false,
},
{
// Bob is only in beta-sub, which is always truncated at width=1.
name: "width=1: member only in truncated branch is indeterminate",
subject: "User:Bob",
width: 1,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxWidthExceeded,
expectedIsMember: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
widthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
ctx := t.Context()
e := check.NewEngine(reg)
if tc.width > 0 {
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
}
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
require.NoError(t, res.Err)
assert.Equal(t, tc.expectedMembership, res.Membership)
assert.Equal(t, tc.expectedLimitation, res.Limitation)
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
require.NoError(t, err)
assert.Equal(t, tc.expectedIsMember, isMember)
})
// in strict mode, expect Error when limitation is hit
widthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
ctx := t.Context()
e := check.NewEngine(reg)
if tc.width > 0 {
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
}
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
require.NoError(t, res.Err)
assert.Equal(t, tc.expectedMembership, res.Membership)
assert.Equal(t, tc.expectedLimitation, res.Limitation)
if tc.expectedLimitation != "" {
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
require.Error(t, err)
var he *herodot.DefaultError
require.ErrorAs(t, err, &he)
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
} else {
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
require.NoError(t, err)
assert.Equal(t, tc.expectedIsMember, isMember)
}
})
})
}
}
// Alice is in both deep and short chains.
// Depth of 3 is needed to reach Alice via shortRel.
// Depth of 5 is needed to reach Alice via deepRel.
var depthLimitScenario = testhelpers.Scenario{
Name: "Depth Limit",
Opl: `
class User implements Namespace {}
class Group implements Namespace {
related: {
members: (User | SubjectSet<Group, "members">)[]
}
}
class Resource implements Namespace {
related: {
deepRel: (User | SubjectSet<Group, "members">)[]
shortRel: (User | SubjectSet<Group, "members">)[]
}
permits = {
union: (ctx:Context) =>
this.related.deepRel.includes(ctx.subject)
|| this.related.shortRel.includes(ctx.subject),
intersection: (ctx:Context) =>
this.related.deepRel.includes(ctx.subject)
&& this.related.shortRel.includes(ctx.subject),
shortButNotDeep: (ctx:Context) => this.related.shortRel.includes(ctx.subject)
&& !this.related.deepRel.includes(ctx.subject),
// notEither simulates a client that authorizes users when Keto returns NotMember
notEither: (ctx:Context) => !this.permits.union(ctx),
}
}
`,
InputTuples: []string{
"Resource:doc#deepRel@Group:deep#members",
"Resource:doc#shortRel@Group:short#members",
"Group:deep#members@Group:deep1#members",
"Group:deep1#members@Group:deep2#members",
"Group:deep2#members@Group:deep3#members",
"Group:deep3#members@Group:deep4#members",
"Group:deep4#members@User:Alice",
"Group:short#members@Group:short1#members",
"Group:short1#members@Group:short2#members",
"Group:short2#members@User:Alice",
"Group:short2#members@User:Charlie",
},
}
func TestDepthLimit(t *testing.T) {
type tc struct {
name string
tuple string
depth int
expectedMembership check.Membership
expectedLimitation check.LimitationKind
expectedIsMember bool
}
tests := []tc{
{
name: "union: too shallow depth for both branches yields NotMember",
tuple: "Resource:doc#union@User:Alice",
depth: 2,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
{
name: "union: enough depth for 1 Member branch yields Member",
tuple: "Resource:doc#union@User:Alice",
depth: 4,
expectedMembership: check.IsMember,
expectedIsMember: true,
},
{
name: "union: enough depth for 1 NotMember branch yields Unknown",
tuple: "Resource:doc#union@User:Bob", // Bob is in no branch
depth: 4,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
{
name: "intersection: too shallow depth for both branches yields Unknown",
tuple: "Resource:doc#intersection@User:Alice",
depth: 2,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
{
name: "intersection: enough depth for only 1 branch yields Unknown",
tuple: "Resource:doc#intersection@User:Alice",
depth: 4,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
{
name: "intersection: enough depth for both branches yields Member",
tuple: "Resource:doc#intersection@User:Alice",
depth: 5,
expectedMembership: check.IsMember,
expectedIsMember: true,
},
{
name: "shortButNotDeep: too shallow depth for both branches yields Unknown",
tuple: "Resource:doc#shortButNotDeep@User:Alice",
depth: 2,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
{
name: "shortButNotDeep: enough depth for only 1 branch yields Unknown",
tuple: "Resource:doc#shortButNotDeep@User:Alice",
depth: 4,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
{
name: "shortButNotDeep: user only in shortRel yields Member at sufficient depth",
tuple: "Resource:doc#shortButNotDeep@User:Charlie",
depth: 7,
expectedMembership: check.IsMember,
expectedIsMember: true,
},
{
name: "notEither: not enough depth for any branch yields Unknown",
tuple: "Resource:doc#notEither@User:Alice",
depth: 2,
expectedMembership: check.MembershipUnknown,
expectedLimitation: check.LimitationMaxDepthExceeded,
expectedIsMember: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
depthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
ctx := t.Context()
e := check.NewEngine(reg)
if tc.depth > 0 {
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
}
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
require.NoError(t, res.Err)
assert.Equal(t, tc.expectedMembership, res.Membership)
assert.Equal(t, tc.expectedLimitation, res.Limitation)
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
require.NoError(t, err)
assert.Equal(t, tc.expectedIsMember, isMember)
})
// in strict mode, expect Error when limitation is hit
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
ctx := t.Context()
e := check.NewEngine(reg)
if tc.depth > 0 {
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
}
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
require.NoError(t, res.Err)
assert.Equal(t, tc.expectedMembership, res.Membership)
assert.Equal(t, tc.expectedLimitation, res.Limitation)
if tc.expectedLimitation != "" {
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
require.Error(t, err)
var he *herodot.DefaultError
require.ErrorAs(t, err, &he)
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
} else {
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
require.NoError(t, err)
assert.Equal(t, tc.expectedIsMember, isMember)
}
})
})
}
}
// TestCheckSnapshotIsolation verifies that a concurrent write committed between
// two SQL queries within the same check does not affect the result.
func TestCheckSnapshotIsolation(t *testing.T) {
t.Parallel()
const opl = `
class User implements Namespace {}
class Folder implements Namespace {
related: {
members: User[]
}
permits = {
isMember: (ctx: Context) => this.related.members.includes(ctx.subject),
}
}
class File implements Namespace {
related: {
parent: Folder[]
}
permits = {
view: (ctx: Context) => this.related.parent.traverse(p => p.permits.isMember(ctx)),
}
}
`
inputTuples := []string{
"File:readme#parent@Folder:docs",
"Folder:docs#members@User:u1", // this tuple will be deleted during check execution
}
var snapshotDSNs []*dbx.DsnT
for _, dsn := range dbx.GetDSNs(t) {
switch dsn.Name {
case "cockroach", "mysql", "postgres":
snapshotDSNs = append(snapshotDSNs, dsn)
}
}
if len(snapshotDSNs) == 0 {
// SQLite is excluded. pop serialises all SQLite transactions with a mutex,
// so the middleware's concurrent delete gets deadlocked against the outer check
// transaction
t.Skip("sqlite is excluded from snapshot isolation test; run without -short to start CockroachDB/MySQL/PostgreSQL")
}
for _, dsn := range snapshotDSNs {
t.Run("dsn="+dsn.Name, func(t *testing.T) {
t.Parallel()
reg := driver.NewTestRegistry(t, dsn,
driver.WithOPL(opl),
driver.WithMapperNamespace(testhelpers.CustomMapperNamespace),
driver.WithConfig(config.KeyFeatureFlagStrictMode, true),
)
testhelpers.MapAndInsertTuplesFromString(t, reg, inputTuples)
ctx := t.Context()
// The delete must happen after the first SQL query in the transaction so
// the snapshot is already established in PostgreSQL/MySQL REPEATABLE READ
// (which snapshots at first statement, not at BEGIN).
var once sync.Once
middleware := func(mCtx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
if s.Kind() == check.StepIsAllowed && req.Tuple.Namespace == "Folder" && req.Tuple.Relation == "isMember" {
once.Do(func() {
assert.NoError(t, reg.RelationTupleManager().DeleteRelationTuples(t.Context(),
testhelpers.TupleFromString(t, "Folder:docs#members@User:u1"),
))
})
}
return next(mCtx, s, req)
}
engine := check.NewEngine(&checkerOverrideRegistry{Registry: reg, mw: middleware})
result := engine.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "File:readme#view@User:u1"), 5)
require.NoError(t, result.Err)
assert.Equal(t, check.IsMember, result.Membership,
"check must reflect the database state at the start of the evaluation")
})
}
}
// checkerOverrideRegistry wraps a Registry and replaces its Checker.
// mw can be used to inject a middleware to the Executor.
type checkerOverrideRegistry struct {
driver.Registry
mw check.Middleware
}
func (r *checkerOverrideRegistry) Checker() check.Checker {
return step.NewExecutor(r.Registry, r.mw)
}

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
var (
Version = "master"
Date = "undefined"
Commit = "undefined"
)

View File

@@ -0,0 +1,87 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"reflect"
"sync"
"github.com/ory/herodot"
"github.com/pkg/errors"
"github.com/ory/keto/internal/namespace"
)
type (
memoryNamespaceManager struct {
byName map[string]*namespace.Namespace
sync.RWMutex
}
)
var _ namespace.Manager = &memoryNamespaceManager{}
func NewMemoryNamespaceManager(nn ...*namespace.Namespace) *memoryNamespaceManager {
s := &memoryNamespaceManager{}
s.set(nn)
return s
}
func (s *memoryNamespaceManager) GetNamespaceByName(_ context.Context, name string) (*namespace.Namespace, error) {
s.RLock()
defer s.RUnlock()
if n, ok := s.byName[name]; ok {
return n, nil
}
return nil, errors.WithStack(herodot.ErrNotFound().WithReasonf("Unknown namespace with name %q.", name))
}
func (s *memoryNamespaceManager) GetNamespaceByConfigID(_ context.Context, id int32) (*namespace.Namespace, error) {
s.RLock()
defer s.RUnlock()
for _, n := range s.byName {
//lint:ignore SA1019 backwards compatibility
//nolint:staticcheck
if n.ID == id {
return n, nil
}
}
return nil, errors.WithStack(herodot.ErrNotFound().WithReasonf("Unknown namespace with id %d.", id))
}
func (s *memoryNamespaceManager) Namespaces(_ context.Context) ([]*namespace.Namespace, error) {
s.RLock()
defer s.RUnlock()
nn := make([]*namespace.Namespace, 0, len(s.byName))
for _, n := range s.byName {
nn = append(nn, n)
}
return nn, nil
}
func (s *memoryNamespaceManager) ShouldReload(newValue interface{}) bool {
s.RLock()
defer s.RUnlock()
nn, _ := s.Namespaces(context.Background())
return !reflect.DeepEqual(newValue, nn)
}
func (s *memoryNamespaceManager) set(nn []*namespace.Namespace) {
s.Lock()
defer s.Unlock()
s.byName = make(map[string]*namespace.Namespace, len(nn))
for _, n := range nn {
s.byName[n.Name] = n
}
}

View File

@@ -0,0 +1,25 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/keto/internal/namespace"
)
func TestNamespaceMemoryManager(t *testing.T) {
t.Run("method=should reload", func(t *testing.T) {
n := &namespace.Namespace{Name: "2"}
nm := NewMemoryNamespaceManager(n)
assert.False(t, nm.ShouldReload([]*namespace.Namespace{n}))
assert.False(t, nm.ShouldReload([]*namespace.Namespace{{Name: "2"}}))
assert.True(t, nm.ShouldReload([]*namespace.Namespace{{Name: "3"}}))
assert.True(t, nm.ShouldReload("foo"))
})
}

View File

@@ -0,0 +1,295 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"github.com/ghodss/yaml"
"github.com/ory/herodot"
"github.com/ory/x/logrusx"
"github.com/ory/x/stringsx"
"github.com/ory/x/urlx"
"github.com/ory/x/watcherx"
"github.com/pelletier/go-toml"
"github.com/pkg/errors"
"github.com/ory/keto/internal/namespace"
)
type (
Parser func([]byte, interface{}) error
NamespaceFile struct {
Name string
Contents []byte
Parser Parser
namespace *namespace.Namespace // last successfully parsed namespace. May be nil if there was a parse error
}
NamespaceWatcher struct {
sync.RWMutex
namespaces map[string]*NamespaceFile
logger *logrusx.Logger
target string
}
eventHandler interface {
handleRemove(*watcherx.RemoveEvent)
handleChange(*watcherx.ChangeEvent)
handleError(*watcherx.ErrorEvent)
}
)
var _ namespace.Manager = (*NamespaceWatcher)(nil)
func NewNamespaceWatcher(ctx context.Context, l *logrusx.Logger, target string) (*NamespaceWatcher, error) {
nw := NamespaceWatcher{
logger: l,
target: target,
namespaces: make(map[string]*NamespaceFile),
}
return &nw, watchTarget(ctx, target, &nw, l)
}
func (nw *NamespaceWatcher) handleRemove(e *watcherx.RemoveEvent) {
nw.Lock()
defer nw.Unlock()
delete(nw.namespaces, e.Source())
}
func (nw *NamespaceWatcher) handleChange(e *watcherx.ChangeEvent) {
// the lock is acquired before parsing to ensure that the getters are
// waiting for the updated values
nw.Lock()
defer nw.Unlock()
n := nw.readNamespaceFile(e.Reader(), e.Source())
if n == nil {
return
} else if n.namespace == nil {
// parse failed, rolling back to previous working version
if existing, ok := nw.namespaces[e.Source()]; ok {
existing.Contents = n.Contents
} else {
nw.namespaces[e.Source()] = n
}
} else {
nw.namespaces[e.Source()] = n
}
}
func (nw *NamespaceWatcher) handleError(e *watcherx.ErrorEvent) {
nw.logger.
WithError(e).
Errorf("Received error while watching namespace files at target %s.", nw.target)
}
func watchTarget(ctx context.Context, target string, handler eventHandler, log *logrusx.Logger) error {
var (
eventCh = make(watcherx.EventChannel)
watcher watcherx.Watcher
)
targetUrl, err := urlx.Parse(target)
if err != nil {
return errors.WithStack(err)
}
info, err := os.Stat(targetUrl.Path) //nolint:gosec // Path is from a validated configuration URL, not user input.
if err != nil {
return errors.WithStack(err)
}
if info.IsDir() {
watcher, err = watcherx.WatchDirectory(ctx, urlx.GetURLFilePath(targetUrl), eventCh)
} else {
watcher, err = watcherx.Watch(ctx, targetUrl, eventCh)
}
// this handles the watcher init error
if err != nil {
return err
}
// trigger initial load
done, err := watcher.DispatchNow()
if err != nil {
return errors.WithStack(err)
}
initialEventsProcessed := make(chan struct{})
go startEventHandler(ctx, eventCh, handler, done, initialEventsProcessed, log)
// wait for initial load to be done
<-initialEventsProcessed
return nil
}
func startEventHandler(ctx context.Context,
eventCh watcherx.EventChannel,
handler eventHandler,
done <-chan int,
initialEventsProcessed chan<- struct{},
log *logrusx.Logger) {
initialDone := false
for {
select {
// because we use an unbuffered chan we can be sure that at least all
// initial events are handled
case <-done:
initialDone = true
close(initialEventsProcessed)
// Disable this case after it fires once. watcherx closes done when
// the watcher shuts down (e.g. directoryWatcher on ctx cancel); a
// closed channel is always ready, so leaving done set would re-enter
// this branch and close(initialEventsProcessed) a second time (panic).
done = nil
case <-ctx.Done():
return
case e, open := <-eventCh:
if !open {
return
}
if initialDone {
log.
WithField("file", e.Source()).
WithField("event_type", fmt.Sprintf("%T", e)).
Info("A change to a namespace file was detected.")
}
switch e := e.(type) {
case *watcherx.RemoveEvent:
handler.handleRemove(e)
case *watcherx.ChangeEvent:
handler.handleChange(e)
case *watcherx.ErrorEvent:
handler.handleError(e)
default:
log.Warnf("Ignored unknown event %T", e)
}
}
}
}
func (nw *NamespaceWatcher) readNamespaceFile(r io.Reader, source string) *NamespaceFile {
parse, err := GetParser(source)
if err != nil {
nw.logger.
WithError(err).
WithField("file_name", source).
Warn("could not infer format from file extension")
return nil
}
raw, err := io.ReadAll(r)
if err != nil {
nw.logger.
WithError(errors.WithStack(err)).
WithField("file_name", source).
Error("could not read namespace file")
return nil
}
n := namespace.Namespace{}
if err := parse(raw, &n); err != nil {
nw.logger.
WithError(errors.WithStack(err)).
WithField("file_name", source).
Error("could not parse namespace file")
return &NamespaceFile{Name: source, Contents: raw, Parser: parse}
}
return &NamespaceFile{Name: source, Contents: raw, Parser: parse, namespace: &n}
}
func (nw *NamespaceWatcher) GetNamespaceByName(_ context.Context, name string) (*namespace.Namespace, error) {
nw.RLock()
defer nw.RUnlock()
for _, nsf := range nw.namespaces {
if nsf.namespace != nil && nsf.namespace.Name == name {
return nsf.namespace, nil
}
}
return nil, errors.WithStack(herodot.ErrNotFound().WithErrorf(
"Unknown namespace with name %s", name))
}
func (nw *NamespaceWatcher) GetNamespaceByConfigID(_ context.Context, id int32) (*namespace.Namespace, error) {
nw.RLock()
defer nw.RUnlock()
for _, nspace := range nw.namespaces {
//lint:ignore SA1019 backwards compatibility
//nolint:staticcheck
if nspace.namespace.ID == id {
return nspace.namespace, nil
}
}
return nil, errors.WithStack(herodot.ErrNotFound().WithErrorf(
"Unknown namespace with ID %d", id))
}
func (nw *NamespaceWatcher) Namespaces(_ context.Context) ([]*namespace.Namespace, error) {
nw.RLock()
defer nw.RUnlock()
nspaces := make([]*namespace.Namespace, 0, len(nw.namespaces))
for _, nsf := range nw.namespaces {
if nsf.namespace != nil {
nspaces = append(nspaces, nsf.namespace)
}
}
return nspaces, nil
}
func (nw *NamespaceWatcher) NamespaceFiles() []*NamespaceFile {
nw.RLock()
defer nw.RUnlock()
nsfs := make([]*NamespaceFile, 0, len(nw.namespaces))
for _, nsf := range nw.namespaces {
nsfs = append(nsfs, nsf)
}
return nsfs
}
func (nw *NamespaceWatcher) ShouldReload(newValue interface{}) bool {
v, ok := newValue.(string)
if !ok {
// the manager type changed
return true
}
// reload if target changed
return v != nw.target
}
func GetParser(fn string) (Parser, error) {
switch ext := stringsx.SwitchExact(filepath.Ext(fn)); {
case ext.AddCase(".yaml"), ext.AddCase(".yml"):
return func(b []byte, i interface{}) error {
return yaml.Unmarshal(b, i)
}, nil
case ext.AddCase(".json"):
return json.Unmarshal, nil
case ext.AddCase(".toml"):
return toml.Unmarshal, nil
default:
return nil, ext.ToUnknownCaseErr()
}
}

View File

@@ -0,0 +1,250 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ghodss/yaml"
"github.com/ory/x/logrusx"
"github.com/ory/x/watcherx"
"github.com/pelletier/go-toml"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/namespace"
)
func TestNamespaceProvider(t *testing.T) {
setup := func(t *testing.T, target string) (*NamespaceWatcher, *test.Hook) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
hook := &test.Hook{}
l := logrusx.New("", "", logrusx.WithHook(hook))
nw, err := NewNamespaceWatcher(ctx, l, target)
require.NoError(t, err)
return nw, hook
}
writeNamespace := func(t *testing.T, n *namespace.Namespace, fn string) {
var marshal func(interface{}) ([]byte, error)
switch ext := filepath.Ext(fn); ext {
case ".yaml", ".yml":
marshal = yaml.Marshal
case ".toml":
marshal = toml.Marshal
case ".json":
marshal = json.Marshal
default:
t.Logf("got unexpected file extension %s", ext)
t.FailNow()
}
nEnc, err := marshal(n)
require.NoError(t, err)
require.NoError(t, os.WriteFile(fn, nEnc, 0600))
}
writeDir := func(t *testing.T, dir string, files map[string]interface{}) {
for fn, fc := range files {
fp := filepath.Join(dir, fn)
require.NoError(t, os.MkdirAll(filepath.Dir(fp), 0700))
if n, ok := fc.(*namespace.Namespace); ok {
writeNamespace(t, n, fp)
continue
}
s, ok := fc.(string)
if !ok {
t.Logf("expected file content to be string or *namespace.Namespace but got %T", fc)
t.FailNow()
}
require.NoError(t, os.WriteFile(fp, []byte(s), 0600))
}
}
writeJsonNamespace := func(t *testing.T) (string, *namespace.Namespace) {
dir := t.TempDir()
n := &namespace.Namespace{
Name: "test namespace 1",
}
fn := filepath.Join(dir, "n.json")
writeNamespace(t, n, fn)
return fn, n
}
t.Run("case=loads JSON namespace file", func(t *testing.T) {
fn, n := writeJsonNamespace(t)
ws, _ := setup(t, "file://"+fn)
nspaces, err := ws.Namespaces(context.Background())
require.NoError(t, err)
assert.Equal(t, []*namespace.Namespace{n}, nspaces)
})
t.Run("case=reads namespace files from directory", func(t *testing.T) {
dir := t.TempDir()
files := map[string]interface{}{
"b.yml": &namespace.Namespace{
Name: "b",
},
"a.toml": &namespace.Namespace{
Name: "a",
},
"c.json": &namespace.Namespace{
Name: "c",
},
}
writeDir(t, dir, files)
nw, _ := setup(t, "file://"+dir)
nspaces, err := nw.Namespaces(context.Background())
require.NoError(t, err)
for _, n := range files {
assert.Contains(t, nspaces, n.(*namespace.Namespace))
}
nsfs := nw.NamespaceFiles()
assert.Equal(t, len(nspaces), len(nsfs))
for _, n := range nsfs {
assert.NotNil(t, n.Contents)
assert.NotNil(t, n.Parser)
}
})
t.Run("case=ignores but warns about unsupported file extensions", func(t *testing.T) {
dir := t.TempDir()
n := &namespace.Namespace{
Name: "some name",
}
nJson, err := json.Marshal(n)
require.NoError(t, err)
writeDir(t, dir, map[string]interface{}{
"unsupported.file": "foo bar\n",
"supported.json": string(nJson),
})
nw, hook := setup(t, "file://"+dir)
require.Len(t, hook.Entries, 1)
assert.Equal(t, logrus.WarnLevel, hook.Entries[0].Level, "%+v", hook.Entries[0])
assert.True(t, strings.HasSuffix(hook.Entries[0].Data["file_name"].(string), "unsupported.file"))
namespaces, err := nw.Namespaces(context.Background())
require.NoError(t, err)
require.Len(t, namespaces, 1)
assert.Equal(t, n, namespaces[0])
})
t.Run("case=still returns successful namespace if one errors", func(t *testing.T) {
dir := t.TempDir()
n := &namespace.Namespace{
Name: "some name",
}
nJson, err := json.Marshal(n)
require.NoError(t, err)
writeDir(t, dir, map[string]interface{}{
"malformed.yml": "foo bar\n",
"correct.json": string(nJson),
})
nw, hook := setup(t, "file://"+dir)
require.Len(t, hook.Entries, 1)
assert.Equal(t, logrus.ErrorLevel, hook.Entries[0].Level, "%+v", hook.Entries[0])
assert.True(t, strings.HasSuffix(hook.Entries[0].Data["file_name"].(string), "malformed.yml"))
namespaces, err := nw.Namespaces(context.Background())
require.NoError(t, err)
require.Len(t, namespaces, 1)
assert.Equal(t, n, namespaces[0])
// files are included even if ns is unparsable
nsfs := nw.NamespaceFiles()
assert.Equal(t, 2, len(nsfs))
})
t.Run("method=should reload", func(t *testing.T) {
nw := &NamespaceWatcher{
target: "foo",
}
assert.False(t, nw.ShouldReload("foo"))
assert.True(t, nw.ShouldReload("bar"))
assert.True(t, nw.ShouldReload([]*namespace.Namespace{}))
})
}
type noopEventHandler struct{}
func (noopEventHandler) handleRemove(*watcherx.RemoveEvent) {}
func (noopEventHandler) handleChange(*watcherx.ChangeEvent) {}
func (noopEventHandler) handleError(*watcherx.ErrorEvent) {}
func TestStartEventHandlerDoesNotCloseInitialDoneTwice(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
eventCh := make(watcherx.EventChannel)
done := make(chan int, 1)
initialEventsProcessed := make(chan struct{})
handlerReturned := make(chan any, 1)
go func() {
defer func() {
handlerReturned <- recover()
}()
startEventHandler(ctx, eventCh, noopEventHandler{}, done, initialEventsProcessed, logrusx.New("", ""))
}()
done <- 0
select {
case <-initialEventsProcessed:
case <-time.After(time.Second):
t.Fatal("timed out waiting for initial events to be processed")
}
close(done)
select {
case recovered := <-handlerReturned:
require.Nil(t, recovered)
t.Fatal("event handler returned before context cancellation")
case <-time.After(100 * time.Millisecond):
}
cancel()
select {
case recovered := <-handlerReturned:
require.Nil(t, recovered)
case <-time.After(time.Second):
t.Fatal("timed out waiting for event handler to stop")
}
}

View File

@@ -0,0 +1,193 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/synctest"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/ory/herodot"
"github.com/ory/x/configx"
"github.com/ory/x/fetcher"
"github.com/ory/x/logrusx"
)
func TestNewOPLConfigWatcher(t *testing.T) {
var hits atomic.Int64
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if _, err := io.WriteString(w, testOPL); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}))
t.Cleanup(ts.Close)
ctx := context.Background()
cfg, err := NewDefault(ctx, nil, logrusx.New("", ""), configx.SkipValidation())
require.NoError(t, err)
cw, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "HTTP request made")
_, err = cw.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
_, err = cw.GetNamespaceByName(ctx, "Document")
require.NoError(t, err)
cache.Wait()
cw, err = newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "content was cached")
_, err = cw.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
_, err = cw.GetNamespaceByName(ctx, "Document")
require.NoError(t, err)
}
func TestOPLConfigWatcher(t *testing.T) {
t.Parallel()
t.Run("caches parsed namespaces", func(t *testing.T) {
t.Parallel()
ts, hits := newCountingServer(t, http.StatusOK, testOPL)
cfg := newTestConfig(t)
ctx := t.Context()
first, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
cache.Wait()
second, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "content was fetched only once")
firstUser, err := first.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
secondUser, err := second.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
require.Same(t, firstUser, secondUser, "the parsed namespaces are shared, not re-parsed per construction")
})
t.Run("deduplicates concurrent fetches", func(t *testing.T) {
const parallel = 5
const target = "https://opl.invalid/namespaces.ts"
// The cache is process-global and outlives the test binary's runs.
cache.Del(target)
// The config warms package-level caches whose goroutines live outside
// the bubble, so build it here.
cfg := newTestConfig(t)
synctest.Test(t, func(t *testing.T) {
var hits atomic.Int64
release := make(chan struct{})
hc := retryablehttp.NewClient()
hc.Logger = nil
hc.HTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
hits.Add(1)
<-release
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(testOPL)),
Header: make(http.Header),
Request: r,
}, nil
})}
newFetcher := func() *fetcher.Fetcher { return fetcher.NewFetcher(fetcher.WithClient(hc)) }
ctx := t.Context()
watchers := make([]*oplConfigWatcher, parallel)
var eg errgroup.Group
for i := range parallel {
eg.Go(func() error {
cw, err := newOPLConfigWatcher(ctx, cfg.l, newFetcher, target)
watchers[i] = cw
return err
})
}
synctest.Wait() // One caller is inside the fetch, the rest joined its flight.
close(release)
require.NoError(t, eg.Wait())
require.EqualValues(t, 1, hits.Load(), "concurrent constructions share a single fetch")
for _, cw := range watchers {
_, err := cw.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
}
})
})
t.Run("retries failed fetches", func(t *testing.T) {
t.Parallel()
ts, hits := newCountingServer(t, http.StatusNotFound, "")
cfg := newTestConfig(t)
ctx := t.Context()
_, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.ErrorContains(t, err, "404")
cache.Wait()
_, err = newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.ErrorContains(t, err, "404")
require.EqualValues(t, 2, hits.Load(), "failed fetches are not cached")
})
t.Run("caches parse failures", func(t *testing.T) {
t.Parallel()
ts, hits := newCountingServer(t, http.StatusOK, "/* unclosed comment")
cfg := newTestConfig(t)
ctx := t.Context()
first, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err, "a parse failure does not fail construction")
_, err = first.GetNamespaceByName(ctx, "User")
require.ErrorIs(t, err, herodot.ErrNotFound())
cache.Wait()
second, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "a parse failure is cached and does not trigger a fetch per construction")
_, err = second.GetNamespaceByName(ctx, "User")
require.ErrorIs(t, err, herodot.ErrNotFound())
})
}
func newTestConfig(t *testing.T) *Config {
t.Helper()
cfg, err := NewDefault(t.Context(), nil, logrusx.New("", ""), configx.SkipValidation())
require.NoError(t, err)
return cfg
}
func newCountingServer(t *testing.T, status int, body string) (*httptest.Server, *atomic.Int64) {
t.Helper()
hits := new(atomic.Int64)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(ts.Close)
return ts, hits
}
var testOPL = `
import { Namespace } from "@ory/keto-namespace-types"
class User implements Namespace {}
class Document implements Namespace {}
`
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

View File

@@ -0,0 +1,181 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"bytes"
"context"
"fmt"
"io"
"sync"
"time"
"github.com/dgraph-io/ristretto/v2"
"github.com/ory/x/fetcher"
"github.com/ory/x/logrusx"
"github.com/ory/x/urlx"
"github.com/ory/x/watcherx"
"github.com/pkg/errors"
"golang.org/x/sync/singleflight"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/schema"
)
type (
configFiles struct {
byPath map[string]io.Reader
sync.Mutex
}
oplConfigWatcher struct {
logger *logrusx.Logger
target string
files configFiles
memoryNamespaceManager
}
)
var (
_ namespace.Manager = (*oplConfigWatcher)(nil)
// cache holds the parsed namespaces per remote OPL location. Content at a location is
// assumed to be immutable, therefore, a change to the OPL should create a new file location,
// therefore a new cache entry.
cache, _ = ristretto.NewCache(&ristretto.Config[string, []*namespace.Namespace]{
MaxCost: 20_000_000, // 20 MB of estimated heap, each item ca. 10 KB => max 2000 items
NumCounters: 20_000, // max 2000 items => 20000 counters
BufferItems: 64,
Cost: namespace.EstimatedSize,
})
fetchGroup singleflight.Group
)
func newOPLConfigWatcher(ctx context.Context, l *logrusx.Logger, newFetcher func() *fetcher.Fetcher, target string) (*oplConfigWatcher, error) {
nw := &oplConfigWatcher{
logger: l,
target: target,
files: configFiles{byPath: make(map[string]io.Reader)},
memoryNamespaceManager: *NewMemoryNamespaceManager(),
}
targetUrl, err := urlx.Parse(target)
if err != nil {
return nil, errors.WithStack(err)
}
switch targetUrl.Scheme {
case "file", "":
return nw, watchTarget(ctx, target, nw, l)
case "base64":
file, err := newFetcher().FetchContext(ctx, target)
if err != nil {
return nil, err
}
nw.files.Lock()
defer nw.files.Unlock()
nw.files.byPath[targetUrl.String()] = file
nw.parseFiles()
return nw, err
case "http", "https":
if namespaces, ok := cache.Get(target); ok {
nw.set(namespaces)
return nw, nil
}
v, err, _ := fetchGroup.Do(target, func() (any, error) {
// Detach from the leader's context so that its cancellation does not
// fail the concurrent callers sharing this fetch.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
buf, err := newFetcher().FetchContext(ctx, target)
if err != nil {
return nil, err
}
b := buf.Bytes()
nw.files.Lock()
defer nw.files.Unlock()
nw.files.byPath[targetUrl.String()] = bytes.NewReader(b)
namespaces, ok := nw.parseFiles()
if !ok {
// Cache a parse failure as an empty result, so that a broken
// OPL file does not trigger a fetch per construction.
namespaces = []*namespace.Namespace{}
}
// Cost 0 lets the cache's Cost function compute the entry size.
cache.SetWithTTL(target, namespaces, 0, 30*time.Minute)
return namespaces, nil
})
if err != nil {
return nil, err
}
if namespaces, ok := v.([]*namespace.Namespace); ok {
nw.set(namespaces)
}
return nw, nil
default:
return nil, fmt.Errorf("unexpected url scheme: %q", targetUrl.Scheme)
}
}
func (nw *oplConfigWatcher) handleChange(e *watcherx.ChangeEvent) {
// the lock is acquired before parsing to ensure that the getters are
// waiting for the updated values
nw.files.Lock()
defer nw.files.Unlock()
nw.files.byPath[e.Source()] = e.Reader()
nw.parseFiles()
}
func (nw *oplConfigWatcher) handleRemove(e *watcherx.RemoveEvent) {
nw.files.Lock()
defer nw.files.Unlock()
delete(nw.files.byPath, e.Source())
nw.parseFiles()
}
func (nw *oplConfigWatcher) handleError(e *watcherx.ErrorEvent) {
nw.logger.
WithError(e).
Errorf("Received error while watching OPL config files at target %s.",
nw.target)
}
// parseFiles loops through all files, parsing each and getting the namespaces.
// It then sets and returns the namespaces only if there were no errors.
//
// The caller must hold the lock to nw.files.
func (nw *oplConfigWatcher) parseFiles() ([]*namespace.Namespace, bool) {
var (
namespaces = make([]*namespace.Namespace, 0)
errs []error
)
for _, reader := range nw.files.byPath {
content, err := io.ReadAll(reader)
if err != nil {
errs = append(errs, err)
continue
}
nn, ee := schema.Parse(string(content))
for _, e := range ee {
errs = append(errs, e)
}
for _, n := range nn {
namespaces = append(namespaces, &n)
}
}
if len(errs) > 0 {
for _, err := range errs {
nw.logger.
WithError(err).
Errorf("Failed to parse OPL config files at target %s.",
nw.target)
}
return nil, false
}
nw.set(namespaces)
return namespaces, true
}

View File

@@ -0,0 +1,397 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"crypto/sha512"
_ "embed"
"encoding/json"
"fmt"
"sync"
"github.com/ory/herodot"
_ "github.com/ory/jsonschema/v3/httploader"
"github.com/ory/x/configx"
"github.com/ory/x/fetcher"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/watcherx"
"github.com/pkg/errors"
"github.com/rs/cors"
"github.com/spf13/pflag"
"github.com/ory/keto/embedx"
"github.com/ory/keto/internal/namespace"
)
type EndpointType string
const (
EndpointRead EndpointType = "read"
EndpointWrite EndpointType = "write"
EndpointMetrics EndpointType = "metrics"
EndpointOPLSyntax EndpointType = "opl"
KeyDSN = "dsn"
KeyLimitMaxReadDepth = "limit.max_read_depth"
KeyLimitMaxReadWidth = "limit.max_read_width"
KeyLimitExpandMaxSize = "limit.max_expand_size"
KeyBatchCheckMaxBatchSize = "limit.max_batch_check_size"
KeyBatchCheckParallelizationLimit = "limit.batch_check_max_parallelization"
KeyReadAPIHost = "serve." + string(EndpointRead) + ".host"
KeyReadAPIPort = "serve." + string(EndpointRead) + ".port"
KeyReadAPIListenFile = "serve." + string(EndpointRead) + ".write_listen_file"
KeyWriteAPIHost = "serve." + string(EndpointWrite) + ".host"
KeyWriteAPIPort = "serve." + string(EndpointWrite) + ".port"
KeyWriteAPIListenFile = "serve." + string(EndpointWrite) + ".write_listen_file"
KeyOPLSyntaxAPIHost = "serve." + string(EndpointOPLSyntax) + ".host"
KeyOPLSyntaxAPIPort = "serve." + string(EndpointOPLSyntax) + ".port"
KeyOPLSyntaxListenFile = "serve." + string(EndpointOPLSyntax) + ".write_listen_file"
KeyMetricsHost = "serve." + string(EndpointMetrics) + ".host"
KeyMetricsPort = "serve." + string(EndpointMetrics) + ".port"
KeyMetricsListenFile = "serve." + string(EndpointMetrics) + ".write_listen_file"
KeyNamespaces = "namespaces"
KeyNamespacesExperimentalStrictMode = KeyNamespaces + ".experimental_strict_mode"
KeyFeatureFlagExpandRewrites = "feature_flags.expand_rewrites"
KeyFeatureFlagStrictMode = "feature_flags.strict_mode"
DSNMemory = "sqlite://file::memory:?_fk=true&"
KeySecretsPagination = "secrets.pagination"
)
type (
Config struct {
p *configx.Provider
l *logrusx.Logger
ctx context.Context
nm namespace.Manager
cancelNamespaceManager context.CancelFunc
nmLock sync.Mutex
}
Provider interface {
Config(ctx context.Context) *Config
}
)
func New(ctx context.Context, l *logrusx.Logger, p *configx.Provider) *Config {
return &Config{
p: p,
l: l,
ctx: ctx,
}
}
func NewDefault(ctx context.Context, flags *pflag.FlagSet, l *logrusx.Logger, opts ...configx.OptionModifier) (*Config, error) {
c := New(ctx, l, nil)
cp, err := NewProvider(ctx, flags, c, opts...)
if err != nil {
return nil, err
}
c.WithSource(cp)
return c, nil
}
func NewProvider(ctx context.Context, flags *pflag.FlagSet, config *Config, opts ...configx.OptionModifier) (*configx.Provider, error) {
p, err := configx.New(
ctx,
embedx.ConfigSchema,
append(opts,
configx.WithFlags(flags),
configx.WithStderrValidationReporter(),
configx.WithImmutables(KeyDSN, "serve"),
configx.OmitKeysFromTracing(KeyDSN),
configx.WithLogrusWatcher(config.l),
configx.WithContext(ctx),
configx.AttachWatcher(config.namespaceWatcher),
)...,
)
if err != nil {
return nil, err
}
return p, nil
}
func (k *Config) Source() *configx.Provider {
return k.p
}
func (k *Config) WithSource(p *configx.Provider) {
k.p = p
k.l.UseConfig(p)
}
func (k *Config) namespaceWatcher(_ watcherx.Event, err error) {
if err != nil {
return
}
nm, err := k.NamespaceManager()
if err != nil {
k.l.WithError(err).Error("got internal error in config namespace watcher: could not get namespace manager")
return
}
nnCfg, err := k.namespaceConfig()
if err != nil {
k.l.WithError(err).Error("could not get namespaces from config")
return
}
if nm.ShouldReload(nnCfg.value()) {
k.resetNamespaceManager()
}
}
func (k *Config) resetNamespaceManager() {
k.nmLock.Lock()
defer k.nmLock.Unlock()
if k.cancelNamespaceManager == nil {
return
}
// cancel and delete old namespace manager
// the next read request will result in a new one being created
k.cancelNamespaceManager()
k.nm, k.cancelNamespaceManager = nil, nil
}
func (k *Config) Set(key string, v any) error {
if err := k.p.Set(key, v); err != nil {
return err
}
if key == KeyNamespaces {
k.resetNamespaceManager()
}
return nil
}
func (k *Config) addressFor(endpoint EndpointType) (addr string, listenFile string) {
return fmt.Sprintf(
"%s:%d",
k.p.StringF("serve."+string(endpoint)+".host", ""),
k.p.IntF("serve."+string(endpoint)+".port", 0),
), k.p.StringF("serve."+string(endpoint)+".write_listen_file", "")
}
func (k *Config) ReadAPIListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointRead)
}
func (k *Config) WriteAPIListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointWrite)
}
func (k *Config) MetricsListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointMetrics)
}
func (k *Config) OPLSyntaxAPIListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointOPLSyntax)
}
func (k *Config) MaxReadDepth() int {
return k.p.Int(KeyLimitMaxReadDepth)
}
func (k *Config) MaxReadWidth() int {
return k.p.Int(KeyLimitMaxReadWidth)
}
func (k *Config) BatchCheckMaxBatchSize() int {
return k.p.Int(KeyBatchCheckMaxBatchSize)
}
func (k *Config) BatchCheckParallelizationLimit() int {
return k.p.Int(KeyBatchCheckParallelizationLimit)
}
func (k *Config) ExpandMaxTupleCount() int {
return k.p.Int(KeyLimitExpandMaxSize)
}
func (k *Config) CORS(iface string) (cors.Options, bool) {
switch iface {
case "read", "write", "metrics":
default:
panic("expected interface 'read' or 'write', but got unknown interface " + iface)
}
return k.p.CORS("serve."+iface, cors.Options{
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
ExposedHeaders: []string{"Content-Type"},
AllowCredentials: true,
})
}
func (k *Config) DSN() string {
dsn := k.p.String(KeyDSN)
if dsn == "memory" {
return DSNMemory
}
return dsn
}
func (k *Config) Fetcher() *fetcher.Fetcher {
// Tracing still works correctly even though we pass a no-op tracer
// here, because the otelhttp package will preferentially use the
// tracer from the incoming request context over this one.
opts := []httpx.ResilientOptions{}
if k.p.Bool("clients.http.disallow_private_ip_ranges") {
opts = append(opts, httpx.ResilientClientDisallowInternalIPs())
}
return fetcher.NewFetcher(
fetcher.WithClient(httpx.NewResilientClient(opts...)),
)
}
func (k *Config) TracingServiceName() string {
return k.p.StringF("tracing.service_name", "Ory Keto")
}
func (k *Config) TracingProvider() string {
return k.p.StringF("tracing.provider", "")
}
func (k *Config) TracingConfig() *otelx.Config {
return k.p.TracingConfig("Ory Keto")
}
func (k *Config) NamespaceManager() (namespace.Manager, error) {
k.nmLock.Lock()
defer k.nmLock.Unlock()
if k.nm == nil {
var ctx context.Context
ctx, k.cancelNamespaceManager = context.WithCancel(k.ctx)
nnCfg, err := k.namespaceConfig()
if err != nil {
return nil, err
}
k.nm, err = nnCfg.newManager()(ctx, k)
if err != nil {
return nil, err
}
}
return k.nm, nil
}
func (k *Config) StrictMode() bool {
return k.p.Bool(KeyFeatureFlagStrictMode) || k.p.Bool(KeyNamespacesExperimentalStrictMode)
}
func (k *Config) ExpandRewrites() bool {
return k.p.Bool(KeyFeatureFlagExpandRewrites)
}
type (
buildNamespaceFn func(context.Context, *Config) (namespace.Manager, error)
namespaceConfig interface {
// newManager builds a new namespace manager.
newManager() buildNamespaceFn
// value returns the wrapped value (for comparing if we should reload)
value() any
}
legacyURINamespaceConfig string
literalNamespaceConfig []*namespace.Namespace
oplNamespaceConfig map[string]any
)
func (uri legacyURINamespaceConfig) newManager() buildNamespaceFn {
return func(ctx context.Context, c *Config) (namespace.Manager, error) {
return NewNamespaceWatcher(ctx, c.l, string(uri))
}
}
func (uri legacyURINamespaceConfig) value() any {
return string(uri)
}
func (namespaces literalNamespaceConfig) newManager() buildNamespaceFn {
return func(ctx context.Context, _ *Config) (namespace.Manager, error) {
return NewMemoryNamespaceManager(namespaces...), nil
}
}
func (namespaces literalNamespaceConfig) value() any {
return []*namespace.Namespace(namespaces)
}
func (oplConfig oplNamespaceConfig) newManager() buildNamespaceFn {
return func(ctx context.Context, c *Config) (namespace.Manager, error) {
entry, ok := oplConfig["location"]
if !ok {
return nil, errors.New("location key not found")
}
target, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("config value must be string, was %T", entry)
}
return newOPLConfigWatcher(ctx, c.l, c.Fetcher, target)
}
}
func (oplConfig oplNamespaceConfig) value() any {
return map[string]any(oplConfig)
}
// namespaceConfig returns a namespace config, which can be either a URI (in
// which case we want to watch that URI), or a literal list of namespaces (in
// which case we just load them into memory), or a list of URIs referencing OPL
// definitions (in which case we want to watch each URI and parse the content).
func (k *Config) namespaceConfig() (namespaceConfig, error) {
switch nTyped := k.p.GetF(KeyNamespaces, "file://./keto_namespaces").(type) {
case string:
return legacyURINamespaceConfig(nTyped), nil
case []*namespace.Namespace:
return literalNamespaceConfig(nTyped), nil
case []any:
nEnc, err := json.Marshal(nTyped)
if err != nil {
return nil, errors.WithStack(err)
}
nn := make([]*namespace.Namespace, len(nTyped))
if err := json.Unmarshal(nEnc, &nn); err != nil {
return nil, errors.WithStack(err)
}
return literalNamespaceConfig(nn), nil
case map[string]any:
return oplNamespaceConfig(nTyped), nil
default:
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("could not infer namespaces for type %T", nTyped))
}
}
func (k *Config) PaginationEncryptionKeys() [][32]byte {
secrets := k.p.Strings(KeySecretsPagination)
encryptionKeys := make([][32]byte, len(secrets))
for i, key := range secrets {
encryptionKeys[i] = sha512.Sum512_256([]byte(key))
}
return encryptionKeys
}

View File

@@ -0,0 +1,302 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
"github.com/gobuffalo/httptest"
"github.com/ory/x/configx"
"github.com/ory/x/logrusx"
"github.com/sirupsen/logrus/hooks/test"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/embedx"
"github.com/ory/keto/internal/namespace"
)
// createFile writes the content to a temporary file, returning the path.
// Good for testing config files.
func createFile(t *testing.T, content string) (path string) {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "config-*.yaml")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(f.Name()) }) //nolint:gosec // Test file path from os.CreateTemp, not user input.
n, err := f.WriteString(content)
if err != nil {
t.Fatal(err)
}
if n != len(content) {
t.Fatal("failed to write the complete content")
}
return f.Name()
}
// createFileF writes the content format string with the applied args to a
// temporary file, returning the path. Good for testing config files.
func createFileF(t *testing.T, contentF string, args ...any) (path string) {
return createFile(t, fmt.Sprintf(contentF, args...))
}
func setup(t *testing.T, configFile string) (*test.Hook, *Config) {
t.Helper()
hook := test.Hook{}
l := logrusx.New("test", "today", logrusx.WithHook(&hook))
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
t.Cleanup(cancel)
config, err := NewDefault(
ctx,
pflag.NewFlagSet("test", pflag.ContinueOnError),
l,
configx.WithConfigFiles(configFile),
)
require.NoError(t, err)
return &hook, config
}
func assertNamespaces(t *testing.T, p *Config, nn ...*namespace.Namespace) {
t.Helper()
nm, err := p.NamespaceManager()
require.NoError(t, err)
actualNamespaces, err := nm.Namespaces(context.Background())
require.NoError(t, err)
assert.Equal(t, len(nn), len(actualNamespaces))
assert.ElementsMatch(t, nn, actualNamespaces)
}
// The new way to configure namespaces is through the Ory Permissions Language.
// We check here that we still support enumerating the namespaces directly in
// the config or through a file reference, in which case there should be no
// rewrites configured.
func TestLegacyNamespaceConfig(t *testing.T) {
t.Run("case=creates memory namespace manager when namespaces are set", func(t *testing.T) {
config := createFile(t, `
dsn: memory
namespaces:
- name: n0
- name: n1
- name: n2`)
run := func(namespaces []*namespace.Namespace) func(*testing.T) {
return func(t *testing.T) {
_, p := setup(t, config)
assertNamespaces(t, p, namespaces...)
nm, err := p.NamespaceManager()
require.NoError(t, err)
_, ok := nm.(*memoryNamespaceManager)
assert.True(t, ok)
assert.False(t, p.StrictMode())
}
}
nn := []*namespace.Namespace{
{Name: "n0"},
{Name: "n1"},
{Name: "n2"},
}
nnJson, err := json.Marshal(nn)
require.NoError(t, err)
nnValue := make([]interface{}, 0)
require.NoError(t, json.Unmarshal(nnJson, &nnValue))
t.Run(
"type=[]*namespace.Namespace",
run(nn),
)
})
t.Run("case=reloads namespace manager when namespaces are updated using Set()", func(t *testing.T) {
_, p := setup(t, createFile(t, "dsn: memory"))
n0 := &namespace.Namespace{
Name: "n0",
}
n1 := &namespace.Namespace{
Name: "n1",
}
require.NoError(t, p.Set(KeyNamespaces, []*namespace.Namespace{n0}))
assertNamespaces(t, p, n0)
require.NoError(t, p.Set(KeyNamespaces, []*namespace.Namespace{n1}))
assertNamespaces(t, p, n1)
})
t.Run("case=creates watcher manager when namespaces is string URL", func(t *testing.T) {
_, p := setup(t, createFileF(t, `
dsn: memory
namespaces: file://%s`,
t.TempDir()))
nm, err := p.NamespaceManager()
require.NoError(t, err)
_, ok := nm.(*NamespaceWatcher)
assert.True(t, ok)
assert.False(t, p.StrictMode())
})
t.Run("case=uses passed configx provider", func(t *testing.T) {
ctx := context.Background()
cp, err := configx.New(ctx, embedx.ConfigSchema, configx.WithValue(KeyDSN, "foobar"))
require.NoError(t, err)
p := New(ctx, logrusx.New("test", "today"), cp)
assert.Equal(t, "foobar", p.DSN())
assert.Same(t, cp, p.p)
})
}
func TestInvalidNamespaceConfig(t *testing.T) {
config := createFile(t, `
dsn: memory
namespaces:
- name: n0
id: 2147483648
`)
hook := test.Hook{}
l := logrusx.New("test", "today", logrusx.WithHook(&hook))
_, err := NewDefault(
context.Background(),
pflag.NewFlagSet("test", pflag.ContinueOnError),
l,
configx.WithConfigFiles(config),
)
assert.Error(t, err)
assert.Contains(t, err.Error(), "must be <= 2.147483647e+09 but found 2147483648")
}
// Test that the namespaces can be configured through the Ory Permission
// Language.
func TestRewritesNamespaceConfig(t *testing.T) {
oplContent := `
class User implements Namespace {
related: {
manager: User[]
}
}
class Group implements Namespace {
related: {
members: (User | Group)[]
}
}`
oplConfigFile := createFile(t, oplContent)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(oplContent))
if err != nil {
t.Fatal(err)
}
}))
t.Cleanup(func() { srv.Close() })
cases := []struct {
name string
location string
disallowPrivateIPRanges bool
expectErr bool
}{{
name: "local file",
location: "file://" + oplConfigFile,
}, {
name: "HTTP url forbidden",
location: srv.URL,
disallowPrivateIPRanges: true,
expectErr: true,
}, {
name: "HTTP url allowed",
location: srv.URL,
}}
for _, tc := range cases {
t.Run("case="+tc.name, func(t *testing.T) {
config := createFileF(t, `
dsn: memory
clients:
http:
disallow_private_ip_ranges: %v
namespaces:
location: %s`, tc.disallowPrivateIPRanges, tc.location)
_, p := setup(t, config)
nm, err := p.NamespaceManager()
if tc.expectErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
namespaces, err := nm.Namespaces(context.Background())
require.NoError(t, err)
require.Len(t, namespaces, 2)
names, relationNames := []string{namespaces[0].Name, namespaces[1].Name}, []string{namespaces[0].Relations[0].Name, namespaces[1].Relations[0].Name}
assert.False(t, p.StrictMode())
assert.ElementsMatch(t, names, []string{"User", "Group"})
assert.ElementsMatch(t, relationNames, []string{"manager", "members"})
})
}
for _, tt := range []struct {
name string
deprecated bool
new bool
want bool
}{
{name: "neither key set", deprecated: false, new: false, want: false},
{name: "deprecated key only", deprecated: true, new: false, want: true},
{name: "new key only", deprecated: false, new: true, want: true},
{name: "both keys true", deprecated: true, new: true, want: true},
} {
t.Run("case=strict_mode/"+tt.name, func(t *testing.T) {
cfg := createFileF(t, `
dsn: memory
namespaces:
location: file://%s
experimental_strict_mode: %v
feature_flags:
strict_mode: %v`, oplConfigFile, tt.deprecated, tt.new)
_, p := setup(t, cfg)
assert.Equal(t, tt.want, p.StrictMode())
})
}
}
func TestProvider_DefaultReadAPIListenOn(t *testing.T) {
ctx := context.Background()
config, err := NewDefault(
ctx,
pflag.NewFlagSet("test", pflag.ContinueOnError),
logrusx.New("", ""),
configx.WithValue("dsn", "foo"),
)
require.NoError(t, err)
addr, listenFile := config.ReadAPIListenOn()
assert.Equal(t, ":4466", addr)
assert.Zero(t, listenFile)
}

570
internal/driver/daemon.go Normal file
View File

@@ -0,0 +1,570 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
stderrors "errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"runtime/debug"
"strings"
"syscall"
"time"
"connectrpc.com/connect"
"connectrpc.com/grpchealth"
"connectrpc.com/grpcreflect"
"github.com/ory/analytics-go/v5"
"github.com/ory/graceful"
"github.com/ory/herodot"
"github.com/ory/x/healthx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/logrusx"
"github.com/ory/x/metricsx"
"github.com/ory/x/otelx"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/prometheusx"
"github.com/ory/x/reqlog"
"github.com/ory/x/urlx"
"github.com/pkg/errors"
"github.com/rs/cors"
"github.com/spf13/cobra"
"github.com/urfave/negroni"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"golang.org/x/sync/errgroup"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/protobuf/reflect/protoregistry"
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/check"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/namespace/namespacehandler"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/schema"
)
func (r *RegistryDefault) enableSqa(cmd *cobra.Command) {
ctx := cmd.Context()
var urls []string
addr, _ := r.Config(ctx).ReadAPIListenOn()
urls = append(urls, addr)
addr, _ = r.Config(ctx).WriteAPIListenOn()
urls = append(urls, addr)
addr, _ = r.Config(ctx).MetricsListenOn()
urls = append(urls, addr)
addr, _ = r.Config(ctx).OPLSyntaxAPIListenOn()
urls = append(urls, addr)
if c, y := r.Config(ctx).CORS("read"); y {
urls = append(urls, c.AllowedOrigins...)
}
if c, y := r.Config(ctx).CORS("write"); y {
urls = append(urls, c.AllowedOrigins...)
}
if c, y := r.Config(ctx).CORS("metrics"); y {
urls = append(urls, c.AllowedOrigins...)
}
host := urlx.ExtractPublicAddress(urls...)
r.sqaService = metricsx.New(
cmd,
r.Logger(),
r.Config(ctx).Source(),
&metricsx.Options{
Service: "keto",
DeploymentId: metricsx.Hash(r.Persister().NetworkID(ctx).String()),
IsDevelopment: strings.HasPrefix(r.Config(ctx).DSN(), "sqlite"),
WriteKey: "jk32cFATnj9GKbQdFL7fBB9qtKZdX9j7",
WhitelistedPaths: []string{
"/",
healthx.AliveCheckPath,
healthx.ReadyCheckPath,
healthx.VersionPath,
relationtuplesconnect.VersionServiceGetVersionProcedure,
"/" + grpchealth.HealthV1ServiceName + "/Check",
"/" + grpchealth.HealthV1ServiceName + "/Watch",
relationtuple.ReadRouteBase,
relationtuplesconnect.ReadServiceListRelationTuplesProcedure,
check.RouteBase,
relationtuplesconnect.CheckServiceCheckProcedure,
relationtuplesconnect.CheckServiceBatchCheckProcedure,
expand.RouteBase,
relationtuplesconnect.ExpandServiceExpandProcedure,
relationtuplesconnect.WriteServiceTransactRelationTuplesProcedure,
relationtuplesconnect.WriteServiceDeleteRelationTuplesProcedure,
},
BuildVersion: config.Version,
BuildHash: config.Commit,
BuildTime: config.Date,
Config: &analytics.Config{
Endpoint: "https://sqa.ory.sh",
GzipCompressionLevel: 6,
BatchMaxSize: 500 * 1000,
BatchSize: 1000,
Interval: time.Hour * 6,
},
Hostname: host,
},
)
}
func (r *RegistryDefault) ServeAllSQA(cmd *cobra.Command) error {
r.enableSqa(cmd)
return r.ServeAll(cmd.Context())
}
func (r *RegistryDefault) ServeAll(ctx context.Context) error {
innerCtx, cancel := context.WithCancel(ctx)
defer cancel()
serveFuncs := []func(context.Context, chan<- struct{}) func() error{
r.serveRead,
r.serveWrite,
r.serveOPLSyntax,
r.serveMetrics,
}
doneShutdown := make(chan struct{}, len(serveFuncs))
go func() {
osSignals := make(chan os.Signal, 1)
signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM)
select {
case <-osSignals:
cancel()
case <-innerCtx.Done():
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), graceful.DefaultShutdownTimeout)
defer cancel()
nWaitingForShutdown := len(serveFuncs)
for {
select {
case <-ctx.Done():
return
case <-doneShutdown:
nWaitingForShutdown--
if nWaitingForShutdown == 0 {
// graceful shutdown done
return
}
}
}
}()
eg := &errgroup.Group{}
// We need to separate the setup (invoking the functions that return the serve functions) from running the serve
// functions to mitigate race conditions in the HTTP router.
for _, serve := range serveFuncs {
eg.Go(serve(innerCtx, doneShutdown))
}
return eg.Wait()
}
func (r *RegistryDefault) serveRead(ctx context.Context, done chan<- struct{}) func() error {
rt := r.ReadRouter(ctx)
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
rt = otelx.NewMiddleware(rt, "serveRead",
otelhttp.WithTracerProvider(tracer.Provider()),
)
}
return func() error {
addr, listenFile := r.Config(ctx).ReadAPIListenOn()
return servePort(ctx, r.Logger().WithField("endpoint", "read"), addr, listenFile, rt, done)
}
}
func (r *RegistryDefault) serveWrite(ctx context.Context, done chan<- struct{}) func() error {
rt := r.WriteRouter(ctx)
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
rt = otelx.NewMiddleware(rt, "serveWrite",
otelhttp.WithTracerProvider(tracer.Provider()),
)
}
return func() error {
addr, listenFile := r.Config(ctx).WriteAPIListenOn()
return servePort(ctx, r.Logger().WithField("endpoint", "write"), addr, listenFile, rt, done)
}
}
func (r *RegistryDefault) serveOPLSyntax(ctx context.Context, done chan<- struct{}) func() error {
rt := r.OPLSyntaxRouter(ctx)
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
rt = otelx.NewMiddleware(rt, "serveOPLSyntax",
otelhttp.WithTracerProvider(tracer.Provider()),
)
}
return func() error {
addr, listenFile := r.Config(ctx).OPLSyntaxAPIListenOn()
return servePort(ctx, r.Logger().WithField("endpoint", "opl"), addr, listenFile, rt, done)
}
}
func (r *RegistryDefault) serveMetrics(ctx context.Context, done chan<- struct{}) func() error {
ctx, cancel := context.WithCancel(ctx)
//nolint:gosec // graceful.WithDefaults already sets a timeout
s := graceful.WithDefaults(&http.Server{
Handler: r.metricsRouter(ctx),
})
return func() error {
defer cancel()
eg := &errgroup.Group{}
addr, listenFile := r.Config(ctx).MetricsListenOn()
l, err := listenAndWriteFile(ctx, addr, listenFile)
if err != nil {
return err
}
eg.Go(func() error {
if err := s.Serve(l); !errors.Is(err, http.ErrServerClosed) {
return errors.WithStack(err)
}
return nil
})
eg.Go(func() (err error) {
defer func() {
l := r.Logger().WithField("endpoint", "metrics")
if err != nil {
l.WithError(err).Error("graceful shutdown failed")
} else {
l.Info("gracefully shutdown server")
}
done <- struct{}{}
}()
<-ctx.Done()
ctx, cancel := context.WithTimeout(context.Background(), graceful.DefaultShutdownTimeout)
defer cancel()
return s.Shutdown(ctx)
})
return eg.Wait()
}
}
func servePort(ctx context.Context, log *logrusx.Logger, addr, listenFile string, router http.Handler, done chan<- struct{}) error {
l, err := listenAndWriteFile(ctx, addr, listenFile)
if err != nil {
return err
}
p := new(http.Protocols)
p.SetHTTP1(true)
// For gRPC clients, it's convenient to support HTTP/2 without TLS.
p.SetUnencryptedHTTP2(true)
p.SetHTTP2(true)
//nolint:gosec // graceful.WithDefaults already sets a timeout
restS := graceful.WithDefaults(&http.Server{
Handler: router,
Protocols: p,
})
eg := &errgroup.Group{}
eg.Go(func() error {
if err := restS.Serve(l); !errors.Is(err, http.ErrServerClosed) {
return errors.WithStack(err)
}
return nil
})
eg.Go(func() (err error) {
defer func() {
if err != nil {
log.WithError(err).Error("graceful shutdown failed")
} else {
log.Info("gracefully shutdown server")
}
done <- struct{}{}
}()
<-ctx.Done()
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), graceful.DefaultShutdownTimeout)
defer cancel()
shutdownEg := errgroup.Group{}
shutdownEg.Go(func() error {
// we ignore net.ErrClosed, because a cmux listener's close func is actually the one of the root listener (which is closed in a racy fashion)
if err := restS.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
// unexpected error
return errors.WithStack(err)
}
return nil
})
shutdownEg.Go(func() error {
gracefulDone := make(chan struct{})
go func() {
close(gracefulDone)
}()
select {
case <-gracefulDone:
return nil
case <-ctx.Done():
return errors.New("graceful stop of gRPC server timed out, had to force it")
}
})
return shutdownEg.Wait()
})
return eg.Wait()
}
func (r *RegistryDefault) allHandlers() []Handler {
return []Handler{
relationtuple.NewReadHandler(r),
relationtuple.NewWriteHandler(r),
check.NewHandler(r),
expand.NewHandler(r),
namespacehandler.New(r),
schema.NewHandler(r),
}
}
func listenAndWriteFile(ctx context.Context, addr, listenFile string) (net.Listener, error) {
l, err := (&net.ListenConfig{}).Listen(ctx, "tcp", addr)
if err != nil {
return nil, errors.WithStack(fmt.Errorf("unable to listen on %q: %w", addr, err))
}
const filePrefix = "file://"
if strings.HasPrefix(listenFile, filePrefix) {
if err := os.WriteFile(listenFile[len(filePrefix):], []byte(l.Addr().String()), 0o600); err != nil {
return nil, errors.WithStack(fmt.Errorf("unable to write listen file %q: %w", listenFile, err))
}
}
return l, nil
}
var httpMetrics = prometheusx.NewHTTPMetrics("keto", prometheusx.HTTPPrefix, config.Version, config.Commit, config.Date)
func (r *RegistryDefault) ReadRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
router := httprouterx.NewRouterPublic()
n := negroni.New(
recovery,
httpMetrics,
)
for _, f := range r.defaultHttpMiddlewares {
n.UseFunc(f)
}
n.UseFunc(semconv.Middleware)
n.Use(reqlog.NewMiddlewareFromLogger(r.l, "read#Ory Keto").ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath))
prometheusx.SetMuxRoutes(router)
r.HealthHandler().SetHealthRoutes(router, false)
r.HealthHandler().SetVersionRoutes(router)
for _, h := range registerReflectionAndHealth[ReadHandler](r, router) {
h.RegisterReadRoutes(router)
}
if r.sqaService != nil {
n.Use(r.sqaService)
}
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("read")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func (r *RegistryDefault) WriteRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
router := httprouterx.NewRouterAdmin()
n := negroni.New(
recovery,
httpMetrics,
)
for _, f := range r.defaultHttpMiddlewares {
n.UseFunc(f)
}
n.UseFunc(semconv.Middleware)
n.Use(reqlog.NewMiddlewareFromLogger(r.l, "write#Ory Keto").ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath))
prometheusx.SetMuxRoutes(router)
r.HealthHandler().SetHealthRoutes(router, false)
r.HealthHandler().SetVersionRoutes(router)
for _, h := range registerReflectionAndHealth[WriteHandler](r, router) {
h.RegisterWriteRoutes(router)
}
if r.sqaService != nil {
n.Use(r.sqaService)
}
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("write")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func (r *RegistryDefault) OPLSyntaxRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
router := httprouterx.NewRouter()
n := negroni.New(
recovery,
httpMetrics,
)
for _, f := range r.defaultHttpMiddlewares {
n.UseFunc(f)
}
n.UseFunc(semconv.Middleware)
n.Use(reqlog.NewMiddlewareFromLogger(r.l, "syntax#Ory Keto").ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath))
prometheusx.SetMuxRoutes(router)
r.HealthHandler().SetHealthRoutes(router, false)
r.HealthHandler().SetVersionRoutes(router)
for _, h := range registerReflectionAndHealth[OPLSyntaxHandler](r, router) {
h.RegisterSyntaxRoutes(router)
}
if r.sqaService != nil {
n.Use(r.sqaService)
}
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("write")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func (r *RegistryDefault) recoveryHandler(_ context.Context, spec connect.Spec, _ http.Header, val any) error {
r.Logger().
WithField("reason", val).
WithField("procedure", spec.Procedure).
WithField("stack_trace", string(debug.Stack())).
Error("panic recovered")
return connect.NewError(connect.CodeInternal, fmt.Errorf("%v", val))
}
func (r *RegistryDefault) HandlerOptions() []connect.HandlerOption {
return append([]connect.HandlerOption{
connect.WithRecover(r.recoveryHandler),
connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
resp, err := next(ctx, req)
if hErr, ok := stderrors.AsType[*herodot.DefaultError](err); ok {
cErr := connect.NewError(connect.Code(hErr.GRPCCodeField), hErr)
if reason := hErr.Reason(); reason != "" {
d, err := connect.NewErrorDetail(&errdetails.ErrorInfo{Reason: reason})
if err == nil {
cErr.AddDetail(d)
}
}
return resp, cErr
}
return resp, err
}
})),
connect.WithInterceptors(r.defaultConnectInterceptors...),
},
r.defaultHandlerOptions...)
}
func (r *RegistryDefault) metricsRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
n := negroni.New(
recovery,
reqlog.NewMiddlewareFromLogger(r.Logger(), "keto").ExcludePaths(prometheusx.MetricsPrometheusPath),
)
router := http.NewServeMux()
prometheusx.SetMuxRoutes(router)
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("metrics")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func registerReflectionAndHealth[H Handler](r *RegistryDefault, router httprouterx.Router) (handlers []H) {
protoFiles := &protoregistry.Files{}
var protoServices []string
for _, h := range r.allHandlers() {
if h, ok := h.(H); ok {
handlers = append(handlers, h)
for _, f := range h.ProtoFiles() {
if err := protoFiles.RegisterFile(f); err != nil && !strings.Contains(err.Error(), "is already registered") {
r.Logger().WithError(err).
WithField("file_path", f.Path()).
Error("unable to register proto file for reflection")
}
s := f.Services()
for i := range s.Len() {
protoServices = append(protoServices, string(s.Get(i).FullName()))
}
}
}
}
router.Handle(relationtuplesconnect.NewVersionServiceHandler(r, r.HandlerOptions()...))
protoServices = append(protoServices, relationtuplesconnect.VersionServiceName)
if err := protoFiles.RegisterFile(rts.File_ory_keto_relation_tuples_v1alpha2_version_proto); err != nil {
r.Logger().WithError(err).Error("unable to register version proto file for reflection")
}
reflector := grpcreflect.NewReflector(
grpcreflect.NamerFunc(func() []string { return protoServices }),
grpcreflect.WithDescriptorResolver(protoFiles),
)
router.Handle(grpcreflect.NewHandlerV1(reflector, r.HandlerOptions()...))
router.Handle(grpcreflect.NewHandlerV1Alpha(reflector, r.HandlerOptions()...))
router.Handle(grpchealth.NewHandler(grpchealth.NewStaticChecker(protoServices...), r.HandlerOptions()...))
return
}

View File

@@ -0,0 +1,182 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"testing"
"time"
"connectrpc.com/connect"
ioprometheusclient "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/status"
prometheus "github.com/ory/x/prometheusx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
)
func TestScrapingEndpoint(t *testing.T) {
t.Parallel()
r := NewSqliteTestRegistry(t)
getAddr := UseDynamicPorts(t, r)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
eg := errgroup.Group{}
doneShutdown := make(chan struct{})
eg.Go(r.serveRead(ctx, doneShutdown))
eg.Go(r.serveMetrics(ctx, doneShutdown))
_, readPort, _ := getAddr(t, "read")
_, metricsPort, _ := getAddr(t, "metrics")
t.Logf("write port: %s, metrics port: %s", readPort, metricsPort)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%s", readPort), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close()) }()
cl := grpcHealthV1.NewHealthClient(conn)
resp, err := cl.Check(ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
assert.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING, resp.Status)
}, 2*time.Second, 10*time.Millisecond)
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%s", readPort), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close()) }()
cl := rts.NewReadServiceClient(conn)
resp, err := cl.ListRelationTuples(ctx, &rts.ListRelationTuplesRequest{RelationQuery: &rts.RelationQuery{}})
require.NoError(t, err)
require.Len(t, resp.RelationTuples, 0)
// A dedicated transport keeps httptest.Server.Close in parallel tests from
// breaking these requests via http.DefaultTransport.CloseIdleConnections.
transport := http.DefaultTransport.(*http.Transport).Clone()
t.Cleanup(transport.CloseIdleConnections)
hc := &http.Client{Transport: transport}
respx, err := hc.Get(fmt.Sprintf("http://127.0.0.1:%s/relation-tuples", readPort))
require.NoError(t, err)
t.Cleanup(func() { _ = respx.Body.Close() })
require.EqualValues(t, http.StatusOK, respx.StatusCode)
body, err := io.ReadAll(respx.Body)
require.NoError(t, err)
require.Contains(t, string(body), `"relation_tuples":[]`)
promresp, err := hc.Get(fmt.Sprintf("http://127.0.0.1:%s%s", metricsPort, prometheus.MetricsPrometheusPath))
require.NoError(t, err)
t.Cleanup(func() { _ = promresp.Body.Close() })
require.EqualValues(t, http.StatusOK, promresp.StatusCode)
promrespBody, err := io.ReadAll(promresp.Body)
require.NoError(t, err)
textParser := expfmt.NewTextParser(model.UTF8Validation)
text, err := textParser.TextToMetricFamilies(bytes.NewReader(promrespBody))
require.NoError(t, err)
require.EqualValuesf(t, "http_requests_total", *text["http_requests_total"].Name, "%s", promrespBody)
require.ElementsMatch(t,
[]string{"/grpc.health.v1.Health/", "/ory.keto.relation_tuples.v1alpha2.ReadService/ListRelationTuples", "/relation-tuples"},
getLabelValues("endpoint", text["http_requests_total"].Metric),
)
cancel()
<-doneShutdown
<-doneShutdown
require.NoError(t, eg.Wait())
}
type panicInterceptor struct{}
func (panicInterceptor) WrapUnary(connect.UnaryFunc) connect.UnaryFunc {
return func(context.Context, connect.AnyRequest) (connect.AnyResponse, error) { panic("test panic") }
}
func (panicInterceptor) WrapStreamingClient(connect.StreamingClientFunc) connect.StreamingClientFunc {
return func(context.Context, connect.Spec) connect.StreamingClientConn { panic("test panic") }
}
func (panicInterceptor) WrapStreamingHandler(connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return func(context.Context, connect.StreamingHandlerConn) error { panic("test panic") }
}
var _ connect.Interceptor = (*panicInterceptor)(nil)
func TestPanicRecovery(t *testing.T) {
t.Parallel()
r := NewSqliteTestRegistry(t, WithHandlerOptions(connect.WithInterceptors(panicInterceptor{})))
getAddr := UseDynamicPorts(t, r)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
eg := errgroup.Group{}
doneShutdown := make(chan struct{})
eg.Go(r.serveWrite(ctx, doneShutdown))
_, port, _ := getAddr(t, "write")
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%s", port), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close()) }()
assert.EventuallyWithT(t, func(t *assert.CollectT) {
cl := grpcHealthV1.NewHealthClient(conn)
watcher, err := cl.Watch(ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
require.NoError(t, watcher.CloseSend())
for err := status.Error(codes.Unavailable, "init"); status.Code(err) != codes.Unavailable; _, err = watcher.Recv() {
}
}, 2*time.Second, 10*time.Millisecond)
cl := grpcHealthV1.NewHealthClient(conn)
// we want to ensure the server is still running after the panic
for range 10 {
// Unary call
resp, err := cl.Check(ctx, &grpcHealthV1.HealthCheckRequest{})
require.Errorf(t, err, "%+v", resp)
assert.Equal(t, codes.Internal, status.Code(err))
// Streaming call
wResp, err := cl.Watch(ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
err = wResp.RecvMsg(nil)
require.Error(t, err)
assert.Equal(t, codes.Internal, status.Code(err))
}
cancel()
<-doneShutdown
require.NoError(t, eg.Wait())
}
func getLabelValues(name string, metric []*ioprometheusclient.Metric) (vals []string) {
for _, m := range metric {
for _, label := range m.Label {
if *label.Name == name {
vals = append(vals, *label.Value)
}
}
}
return vals
}

View File

@@ -0,0 +1,40 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"github.com/sirupsen/logrus"
)
// InterceptorLogger adapts logrus logger to interceptor logger.
// This code is simple enough to be copied and not imported.
//
// Source from: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/main/interceptors/logging/examples/logrus/example_test.go
func InterceptorLogger(l logrus.FieldLogger) logging.Logger {
return logging.LoggerFunc(func(_ context.Context, lvl logging.Level, msg string, fields ...any) {
f := make(map[string]any, len(fields)/2)
i := logging.Fields(fields).Iterator()
if i.Next() {
k, v := i.At()
f[k] = v
}
l := l.WithFields(f)
switch lvl {
case logging.LevelDebug:
l.Debug(msg)
case logging.LevelInfo:
l.Info(msg)
case logging.LevelWarn:
l.Warn(msg)
case logging.LevelError:
l.Error(msg)
default:
l.Info(msg)
}
})
}

View File

@@ -0,0 +1,84 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"time"
"github.com/cenkalti/backoff/v3"
"github.com/pkg/errors"
"github.com/ory/pop/v6"
"github.com/ory/x/sqlcon"
)
func (r *RegistryDefault) PopConnectionWithOpts(ctx context.Context, popOpts ...func(*pop.ConnectionDetails)) (*pop.Connection, error) {
pool, idlePool, connMaxLifetime, connMaxIdleTime, cleanedDSN := sqlcon.ParseConnectionOptions(r.Logger(), r.Config(ctx).DSN())
connDetails := &pop.ConnectionDetails{
URL: sqlcon.FinalizeDSN(r.Logger(), cleanedDSN),
IdlePool: idlePool,
ConnMaxLifetime: connMaxLifetime,
ConnMaxIdleTime: connMaxIdleTime,
Pool: pool,
TracerProvider: r.Tracer(ctx).Provider(),
}
for _, o := range popOpts {
o(connDetails)
}
bc := backoff.NewExponentialBackOff()
bc.MaxElapsedTime = time.Minute * 5
bc.Reset()
var conn *pop.Connection
if err := backoff.Retry(func() (err error) {
conn, err = pop.NewConnection(connDetails)
if err != nil {
r.Logger().WithError(err).Error("Unable to connect to database, retrying.")
return errors.WithStack(err)
}
if err := conn.Open(); err != nil {
r.Logger().WithError(err).Error("Unable to open the database connection, retrying.")
return errors.WithStack(err)
}
if err := conn.Store.(interface{ Ping() error }).Ping(); err != nil {
r.Logger().WithError(err).Error("Unable to ping the database connection, retrying.")
return errors.WithStack(err)
}
return nil
}, bc); err != nil {
return nil, errors.WithStack(err)
}
// Wrap the connection with the context before starting the close
// goroutine. WithContext reads conn.Store (via copy), and conn.Close sets
// conn.Store to nil; if the context is already canceled, the goroutine
// would otherwise race the read. Ordering the read first makes it
// happen-before the goroutine starts.
wrapped := conn.WithContext(ctx)
// Close this connection when the context is closed.
go func() {
<-ctx.Done()
if err := conn.Close(); err != nil {
r.Logger().WithError(err).Error("Unable to close database connection")
}
}()
return wrapped, nil
}
// PopConnection returns the standard connection that is kept for the whole time.
func (r *RegistryDefault) PopConnection(ctx context.Context) (*pop.Connection, error) {
if r.conn == nil {
var err error
r.conn, err = r.PopConnectionWithOpts(ctx, r.dbOpts...)
return r.conn, err
}
return r.conn, nil
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"net/http"
"connectrpc.com/connect"
"github.com/ory/pop/v6"
"github.com/ory/x/healthx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/spf13/cobra"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/persistence"
"github.com/ory/keto/internal/relationtuple"
)
type (
Registry interface {
Init(context.Context) error
config.Provider
logrusx.Provider
httpx.WriterProvider
relationtuple.ManagerProvider
relationtuple.MapperProvider
expand.EngineProvider
check.CheckerProvider
check.EngineProvider
persistence.Migrator
persistence.Provider
PopConnection(ctx context.Context) (*pop.Connection, error)
PopConnectionWithOpts(ctx context.Context, f ...func(*pop.ConnectionDetails)) (*pop.Connection, error)
HealthHandler() *healthx.Handler
Tracer(ctx context.Context) *otelx.Tracer
ReadRouter(ctx context.Context) http.Handler
WriteRouter(ctx context.Context) http.Handler
OPLSyntaxRouter(ctx context.Context) http.Handler
ServeAll(ctx context.Context) error
ServeAllSQA(cmd *cobra.Command) error
HandlerOptions() []connect.HandlerOption
}
contextKeys string
)
const (
LogrusHookContextKey contextKeys = "logrus hook"
RegistryContextKey contextKeys = "registry"
)

View File

@@ -0,0 +1,392 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"io/fs"
"maps"
"net/http"
"sync"
"connectrpc.com/connect"
"github.com/gofrs/uuid"
"github.com/ory/herodot"
"github.com/pkg/errors"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/health"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/ory/pop/v6"
"github.com/ory/x/contextx"
"github.com/ory/x/dbal"
"github.com/ory/x/fsx"
"github.com/ory/x/healthx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/metricsx"
"github.com/ory/x/networkx"
"github.com/ory/x/otelx"
"github.com/ory/x/popx"
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/check"
"github.com/ory/keto/internal/check/step"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/persistence"
"github.com/ory/keto/internal/persistence/sql"
"github.com/ory/keto/internal/persistence/sql/migrations/uuidmapping"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoctx"
)
var (
_ relationtuple.ManagerProvider = (*RegistryDefault)(nil)
_ relationtuple.MapperProvider = (*RegistryDefault)(nil)
_ relationtuple.MappingManagerProvider = (*RegistryDefault)(nil)
_ httpx.WriterProvider = (*RegistryDefault)(nil)
_ logrusx.Provider = (*RegistryDefault)(nil)
_ Registry = (*RegistryDefault)(nil)
_ relationtuplesconnect.VersionServiceHandler = (*RegistryDefault)(nil)
)
type (
RegistryDefault struct {
relationtuplesconnect.UnimplementedVersionServiceHandler
p persistence.Persister
mb *popx.MigrationBox
extraMigrations []fs.FS
l *logrusx.Logger
w herodot.Writer
ce *check.Engine
ck *step.Executor
ee *expand.Engine
c *config.Config
conn *pop.Connection
ctxer contextx.Contextualizer
mapper *relationtuple.Mapper
readOnlyMapper *relationtuple.Mapper
replaceMapperNs func(ctx context.Context) uuid.UUID
replaceShardID func() uuid.UUID
init1, init2 sync.Once
init1err, init2err error
healthH *healthx.Handler
healthServer *health.Server
sqaService *metricsx.Service
tracer *otelx.Tracer
tracerWrapper ketoctx.TracerWrapper
defaultHandlerOptions []connect.HandlerOption
defaultConnectInterceptors []connect.Interceptor
defaultHttpMiddlewares []func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc)
grpcTransportCredentials credentials.TransportCredentials
defaultMigrationOptions []popx.MigrationBoxOption
healthReadyCheckers healthx.ReadyCheckers
dbOpts []func(details *pop.ConnectionDetails)
}
ReadHandler interface {
Handler
RegisterReadRoutes(r *httprouterx.RouterPublic)
}
WriteHandler interface {
Handler
RegisterWriteRoutes(r *httprouterx.RouterAdmin)
}
OPLSyntaxHandler interface {
Handler
RegisterSyntaxRoutes(r httprouterx.Router)
}
Handler interface {
ProtoFiles() []protoreflect.FileDescriptor
}
)
func (r *RegistryDefault) Mapper() *relationtuple.Mapper {
if r.mapper == nil {
r.mapper = &relationtuple.Mapper{D: r}
}
return r.mapper
}
func (r *RegistryDefault) ReadOnlyMapper() *relationtuple.Mapper {
if r.readOnlyMapper == nil {
r.readOnlyMapper = &relationtuple.Mapper{D: r, ReadOnly: true}
}
return r.readOnlyMapper
}
func (r *RegistryDefault) Contextualizer() contextx.Contextualizer {
return r.ctxer
}
func (r *RegistryDefault) Config(ctx context.Context) *config.Config {
if provider := r.ctxer.Config(ctx, r.c.Source()); provider != r.c.Source() {
return config.New(ctx, r.Logger(), provider)
}
return r.c
}
func (r *RegistryDefault) HealthHandler() *healthx.Handler {
if r.healthH != nil {
return r.healthH
}
h := healthx.ReadyCheckers{
"database": func(req *http.Request) error {
return r.Persister().Connection(req.Context()).Store.PingContext(req.Context())
},
"migrations": func(req *http.Request) error {
mb, err := r.MigrationBox(req.Context())
if err != nil {
return err
}
status, err := mb.Status(req.Context())
if err != nil {
return err
}
if status.HasPending() {
return errors.Errorf("migrations have not yet been fully applied")
}
return nil
},
}
maps.Copy(h, r.healthReadyCheckers) // possibly override default checkers
r.healthH = healthx.NewHandler(r.Writer(), config.Version, h)
return r.healthH
}
func (r *RegistryDefault) HealthServer() *health.Server {
if r.healthServer == nil {
r.healthServer = health.NewServer()
}
return r.healthServer
}
func (r *RegistryDefault) GetVersion(_ context.Context, _ *connect.Request[rts.GetVersionRequest]) (*connect.Response[rts.GetVersionResponse], error) {
return connect.NewResponse(&rts.GetVersionResponse{Version: config.Version}), nil
}
func (r *RegistryDefault) Tracer(ctx context.Context) *otelx.Tracer {
if r.tracer == nil {
// Tracing is initialized only once, so it can not be hot reloaded or context-aware.
t, err := otelx.New("Ory Keto", r.Logger(), r.Config(ctx).TracingConfig())
if err != nil {
r.Logger().WithError(err).Fatalf("Unable to initialize Tracer.")
}
// Wrap the tracer if required
if r.tracerWrapper != nil {
t = r.tracerWrapper(t)
}
r.tracer = t
}
return r.tracer
}
func (r *RegistryDefault) Logger() *logrusx.Logger {
if r.l == nil {
r.l = logrusx.New("Ory Keto", config.Version)
}
return r.l
}
func (r *RegistryDefault) Writer() herodot.Writer {
if r.w == nil {
r.w = herodot.NewJSONWriter(r.Logger())
}
return r.w
}
func (r *RegistryDefault) RelationTupleManager() relationtuple.Manager {
if r.p == nil {
panic("no relation tuple manager, but expected to have one")
}
return r.p
}
func (r *RegistryDefault) MappingManager() relationtuple.MappingManager {
if r.p == nil {
panic("no relation tuple manager, but expected to have one")
}
return r.p
}
func (r *RegistryDefault) Persister() persistence.Persister {
if r.p == nil {
panic("no persister, but expected to have one")
}
return r.p
}
func (r *RegistryDefault) NetworkID(ctx context.Context) uuid.UUID {
if r.p == nil {
panic("no persister, but expected to have one")
}
return r.p.NetworkID(ctx)
}
func (r *RegistryDefault) NewShardID() uuid.UUID {
if r.replaceShardID != nil {
return r.replaceShardID()
}
return uuid.Must(uuid.NewV4())
}
func (r *RegistryDefault) MapperNamespace(ctx context.Context) uuid.UUID {
if r.replaceMapperNs != nil {
return r.replaceMapperNs(ctx)
}
return r.p.NetworkID(ctx)
}
func (r *RegistryDefault) PermissionEngine() *check.Engine {
if r.ce == nil {
r.ce = check.NewEngine(r)
}
return r.ce
}
func (r *RegistryDefault) ExpandEngine() *expand.Engine {
if r.ee == nil {
r.ee = expand.NewEngine(r)
}
return r.ee
}
func (r *RegistryDefault) Checker() check.Checker {
if r.ck == nil {
r.ck = step.NewExecutor(r, step.CallLimitMiddleware())
}
return r.ck
}
func (r *RegistryDefault) MigrationBox(ctx context.Context, opts ...popx.MigrationBoxOption) (*popx.MigrationBox, error) {
if r.mb == nil {
c, err := r.PopConnection(ctx)
if err != nil {
return nil, err
}
namespaces, err := r.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
migrationBoxOptions := append(opts,
popx.WithGoMigrations(uuidmapping.Migrations(namespaces)))
migrationBoxOptions = append(migrationBoxOptions, r.defaultMigrationOptions...)
mb, err := popx.NewMigrationBox(
fsx.Merge(append([]fs.FS{sql.Migrations, networkx.Migrations}, r.extraMigrations...)...),
c, r.Logger(),
migrationBoxOptions...,
)
if err != nil {
return nil, err
}
r.mb = mb
}
return r.mb, nil
}
func (r *RegistryDefault) MigrateUp(ctx context.Context) error {
mb, err := r.MigrationBox(ctx)
if err != nil {
return err
}
if err := mb.Up(ctx); err != nil {
return err
}
return r.Init(ctx)
}
func (r *RegistryDefault) MigrateDown(ctx context.Context) error {
mb, err := r.MigrationBox(ctx)
if err != nil {
return err
}
return mb.Down(ctx, -1)
}
func (r *RegistryDefault) DetermineNetwork(ctx context.Context) (*networkx.Network, error) {
c, err := r.PopConnection(ctx)
if err != nil {
return nil, err
}
mb, err := popx.NewMigrationBox(networkx.Migrations, c, r.Logger())
if err != nil {
return nil, err
}
s, err := mb.Status(ctx)
if err != nil {
return nil, err
}
if s.HasPending() {
return nil, errors.WithStack(persistence.ErrNetworkMigrationsMissing)
}
return networkx.Determine(c.WithContext(ctx))
}
func (r *RegistryDefault) InitWithoutNetworkID(ctx context.Context) error {
r.init1.Do(func() {
if dbal.IsMemorySQLite(r.Config(ctx).DSN()) {
mb, err := r.MigrationBox(ctx)
if err != nil {
r.init1err = err
return
}
if err := mb.Up(ctx); err != nil {
r.init1err = err
return
}
}
p, err := sql.NewPersister(ctx, r, uuid.Nil)
if err != nil {
r.init1err = err
return
}
r.p = p
r.initEngines()
})
return r.init1err
}
func (r *RegistryDefault) initEngines() {
_ = r.PermissionEngine()
_ = r.ExpandEngine()
_ = r.Checker()
}
func (r *RegistryDefault) Init(ctx context.Context) (err error) {
r.init2.Do(func() {
if err := r.InitWithoutNetworkID(ctx); err != nil {
r.init2err = err
return
}
network, err := r.DetermineNetwork(ctx)
if err != nil {
r.init2err = err
return
}
r.p.SetNetwork(network.ID)
})
return r.init2err
}

View File

@@ -0,0 +1,242 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"fmt"
"os"
"sync"
"testing"
"connectrpc.com/connect"
"github.com/gofrs/uuid"
"github.com/ory/x/contextx"
"github.com/ory/x/configx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/tlsx"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/credentials"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/x/dbx"
"github.com/ory/keto/ketoctx"
)
// createFile writes the content to a temporary file, returning the path.
// Good for testing config files.
func createFile(t testing.TB, content string) (path string) {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "config-*.yaml")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(f.Name()) }) //nolint:gosec // Test file path from os.CreateTemp, not user input.
n, err := f.WriteString(content)
if err != nil {
t.Fatal(err)
}
if n != len(content) {
t.Fatal("failed to write the complete content")
}
return f.Name()
}
func NewDefaultRegistry(ctx context.Context, flags *pflag.FlagSet, withoutNetwork bool, opts []ketoctx.Option) (Registry, error) {
reg, ok := ctx.Value(RegistryContextKey).(Registry)
if ok {
return reg, nil
}
options := ketoctx.Options(opts...)
l := options.Logger()
if l == nil {
l = newLogger(ctx)
}
c := config.New(ctx, l, nil)
cp, err := config.NewProvider(ctx, flags, c)
if err != nil {
return nil, errors.Wrap(err, "unable to initialize config provider")
}
c.WithSource(options.Contextualizer().Config(ctx, cp))
r := &RegistryDefault{
c: c,
l: l,
tracerWrapper: options.TracerWrapper,
ctxer: options.Contextualizer(),
defaultConnectInterceptors: options.ConnectInterceptors(),
defaultHttpMiddlewares: options.HTTPMiddlewares(),
extraMigrations: options.ExtraMigrations(),
defaultMigrationOptions: options.MigrationOptions(),
healthReadyCheckers: options.ReadyCheckers(),
dbOpts: options.DBOptionsModifiers(),
}
init := r.Init
if withoutNetwork {
init = r.InitWithoutNetworkID
}
if err := init(ctx); err != nil {
return nil, errors.Wrap(err, "unable to initialize service registry")
}
if inspect := options.Inspect(); inspect != nil {
if err := inspect(r.Persister().Connection(ctx)); err != nil {
return nil, errors.Wrap(err, "inspect")
}
}
return r, nil
}
func NewSqliteTestRegistry(t testing.TB, opts ...TestRegistryOption) *RegistryDefault {
return NewTestRegistry(t, dbx.GetSqlite(t), opts...)
}
type TestRegistryOption func(t testing.TB, r *RegistryDefault)
func WithConfig(key string, value any) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
require.NoError(t, r.c.Set(key, value))
}
}
func WithMapperNamespace(f func(ctx context.Context) uuid.UUID) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
r.replaceMapperNs = f
}
}
// WithOrderedShardID switches shard ID generation to UUID v7 so that inserts
// are monotonically ordered and predictable in tests.
func WithOrderedShardID() TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
r.replaceShardID = func() uuid.UUID { return uuid.Must(uuid.NewV7()) }
}
}
func WithNamespaces(namespaces []*namespace.Namespace) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
require.NoError(t, r.c.Set(config.KeyNamespaces, namespaces))
}
}
func WithOPL(opl string) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
f := createFile(t, opl)
require.NoError(t, r.c.Set(config.KeyNamespaces+".location", "file://"+f))
}
}
func WithHandlerOptions(opts ...connect.HandlerOption) TestRegistryOption {
return func(_ testing.TB, r *RegistryDefault) {
r.defaultHandlerOptions = append(r.defaultHandlerOptions, opts...)
}
}
func WithTracer(tracer trace.Tracer) TestRegistryOption {
return func(_ testing.TB, r *RegistryDefault) {
r.tracer = new(otelx.Tracer).WithOTLP(tracer)
}
}
func WithLogLevel(level string) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
require.NoError(t, r.c.Set("log.level", level))
}
}
type selfSignedCert struct {
once sync.Once
cert *tls.Certificate
err error
}
var sharedTestCert selfSignedCert
func (s *selfSignedCert) generate() {
s.once.Do(func() {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.err = fmt.Errorf("could not create key: %v", err)
return
}
s.cert, err = tlsx.CreateSelfSignedTLSCertificate(key)
if err != nil {
s.err = fmt.Errorf("could not create TLS certificate: %v", err)
}
})
}
func WithSelfSignedTransportCredentials() TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
sharedTestCert.generate()
require.NoError(t, sharedTestCert.err)
r.grpcTransportCredentials = credentials.NewServerTLSFromCert(sharedTestCert.cert)
}
}
func NewTestRegistry(t testing.TB, dsn *dbx.DsnT, opts ...TestRegistryOption) *RegistryDefault {
l := logrusx.New("Ory Keto", "testing")
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ctx = configx.ContextWithConfigOptions(ctx, configx.WithValues(map[string]interface{}{
config.KeyDSN: dsn.Conn,
"log.level": "info",
config.KeyNamespaces: []*namespace.Namespace{},
config.KeySecretsPagination: []string{uuid.Must(uuid.NewV4()).String()},
}))
c, err := config.NewDefault(ctx, nil, l)
require.NoError(t, err)
r := &RegistryDefault{
c: c,
l: l,
ctxer: &contextx.Default{},
}
for _, opt := range opts {
opt(t, r)
}
if dsn.MigrateUp {
require.NoError(t, r.MigrateUp(ctx))
}
require.NoError(t, r.Init(ctx))
return r
}
func newLogger(ctx context.Context) *logrusx.Logger {
hook, ok := ctx.Value(LogrusHookContextKey).(logrus.Hook)
var opts []logrusx.Option
if ok {
opts = append(opts, logrusx.WithHook(hook))
}
return logrusx.New("Ory Keto", config.Version, opts...)
}

View File

@@ -0,0 +1,41 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"testing"
"github.com/ory/x/tlsx"
)
func BenchmarkCertificateGeneration(b *testing.B) {
cases := []struct {
name string
curve elliptic.Curve
}{
{"P256", elliptic.P256()},
{"P224", elliptic.P224()},
{"P384", elliptic.P384()},
{"P521", elliptic.P521()},
}
for _, tc := range cases {
tc := tc
b.Run(tc.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
key, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
if err != nil {
b.Fatalf("could not create key: %v", err)
}
if _, err = tlsx.CreateSelfSignedTLSCertificate(key); err != nil {
b.Fatalf("could not create TLS certificate: %v", err)
}
}
})
}
}

View File

@@ -0,0 +1,84 @@
package driver
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/credentials"
"github.com/ory/keto/internal/driver/config"
)
type GetAddr = func(t testing.TB, endpoint string) (host string, port string, fullAddr string)
func UseDynamicPorts(t testing.TB, r Registry) GetAddr {
t.Helper()
listenDir := t.TempDir()
readListenFile := fmt.Sprintf("%s/read.addr", listenDir)
writeListenFile := fmt.Sprintf("%s/write.addr", listenDir)
metricsListenFile := fmt.Sprintf("%s/metrics.addr", listenDir)
oplListenFile := fmt.Sprintf("%s/opl.addr", listenDir)
require.NoError(t, r.Config(t.Context()).Set(config.KeyReadAPIPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyReadAPIListenFile, "file://"+readListenFile))
require.NoError(t, r.Config(t.Context()).Set(config.KeyWriteAPIPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyWriteAPIListenFile, "file://"+writeListenFile))
require.NoError(t, r.Config(t.Context()).Set(config.KeyMetricsPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyMetricsListenFile, "file://"+metricsListenFile))
require.NoError(t, r.Config(t.Context()).Set(config.KeyOPLSyntaxAPIPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyOPLSyntaxListenFile, "file://"+oplListenFile))
return func(t testing.TB, endpoint string) (string, string, string) {
fp := ""
switch endpoint {
case "read":
fp = readListenFile
case "write":
fp = writeListenFile
case "metrics":
fp = metricsListenFile
case "opl":
fp = oplListenFile
default:
t.Fatalf("unknown endpoint: %q", endpoint)
}
var addr []byte
var host, port string
require.EventuallyWithT(t, func(t *assert.CollectT) {
var err error
addr, err = os.ReadFile(fp)
require.NotEmpty(t, addr)
require.NoError(t, err)
host, port, err = net.SplitHostPort(string(addr))
require.NoError(t, err)
require.NotEmpty(t, host)
require.NotEmpty(t, port)
}, 2*time.Second, 10*time.Millisecond)
return host, port, string(addr)
}
}
func HTTP2TestServer(t testing.TB, handler http.Handler) (*httptest.Server, credentials.TransportCredentials) {
ts := httptest.NewUnstartedServer(handler)
ts.EnableHTTP2 = true
ts.StartTLS()
ts.Client()
t.Cleanup(ts.Close)
certPool := x509.NewCertPool()
certPool.AddCert(ts.Certificate())
return ts, credentials.NewTLS(&tls.Config{RootCAs: certPool})
}

View File

@@ -0,0 +1,155 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
"github.com/ory/herodot"
"github.com/ory/x/cmdx"
gprclient "github.com/ory/keto/cmd/client"
cliexpand "github.com/ory/keto/cmd/expand"
clirelationtuple "github.com/ory/keto/cmd/relationtuple"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/ketoapi"
)
type cliClient struct {
c *cmdx.CommandExecuter
}
func (g *cliClient) queryNamespaces(t testing.TB) (res ketoapi.GetNamespacesResponse) {
t.Skip("not implemented for the CLI")
return
}
var _ client = (*cliClient)(nil)
func (g *cliClient) oplCheckSyntax(t testing.TB, _ []byte) []*ketoapi.ParseError {
t.Skip("not implemented as a command yet")
return []*ketoapi.ParseError{}
}
func (g *cliClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
tupleEnc, err := json.Marshal(r)
require.NoError(t, err)
stdout, stderr, err := g.c.Exec(bytes.NewBuffer(tupleEnc), "relation-tuple", "create", "-f", "-")
require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr)
assert.Len(t, stderr, 0, stdout)
}
func (g *cliClient) assembleQueryFlags(q *ketoapi.RelationQuery, opts []paginationOptionSetter) []string {
var flags []string
if q.Namespace != nil {
flags = append(flags, "--"+clirelationtuple.FlagNamespace, *q.Namespace)
}
if q.SubjectID != nil {
flags = append(flags, "--"+clirelationtuple.FlagSubjectID, *q.SubjectID)
}
if q.SubjectSet != nil {
flags = append(flags, "--"+clirelationtuple.FlagSubjectSet, q.SubjectSet.String())
}
if q.Relation != nil {
flags = append(flags, "--"+clirelationtuple.FlagRelation, *q.Relation)
}
if q.Object != nil {
flags = append(flags, "--"+clirelationtuple.FlagObject, *q.Object)
}
pagination := getPaginationOptions(opts...)
if pagination.Token != "" {
flags = append(flags, "--"+clirelationtuple.FlagPageToken, pagination.Token)
}
if pagination.Size != 0 {
flags = append(flags, "--"+clirelationtuple.FlagPageSize, strconv.Itoa(pagination.Size))
}
return flags
}
func (g *cliClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
out := g.c.ExecNoErr(t, append(g.assembleQueryFlags(q, opts), "relation-tuple", "get")...)
var resp ketoapi.GetResponse
require.NoError(t, json.Unmarshal([]byte(out), &resp), "%s", out)
return &resp
}
func (g *cliClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
_, stdErr, err := g.c.Exec(nil, append(g.assembleQueryFlags(q, opts), "relation-tuple", "get")...)
assert.Error(t, err)
assert.Contains(t, stdErr, expected.GRPCCodeField.String())
assert.Contains(t, stdErr, expected.Error())
}
func (g *cliClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
var sub string
if r.SubjectID != nil {
sub = *r.SubjectID
} else {
sub = r.SubjectSet.String()
}
out := g.c.ExecNoErr(t, "check", sub, r.Relation, r.Namespace+":"+r.Object)
var res rts.CheckResponse
require.NoError(t, json.Unmarshal([]byte(out), &res))
return res.Allowed
}
func (g *cliClient) batchCheckErr(t testing.TB, _ []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
t.Skip("not implemented for the CLI")
}
func (g *cliClient) batchCheck(t testing.TB, r []*ketoapi.RelationTuple) []checkResponse {
t.Skip("not implemented for the CLI")
return nil
}
func (g *cliClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
out := g.c.ExecNoErr(t, "expand", r.Relation, r.Namespace+":"+r.Object, "--"+cliexpand.FlagMaxDepth, fmt.Sprintf("%d", depth), "--"+cmdx.FlagFormat, string(cmdx.FormatJSON))
res := ketoapi.Tree[*ketoapi.RelationTuple]{}
require.NoError(t, json.Unmarshal([]byte(out), &res))
return &res
}
func (g *cliClient) waitUntilLive(t testing.TB) {
flags := make([]string, len(g.c.PersistentArgs))
copy(flags, g.c.PersistentArgs)
for i, f := range flags {
if f == "--"+cmdx.FlagFormat {
flags = append(flags[:i], flags[i+2:]...)
break
}
}
ctx, cancel := context.WithTimeout(g.c.Ctx, time.Minute)
defer cancel()
out := cmdx.ExecNoErrCtx(ctx, t, g.c.New(), append(flags, "status", "--"+gprclient.FlagBlock)...)
require.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING.String()+"\n", out)
}
func (g *cliClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
tupleEnc, err := json.Marshal(r)
require.NoError(t, err)
stdout, stderr, err := g.c.Exec(bytes.NewBuffer(tupleEnc), "relation-tuple", "delete", "-f", "-")
require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr)
assert.Len(t, stderr, 0, stderr)
}
func (g *cliClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
_ = g.c.ExecNoErr(t, append(g.assembleQueryFlags(q, nil), "relation-tuple", "delete-all", "--force")...)
}

View File

@@ -0,0 +1,160 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"fmt"
"io"
"net/http"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/x/cmdx"
prometheus "github.com/ory/x/prometheusx"
"github.com/ory/keto/cmd"
cliclient "github.com/ory/keto/cmd/client"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x/dbx"
"github.com/ory/keto/ketoapi"
)
type (
transactClient interface {
client
transactTuples(t testing.TB, ins []*ketoapi.RelationTuple, del []*ketoapi.RelationTuple)
}
client interface {
createTuple(t testing.TB, r *ketoapi.RelationTuple)
deleteTuple(t testing.TB, r *ketoapi.RelationTuple)
deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery)
queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse
queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter)
check(t testing.TB, r *ketoapi.RelationTuple) bool
batchCheck(t testing.TB, r []*ketoapi.RelationTuple) []checkResponse
batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError)
expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple]
oplCheckSyntax(t testing.TB, content []byte) []*ketoapi.ParseError
waitUntilLive(t testing.TB)
queryNamespaces(t testing.TB) ketoapi.GetNamespacesResponse
}
)
const (
promLogLine = "promhttp_metric_handler_requests_total"
)
func Test(t *testing.T) {
t.Parallel()
for _, dsn := range dbx.GetDSNs(t) {
t.Run(fmt.Sprintf("dsn=%s", dsn.Name), func(t *testing.T) {
t.Parallel()
ctx, reg, namespaceTestMgr, getAddr := newInitializedReg(t, dsn, nil)
closeServer := startServer(ctx, t, reg)
t.Cleanup(closeServer)
_, _, readAddr := getAddr(t, "read")
_, _, writeAddr := getAddr(t, "write")
_, _, oplAddr := getAddr(t, "opl")
_, _, metricsAddr := getAddr(t, "metrics")
// The test cases start here
// We execute every test with all clients available
for _, cl := range []client{
newGrpcClient(t, ctx,
readAddr,
writeAddr,
oplAddr,
),
&restClient{
readURL: "http://" + readAddr,
writeURL: "http://" + writeAddr,
oplSyntaxURL: "http://" + oplAddr,
},
&cliClient{c: &cmdx.CommandExecuter{
New: func() *cobra.Command {
return cmd.NewRootCmd(nil)
},
Ctx: ctx,
PersistentArgs: []string{
"--" + cliclient.FlagReadRemote, readAddr,
"--" + cliclient.FlagWriteRemote, writeAddr,
"--insecure-disable-transport-security=true",
"--" + cmdx.FlagFormat, string(cmdx.FormatJSON),
},
}},
&sdkClient{
readRemote: readAddr,
writeRemote: writeAddr,
syntaxRemote: oplAddr,
},
} {
t.Run(fmt.Sprintf("client=%T", cl), runCases(cl, namespaceTestMgr))
if tc, ok := cl.(transactClient); ok {
t.Run(fmt.Sprintf("transactClient=%T", cl), runTransactionCases(tc, namespaceTestMgr))
}
}
t.Run("case=metrics are served", func(t *testing.T) {
t.Parallel()
newGrpcClient(t, ctx,
readAddr,
writeAddr,
oplAddr,
).waitUntilLive(t)
t.Run("case=on "+prometheus.MetricsPrometheusPath, func(t *testing.T) {
t.Parallel()
resp, err := http.Get(fmt.Sprintf("http://%s%s", metricsAddr, prometheus.MetricsPrometheusPath))
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusOK)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), promLogLine)
})
t.Run("case=not on /", func(t *testing.T) {
t.Parallel()
resp, err := http.Get(fmt.Sprintf("http://%s", metricsAddr))
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusNotFound)
})
})
})
}
}
func TestServeCORS(t *testing.T) {
t.Parallel()
ctx, reg, _, getAddr := newInitializedReg(t, dbx.GetSqlite(t), map[string]any{
"serve.read.cors.enabled": true,
"serve.read.cors.debug": true,
"serve.read.cors.allowed_methods": []string{http.MethodGet},
"serve.read.cors.allowed_origins": []string{"https://ory.sh"},
})
closeServer := startServer(ctx, t, reg)
t.Cleanup(closeServer)
_, _, readAddr := getAddr(t, "read")
waitUntilLive(t, "http://"+readAddr)
t.Log("Health check is ready")
req, err := http.NewRequest(http.MethodOptions, "http://"+readAddr+relationtuple.ReadRouteBase, nil)
require.NoError(t, err)
req.Header.Set("Origin", "https://ory.sh")
req.Header.Set("Access-Control-Request-Method", "GET")
resp, err := http.DefaultClient.Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equalf(t, "https://ory.sh", resp.Header.Get("Access-Control-Allow-Origin"), "%+v", resp.Header)
}

View File

@@ -0,0 +1,285 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"encoding/json"
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/status"
"github.com/ory/herodot"
opl "github.com/ory/keto/gen/go/ory/keto/opl/v1alpha1"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/ketoapi"
)
type grpcClient struct {
read, write, oplSyntax *grpc.ClientConn
ctx context.Context
}
func newGrpcClient(t testing.TB, ctx context.Context, readRemote, writeRemote, oplSyntaxRemote string) *grpcClient {
read, err := grpc.NewClient(readRemote, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, read.Close()) })
write, err := grpc.NewClient(writeRemote, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, write.Close()) })
oplSyntax, err := grpc.NewClient(oplSyntaxRemote, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, oplSyntax.Close()) })
ctx, cancel := context.WithCancel(ctx)
t.Cleanup(cancel)
return &grpcClient{
read: read,
write: write,
oplSyntax: oplSyntax,
ctx: ctx,
}
}
func (g *grpcClient) queryNamespaces(t testing.TB) (apiResponse ketoapi.GetNamespacesResponse) {
client := rts.NewNamespacesServiceClient(g.read)
res, err := client.ListNamespaces(g.ctx, &rts.ListNamespacesRequest{})
require.NoError(t, err)
require.NoError(t, convert(res, &apiResponse))
return
}
var _ transactClient = (*grpcClient)(nil)
func (g *grpcClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
g.transactTuples(t, []*ketoapi.RelationTuple{r}, nil)
}
func (*grpcClient) createQuery(q *ketoapi.RelationQuery) *rts.RelationQuery {
query := &rts.RelationQuery{
Namespace: q.Namespace,
Object: q.Object,
Relation: q.Relation,
}
if q.SubjectID != nil {
query.Subject = rts.NewSubjectID(*q.SubjectID)
} else if q.SubjectSet != nil {
query.Subject = rts.NewSubjectSet(q.SubjectSet.Namespace, q.SubjectSet.Object, q.SubjectSet.Relation)
}
return query
}
func requireInt32WithinBounds(t testing.TB, v int) int32 {
require.GreaterOrEqual(t, v, math.MinInt32)
require.LessOrEqual(t, v, math.MaxInt32)
return int32(v) // #nosec G115 -- value validated above
}
func (g *grpcClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
c := rts.NewReadServiceClient(g.read)
pagination := getPaginationOptions(opts...)
resp, err := c.ListRelationTuples(g.ctx, &rts.ListRelationTuplesRequest{
RelationQuery: g.createQuery(q),
PageToken: pagination.Token,
PageSize: requireInt32WithinBounds(t, pagination.Size),
})
require.NoError(t, err)
irs := make([]*ketoapi.RelationTuple, len(resp.RelationTuples))
for i := range irs {
irs[i], err = (&ketoapi.RelationTuple{}).FromDataProvider(resp.RelationTuples[i])
require.NoError(t, err)
}
return &ketoapi.GetResponse{
RelationTuples: irs,
NextPageToken: resp.NextPageToken,
}
}
func (g *grpcClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
c := rts.NewReadServiceClient(g.read)
pagination := getPaginationOptions(opts...)
_, err := c.ListRelationTuples(g.ctx, &rts.ListRelationTuplesRequest{
RelationQuery: g.createQuery(q),
PageToken: pagination.Token,
PageSize: requireInt32WithinBounds(t, pagination.Size),
})
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, expected.GRPCCodeField, s.Code(), "%+v", err)
}
func (g *grpcClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
c := rts.NewCheckServiceClient(g.read)
req := &rts.CheckRequest{
Tuple: &rts.RelationTuple{
Namespace: r.Namespace,
Object: r.Object,
Relation: r.Relation,
},
}
if r.SubjectID != nil {
req.Tuple.Subject = rts.NewSubjectID(*r.SubjectID)
} else {
req.Tuple.Subject = rts.NewSubjectSet(r.SubjectSet.Namespace, r.SubjectSet.Object, r.SubjectSet.Relation)
}
resp, err := c.Check(g.ctx, req)
require.NoError(t, err)
return resp.Allowed
}
type checkResponse struct {
allowed bool
errorMessage string
}
func (g *grpcClient) batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
_, err := g.doBatchCheck(t, requestTuples)
require.Error(t, err)
s, ok := status.FromError(err)
require.True(t, ok)
assert.Equalf(t, expected.GRPCCodeField, s.Code(), "%+v", err)
assert.Contains(t, s.Message(), expected.Reason())
}
func (g *grpcClient) batchCheck(t testing.TB, requestTuples []*ketoapi.RelationTuple) []checkResponse {
resp, err := g.doBatchCheck(t, requestTuples)
require.NoError(t, err)
checkResponses := make([]checkResponse, len(resp.Results))
for i, r := range resp.Results {
checkResponses[i] = checkResponse{
allowed: r.Allowed,
errorMessage: r.Error,
}
}
return checkResponses
}
func (g *grpcClient) doBatchCheck(_ testing.TB, requestTuples []*ketoapi.RelationTuple) (*rts.BatchCheckResponse, error) {
c := rts.NewCheckServiceClient(g.read)
tuples := make([]*rts.RelationTuple, len(requestTuples))
for i, tuple := range requestTuples {
var subject *rts.Subject
if tuple.SubjectID != nil {
subject = rts.NewSubjectID(*tuple.SubjectID)
} else {
subject = rts.NewSubjectSet(tuple.SubjectSet.Namespace, tuple.SubjectSet.Object, tuple.SubjectSet.Relation)
}
tuples[i] = &rts.RelationTuple{
Namespace: tuple.Namespace,
Object: tuple.Object,
Relation: tuple.Relation,
Subject: subject,
}
}
req := &rts.BatchCheckRequest{
Tuples: tuples,
}
return c.BatchCheck(g.ctx, req)
}
func (g *grpcClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
c := rts.NewExpandServiceClient(g.read)
resp, err := c.Expand(g.ctx, &rts.ExpandRequest{
Subject: rts.NewSubjectSet(r.Namespace, r.Object, r.Relation),
MaxDepth: requireInt32WithinBounds(t, depth),
})
require.NoError(t, err)
return ketoapi.TreeFromProto[*ketoapi.RelationTuple](resp.Tree)
}
func (g *grpcClient) waitUntilLive(t testing.TB) {
require.EventuallyWithT(t, func(t *assert.CollectT) {
c := grpcHealthV1.NewHealthClient(g.read)
res, err := c.Check(g.ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
assert.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING, res.Status)
}, 2*time.Second, 10*time.Millisecond)
}
func (g *grpcClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
g.transactTuples(t, nil, []*ketoapi.RelationTuple{r})
}
func (g *grpcClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
c := rts.NewWriteServiceClient(g.write)
query := &rts.RelationQuery{
Namespace: q.Namespace,
Object: q.Object,
Relation: q.Relation,
}
if q.SubjectID != nil {
query.Subject = rts.NewSubjectID(*q.SubjectID)
}
if q.SubjectSet != nil {
query.Subject = rts.NewSubjectSet(q.SubjectSet.Namespace, q.SubjectSet.Object, q.SubjectSet.Relation)
}
_, err := c.DeleteRelationTuples(g.ctx, &rts.DeleteRelationTuplesRequest{
RelationQuery: query,
})
require.NoError(t, err)
}
func (g *grpcClient) transactTuples(t testing.TB, ins []*ketoapi.RelationTuple, del []*ketoapi.RelationTuple) {
c := rts.NewWriteServiceClient(g.write)
deltas := make([]*rts.RelationTupleDelta, len(ins)+len(del))
for i := range ins {
deltas[i] = &rts.RelationTupleDelta{
RelationTuple: ins[i].ToProto(),
Action: rts.RelationTupleDelta_ACTION_INSERT,
}
}
for i := range del {
deltas[len(ins)+i] = &rts.RelationTupleDelta{
RelationTuple: del[i].ToProto(),
Action: rts.RelationTupleDelta_ACTION_DELETE,
}
}
_, err := c.TransactRelationTuples(g.ctx, &rts.TransactRelationTuplesRequest{
RelationTupleDeltas: deltas,
})
require.NoError(t, err)
}
func (g *grpcClient) oplCheckSyntax(t testing.TB, content []byte) (parseErrors []*ketoapi.ParseError) {
c := opl.NewSyntaxServiceClient(g.oplSyntax)
res, err := c.Check(g.ctx, &opl.CheckRequest{Content: content})
require.NoError(t, err)
raw, err := json.Marshal(res.ParseErrors)
require.NoError(t, err)
err = json.Unmarshal(raw, &parseErrors)
require.NoError(t, err)
return
}

164
internal/e2e/helpers.go Normal file
View File

@@ -0,0 +1,164 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/ory/x/configx"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x/dbx"
)
type namespaceTestManager struct {
sync.Mutex
reg driver.Registry
ctx context.Context
nspaces []*namespace.Namespace
}
func (m *namespaceTestManager) add(t testing.TB, nn ...*namespace.Namespace) {
m.Lock()
defer m.Unlock()
m.nspaces = append(m.nspaces, nn...)
require.NoError(t, m.reg.Config(m.ctx).Set(config.KeyNamespaces, m.nspaces))
t.Cleanup(func() {
for _, n := range nn {
require.NoError(t, m.reg.RelationTupleManager().DeleteAllRelationTuples(m.ctx, &relationtuple.RelationQuery{
Namespace: &n.Name,
}))
}
})
}
func (m *namespaceTestManager) remove(t *testing.T, name string) {
newNamespaces := make([]*namespace.Namespace, 0, len(m.nspaces))
for _, n := range m.nspaces {
if n.Name != name {
newNamespaces = append(newNamespaces, n)
}
}
m.nspaces = newNamespaces
require.NoError(t, m.reg.Config(m.ctx).Set(config.KeyNamespaces, m.nspaces))
}
func newInitializedReg(t testing.TB, dsn *dbx.DsnT, cfgOverwrites map[string]interface{}) (context.Context, driver.Registry, *namespaceTestManager, driver.GetAddr) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
})
flags := pflag.NewFlagSet("", pflag.ContinueOnError)
configx.RegisterConfigFlag(flags, nil)
cfgValues := map[string]interface{}{
config.KeyDSN: dsn.Conn,
"log.level": "debug",
"log.leak_sensitive_values": true,
config.KeyReadAPIHost: "127.0.0.1",
config.KeyWriteAPIHost: "127.0.0.1",
config.KeyOPLSyntaxAPIHost: "127.0.0.1",
config.KeyMetricsHost: "127.0.0.1",
config.KeyNamespaces: []*namespace.Namespace{},
config.KeySecretsPagination: []string{"test pagination secret"},
}
for k, v := range cfgOverwrites {
cfgValues[k] = v
}
cf := dbx.ConfigFile(t, cfgValues)
require.NoError(t, flags.Parse([]string{"--" + configx.FlagConfig, cf}))
reg, err := driver.NewDefaultRegistry(ctx, flags, true, nil)
require.NoError(t, err)
getAddr := driver.UseDynamicPorts(t, reg)
require.NoError(t, reg.MigrateUp(ctx))
assertMigrated(ctx, t, reg)
return ctx, reg, &namespaceTestManager{
reg: reg,
ctx: ctx,
nspaces: []*namespace.Namespace{},
}, getAddr
}
func assertMigrated(ctx context.Context, t testing.TB, r driver.Registry) {
mb, err := r.MigrationBox(ctx)
require.NoError(t, err)
s, err := mb.Status(ctx)
require.NoError(t, err)
assert.False(t, s.HasPending())
}
func startServer(ctx context.Context, t testing.TB, reg driver.Registry) func() {
// Start the server
serverCtx, serverCancel := context.WithCancel(ctx)
serverErr := make(chan error)
go func() {
serverErr <- reg.ServeAll(serverCtx)
}()
// defer this close function to make sure it is shutdown on test failure as well
return func() {
// stop the server
serverCancel()
// wait for it to stop
require.NoError(t, <-serverErr)
}
}
// convert the struct in `from` to the pointer in `to` using JSON marshal and unmarshal. from and toPtr must have the
// same json field tags.
func convert(from, toPtr any) error {
raw, err := json.Marshal(from)
if err != nil {
return err
}
return json.Unmarshal(raw, toPtr)
}
type (
paginationOptions struct {
Token string `json:"page_token"`
Size int `json:"page_size"`
}
paginationOptionSetter func(*paginationOptions) *paginationOptions
)
func withToken(t string) paginationOptionSetter {
return func(opts *paginationOptions) *paginationOptions {
opts.Token = t
return opts
}
}
func withSize(size int) paginationOptionSetter {
return func(opts *paginationOptions) *paginationOptions {
opts.Size = size
return opts
}
}
func getPaginationOptions(modifiers ...paginationOptionSetter) *paginationOptions {
opts := &paginationOptions{}
for _, f := range modifiers {
opts = f(opts)
}
return opts
}

View File

@@ -0,0 +1,222 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/x/healthx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/expand"
httpclient "github.com/ory/keto/internal/httpclient"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
"github.com/ory/keto/schema"
)
var _ client = &restClient{}
type restClient struct {
readURL, writeURL, oplSyntaxURL string
}
func (rc *restClient) queryNamespaces(t testing.TB) (res ketoapi.GetNamespacesResponse) {
body, code := rc.makeRequest(t, http.MethodGet, "/namespaces", "", rc.readURL)
assert.Equal(t, http.StatusOK, code, body)
require.NoError(t, json.Unmarshal([]byte(body), &res))
return
}
func (rc *restClient) oplCheckSyntax(t testing.TB, content []byte) []*ketoapi.ParseError {
body, code := rc.makeRequest(t, http.MethodPost, schema.RouteBase, string(content), rc.oplSyntaxURL)
assert.Equal(t, http.StatusOK, code, body)
var response ketoapi.CheckOPLSyntaxResponse
require.NoError(t, json.Unmarshal([]byte(body), &response))
return response.Errors
}
func (rc *restClient) makeRequest(t testing.TB, method, path, body string, baseURL string) (string, int) {
var b io.Reader
if body != "" {
b = bytes.NewBufferString(body)
}
// t.Logf("Requesting %s %s%s with body %#v", method, baseURL, path, body)
req, err := http.NewRequest(method, baseURL+path, b)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // Test code with controlled URL.
require.NoError(t, err)
respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return string(respBody), resp.StatusCode
}
func (rc *restClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
tEnc, err := json.Marshal(r)
require.NoError(t, err)
body, code := rc.makeRequest(t, http.MethodPut, relationtuple.WriteRouteBase, string(tEnc), rc.writeURL)
assert.Equal(t, http.StatusCreated, code, body)
}
func (rc *restClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
body, code := rc.makeRequest(t, http.MethodDelete, relationtuple.WriteRouteBase+"?"+r.ToURLQuery().Encode(), "", rc.writeURL)
require.Equal(t, http.StatusNoContent, code, body)
}
func (rc *restClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
body, code := rc.makeRequest(t, http.MethodDelete, relationtuple.WriteRouteBase+"?"+q.ToURLQuery().Encode(), "", rc.writeURL)
require.Equal(t, http.StatusNoContent, code, body)
}
func (rc *restClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
urlQuery := q.ToURLQuery()
pagination := getPaginationOptions(opts...)
if pagination.Size != 0 {
urlQuery.Set("page_size", strconv.Itoa(pagination.Size))
}
if pagination.Token != "" {
urlQuery.Set("page_token", pagination.Token)
}
body, code := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", relationtuple.ReadRouteBase, urlQuery.Encode()), "", rc.readURL)
require.Equal(t, http.StatusOK, code, body)
var dec ketoapi.GetResponse
require.NoError(t, json.Unmarshal([]byte(body), &dec))
return &dec
}
func (rc *restClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
urlQuery := q.ToURLQuery()
pagination := getPaginationOptions(opts...)
if pagination.Size != 0 {
urlQuery.Set("page_size", strconv.Itoa(pagination.Size))
}
if pagination.Token != "" {
urlQuery.Set("page_token", pagination.Token)
}
body, code := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", relationtuple.ReadRouteBase, urlQuery.Encode()), "", rc.readURL)
assert.Equal(t, expected.CodeField, code)
assert.Equal(t, int64(expected.StatusCode()), gjson.Get(body, "error.code").Int())
assert.Equal(t, expected.Status(), gjson.Get(body, "error.status").String())
assert.Equal(t, expected.Error(), gjson.Get(body, "error.message").String(), body)
}
func (rc *restClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
q := r.ToURLQuery()
bodyGet, codeGet := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", check.RouteBase, q.Encode()), "", rc.readURL)
var respGet rts.CheckResponse
require.NoError(t, json.Unmarshal([]byte(bodyGet), &respGet))
j, err := json.Marshal(r)
require.NoError(t, err)
bodyPost, codePost := rc.makeRequest(t, http.MethodPost, check.RouteBase, string(j), rc.readURL)
var respPost rts.CheckResponse
require.NoError(t, json.Unmarshal([]byte(bodyPost), &respPost))
if codeGet == http.StatusOK && codePost == http.StatusOK {
assert.Equal(t, true, respGet.Allowed, "%s", bodyGet)
assert.Equal(t, true, respPost.Allowed, "%s", bodyPost)
return true
}
assert.Equal(t, http.StatusForbidden, codeGet, bodyGet)
assert.Equal(t, http.StatusForbidden, codePost, bodyPost)
assert.Equal(t, false, respGet.Allowed)
assert.Equal(t, false, respPost.Allowed)
return false
}
func (rc *restClient) batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
req := httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
}
j, err := json.Marshal(req)
require.NoError(t, err)
body, code := rc.makeRequest(t, http.MethodPost, check.BatchRoute, string(j), rc.readURL)
assert.Equal(t, expected.CodeField, code)
assert.Contains(t, body, expected.Reason())
}
func (rc *restClient) batchCheck(t testing.TB, requestTuples []*ketoapi.RelationTuple) []checkResponse {
req := httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
}
j, err := json.Marshal(req)
require.NoError(t, err)
body, code := rc.makeRequest(t, http.MethodPost, check.BatchRoute, string(j), rc.readURL)
require.Equal(t, http.StatusOK, code, "batch check failed unexpected with error code %d", code)
var respPost rts.BatchCheckResponse
require.NoError(t, json.Unmarshal([]byte(body), &respPost))
responseChecks := make([]checkResponse, len(respPost.Results))
for i, result := range respPost.Results {
responseChecks[i] = checkResponse{
allowed: result.Allowed,
errorMessage: result.Error,
}
}
return responseChecks
}
func (rc *restClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
query := r.ToURLQuery()
query.Set("max-depth", fmt.Sprintf("%d", depth))
body, code := rc.makeRequest(t, http.MethodGet, fmt.Sprintf("%s?%s", expand.RouteBase, query.Encode()), "", rc.readURL)
require.Equal(t, http.StatusOK, code, body)
tree := &ketoapi.Tree[*ketoapi.RelationTuple]{}
require.NoError(t, json.Unmarshal([]byte(body), tree))
return tree
}
func healthReady(t require.TestingT, readURL string) int {
req, err := http.NewRequest("GET", readURL+healthx.ReadyCheckPath, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // Test code with controlled URL.
if err != nil {
return 0
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
func waitUntilLive(t testing.TB, readURL string) {
require.EventuallyWithT(t, func(t *assert.CollectT) {
assert.Equal(t, http.StatusOK, healthReady(t, readURL))
}, 5*time.Second, 10*time.Millisecond)
}
func (rc *restClient) waitUntilLive(t testing.TB) {
waitUntilLive(t, rc.readURL)
}

View File

@@ -0,0 +1,360 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
httpclient "github.com/ory/keto/internal/httpclient"
"github.com/ory/keto/ketoapi"
)
type (
sdkClient struct {
rc, wc, sc *httpclient.APIClient
readRemote, writeRemote, syntaxRemote string
}
)
var _ client = (*sdkClient)(nil)
var requestTimeout = 5 * time.Second
func (c *sdkClient) requestCtx(t testing.TB) context.Context {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
t.Cleanup(cancel)
return ctx
}
func (c *sdkClient) oplCheckSyntax(t testing.TB, content []byte) (parseErrors []*ketoapi.ParseError) {
res, _, err := c.getOPLSyntaxClient().
RelationshipApi.
CheckOplSyntax(c.requestCtx(t)).
Body(string(content)).
Execute()
require.NoError(t, err)
raw, err := json.Marshal(res.Errors)
require.NoError(t, err)
err = json.Unmarshal(raw, &parseErrors)
require.NoError(t, err)
return
}
func (c *sdkClient) getReadClient() *httpclient.APIClient {
if c.rc == nil {
cfg := httpclient.NewConfiguration()
cfg.Host = c.readRemote
cfg.Scheme = "http"
c.rc = httpclient.NewAPIClient(cfg)
}
return c.rc
}
func (c *sdkClient) getWriteClient() *httpclient.APIClient {
if c.wc == nil {
cfg := httpclient.NewConfiguration()
cfg.Host = c.writeRemote
cfg.Scheme = "http"
c.wc = httpclient.NewAPIClient(cfg)
}
return c.wc
}
func (c *sdkClient) getOPLSyntaxClient() *httpclient.APIClient {
if c.sc == nil {
cfg := httpclient.NewConfiguration()
cfg.Host = c.syntaxRemote
cfg.Scheme = "http"
c.sc = httpclient.NewAPIClient(cfg)
}
return c.sc
}
func (c *sdkClient) createTuple(t testing.TB, r *ketoapi.RelationTuple) {
payload := httpclient.CreateRelationshipBody{
Namespace: new(r.Namespace),
Object: new(r.Object),
Relation: new(r.Relation),
SubjectId: r.SubjectID,
}
if r.SubjectID == nil {
payload.SubjectSet = &httpclient.SubjectSet{
Namespace: r.SubjectSet.Namespace,
Object: r.SubjectSet.Object,
Relation: r.SubjectSet.Relation,
}
}
_, _, err := c.getWriteClient().RelationshipApi.
CreateRelationship(c.requestCtx(t)).
CreateRelationshipBody(payload).
Execute()
require.NoError(t, err)
}
func withSubject[P interface {
SubjectId(string) P
SubjectSetNamespace(string) P
SubjectSetObject(string) P
SubjectSetRelation(string) P
}](params P, subID *string, subSet *ketoapi.SubjectSet) P {
if subID != nil {
return params.SubjectId(*subID)
}
if subSet != nil {
return params.
SubjectSetNamespace(subSet.Namespace).
SubjectSetObject(subSet.Object).
SubjectSetRelation(subSet.Relation)
}
return params
}
func (c *sdkClient) deleteTuple(t testing.TB, r *ketoapi.RelationTuple) {
request := c.getWriteClient().RelationshipApi.
DeleteRelationships(c.requestCtx(t)).
Namespace(r.Namespace).
Object(r.Object).
Relation(r.Relation)
request = withSubject(request, r.SubjectID, r.SubjectSet)
_, err := request.Execute()
require.NoError(t, err)
}
func (c *sdkClient) deleteAllTuples(t testing.TB, q *ketoapi.RelationQuery) {
request := c.getWriteClient().RelationshipApi.DeleteRelationships(c.requestCtx(t))
if q.Namespace != nil {
request = request.Namespace(*q.Namespace)
}
if q.Object != nil {
request = request.Object(*q.Object)
}
if q.Relation != nil {
request = request.Relation(*q.Relation)
}
request = withSubject(request, q.SubjectID, q.SubjectSet)
_, err := request.Execute()
require.NoError(t, err)
}
func compileParams(req httpclient.RelationshipApiApiGetRelationshipsRequest, q *ketoapi.RelationQuery, opts []paginationOptionSetter) httpclient.RelationshipApiApiGetRelationshipsRequest {
if q.Namespace != nil {
req = req.Namespace(*q.Namespace)
}
if q.Relation != nil {
req = req.Relation(*q.Relation)
}
if q.Object != nil {
req = req.Object(*q.Object)
}
if q.SubjectID != nil {
req = req.SubjectId(*q.SubjectID)
}
if q.SubjectSet != nil {
req = req.
SubjectSetNamespace(q.SubjectSet.Namespace).
SubjectSetObject(q.SubjectSet.Object).
SubjectSetRelation(q.SubjectSet.Relation)
}
pagination := getPaginationOptions(opts...)
if pagination.Size != 0 {
req = req.PageSize(int64(pagination.Size))
}
if pagination.Token != "" {
req = req.PageToken(pagination.Token)
}
return req
}
func (c *sdkClient) queryTuple(t testing.TB, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) *ketoapi.GetResponse {
request := c.getReadClient().RelationshipApi.GetRelationships(c.requestCtx(t))
request = compileParams(request, q, opts)
resp, _, err := request.Execute()
require.NoError(t, err)
getResp := &ketoapi.GetResponse{
RelationTuples: make([]*ketoapi.RelationTuple, len(resp.RelationTuples)),
NextPageToken: resp.GetNextPageToken(),
}
for i, rt := range resp.RelationTuples {
getResp.RelationTuples[i] = &ketoapi.RelationTuple{
Namespace: rt.Namespace,
Object: rt.Object,
Relation: rt.Relation,
}
if rt.SubjectSet != nil {
getResp.RelationTuples[i].SubjectSet = &ketoapi.SubjectSet{
Namespace: rt.SubjectSet.Namespace,
Object: rt.SubjectSet.Object,
Relation: rt.SubjectSet.Relation,
}
} else {
getResp.RelationTuples[i].SubjectID = rt.SubjectId
}
}
return getResp
}
func (c *sdkClient) queryTupleErr(t testing.TB, expected *herodot.DefaultError, q *ketoapi.RelationQuery, opts ...paginationOptionSetter) {
request := c.getReadClient().RelationshipApi.GetRelationships(c.requestCtx(t))
request = compileParams(request, q, opts)
_, _, err := request.Execute()
switch err.(type) {
case nil:
require.FailNow(t, "expected error but got nil")
case *httpclient.GenericOpenAPIError:
assert.Equal(t, expected.CodeField, http.StatusNotFound)
default:
require.FailNow(t, "got unknown error %+v\nexpected %+v", err, expected)
}
}
func (c *sdkClient) check(t testing.TB, r *ketoapi.RelationTuple) bool {
request := c.getReadClient().PermissionApi.CheckPermission(c.requestCtx(t)).
Namespace(r.Namespace).
Object(r.Object).
Relation(r.Relation)
request = withSubject(request, r.SubjectID, r.SubjectSet)
resp, _, err := request.Execute()
require.NoError(t, err)
return resp.GetAllowed()
}
func (c *sdkClient) batchCheckErr(t testing.TB, requestTuples []*ketoapi.RelationTuple, expected *herodot.DefaultError) {
request := c.getReadClient().PermissionApi.BatchCheckPermission(c.requestCtx(t)).
BatchCheckPermissionBody(httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
})
_, _, err := request.Execute()
switch typedErr := err.(type) {
case nil:
require.FailNow(t, "expected error but got nil")
case *httpclient.GenericOpenAPIError:
assert.Contains(t, typedErr.Error(), expected.Reason())
default:
require.FailNow(t, "got unknown error %+v\nexpected %+v", err, expected)
}
}
func (c *sdkClient) batchCheck(t testing.TB, requestTuples []*ketoapi.RelationTuple) []checkResponse {
request := c.getReadClient().PermissionApi.BatchCheckPermission(c.requestCtx(t)).
BatchCheckPermissionBody(httpclient.BatchCheckPermissionBody{
Tuples: tuplesToRelationships(requestTuples),
})
resp, _, err := request.Execute()
require.NoError(t, err)
responses := make([]checkResponse, len(resp.Results))
for i, result := range resp.Results {
errMsg := ""
if result.Error != nil {
errMsg = *result.Error
}
responses[i] = checkResponse{
allowed: result.Allowed,
errorMessage: errMsg,
}
}
return responses
}
func tuplesToRelationships(tuples []*ketoapi.RelationTuple) []httpclient.Relationship {
relationships := make([]httpclient.Relationship, len(tuples))
for i, requestTuple := range tuples {
relationship := httpclient.Relationship{
Namespace: requestTuple.Namespace,
Object: requestTuple.Object,
Relation: requestTuple.Relation,
SubjectId: requestTuple.SubjectID,
}
if requestTuple.SubjectSet != nil {
relationship.SubjectSet = &httpclient.SubjectSet{
Namespace: requestTuple.SubjectSet.Namespace,
Object: requestTuple.SubjectSet.Object,
Relation: requestTuple.SubjectSet.Relation,
}
}
relationships[i] = relationship
}
return relationships
}
func buildTree(t testing.TB, mt *httpclient.ExpandedPermissionTree) *ketoapi.Tree[*ketoapi.RelationTuple] {
result := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeType(mt.Type),
}
if mt.Tuple.SubjectSet != nil {
result.Tuple = &ketoapi.RelationTuple{
SubjectSet: &ketoapi.SubjectSet{
Namespace: mt.Tuple.SubjectSet.Namespace,
Object: mt.Tuple.SubjectSet.Object,
Relation: mt.Tuple.SubjectSet.Relation,
},
}
} else {
result.Tuple = &ketoapi.RelationTuple{
SubjectID: mt.Tuple.SubjectId,
}
}
if result.Type != ketoapi.TreeNodeLeaf && len(mt.Children) != 0 {
result.Children = make([]*ketoapi.Tree[*ketoapi.RelationTuple], len(mt.Children))
for i, c := range mt.Children {
c := c
result.Children[i] = buildTree(t, &c)
}
}
return result
}
func (c *sdkClient) expand(t testing.TB, r *ketoapi.SubjectSet, depth int) *ketoapi.Tree[*ketoapi.RelationTuple] {
request := c.getReadClient().PermissionApi.ExpandPermissions(c.requestCtx(t)).
Namespace(r.Namespace).
Object(r.Object).
Relation(r.Relation).
MaxDepth(int64(depth))
resp, _, err := request.Execute()
require.NoError(t, err)
return buildTree(t, resp)
}
func (c *sdkClient) waitUntilLive(t testing.TB) {
resp, _, err := c.getReadClient().MetadataApi.IsReady(c.requestCtx(t)).Execute()
for err != nil {
resp, _, err = c.getReadClient().MetadataApi.IsReady(c.requestCtx(t)).Execute()
}
require.Equal(t, "ok", resp.Status)
}
func (c *sdkClient) queryNamespaces(t testing.TB) (response ketoapi.GetNamespacesResponse) {
res, _, err := c.getReadClient().RelationshipApi.ListRelationshipNamespaces(c.requestCtx(t)).Execute()
require.NoError(t, err)
require.NoError(t, convert(res, &response))
return
}

View File

@@ -0,0 +1,401 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"fmt"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/ketoapi"
)
func runCases(c client, m *namespaceTestManager) func(*testing.T) {
return func(t *testing.T) {
c.waitUntilLive(t)
t.Run("case=list namespaces", func(t *testing.T) {
first := namespace.Namespace{Name: "my namespace"}
second := namespace.Namespace{Name: "my other namespace"}
m.add(t, &first, &second)
resp := c.queryNamespaces(t)
assert.GreaterOrEqual(t, len(resp.Namespaces), 2)
assertNamespacesContains(t, resp.Namespaces, "my namespace")
assertNamespacesContains(t, resp.Namespaces, "my other namespace")
})
t.Run("case=gets empty namespace", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.Len(t, resp.RelationTuples, 0)
})
t.Run("case=creates tuple and uses it then", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuple := &ketoapi.RelationTuple{
Namespace: n.Name,
Object: fmt.Sprintf("object for client %T", c),
Relation: "access",
SubjectID: new("client"),
}
c.createTuple(t, tuple)
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &tuple.Namespace})
require.Len(t, resp.RelationTuples, 1)
assert.Equal(t, tuple, resp.RelationTuples[0])
// try the check API to see whether the tuple is interpreted correctly
assert.True(t, c.check(t, tuple))
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{tuple})
require.Len(t, batchResult, 1)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
})
t.Run("case=creates tuple with empty IDs", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuples := []*ketoapi.RelationTuple{{
Namespace: n.Name,
Object: "",
Relation: "access",
SubjectID: new(""),
}, {
Namespace: n.Name,
Object: "",
Relation: "access",
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: "",
Relation: "access",
},
}}
for _, tp := range tuples {
c.createTuple(t, tp)
// try the check API to see whether the tuple is interpreted correctly
assert.True(t, c.check(t, tp))
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{tp})
require.Len(t, batchResult, 1)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
}
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.ElementsMatch(t, tuples, resp.RelationTuples)
})
t.Run("case=check subjectSet relations", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
obj := fmt.Sprintf("obj for client %T", c)
rel := "check"
rt := &ketoapi.RelationTuple{
Namespace: n.Name,
Object: obj,
Relation: rel,
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: obj,
Relation: rel,
},
}
c.createTuple(t, rt)
assert.True(t, c.check(t, rt))
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{rt})
require.Len(t, batchResult, 1)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
})
t.Run("case=batch check", func(t *testing.T) {
/*
Test batch check with four cases:
- Allowed single subject tuple
- Allowed subject set tuple
- Tuple with unknown namespace (will return an error for this case)
- Dis-allowed tuple
*/
namespace1 := &namespace.Namespace{Name: t.Name()}
namespace2 := &namespace.Namespace{Name: t.Name() + "-2"}
m.add(t, namespace1)
m.add(t, namespace2)
obj1 := fmt.Sprintf("obj for client %T", c)
obj2 := fmt.Sprintf("another obj for client %T", c)
rel1 := "check"
rel2 := "access"
tupleSubjectSet := &ketoapi.RelationTuple{
Namespace: namespace1.Name,
Object: obj1,
Relation: rel1,
SubjectSet: &ketoapi.SubjectSet{
Namespace: namespace1.Name,
Object: obj1,
Relation: rel1,
},
}
c.createTuple(t, tupleSubjectSet)
tupleSingleSubject := &ketoapi.RelationTuple{
Namespace: namespace2.Name,
Object: obj2,
Relation: rel2,
SubjectID: new("sub1"),
}
c.createTuple(t, tupleSingleSubject)
unknownNamespaceTuple := &ketoapi.RelationTuple{
Namespace: "unknown-namespace",
Object: obj1,
Relation: rel1,
SubjectID: new("sub1"),
}
unknownSubjectTuple := &ketoapi.RelationTuple{
Namespace: namespace1.Name,
Object: obj1,
Relation: rel1,
SubjectID: new("unknown-sub"),
}
batchResult := c.batchCheck(t, []*ketoapi.RelationTuple{
tupleSubjectSet, tupleSingleSubject,
unknownNamespaceTuple, unknownSubjectTuple,
})
require.Len(t, batchResult, 4)
assert.True(t, batchResult[0].allowed)
assert.Empty(t, batchResult[0].errorMessage)
assert.True(t, batchResult[1].allowed)
assert.Empty(t, batchResult[1].errorMessage)
assert.False(t, batchResult[2].allowed)
assert.Contains(t, batchResult[2].errorMessage, "The requested resource could not be found")
assert.False(t, batchResult[3].allowed)
assert.Empty(t, batchResult[3].errorMessage)
// Verify a call with no tuples returns successfully with no results
emptyResults := c.batchCheck(t, []*ketoapi.RelationTuple{})
require.Empty(t, emptyResults)
})
t.Run("case=batch check validation errors", func(t *testing.T) {
// Pass in 11 tuples to check, more than the default limit of 10, and verify the request is rejected
tuples := make([]*ketoapi.RelationTuple, 11)
for i := range tuples {
tuples[i] = &ketoapi.RelationTuple{
Namespace: "namespace-name",
Object: "obj",
Relation: "rel",
SubjectID: new("sub"),
}
}
c.batchCheckErr(t, tuples, herodot.ErrBadRequest())
})
t.Run("case=expand API", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
obj := fmt.Sprintf("tree for client %T", c)
rel := "expand"
subjects := []string{"s1", "s2"}
expectedTree := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeUnion,
Tuple: &ketoapi.RelationTuple{
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: obj,
Relation: rel,
},
},
Children: make([]*ketoapi.Tree[*ketoapi.RelationTuple], len(subjects)),
}
for i, subjectID := range subjects {
c.createTuple(t, &ketoapi.RelationTuple{
Namespace: n.Name,
Object: obj,
Relation: rel,
SubjectID: &subjectID,
})
expectedTree.Children[i] = &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeLeaf,
Tuple: &ketoapi.RelationTuple{
SubjectID: &subjectID,
},
}
}
actualTree := c.expand(t, expectedTree.Tuple.SubjectSet, 100)
assert.Equal(t, expectedTree.Type, actualTree.Type)
assert.Equal(t, expectedTree.Tuple, actualTree.Tuple)
assert.Len(t, actualTree.Children, len(expectedTree.Children))
expand.AssertExternalTreesAreEqual(t, expectedTree, actualTree)
})
t.Run("case=gets result paginated", func(t *testing.T) {
const nTuples = 10
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
rel := fmt.Sprintf("some unique relation %T", c)
for i := 0; i < nTuples; i++ {
c.createTuple(t, &ketoapi.RelationTuple{
Namespace: n.Name,
Object: "o" + strconv.Itoa(i),
Relation: rel,
SubjectID: new("s" + strconv.Itoa(i)),
})
}
var (
resp ketoapi.GetResponse
nPages int
)
// do ... while resp.NextPageToken != ""
for ok := true; ok; ok = resp.NextPageToken != "" {
resp = *c.queryTuple(t,
&ketoapi.RelationQuery{
Namespace: &n.Name,
Relation: &rel,
},
withToken(resp.NextPageToken),
withSize(1),
)
nPages++
assert.Len(t, resp.RelationTuples, 1)
}
assert.Equal(t, nTuples, nPages)
})
t.Run("case=deletes tuple", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
for _, rt := range []*ketoapi.RelationTuple{
{
SubjectID: new("s"),
},
{
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: "so",
Relation: "rel",
},
},
} {
t.Run(fmt.Sprintf("subject_id=%v", rt.SubjectID == nil), func(t *testing.T) {
rt.Namespace = n.Name
rt.Object = "o"
rt.Relation = "rel"
c.createTuple(t, rt)
resp := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.Equal(t, []*ketoapi.RelationTuple{rt}, resp.RelationTuples)
c.deleteTuple(t, rt)
resp = c.queryTuple(t, &ketoapi.RelationQuery{Namespace: &n.Name})
assert.Len(t, resp.RelationTuples, 0)
})
}
})
t.Run("case=deletes tuples based on relation query", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
rts := []*ketoapi.RelationTuple{
{
Namespace: n.Name,
Object: "o1",
Relation: "rel",
SubjectID: new("s1"),
},
{
Namespace: n.Name,
Object: "o2",
Relation: "rel",
SubjectID: new("s2"),
},
}
for i := 0; i < len(rts); i++ {
c.createTuple(t, rts[i])
}
q := &ketoapi.RelationQuery{
Namespace: &n.Name,
Relation: new("rel"),
}
resp := c.queryTuple(t, q)
require.ElementsMatch(t, resp.RelationTuples, rts)
c.deleteAllTuples(t, q)
resp = c.queryTuple(t, q)
assert.Equal(t, 0, len(resp.RelationTuples))
})
t.Run("case=returns error with status code on unknown namespace", func(t *testing.T) {
c.queryTupleErr(t, herodot.ErrNotFound(), &ketoapi.RelationQuery{Namespace: new("unknown namespace")})
})
t.Run("case=still serves tuples from deleted namespace", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuple := &ketoapi.RelationTuple{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("s"),
}
c.createTuple(t, tuple)
m.remove(t, n.Name)
resp := c.queryTuple(t, &ketoapi.RelationQuery{})
assert.Equal(t, []*ketoapi.RelationTuple{tuple}, resp.RelationTuples)
// Add the namespace again here, so that we can clean up properly.
m.add(t, n)
})
t.Run("case=OPL syntax check", func(t *testing.T) {
parseErrors := c.oplCheckSyntax(t, []byte("/* unclosed comment"))
require.Len(t, parseErrors, 1)
assert.Contains(t, parseErrors[0].Message, "unclosed comment")
})
}
}
func assertNamespacesContains(t *testing.T, list []ketoapi.Namespace, name string) {
t.Helper()
for _, n := range list {
if n.Name == name {
return
}
}
t.Errorf("Could not find %q in %+v", name, list)
}

View File

@@ -0,0 +1,232 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"cmp"
"slices"
"strconv"
"testing"
"time"
"github.com/ory/x/uuidx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/ketoapi"
)
func runTransactionCases(c transactClient, m *namespaceTestManager) func(*testing.T) {
return func(t *testing.T) {
t.Parallel()
t.Run("case=create and delete", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
tuples := []*ketoapi.RelationTuple{
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectSet: &ketoapi.SubjectSet{
Namespace: n.Name,
Object: "o",
Relation: "rel",
},
},
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("sid"),
},
}
c.transactTuples(t, tuples, nil)
resp := c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &n.Name,
})
for i := range tuples {
assert.Contains(t, resp.RelationTuples, tuples[i])
}
c.transactTuples(t, nil, tuples)
resp = c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &n.Name,
})
assert.Len(t, resp.RelationTuples, 0)
})
t.Run("case=duplicate string representations", func(t *testing.T) {
n := &namespace.Namespace{Name: t.Name()}
m.add(t, n)
c.transactTuples(t, []*ketoapi.RelationTuple{
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("sid"),
},
{
Namespace: n.Name,
Object: "o",
Relation: "rel",
SubjectID: new("sid"),
},
}, nil)
})
t.Run("case=large inserts and deletes", func(t *testing.T) {
if testing.Short() {
t.Skip("This test is fairly expensive, especially the deletion.")
}
ns := []*namespace.Namespace{
{Name: uuidx.NewV4().String()},
{Name: uuidx.NewV4().String()},
}
m.add(t, ns...)
var tuples []*ketoapi.RelationTuple
for i := range 1200 {
tuples = append(tuples,
&ketoapi.RelationTuple{
Namespace: ns[0].Name,
Object: "o" + strconv.Itoa(i),
Relation: "rela",
SubjectSet: &ketoapi.SubjectSet{
Namespace: ns[1].Name,
Object: "o" + strconv.Itoa(i),
Relation: "relx",
},
},
&ketoapi.RelationTuple{
Namespace: ns[0].Name,
Object: "o" + strconv.Itoa(i),
Relation: "relb",
SubjectID: new("sid"),
},
)
}
t0 := time.Now()
c.transactTuples(t, tuples, nil)
t.Logf("insert took %s", time.Since(t0))
t0 = time.Now()
var resp []*ketoapi.RelationTuple
var pt string
for {
r := c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &ns[0].Name,
}, withSize(1000), withToken(pt))
resp = append(resp, r.RelationTuples...)
pt = r.NextPageToken
if pt == "" {
break
}
}
t.Logf("query took %s", time.Since(t0))
sort := func(a, b *ketoapi.RelationTuple) int {
return cmp.Or(
cmp.Compare(a.Namespace, b.Namespace),
cmp.Compare(a.Object, b.Object),
cmp.Compare(a.Relation, b.Relation),
)
}
slices.SortFunc(resp, sort)
slices.SortFunc(tuples, sort)
require.Equal(t, tuples, resp)
t0 = time.Now()
c.transactTuples(t, nil, tuples)
t.Logf("delete took %s", time.Since(t0))
resp = c.queryTuple(t, &ketoapi.RelationQuery{
Namespace: &ns[0].Name,
}).RelationTuples
assert.Len(t, resp, 0)
})
t.Run("case=expand-api-display-access docs code sample", func(t *testing.T) {
files := &namespace.Namespace{Name: t.Name() + "files"}
directories := &namespace.Namespace{Name: t.Name() + "directories"}
m.add(t, files, directories)
tuples := []*ketoapi.RelationTuple{
{
Namespace: directories.Name,
Object: "/photos",
Relation: "owner",
SubjectID: new("maureen"),
},
{
Namespace: files.Name,
Object: "/photos/beach.jpg",
Relation: "owner",
SubjectID: new("maureen"),
},
{
Namespace: files.Name,
Object: "/photos/mountains.jpg",
Relation: "owner",
SubjectID: new("laura"),
},
{
Namespace: directories.Name,
Object: "/photos",
Relation: "access",
SubjectID: new("laura"),
},
}
for _, o := range []struct{ n, o string }{
{files.Name, "/photos/beach.jpg"},
{files.Name, "/photos/mountains.jpg"},
{directories.Name, "/photos"},
} {
tuples = append(tuples, &ketoapi.RelationTuple{
Namespace: o.n,
Object: o.o,
Relation: "access",
SubjectSet: &ketoapi.SubjectSet{
Namespace: o.n,
Object: o.o,
Relation: "owner",
},
})
}
for _, obj := range []string{"/photos/beach.jpg", "/photos/mountains.jpg"} {
tuples = append(tuples, &ketoapi.RelationTuple{
Namespace: files.Name,
Object: obj,
Relation: "access",
SubjectSet: &ketoapi.SubjectSet{
Namespace: directories.Name,
Object: "/photos",
Relation: "access",
},
})
}
c.transactTuples(t, tuples, nil)
fileTuples := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(files.Name)})
directoryTuples := c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(directories.Name)})
allTuples := append(fileTuples.RelationTuples, directoryTuples.RelationTuples...)
assert.ElementsMatch(t, allTuples, tuples)
c.transactTuples(t, nil, tuples)
fileTuples = c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(files.Name)})
directoryTuples = c.queryTuple(t, &ketoapi.RelationQuery{Namespace: new(directories.Name)})
allTuples = append(fileTuples.RelationTuples, directoryTuples.RelationTuples...)
assert.Len(t, allTuples, 0)
})
}
}

397
internal/expand/engine.go Normal file
View File

@@ -0,0 +1,397 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand
import (
"context"
"slices"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
"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/relationtuple"
"github.com/ory/keto/ketoapi"
"github.com/ory/keto/x/events"
)
type (
EngineDependencies interface {
relationtuple.ManagerProvider
config.Provider
logrusx.Provider
otelx.Provider
}
Engine struct {
d EngineDependencies
}
EngineProvider interface {
ExpandEngine() *Engine
}
)
func NewEngine(d EngineDependencies) *Engine {
return &Engine{
d: d,
}
}
// treeBuilder holds state shared across a single BuildTree call.
type treeBuilder struct {
d EngineDependencies
namespaceManager namespace.Manager
nodeCount int
maxNodes int
visited map[relationtuple.SubjectSet]struct{}
isStrict bool
expandRewrites bool
}
func (e *Engine) newTreeBuilder(ctx context.Context, namespaceManager namespace.Manager) *treeBuilder {
return &treeBuilder{
d: e.d,
namespaceManager: namespaceManager,
visited: make(map[relationtuple.SubjectSet]struct{}),
maxNodes: e.d.Config(ctx).ExpandMaxTupleCount(),
nodeCount: 0,
isStrict: e.d.Config(ctx).StrictMode(),
expandRewrites: e.d.Config(ctx).ExpandRewrites(),
}
}
func (e *Engine) BuildTree(ctx context.Context, subSet *relationtuple.SubjectSet, restDepth int) (t *relationtuple.Tree, err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "Engine.BuildTree")
defer otelx.End(span, &err)
namespaceManager, err := e.d.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
// global max-depth takes precedence when it is the lesser or if the request max-depth is less than or equal to 0
if globalMaxDepth := e.d.Config(ctx).MaxReadDepth(); restDepth <= 0 || globalMaxDepth < restDepth {
restDepth = globalMaxDepth
}
b := e.newTreeBuilder(ctx, namespaceManager)
if b.isStrict {
ns, _ := b.namespaceManager.GetNamespaceByName(ctx, subSet.Namespace)
rel := ns.FindRelation(subSet.Relation)
if rel == nil || (len(rel.Types) == 0 && rel.SubjectSetRewrite == nil) {
return nil, nil
}
}
t, err = b.buildTreeRecursive(ctx, subSet, restDepth)
if err == nil {
span.AddEvent(events.NewPermissionsExpanded(ctx))
}
return
}
func (b *treeBuilder) buildTreeRecursive(ctx context.Context, subject relationtuple.Subject, restDepth int) (*relationtuple.Tree, error) {
subSet, isSubjectSet := subject.(*relationtuple.SubjectSet)
if !isSubjectSet || subSet.Relation == "" {
b.nodeCount++
return newNode(ketoapi.TreeNodeLeaf, subject), nil
}
// Cycle detection: if this subject is already being expanded in the current
// call path (DFS ancestry), we have a cycle.
if _, exists := b.visited[*subSet]; exists {
return &relationtuple.Tree{
Type: ketoapi.TreeNodeUnion,
Subject: subject,
Truncation: &relationtuple.Truncation{
Reason: relationtuple.TruncationReasonCycle,
},
}, nil
}
b.visited[*subSet] = struct{}{}
defer delete(b.visited, *subSet)
ns, _ := b.namespaceManager.GetNamespaceByName(ctx, subSet.Namespace)
rel := ns.FindRelation(subSet.Relation)
if rel == nil {
if b.isStrict {
return nil, nil
}
// unknown relation, but we're not in strict mode, so we'll try to build it directly
return b.buildTreeDirect(ctx, subSet, nil, nil, restDepth)
}
if rel.SubjectSetRewrite != nil {
if b.expandRewrites {
return b.buildTreeForRewrite(ctx, subSet, rel.SubjectSetRewrite, restDepth)
}
// it is a rewrite, but we don't expand rewrites
// checking direct doesn't make much sense, but that's what we currently do so we keep that behavior
return b.buildTreeDirect(ctx, subSet, rel, nil, restDepth)
}
// rel is not a "permits" but "related", so we check directly
return b.buildTreeDirect(ctx, subSet, rel, nil, restDepth)
}
// buildTreeDirect expands by querying the relation directly, without an OPL rewrites.
func (b *treeBuilder) buildTreeDirect(ctx context.Context, subSet *relationtuple.SubjectSet, rel *ast.Relation, resume *relationtuple.ExpandCursor, restDepth int) (*relationtuple.Tree, error) {
tree := newNode(ketoapi.TreeNodeUnion, subSet)
directResume := func(pageToken keysetpagination.PageToken) *relationtuple.ExpandCursor {
return &relationtuple.ExpandCursor{Kind: relationtuple.ExpandCursorKindDirect, SubjectSet: subSet, NextPageToken: pageToken}
}
if b.isMaxNodeLimit() {
tree.Truncation = tupleLimitTruncation(directResume(keysetpagination.PageToken{}))
return tree, nil
}
if restDepth <= 1 {
tree.Truncation = depthLimitTruncation(directResume(keysetpagination.PageToken{}))
return tree, nil
}
for nextPage, _ := keysetpagination.NewPaginator(); !nextPage.IsLast(); {
var tuples []*relationtuple.RelationTuple
var err error
tuples, nextPage, err = b.d.RelationTupleManager().GetRelationTuples(ctx, &relationtuple.RelationQuery{
Namespace: &subSet.Namespace,
Object: &subSet.Object,
Relation: &subSet.Relation,
},
append(nextPage.ToOptions(), paginatorOptionsFromResume(resume)...)...,
)
if err != nil {
return nil, err
} else if len(tuples) == 0 {
break
}
pageStart := len(tree.Children)
for _, tuple := range tuples {
if b.isStrict && rel != nil { // if b.isStrict then rel != nil as we check that before calling this function, but let's better not rely on that invariant
ss, ok := tuple.Subject.(*relationtuple.SubjectSet)
if !ok || !slices.ContainsFunc(rel.Types, func(rt ast.RelationType) bool {
return rt.Namespace == ss.Namespace && rt.Relation == ss.Relation
}) {
continue
}
}
childTree := newNode(ketoapi.TreeNodeLeaf, tuple.Subject)
if tupleSubSet, ok := tuple.Subject.(*relationtuple.SubjectSet); ok && tupleSubSet.Relation != "" {
childTree.Type = ketoapi.TreeNodeUnion
}
b.nodeCount++
tree.Children = append(tree.Children, childTree)
}
if b.isMaxNodeLimit() && !nextPage.IsLast() {
tree.Truncation = tupleLimitTruncation(directResume(nextPage.PageToken()))
}
// expand all the non-leaf nodes founds in this page
for _, child := range tree.Children[pageStart:] {
if child.Type != ketoapi.TreeNodeLeaf {
updated, err := b.buildTreeRecursive(ctx, child.Subject, restDepth-1)
if err != nil {
return nil, err
}
*child = *updated
}
}
if tree.Truncation != nil {
break
}
}
return tree, nil
}
// buildTreeForRewrite expands a subject set using an OPL-defined rewrite rule.
// OPL rewrite children are structural (never paginated); only data nodes (direct
// relation tuples fetched from the DB) can be paginated.
func (b *treeBuilder) buildTreeForRewrite(ctx context.Context, subSet *relationtuple.SubjectSet, rewrite ast.Child, restDepth int) (*relationtuple.Tree, error) {
switch rewrite := rewrite.(type) {
case *ast.SubjectSetRewrite:
tree := newNode(ketoapi.TreeNodeUnion, subSet)
if rewrite.Operation == ast.OperatorAnd {
tree.Type = ketoapi.TreeNodeIntersection
}
children := slices.SortedFunc(slices.Values(rewrite.Children), func(a, b ast.Child) int {
return rewriteCostOrder(a) - rewriteCostOrder(b)
})
for _, childRewrite := range children {
childTree, err := b.buildTreeForRewrite(ctx, subSet, childRewrite, restDepth)
if err != nil {
return nil, err
}
// SubjectSetRewrite followed by another SubjectSetRewrite means compound expression;
// The subject of the 2nd rewrite is not meaningful at that point.
// ex: view = A AND (B OR C) -> (B OR C) has no meaningul subject
if childTree != nil {
if _, ok := childRewrite.(*ast.SubjectSetRewrite); ok {
childTree.Subject = nil
}
tree.Children = append(tree.Children, childTree)
}
}
return tree, nil
case *ast.ComputedSubjectSet:
return b.buildTreeRecursive(ctx, &relationtuple.SubjectSet{
Namespace: subSet.Namespace,
Object: subSet.Object,
Relation: rewrite.Relation,
}, restDepth-1)
case *ast.TupleToSubjectSet:
return b.buildTreeTupleToSubjectSet(ctx, subSet, rewrite, nil, restDepth)
case *ast.InvertResult:
// inversion never has a meaningful Subject
invertNode := newNode(ketoapi.TreeNodeExclusion, nil)
childTree, err := b.buildTreeForRewrite(ctx, subSet, rewrite.Child, restDepth)
if err != nil {
return nil, err
}
if childTree != nil {
// any compound operation under an InvertResult also has no meaningful subject
// ex: view = !(A OR B)
if _, ok := rewrite.Child.(*ast.SubjectSetRewrite); ok {
childTree.Subject = nil
}
invertNode.Children = []*relationtuple.Tree{childTree}
}
return invertNode, nil
}
return nil, nil
}
// buildTreeTupleToSubjectSet finds subject sets under subSet#child.Relation, and for each,
// recurses into that subject set's child.ComputedSubjectSetRelation.
func (b *treeBuilder) buildTreeTupleToSubjectSet(ctx context.Context, subSet *relationtuple.SubjectSet, rewrite *ast.TupleToSubjectSet, resume *relationtuple.ExpandCursor, restDepth int) (*relationtuple.Tree, error) {
treeSubject := &relationtuple.SubjectSet{
Namespace: subSet.Namespace,
Object: subSet.Object,
Relation: rewrite.Relation,
}
tree := newNode(ketoapi.TreeNodeUnion, treeSubject)
ttuResume := func(pageToken keysetpagination.PageToken) *relationtuple.ExpandCursor {
return &relationtuple.ExpandCursor{Kind: relationtuple.ExpandCursorKindTTU, SubjectSet: treeSubject, TraverseRelation: &rewrite.ComputedSubjectSetRelation, NextPageToken: pageToken}
}
if b.isMaxNodeLimit() {
tree.Truncation = tupleLimitTruncation(ttuResume(keysetpagination.PageToken{}))
return tree, nil
}
if restDepth <= 1 {
tree.Truncation = depthLimitTruncation(ttuResume(keysetpagination.PageToken{}))
return tree, nil
}
for nextPage, _ := keysetpagination.NewPaginator(); !nextPage.IsLast(); {
var tuples []*relationtuple.RelationTuple
var err error
tuples, nextPage, err = b.d.RelationTupleManager().GetRelationTuples(ctx, &relationtuple.RelationQuery{
Namespace: &subSet.Namespace,
Object: &subSet.Object,
Relation: &rewrite.Relation,
},
append(nextPage.ToOptions(), paginatorOptionsFromResume(resume)...)...,
)
if err != nil {
return nil, err
} else if len(tuples) == 0 {
break
}
var toWorkOn []*relationtuple.RelationTuple
for _, tuple := range tuples {
_, ok := tuple.Subject.(*relationtuple.SubjectSet)
if !ok {
continue
}
b.nodeCount++
toWorkOn = append(toWorkOn, tuple)
}
if b.isMaxNodeLimit() && !nextPage.IsLast() {
tree.Truncation = tupleLimitTruncation(ttuResume(nextPage.PageToken()))
}
for _, tuple := range toWorkOn {
tupleSubSet := tuple.Subject.(*relationtuple.SubjectSet)
childTree, err := b.buildTreeRecursive(ctx, &relationtuple.SubjectSet{
Namespace: tupleSubSet.Namespace,
Object: tupleSubSet.Object,
Relation: rewrite.ComputedSubjectSetRelation,
}, restDepth-1)
if err != nil {
return nil, err
}
if childTree != nil {
tree.Children = append(tree.Children, childTree)
}
}
if tree.Truncation != nil {
break
}
}
return tree, nil
}
func tupleLimitTruncation(resume *relationtuple.ExpandCursor) *relationtuple.Truncation {
return &relationtuple.Truncation{Reason: relationtuple.TruncationReasonTupleLimit, Cursor: resume}
}
func depthLimitTruncation(resume *relationtuple.ExpandCursor) *relationtuple.Truncation {
return &relationtuple.Truncation{Reason: relationtuple.TruncationReasonDepthLimit, Cursor: resume}
}
func paginatorOptionsFromResume(resume *relationtuple.ExpandCursor) []keysetpagination.Option {
if resume != nil {
return []keysetpagination.Option{keysetpagination.WithToken(resume.NextPageToken)}
}
return nil
}
func newNode(typ ketoapi.TreeNodeType, subSet relationtuple.Subject) *relationtuple.Tree {
return &relationtuple.Tree{
Type: typ,
Subject: subSet,
}
}
func (b *treeBuilder) isMaxNodeLimit() bool {
return b.nodeCount >= b.maxNodes
}
// rewriteCostOrder returns a sort key for rewrite children so that cheaper,
// direct lookups are evaluated before more expensive traversals.
func rewriteCostOrder(c ast.Child) int {
switch c.(type) {
case *ast.ComputedSubjectSet:
return 0
case *ast.TupleToSubjectSet:
return 1
case *ast.SubjectSetRewrite:
return 2
case *ast.InvertResult:
return 3
default:
return 4
}
}

File diff suppressed because it is too large Load Diff

174
internal/expand/handler.go Normal file
View File

@@ -0,0 +1,174 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand
import (
"context"
"net/http"
"connectrpc.com/connect"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/ketoapi"
"github.com/ory/herodot"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/internal/x"
)
type (
handlerDependencies interface {
EngineProvider
relationtuple.ManagerProvider
relationtuple.MapperProvider
logrusx.Provider
httpx.WriterProvider
x.HandlerOptionsProvider
}
Handler struct {
relationtuplesconnect.UnimplementedExpandServiceHandler
d handlerDependencies
}
)
var (
_ relationtuplesconnect.ExpandServiceHandler = (*Handler)(nil)
_ *expandPermissions = nil
)
const RouteBase = "/relation-tuples/expand"
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterReadRoutes(r *httprouterx.RouterPublic) {
r.GET(RouteBase, h.getExpand)
expandHandler := connect.NewUnaryHandler(
relationtuplesconnect.ExpandServiceExpandProcedure,
h.Expand,
connect.WithSchema(rts.File_ory_keto_relation_tuples_v1alpha2_expand_service_proto.
Services().ByName("ExpandService").
Methods().ByName("Expand")),
connect.WithHandlerOptions(h.d.HandlerOptions()...),
)
r.Handle(relationtuplesconnect.ExpandServiceExpandProcedure, expandHandler)
}
func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor {
return []protoreflect.FileDescriptor{
rts.File_ory_keto_relation_tuples_v1alpha2_expand_service_proto,
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
}
}
// Expand Permissions Request Parameters
//
// swagger:parameters expandPermissions
type expandPermissions struct {
// in:query
MaxDepth int `json:"max-depth"`
// in:query
ketoapi.SubjectSet
}
// swagger:route GET /relation-tuples/expand permission expandPermissions
//
// # Expand a Relationship into permissions.
//
// Use this endpoint to expand a relationship tuple into permissions.
//
// Consumes:
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: expandedPermissionTree
// 400: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: keto-admin-medium
func (h *Handler) getExpand(w http.ResponseWriter, r *http.Request) {
maxDepth, err := x.GetMaxDepthFromQuery(r.URL.Query())
if err != nil {
h.d.Writer().WriteError(w, r, herodot.ErrBadRequest().WithError(err.Error()))
return
}
subSet := (&ketoapi.SubjectSet{}).FromURLQuery(r.URL.Query())
internal, err := h.d.ReadOnlyMapper().FromSubjectSet(r.Context(), subSet)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
res, err := h.d.ExpandEngine().BuildTree(r.Context(), internal, maxDepth)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if res == nil {
h.d.Writer().Write(w, r, herodot.ErrNotFound().WithError("no relation tuple found"))
return
}
tree, err := h.d.ReadOnlyMapper().ToTree(r.Context(), res)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, tree)
}
func (h *Handler) Expand(ctx context.Context, req *connect.Request[rts.ExpandRequest]) (*connect.Response[rts.ExpandResponse], error) {
var subSet *ketoapi.SubjectSet
switch sub := req.Msg.Subject.Ref.(type) {
case *rts.Subject_Id:
return connect.NewResponse(&rts.ExpandResponse{
Tree: &rts.SubjectTree{
NodeType: rts.NodeType_NODE_TYPE_LEAF,
Subject: rts.NewSubjectID(sub.Id),
},
}), nil
case *rts.Subject_Set:
subSet = &ketoapi.SubjectSet{
Namespace: sub.Set.Namespace,
Object: sub.Set.Object,
Relation: sub.Set.Relation,
}
}
internal, err := h.d.ReadOnlyMapper().FromSubjectSet(ctx, subSet)
if err != nil {
return nil, err
}
res, err := h.d.ExpandEngine().BuildTree(ctx, internal, int(req.Msg.MaxDepth))
if err != nil {
return nil, err
}
if res == nil {
return connect.NewResponse(&rts.ExpandResponse{}), nil
}
tree, err := h.d.ReadOnlyMapper().ToTree(ctx, res)
if err != nil {
return nil, err
}
return connect.NewResponse(&rts.ExpandResponse{Tree: tree.ToProto()}), nil
}

View File

@@ -0,0 +1,131 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/ory/x/httprouterx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/driver"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/testhelpers"
"github.com/ory/keto/ketoapi"
)
func TestRESTHandler(t *testing.T) {
nspace := &namespace.Namespace{
Name: "expand handler",
}
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces([]*namespace.Namespace{nspace}))
h := expand.NewHandler(reg)
r := httprouterx.NewRouterPublic()
h.RegisterReadRoutes(r)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("case=returns bad request on malformed int", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?max-depth=foo")
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "invalid syntax")
})
t.Run("case=returns not found on unknown namespace", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?" + url.Values{
"max-depth": {"10"},
"namespace": {"not " + nspace.Name},
}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "Unknown namespace")
})
t.Run("case=returns not found on unknown namespace", func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?" + url.Values{
"max-depth": {"10"},
"namespace": {"not " + nspace.Name},
}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "Unknown namespace")
})
t.Run("case=returns expand tree", func(t *testing.T) {
rootSub := &ketoapi.SubjectSet{
Namespace: nspace.Name,
Object: "root",
Relation: "parent of",
}
expectedTree := &ketoapi.Tree[*ketoapi.RelationTuple]{
Type: ketoapi.TreeNodeUnion,
Tuple: &ketoapi.RelationTuple{
SubjectSet: rootSub,
},
Children: []*ketoapi.Tree[*ketoapi.RelationTuple]{
{
Type: ketoapi.TreeNodeLeaf,
Tuple: &ketoapi.RelationTuple{
SubjectID: new("child0"),
},
},
{
Type: ketoapi.TreeNodeLeaf,
Tuple: &ketoapi.RelationTuple{
SubjectID: new("child1"),
},
},
},
}
testhelpers.MapAndInsertTuples(t, reg,
&ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: rootSub.Object,
Relation: rootSub.Relation,
SubjectID: expectedTree.Children[0].Tuple.SubjectID,
},
&ketoapi.RelationTuple{
Namespace: nspace.Name,
Object: rootSub.Object,
Relation: rootSub.Relation,
SubjectID: expectedTree.Children[1].Tuple.SubjectID,
},
)
qs := rootSub.ToURLQuery()
qs.Set("max-depth", "2")
resp, err := ts.Client().Get(ts.URL + expand.RouteBase + "?" + qs.Encode())
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
actualTree := ketoapi.Tree[*ketoapi.RelationTuple]{}
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Logf("body: %s", string(body))
require.NoError(t, json.NewDecoder(bytes.NewBuffer(body)).Decode(&actualTree))
expand.AssertExternalTreesAreEqual(t, expectedTree, &actualTree)
})
}

View File

@@ -0,0 +1,127 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package expand
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoapi"
)
func AssertExternalTreesAreEqual(t *testing.T, expected, actual *ketoapi.Tree[*ketoapi.RelationTuple]) {
t.Helper()
assert.Truef(t, treesAreEqual(t, expected, actual),
"expected:\n%+v\n\nactual:\n%+v", expected, actual)
}
// TODO(hperl): Refactor to generic tree equality helper.
func treesAreEqual(t *testing.T, expected, actual *ketoapi.Tree[*ketoapi.RelationTuple]) bool {
if expected == nil || actual == nil {
return expected == actual
}
if expected.Type != actual.Type {
t.Logf("expected type %q, actual type %q", expected.Type, actual.Type)
return false
}
if !assert.ObjectsAreEqual(expected.Tuple.SubjectID, actual.Tuple.SubjectID) || !assert.ObjectsAreEqual(expected.Tuple.SubjectSet, actual.Tuple.SubjectSet) {
t.Logf("expected subject: %+v %+v, actual subject: %+v %+v", expected.Tuple.SubjectID, expected.Tuple.SubjectSet, actual.Tuple.SubjectID, actual.Tuple.SubjectSet)
return false
}
if len(expected.Children) != len(actual.Children) {
t.Logf("expected len(children)=%d, actual len(children)=%d", len(expected.Children), len(actual.Children))
return false
}
// For children, we check for equality disregarding the order
outer:
for _, expectedChild := range expected.Children {
for _, actualChild := range actual.Children {
if treesAreEqual(t, expectedChild, actualChild) {
continue outer
}
}
t.Logf("expected child:\n%+v\n\nactual child:\n%+v", expectedChild, actual)
return false
}
return true
}
// formatTree returns a human-readable, indented representation of a tree.
func formatTree(tree *relationtuple.Tree, indent string) string {
if tree == nil {
return indent + "<nil>\n"
}
var b strings.Builder
fmt.Fprintf(&b, "%s(%s) %+v", indent, tree.Type, tree.Subject)
if e := tree.Truncation; e != nil {
fmt.Fprintf(&b, " [truncation: reason=%s cursor=%s]", e.Reason, formatExpandCursor(e.Cursor))
}
b.WriteByte('\n')
for _, child := range tree.Children {
b.WriteString(formatTree(child, indent+" "))
}
return b.String()
}
func formatExpandCursor(c *relationtuple.ExpandCursor) string {
if c == nil {
return "<nil>"
}
var parts []string
parts = append(parts, "kind="+string(c.Kind))
parts = append(parts, "subject_set="+c.SubjectSet.String())
if c.TraverseRelation != nil {
parts = append(parts, "traverseRel="+*c.TraverseRelation)
}
return "{" + strings.Join(parts, " ") + "}"
}
// sortTree recursively sorts the children of a tree by subject string, in-place.
func sortTree(tree *relationtuple.Tree) {
if tree == nil {
return
}
for _, child := range tree.Children {
sortTree(child)
}
if tree.Children == nil {
return
}
slices.SortFunc(tree.Children, func(a, b *relationtuple.Tree) int {
if a.Subject == nil && b.Subject == nil {
return 0
}
if a.Subject == nil {
return 1
}
if b.Subject == nil {
return -1
}
return strings.Compare(a.Subject.String(), b.Subject.String())
})
}
// AssertInternalTreesAreEqual compares two trees for equality, ignoring child order.
func AssertInternalTreesAreEqual(t *testing.T, expected, actual *relationtuple.Tree) bool {
t.Helper()
sortTree(expected)
sortTree(actual)
return assert.Equal(t, formatTree(expected, ""), formatTree(actual, ""))
}
// RequireInternalTreesAreEqual compares two trees for equality, ignoring child order.
func RequireInternalTreesAreEqual(t *testing.T, expected, actual *relationtuple.Tree) {
t.Helper()
sortTree(expected)
sortTree(actual)
require.Equal(t, formatTree(expected, ""), formatTree(actual, ""))
}

24
internal/httpclient/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,24 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof

View File

@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@@ -0,0 +1,70 @@
.gitignore
.openapi-generator-ignore
.travis.yml
README.md
api/openapi.yaml
api_metadata.go
api_permission.go
api_relationship.go
client.go
configuration.go
docs/BatchCheckPermissionBody.md
docs/BatchCheckPermissionResult.md
docs/CheckOplSyntaxResult.md
docs/CheckPermissionResult.md
docs/CheckPermissionResultWithError.md
docs/CreateRelationshipBody.md
docs/ErrorGeneric.md
docs/ExpandedPermissionTree.md
docs/GenericError.md
docs/GetVersion200Response.md
docs/HealthNotReadyStatus.md
docs/HealthStatus.md
docs/IsAlive200Response.md
docs/IsReady503Response.md
docs/MetadataApi.md
docs/Namespace.md
docs/ParseError.md
docs/PermissionApi.md
docs/PostCheckPermissionBody.md
docs/PostCheckPermissionOrErrorBody.md
docs/RelationQuery.md
docs/Relationship.md
docs/RelationshipApi.md
docs/RelationshipNamespaces.md
docs/RelationshipPatch.md
docs/Relationships.md
docs/SourcePosition.md
docs/SubjectSet.md
docs/Version.md
git_push.sh
go.mod
go.sum
model_batch_check_permission_body.go
model_batch_check_permission_result.go
model_check_opl_syntax_result.go
model_check_permission_result.go
model_check_permission_result_with_error.go
model_create_relationship_body.go
model_error_generic.go
model_expanded_permission_tree.go
model_generic_error.go
model_get_version_200_response.go
model_health_not_ready_status.go
model_health_status.go
model_is_alive_200_response.go
model_is_ready_503_response.go
model_namespace.go
model_parse_error.go
model_post_check_permission_body.go
model_post_check_permission_or_error_body.go
model_relation_query.go
model_relationship.go
model_relationship_namespaces.go
model_relationship_patch.go
model_relationships.go
model_source_position.go
model_subject_set.go
model_version.go
response.go
utils.go

View File

@@ -0,0 +1 @@
7.2.0

7
internal/httpclient/.travis.yml generated Normal file
View File

@@ -0,0 +1,7 @@
language: go
install:
- go get -d -v .
script:
- go build -v ./

153
internal/httpclient/README.md generated Normal file
View File

@@ -0,0 +1,153 @@
# Go API client for client
Documentation for all of Ory Keto's REST APIs. gRPC is documented separately.
## Overview
This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [OpenAPI-spec](https://www.openapis.org/) from a remote server, you can easily generate an API client.
- API version:
- Package version: 1.0.0
- Build package: org.openapitools.codegen.languages.GoClientCodegen
## Installation
Install the following dependencies:
```shell
go get github.com/stretchr/testify/assert
go get golang.org/x/oauth2
go get golang.org/x/net/context
```
Put the package under your project folder and add the following in import:
```golang
import client "github.com/ory/keto-client-go"
```
To use a proxy, set the environment variable `HTTP_PROXY`:
```golang
os.Setenv("HTTP_PROXY", "http://proxy_name:proxy_port")
```
## Configuration of Server URL
Default configuration comes with `Servers` field that contains server objects as defined in the OpenAPI specification.
### Select Server Configuration
For using other server than the one defined on index 0 set context value `sw.ContextServerIndex` of type `int`.
```golang
ctx := context.WithValue(context.Background(), client.ContextServerIndex, 1)
```
### Templated Server URL
Templated server URL is formatted using default variables from configuration or from context value `sw.ContextServerVariables` of type `map[string]string`.
```golang
ctx := context.WithValue(context.Background(), client.ContextServerVariables, map[string]string{
"basePath": "v2",
})
```
Note, enum values are always validated and all unused variables are silently ignored.
### URLs Configuration per Operation
Each operation can use different server URL defined using `OperationServers` map in the `Configuration`.
An operation is uniquely identifield by `"{classname}Service.{nickname}"` string.
Similar rules for overriding default operation server index and variables applies by using `sw.ContextOperationServerIndices` and `sw.ContextOperationServerVariables` context maps.
```
ctx := context.WithValue(context.Background(), client.ContextOperationServerIndices, map[string]int{
"{classname}Service.{nickname}": 2,
})
ctx = context.WithValue(context.Background(), client.ContextOperationServerVariables, map[string]map[string]string{
"{classname}Service.{nickname}": {
"port": "8443",
},
})
```
## Documentation for API Endpoints
All URIs are relative to *http://localhost*
Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
*MetadataApi* | [**GetVersion**](docs/MetadataApi.md#getversion) | **Get** /version | Return Running Software Version.
*MetadataApi* | [**IsAlive**](docs/MetadataApi.md#isalive) | **Get** /health/alive | Check HTTP Server Status
*MetadataApi* | [**IsReady**](docs/MetadataApi.md#isready) | **Get** /health/ready | Check HTTP Server and Database Status
*PermissionApi* | [**BatchCheckPermission**](docs/PermissionApi.md#batchcheckpermission) | **Post** /relation-tuples/batch/check | Batch check permissions
*PermissionApi* | [**CheckPermission**](docs/PermissionApi.md#checkpermission) | **Get** /relation-tuples/check/openapi | Check a permission
*PermissionApi* | [**CheckPermissionOrError**](docs/PermissionApi.md#checkpermissionorerror) | **Get** /relation-tuples/check | Check a permission
*PermissionApi* | [**ExpandPermissions**](docs/PermissionApi.md#expandpermissions) | **Get** /relation-tuples/expand | Expand a Relationship into permissions.
*PermissionApi* | [**PostCheckPermission**](docs/PermissionApi.md#postcheckpermission) | **Post** /relation-tuples/check/openapi | Check a permission
*PermissionApi* | [**PostCheckPermissionOrError**](docs/PermissionApi.md#postcheckpermissionorerror) | **Post** /relation-tuples/check | Check a permission
*RelationshipApi* | [**CheckOplSyntax**](docs/RelationshipApi.md#checkoplsyntax) | **Post** /opl/syntax/check | Check the syntax of an OPL file
*RelationshipApi* | [**CreateRelationship**](docs/RelationshipApi.md#createrelationship) | **Put** /admin/relation-tuples | Create a Relationship
*RelationshipApi* | [**DeleteRelationships**](docs/RelationshipApi.md#deleterelationships) | **Delete** /admin/relation-tuples | Delete Relationships
*RelationshipApi* | [**GetRelationships**](docs/RelationshipApi.md#getrelationships) | **Get** /relation-tuples | Query relationships
*RelationshipApi* | [**ListRelationshipNamespaces**](docs/RelationshipApi.md#listrelationshipnamespaces) | **Get** /namespaces | Query namespaces
*RelationshipApi* | [**PatchRelationships**](docs/RelationshipApi.md#patchrelationships) | **Patch** /admin/relation-tuples | Patch Multiple Relationships
## Documentation For Models
- [BatchCheckPermissionBody](docs/BatchCheckPermissionBody.md)
- [BatchCheckPermissionResult](docs/BatchCheckPermissionResult.md)
- [CheckOplSyntaxResult](docs/CheckOplSyntaxResult.md)
- [CheckPermissionResult](docs/CheckPermissionResult.md)
- [CheckPermissionResultWithError](docs/CheckPermissionResultWithError.md)
- [CreateRelationshipBody](docs/CreateRelationshipBody.md)
- [ErrorGeneric](docs/ErrorGeneric.md)
- [ExpandedPermissionTree](docs/ExpandedPermissionTree.md)
- [GenericError](docs/GenericError.md)
- [GetVersion200Response](docs/GetVersion200Response.md)
- [HealthNotReadyStatus](docs/HealthNotReadyStatus.md)
- [HealthStatus](docs/HealthStatus.md)
- [IsAlive200Response](docs/IsAlive200Response.md)
- [IsReady503Response](docs/IsReady503Response.md)
- [Namespace](docs/Namespace.md)
- [ParseError](docs/ParseError.md)
- [PostCheckPermissionBody](docs/PostCheckPermissionBody.md)
- [PostCheckPermissionOrErrorBody](docs/PostCheckPermissionOrErrorBody.md)
- [RelationQuery](docs/RelationQuery.md)
- [Relationship](docs/Relationship.md)
- [RelationshipNamespaces](docs/RelationshipNamespaces.md)
- [RelationshipPatch](docs/RelationshipPatch.md)
- [Relationships](docs/Relationships.md)
- [SourcePosition](docs/SourcePosition.md)
- [SubjectSet](docs/SubjectSet.md)
- [Version](docs/Version.md)
## Documentation For Authorization
Endpoints do not require authorization.
## Documentation for Utility Methods
Due to the fact that model structure members are all pointers, this package contains
a number of utility functions to easily obtain pointers to values of basic types.
Each of these functions takes a value of the given basic type and returns a pointer to it:
* `PtrBool`
* `PtrInt`
* `PtrInt32`
* `PtrInt64`
* `PtrFloat`
* `PtrFloat32`
* `PtrFloat64`
* `PtrString`
* `PtrTime`
## Author
hi@ory.sh

1315
internal/httpclient/api/openapi.yaml generated Normal file

File diff suppressed because it is too large Load Diff

442
internal/httpclient/api_metadata.go generated Normal file
View File

@@ -0,0 +1,442 @@
/*
* Ory Keto API
*
* Documentation for all of Ory Keto's REST APIs. gRPC is documented separately.
*
* API version:
* Contact: hi@ory.sh
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package client
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
)
// Linger please
var (
_ context.Context
)
type MetadataApi interface {
/*
* GetVersion Return Running Software Version.
* This endpoint returns the version of Ory Keto.
If the service supports TLS Edge Termination, this endpoint does not require the
`X-Forwarded-Proto` header to be set.
Be aware that if you are running multiple nodes of this service, the version will never
refer to the cluster state, only to a single instance.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return MetadataApiApiGetVersionRequest
*/
GetVersion(ctx context.Context) MetadataApiApiGetVersionRequest
/*
* GetVersionExecute executes the request
* @return GetVersion200Response
*/
GetVersionExecute(r MetadataApiApiGetVersionRequest) (*GetVersion200Response, *http.Response, error)
/*
* IsAlive Check HTTP Server Status
* This endpoint returns a HTTP 200 status code when Ory Keto is accepting incoming
HTTP requests. This status does currently not include checks whether the database connection is working.
If the service supports TLS Edge Termination, this endpoint does not require the
`X-Forwarded-Proto` header to be set.
Be aware that if you are running multiple nodes of this service, the health status will never
refer to the cluster state, only to a single instance.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return MetadataApiApiIsAliveRequest
*/
IsAlive(ctx context.Context) MetadataApiApiIsAliveRequest
/*
* IsAliveExecute executes the request
* @return IsAlive200Response
*/
IsAliveExecute(r MetadataApiApiIsAliveRequest) (*IsAlive200Response, *http.Response, error)
/*
* IsReady Check HTTP Server and Database Status
* This endpoint returns a HTTP 200 status code when Ory Keto is up running and the environment dependencies (e.g.
the database) are responsive as well.
If the service supports TLS Edge Termination, this endpoint does not require the
`X-Forwarded-Proto` header to be set.
Be aware that if you are running multiple nodes of Ory Keto, the health status will never
refer to the cluster state, only to a single instance.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return MetadataApiApiIsReadyRequest
*/
IsReady(ctx context.Context) MetadataApiApiIsReadyRequest
/*
* IsReadyExecute executes the request
* @return IsAlive200Response
*/
IsReadyExecute(r MetadataApiApiIsReadyRequest) (*IsAlive200Response, *http.Response, error)
}
// MetadataApiService MetadataApi service
type MetadataApiService service
type MetadataApiApiGetVersionRequest struct {
ctx context.Context
ApiService MetadataApi
}
func (r MetadataApiApiGetVersionRequest) Execute() (*GetVersion200Response, *http.Response, error) {
return r.ApiService.GetVersionExecute(r)
}
/*
- GetVersion Return Running Software Version.
- This endpoint returns the version of Ory Keto.
If the service supports TLS Edge Termination, this endpoint does not require the
`X-Forwarded-Proto` header to be set.
Be aware that if you are running multiple nodes of this service, the version will never
refer to the cluster state, only to a single instance.
- @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
- @return MetadataApiApiGetVersionRequest
*/
func (a *MetadataApiService) GetVersion(ctx context.Context) MetadataApiApiGetVersionRequest {
return MetadataApiApiGetVersionRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return GetVersion200Response
*/
func (a *MetadataApiService) GetVersionExecute(r MetadataApiApiGetVersionRequest) (*GetVersion200Response, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *GetVersion200Response
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MetadataApiService.GetVersion")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/version"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type MetadataApiApiIsAliveRequest struct {
ctx context.Context
ApiService MetadataApi
}
func (r MetadataApiApiIsAliveRequest) Execute() (*IsAlive200Response, *http.Response, error) {
return r.ApiService.IsAliveExecute(r)
}
/*
- IsAlive Check HTTP Server Status
- This endpoint returns a HTTP 200 status code when Ory Keto is accepting incoming
HTTP requests. This status does currently not include checks whether the database connection is working.
If the service supports TLS Edge Termination, this endpoint does not require the
`X-Forwarded-Proto` header to be set.
Be aware that if you are running multiple nodes of this service, the health status will never
refer to the cluster state, only to a single instance.
- @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
- @return MetadataApiApiIsAliveRequest
*/
func (a *MetadataApiService) IsAlive(ctx context.Context) MetadataApiApiIsAliveRequest {
return MetadataApiApiIsAliveRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return IsAlive200Response
*/
func (a *MetadataApiService) IsAliveExecute(r MetadataApiApiIsAliveRequest) (*IsAlive200Response, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *IsAlive200Response
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MetadataApiService.IsAlive")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/health/alive"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json", "text/plain"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
var v string
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type MetadataApiApiIsReadyRequest struct {
ctx context.Context
ApiService MetadataApi
}
func (r MetadataApiApiIsReadyRequest) Execute() (*IsAlive200Response, *http.Response, error) {
return r.ApiService.IsReadyExecute(r)
}
/*
- IsReady Check HTTP Server and Database Status
- This endpoint returns a HTTP 200 status code when Ory Keto is up running and the environment dependencies (e.g.
the database) are responsive as well.
If the service supports TLS Edge Termination, this endpoint does not require the
`X-Forwarded-Proto` header to be set.
Be aware that if you are running multiple nodes of Ory Keto, the health status will never
refer to the cluster state, only to a single instance.
- @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
- @return MetadataApiApiIsReadyRequest
*/
func (a *MetadataApiService) IsReady(ctx context.Context) MetadataApiApiIsReadyRequest {
return MetadataApiApiIsReadyRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return IsAlive200Response
*/
func (a *MetadataApiService) IsReadyExecute(r MetadataApiApiIsReadyRequest) (*IsAlive200Response, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *IsAlive200Response
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MetadataApiService.IsReady")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/health/ready"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json", "text/plain"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 503 {
var v IsReady503Response
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
var v string
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}

1067
internal/httpclient/api_permission.go generated Normal file

File diff suppressed because it is too large Load Diff

953
internal/httpclient/api_relationship.go generated Normal file
View File

@@ -0,0 +1,953 @@
/*
* Ory Keto API
*
* Documentation for all of Ory Keto's REST APIs. gRPC is documented separately.
*
* API version:
* Contact: hi@ory.sh
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package client
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
)
// Linger please
var (
_ context.Context
)
type RelationshipApi interface {
/*
* CheckOplSyntax Check the syntax of an OPL file
* The OPL file is expected in the body of the request.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiCheckOplSyntaxRequest
*/
CheckOplSyntax(ctx context.Context) RelationshipApiApiCheckOplSyntaxRequest
/*
* CheckOplSyntaxExecute executes the request
* @return CheckOplSyntaxResult
*/
CheckOplSyntaxExecute(r RelationshipApiApiCheckOplSyntaxRequest) (*CheckOplSyntaxResult, *http.Response, error)
/*
* CreateRelationship Create a Relationship
* Use this endpoint to create a relationship.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiCreateRelationshipRequest
*/
CreateRelationship(ctx context.Context) RelationshipApiApiCreateRelationshipRequest
/*
* CreateRelationshipExecute executes the request
* @return Relationship
*/
CreateRelationshipExecute(r RelationshipApiApiCreateRelationshipRequest) (*Relationship, *http.Response, error)
/*
* DeleteRelationships Delete Relationships
* Use this endpoint to delete relationships
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiDeleteRelationshipsRequest
*/
DeleteRelationships(ctx context.Context) RelationshipApiApiDeleteRelationshipsRequest
/*
* DeleteRelationshipsExecute executes the request
*/
DeleteRelationshipsExecute(r RelationshipApiApiDeleteRelationshipsRequest) (*http.Response, error)
/*
* GetRelationships Query relationships
* Get all relationships that match the query. Only the namespace field is required.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiGetRelationshipsRequest
*/
GetRelationships(ctx context.Context) RelationshipApiApiGetRelationshipsRequest
/*
* GetRelationshipsExecute executes the request
* @return Relationships
*/
GetRelationshipsExecute(r RelationshipApiApiGetRelationshipsRequest) (*Relationships, *http.Response, error)
/*
* ListRelationshipNamespaces Query namespaces
* Get all namespaces
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiListRelationshipNamespacesRequest
*/
ListRelationshipNamespaces(ctx context.Context) RelationshipApiApiListRelationshipNamespacesRequest
/*
* ListRelationshipNamespacesExecute executes the request
* @return RelationshipNamespaces
*/
ListRelationshipNamespacesExecute(r RelationshipApiApiListRelationshipNamespacesRequest) (*RelationshipNamespaces, *http.Response, error)
/*
* PatchRelationships Patch Multiple Relationships
* Use this endpoint to patch one or more relationships.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiPatchRelationshipsRequest
*/
PatchRelationships(ctx context.Context) RelationshipApiApiPatchRelationshipsRequest
/*
* PatchRelationshipsExecute executes the request
*/
PatchRelationshipsExecute(r RelationshipApiApiPatchRelationshipsRequest) (*http.Response, error)
}
// RelationshipApiService RelationshipApi service
type RelationshipApiService service
type RelationshipApiApiCheckOplSyntaxRequest struct {
ctx context.Context
ApiService RelationshipApi
body *string
}
func (r RelationshipApiApiCheckOplSyntaxRequest) Body(body string) RelationshipApiApiCheckOplSyntaxRequest {
r.body = &body
return r
}
func (r RelationshipApiApiCheckOplSyntaxRequest) Execute() (*CheckOplSyntaxResult, *http.Response, error) {
return r.ApiService.CheckOplSyntaxExecute(r)
}
/*
* CheckOplSyntax Check the syntax of an OPL file
* The OPL file is expected in the body of the request.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiCheckOplSyntaxRequest
*/
func (a *RelationshipApiService) CheckOplSyntax(ctx context.Context) RelationshipApiApiCheckOplSyntaxRequest {
return RelationshipApiApiCheckOplSyntaxRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return CheckOplSyntaxResult
*/
func (a *RelationshipApiService) CheckOplSyntaxExecute(r RelationshipApiApiCheckOplSyntaxRequest) (*CheckOplSyntaxResult, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *CheckOplSyntaxResult
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "RelationshipApiService.CheckOplSyntax")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/opl/syntax/check"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"text/plain"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.body
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 400 {
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type RelationshipApiApiCreateRelationshipRequest struct {
ctx context.Context
ApiService RelationshipApi
createRelationshipBody *CreateRelationshipBody
}
func (r RelationshipApiApiCreateRelationshipRequest) CreateRelationshipBody(createRelationshipBody CreateRelationshipBody) RelationshipApiApiCreateRelationshipRequest {
r.createRelationshipBody = &createRelationshipBody
return r
}
func (r RelationshipApiApiCreateRelationshipRequest) Execute() (*Relationship, *http.Response, error) {
return r.ApiService.CreateRelationshipExecute(r)
}
/*
* CreateRelationship Create a Relationship
* Use this endpoint to create a relationship.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiCreateRelationshipRequest
*/
func (a *RelationshipApiService) CreateRelationship(ctx context.Context) RelationshipApiApiCreateRelationshipRequest {
return RelationshipApiApiCreateRelationshipRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return Relationship
*/
func (a *RelationshipApiService) CreateRelationshipExecute(r RelationshipApiApiCreateRelationshipRequest) (*Relationship, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPut
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *Relationship
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "RelationshipApiService.CreateRelationship")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/admin/relation-tuples"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.createRelationshipBody
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 400 {
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type RelationshipApiApiDeleteRelationshipsRequest struct {
ctx context.Context
ApiService RelationshipApi
namespace *string
object *string
relation *string
subjectId *string
subjectSetNamespace *string
subjectSetObject *string
subjectSetRelation *string
}
func (r RelationshipApiApiDeleteRelationshipsRequest) Namespace(namespace string) RelationshipApiApiDeleteRelationshipsRequest {
r.namespace = &namespace
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) Object(object string) RelationshipApiApiDeleteRelationshipsRequest {
r.object = &object
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) Relation(relation string) RelationshipApiApiDeleteRelationshipsRequest {
r.relation = &relation
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) SubjectId(subjectId string) RelationshipApiApiDeleteRelationshipsRequest {
r.subjectId = &subjectId
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) SubjectSetNamespace(subjectSetNamespace string) RelationshipApiApiDeleteRelationshipsRequest {
r.subjectSetNamespace = &subjectSetNamespace
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) SubjectSetObject(subjectSetObject string) RelationshipApiApiDeleteRelationshipsRequest {
r.subjectSetObject = &subjectSetObject
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) SubjectSetRelation(subjectSetRelation string) RelationshipApiApiDeleteRelationshipsRequest {
r.subjectSetRelation = &subjectSetRelation
return r
}
func (r RelationshipApiApiDeleteRelationshipsRequest) Execute() (*http.Response, error) {
return r.ApiService.DeleteRelationshipsExecute(r)
}
/*
* DeleteRelationships Delete Relationships
* Use this endpoint to delete relationships
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiDeleteRelationshipsRequest
*/
func (a *RelationshipApiService) DeleteRelationships(ctx context.Context) RelationshipApiApiDeleteRelationshipsRequest {
return RelationshipApiApiDeleteRelationshipsRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
*/
func (a *RelationshipApiService) DeleteRelationshipsExecute(r RelationshipApiApiDeleteRelationshipsRequest) (*http.Response, error) {
var (
localVarHTTPMethod = http.MethodDelete
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "RelationshipApiService.DeleteRelationships")
if err != nil {
return nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/admin/relation-tuples"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.namespace != nil {
localVarQueryParams.Add("namespace", parameterToString(*r.namespace, ""))
}
if r.object != nil {
localVarQueryParams.Add("object", parameterToString(*r.object, ""))
}
if r.relation != nil {
localVarQueryParams.Add("relation", parameterToString(*r.relation, ""))
}
if r.subjectId != nil {
localVarQueryParams.Add("subject_id", parameterToString(*r.subjectId, ""))
}
if r.subjectSetNamespace != nil {
localVarQueryParams.Add("subject_set.namespace", parameterToString(*r.subjectSetNamespace, ""))
}
if r.subjectSetObject != nil {
localVarQueryParams.Add("subject_set.object", parameterToString(*r.subjectSetObject, ""))
}
if r.subjectSetRelation != nil {
localVarQueryParams.Add("subject_set.relation", parameterToString(*r.subjectSetRelation, ""))
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 400 {
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarHTTPResponse, newErr
}
newErr.model = v
return localVarHTTPResponse, newErr
}
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarHTTPResponse, newErr
}
newErr.model = v
return localVarHTTPResponse, newErr
}
return localVarHTTPResponse, nil
}
type RelationshipApiApiGetRelationshipsRequest struct {
ctx context.Context
ApiService RelationshipApi
pageSize *int64
pageToken *string
namespace *string
object *string
relation *string
subjectId *string
subjectSetNamespace *string
subjectSetObject *string
subjectSetRelation *string
}
func (r RelationshipApiApiGetRelationshipsRequest) PageSize(pageSize int64) RelationshipApiApiGetRelationshipsRequest {
r.pageSize = &pageSize
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) PageToken(pageToken string) RelationshipApiApiGetRelationshipsRequest {
r.pageToken = &pageToken
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) Namespace(namespace string) RelationshipApiApiGetRelationshipsRequest {
r.namespace = &namespace
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) Object(object string) RelationshipApiApiGetRelationshipsRequest {
r.object = &object
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) Relation(relation string) RelationshipApiApiGetRelationshipsRequest {
r.relation = &relation
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) SubjectId(subjectId string) RelationshipApiApiGetRelationshipsRequest {
r.subjectId = &subjectId
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) SubjectSetNamespace(subjectSetNamespace string) RelationshipApiApiGetRelationshipsRequest {
r.subjectSetNamespace = &subjectSetNamespace
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) SubjectSetObject(subjectSetObject string) RelationshipApiApiGetRelationshipsRequest {
r.subjectSetObject = &subjectSetObject
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) SubjectSetRelation(subjectSetRelation string) RelationshipApiApiGetRelationshipsRequest {
r.subjectSetRelation = &subjectSetRelation
return r
}
func (r RelationshipApiApiGetRelationshipsRequest) Execute() (*Relationships, *http.Response, error) {
return r.ApiService.GetRelationshipsExecute(r)
}
/*
* GetRelationships Query relationships
* Get all relationships that match the query. Only the namespace field is required.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiGetRelationshipsRequest
*/
func (a *RelationshipApiService) GetRelationships(ctx context.Context) RelationshipApiApiGetRelationshipsRequest {
return RelationshipApiApiGetRelationshipsRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return Relationships
*/
func (a *RelationshipApiService) GetRelationshipsExecute(r RelationshipApiApiGetRelationshipsRequest) (*Relationships, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *Relationships
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "RelationshipApiService.GetRelationships")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/relation-tuples"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.pageSize != nil {
localVarQueryParams.Add("page_size", parameterToString(*r.pageSize, ""))
}
if r.pageToken != nil {
localVarQueryParams.Add("page_token", parameterToString(*r.pageToken, ""))
}
if r.namespace != nil {
localVarQueryParams.Add("namespace", parameterToString(*r.namespace, ""))
}
if r.object != nil {
localVarQueryParams.Add("object", parameterToString(*r.object, ""))
}
if r.relation != nil {
localVarQueryParams.Add("relation", parameterToString(*r.relation, ""))
}
if r.subjectId != nil {
localVarQueryParams.Add("subject_id", parameterToString(*r.subjectId, ""))
}
if r.subjectSetNamespace != nil {
localVarQueryParams.Add("subject_set.namespace", parameterToString(*r.subjectSetNamespace, ""))
}
if r.subjectSetObject != nil {
localVarQueryParams.Add("subject_set.object", parameterToString(*r.subjectSetObject, ""))
}
if r.subjectSetRelation != nil {
localVarQueryParams.Add("subject_set.relation", parameterToString(*r.subjectSetRelation, ""))
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 404 {
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type RelationshipApiApiListRelationshipNamespacesRequest struct {
ctx context.Context
ApiService RelationshipApi
}
func (r RelationshipApiApiListRelationshipNamespacesRequest) Execute() (*RelationshipNamespaces, *http.Response, error) {
return r.ApiService.ListRelationshipNamespacesExecute(r)
}
/*
* ListRelationshipNamespaces Query namespaces
* Get all namespaces
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiListRelationshipNamespacesRequest
*/
func (a *RelationshipApiService) ListRelationshipNamespaces(ctx context.Context) RelationshipApiApiListRelationshipNamespacesRequest {
return RelationshipApiApiListRelationshipNamespacesRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
* @return RelationshipNamespaces
*/
func (a *RelationshipApiService) ListRelationshipNamespacesExecute(r RelationshipApiApiListRelationshipNamespacesRequest) (*RelationshipNamespaces, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
localVarReturnValue *RelationshipNamespaces
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "RelationshipApiService.ListRelationshipNamespaces")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/namespaces"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.model = v
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type RelationshipApiApiPatchRelationshipsRequest struct {
ctx context.Context
ApiService RelationshipApi
relationshipPatch *[]RelationshipPatch
}
func (r RelationshipApiApiPatchRelationshipsRequest) RelationshipPatch(relationshipPatch []RelationshipPatch) RelationshipApiApiPatchRelationshipsRequest {
r.relationshipPatch = &relationshipPatch
return r
}
func (r RelationshipApiApiPatchRelationshipsRequest) Execute() (*http.Response, error) {
return r.ApiService.PatchRelationshipsExecute(r)
}
/*
* PatchRelationships Patch Multiple Relationships
* Use this endpoint to patch one or more relationships.
* @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
* @return RelationshipApiApiPatchRelationshipsRequest
*/
func (a *RelationshipApiService) PatchRelationships(ctx context.Context) RelationshipApiApiPatchRelationshipsRequest {
return RelationshipApiApiPatchRelationshipsRequest{
ApiService: a,
ctx: ctx,
}
}
/*
* Execute executes the request
*/
func (a *RelationshipApiService) PatchRelationshipsExecute(r RelationshipApiApiPatchRelationshipsRequest) (*http.Response, error) {
var (
localVarHTTPMethod = http.MethodPatch
localVarPostBody interface{}
localVarFormFileName string
localVarFileName string
localVarFileBytes []byte
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "RelationshipApiService.PatchRelationships")
if err != nil {
return nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/admin/relation-tuples"
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
// body params
localVarPostBody = r.relationshipPatch
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
if err != nil {
return nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 400 {
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarHTTPResponse, newErr
}
newErr.model = v
return localVarHTTPResponse, newErr
}
if localVarHTTPResponse.StatusCode == 404 {
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarHTTPResponse, newErr
}
newErr.model = v
return localVarHTTPResponse, newErr
}
var v ErrorGeneric
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarHTTPResponse, newErr
}
newErr.model = v
return localVarHTTPResponse, newErr
}
return localVarHTTPResponse, nil
}

547
internal/httpclient/client.go generated Normal file
View File

@@ -0,0 +1,547 @@
/*
* Ory Keto API
*
* Documentation for all of Ory Keto's REST APIs. gRPC is documented separately.
*
* API version:
* Contact: hi@ory.sh
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package client
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"golang.org/x/oauth2"
)
var (
jsonCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:vnd\.[^;]+\+)?json)`)
xmlCheck = regexp.MustCompile(`(?i:(?:application|text)/xml)`)
)
// APIClient manages communication with the Ory Keto API API v
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
common service // Reuse a single struct instead of allocating one for each service on the heap.
// API Services
MetadataApi MetadataApi
PermissionApi PermissionApi
RelationshipApi RelationshipApi
}
type service struct {
client *APIClient
}
// NewAPIClient creates a new API client. Requires a userAgent string describing your application.
// optionally a custom http.Client to allow for advanced features such as caching.
func NewAPIClient(cfg *Configuration) *APIClient {
if cfg.HTTPClient == nil {
cfg.HTTPClient = http.DefaultClient
}
c := &APIClient{}
c.cfg = cfg
c.common.client = c
// API Services
c.MetadataApi = (*MetadataApiService)(&c.common)
c.PermissionApi = (*PermissionApiService)(&c.common)
c.RelationshipApi = (*RelationshipApiService)(&c.common)
return c
}
func atoi(in string) (int, error) {
return strconv.Atoi(in)
}
// selectHeaderContentType select a content type from the available list.
func selectHeaderContentType(contentTypes []string) string {
if len(contentTypes) == 0 {
return ""
}
if contains(contentTypes, "application/json") {
return "application/json"
}
return contentTypes[0] // use the first content type specified in 'consumes'
}
// selectHeaderAccept join all accept types and return
func selectHeaderAccept(accepts []string) string {
if len(accepts) == 0 {
return ""
}
if contains(accepts, "application/json") {
return "application/json"
}
return strings.Join(accepts, ",")
}
// contains is a case insenstive match, finding needle in a haystack
func contains(haystack []string, needle string) bool {
for _, a := range haystack {
if strings.ToLower(a) == strings.ToLower(needle) {
return true
}
}
return false
}
// Verify optional parameters are of the correct type.
func typeCheckParameter(obj interface{}, expected string, name string) error {
// Make sure there is an object.
if obj == nil {
return nil
}
// Check the type is as expected.
if reflect.TypeOf(obj).String() != expected {
return fmt.Errorf("Expected %s to be of type %s but received %s.", name, expected, reflect.TypeOf(obj).String())
}
return nil
}
// parameterToString convert interface{} parameters to string, using a delimiter if format is provided.
func parameterToString(obj interface{}, collectionFormat string) string {
var delimiter string
switch collectionFormat {
case "pipes":
delimiter = "|"
case "ssv":
delimiter = " "
case "tsv":
delimiter = "\t"
case "csv":
delimiter = ","
}
if reflect.TypeOf(obj).Kind() == reflect.Slice {
return strings.Trim(strings.Replace(fmt.Sprint(obj), " ", delimiter, -1), "[]")
} else if t, ok := obj.(time.Time); ok {
return t.Format(time.RFC3339)
}
return fmt.Sprintf("%v", obj)
}
// helper for converting interface{} parameters to json strings
func parameterToJson(obj interface{}) (string, error) {
jsonBuf, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(jsonBuf), err
}
// callAPI do the request.
func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) {
if c.cfg.Debug {
dump, err := httputil.DumpRequestOut(request, true)
if err != nil {
return nil, err
}
log.Printf("\n%s\n", string(dump))
}
resp, err := c.cfg.HTTPClient.Do(request)
if err != nil {
return resp, err
}
if c.cfg.Debug {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
return resp, err
}
log.Printf("\n%s\n", string(dump))
}
return resp, err
}
// Allow modification of underlying config for alternate implementations and testing
// Caution: modifying the configuration while live can cause data races and potentially unwanted behavior
func (c *APIClient) GetConfig() *Configuration {
return c.cfg
}
// prepareRequest build the request
func (c *APIClient) prepareRequest(
ctx context.Context,
path string, method string,
postBody interface{},
headerParams map[string]string,
queryParams url.Values,
formParams url.Values,
formFileName string,
fileName string,
fileBytes []byte) (localVarRequest *http.Request, err error) {
var body *bytes.Buffer
// Detect postBody type and post.
if postBody != nil {
contentType := headerParams["Content-Type"]
if contentType == "" {
contentType = detectContentType(postBody)
headerParams["Content-Type"] = contentType
}
body, err = setBody(postBody, contentType)
if err != nil {
return nil, err
}
}
// add form parameters and file if available.
if strings.HasPrefix(headerParams["Content-Type"], "multipart/form-data") && len(formParams) > 0 || (len(fileBytes) > 0 && fileName != "") {
if body != nil {
return nil, errors.New("Cannot specify postBody and multipart form at the same time.")
}
body = &bytes.Buffer{}
w := multipart.NewWriter(body)
for k, v := range formParams {
for _, iv := range v {
if strings.HasPrefix(k, "@") { // file
err = addFile(w, k[1:], iv)
if err != nil {
return nil, err
}
} else { // form value
w.WriteField(k, iv)
}
}
}
if len(fileBytes) > 0 && fileName != "" {
w.Boundary()
//_, fileNm := filepath.Split(fileName)
part, err := w.CreateFormFile(formFileName, filepath.Base(fileName))
if err != nil {
return nil, err
}
_, err = part.Write(fileBytes)
if err != nil {
return nil, err
}
}
// Set the Boundary in the Content-Type
headerParams["Content-Type"] = w.FormDataContentType()
// Set Content-Length
headerParams["Content-Length"] = fmt.Sprintf("%d", body.Len())
w.Close()
}
if strings.HasPrefix(headerParams["Content-Type"], "application/x-www-form-urlencoded") && len(formParams) > 0 {
if body != nil {
return nil, errors.New("Cannot specify postBody and x-www-form-urlencoded form at the same time.")
}
body = &bytes.Buffer{}
body.WriteString(formParams.Encode())
// Set Content-Length
headerParams["Content-Length"] = fmt.Sprintf("%d", body.Len())
}
// Setup path and query parameters
url, err := url.Parse(path)
if err != nil {
return nil, err
}
// Override request host, if applicable
if c.cfg.Host != "" {
url.Host = c.cfg.Host
}
// Override request scheme, if applicable
if c.cfg.Scheme != "" {
url.Scheme = c.cfg.Scheme
}
// Adding Query Param
query := url.Query()
for k, v := range queryParams {
for _, iv := range v {
query.Add(k, iv)
}
}
// Encode the parameters.
url.RawQuery = query.Encode()
// Generate a new request
if body != nil {
localVarRequest, err = http.NewRequest(method, url.String(), body)
} else {
localVarRequest, err = http.NewRequest(method, url.String(), nil)
}
if err != nil {
return nil, err
}
// add header parameters, if any
if len(headerParams) > 0 {
headers := http.Header{}
for h, v := range headerParams {
headers.Set(h, v)
}
localVarRequest.Header = headers
}
// Add the user agent to the request.
localVarRequest.Header.Add("User-Agent", c.cfg.UserAgent)
if ctx != nil {
// add context to the request
localVarRequest = localVarRequest.WithContext(ctx)
// Walk through any authentication.
// OAuth2 authentication
if tok, ok := ctx.Value(ContextOAuth2).(oauth2.TokenSource); ok {
// We were able to grab an oauth2 token from the context
var latestToken *oauth2.Token
if latestToken, err = tok.Token(); err != nil {
return nil, err
}
latestToken.SetAuthHeader(localVarRequest)
}
// Basic HTTP Authentication
if auth, ok := ctx.Value(ContextBasicAuth).(BasicAuth); ok {
localVarRequest.SetBasicAuth(auth.UserName, auth.Password)
}
// AccessToken Authentication
if auth, ok := ctx.Value(ContextAccessToken).(string); ok {
localVarRequest.Header.Add("Authorization", "Bearer "+auth)
}
}
for header, value := range c.cfg.DefaultHeader {
localVarRequest.Header.Add(header, value)
}
return localVarRequest, nil
}
func (c *APIClient) decode(v interface{}, b []byte, contentType string) (err error) {
if len(b) == 0 {
return nil
}
if s, ok := v.(*string); ok {
*s = string(b)
return nil
}
if xmlCheck.MatchString(contentType) {
if err = xml.Unmarshal(b, v); err != nil {
return err
}
return nil
}
if jsonCheck.MatchString(contentType) {
if actualObj, ok := v.(interface{ GetActualInstance() interface{} }); ok { // oneOf, anyOf schemas
if unmarshalObj, ok := actualObj.(interface{ UnmarshalJSON([]byte) error }); ok { // make sure it has UnmarshalJSON defined
if err = unmarshalObj.UnmarshalJSON(b); err != nil {
return err
}
} else {
return errors.New("Unknown type with GetActualInstance but no unmarshalObj.UnmarshalJSON defined")
}
} else if err = json.Unmarshal(b, v); err != nil { // simple model
return err
}
return nil
}
return errors.New("undefined response type")
}
// Add a file to the multipart request
func addFile(w *multipart.Writer, fieldName, path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
part, err := w.CreateFormFile(fieldName, filepath.Base(path))
if err != nil {
return err
}
_, err = io.Copy(part, file)
return err
}
// Prevent trying to import "fmt"
func reportError(format string, a ...interface{}) error {
return fmt.Errorf(format, a...)
}
// Prevent trying to import "bytes"
func newStrictDecoder(data []byte) *json.Decoder {
dec := json.NewDecoder(bytes.NewBuffer(data))
dec.DisallowUnknownFields()
return dec
}
// Set request body from an interface{}
func setBody(body interface{}, contentType string) (bodyBuf *bytes.Buffer, err error) {
if bodyBuf == nil {
bodyBuf = &bytes.Buffer{}
}
if reader, ok := body.(io.Reader); ok {
_, err = bodyBuf.ReadFrom(reader)
} else if b, ok := body.([]byte); ok {
_, err = bodyBuf.Write(b)
} else if s, ok := body.(string); ok {
_, err = bodyBuf.WriteString(s)
} else if s, ok := body.(*string); ok {
_, err = bodyBuf.WriteString(*s)
} else if jsonCheck.MatchString(contentType) {
err = json.NewEncoder(bodyBuf).Encode(body)
} else if xmlCheck.MatchString(contentType) {
err = xml.NewEncoder(bodyBuf).Encode(body)
}
if err != nil {
return nil, err
}
if bodyBuf.Len() == 0 {
err = fmt.Errorf("Invalid body type %s\n", contentType)
return nil, err
}
return bodyBuf, nil
}
// detectContentType method is used to figure out `Request.Body` content type for request header
func detectContentType(body interface{}) string {
contentType := "text/plain; charset=utf-8"
kind := reflect.TypeOf(body).Kind()
switch kind {
case reflect.Struct, reflect.Map, reflect.Ptr:
contentType = "application/json; charset=utf-8"
case reflect.String:
contentType = "text/plain; charset=utf-8"
default:
if b, ok := body.([]byte); ok {
contentType = http.DetectContentType(b)
} else if kind == reflect.Slice {
contentType = "application/json; charset=utf-8"
}
}
return contentType
}
// Ripped from https://github.com/gregjones/httpcache/blob/master/httpcache.go
type cacheControl map[string]string
func parseCacheControl(headers http.Header) cacheControl {
cc := cacheControl{}
ccHeader := headers.Get("Cache-Control")
for _, part := range strings.Split(ccHeader, ",") {
part = strings.Trim(part, " ")
if part == "" {
continue
}
if strings.ContainsRune(part, '=') {
keyval := strings.Split(part, "=")
cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",")
} else {
cc[part] = ""
}
}
return cc
}
// CacheExpires helper function to determine remaining time before repeating a request.
func CacheExpires(r *http.Response) time.Time {
// Figure out when the cache expires.
var expires time.Time
now, err := time.Parse(time.RFC1123, r.Header.Get("date"))
if err != nil {
return time.Now()
}
respCacheControl := parseCacheControl(r.Header)
if maxAge, ok := respCacheControl["max-age"]; ok {
lifetime, err := time.ParseDuration(maxAge + "s")
if err != nil {
expires = now
} else {
expires = now.Add(lifetime)
}
} else {
expiresHeader := r.Header.Get("Expires")
if expiresHeader != "" {
expires, err = time.Parse(time.RFC1123, expiresHeader)
if err != nil {
expires = now
}
}
}
return expires
}
func strlen(s string) int {
return utf8.RuneCountInString(s)
}
// GenericOpenAPIError Provides access to the body, error and model on returned errors.
type GenericOpenAPIError struct {
body []byte
error string
model interface{}
}
// Error returns non-empty string if there was an error.
func (e GenericOpenAPIError) Error() string {
return e.error
}
// Body returns the raw bytes of the response
func (e GenericOpenAPIError) Body() []byte {
return e.body
}
// Model returns the unpacked model of the error
func (e GenericOpenAPIError) Model() interface{} {
return e.model
}

230
internal/httpclient/configuration.go generated Normal file
View File

@@ -0,0 +1,230 @@
/*
* Ory Keto API
*
* Documentation for all of Ory Keto's REST APIs. gRPC is documented separately.
*
* API version:
* Contact: hi@ory.sh
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package client
import (
"context"
"fmt"
"net/http"
"strings"
)
// contextKeys are used to identify the type of value in the context.
// Since these are string, it is possible to get a short description of the
// context key for logging and debugging using key.String().
type contextKey string
func (c contextKey) String() string {
return "auth " + string(c)
}
var (
// ContextOAuth2 takes an oauth2.TokenSource as authentication for the request.
ContextOAuth2 = contextKey("token")
// ContextBasicAuth takes BasicAuth as authentication for the request.
ContextBasicAuth = contextKey("basic")
// ContextAccessToken takes a string oauth2 access token as authentication for the request.
ContextAccessToken = contextKey("accesstoken")
// ContextAPIKeys takes a string apikey as authentication for the request
ContextAPIKeys = contextKey("apiKeys")
// ContextHttpSignatureAuth takes HttpSignatureAuth as authentication for the request.
ContextHttpSignatureAuth = contextKey("httpsignature")
// ContextServerIndex uses a server configuration from the index.
ContextServerIndex = contextKey("serverIndex")
// ContextOperationServerIndices uses a server configuration from the index mapping.
ContextOperationServerIndices = contextKey("serverOperationIndices")
// ContextServerVariables overrides a server configuration variables.
ContextServerVariables = contextKey("serverVariables")
// ContextOperationServerVariables overrides a server configuration variables using operation specific values.
ContextOperationServerVariables = contextKey("serverOperationVariables")
)
// BasicAuth provides basic http authentication to a request passed via context using ContextBasicAuth
type BasicAuth struct {
UserName string `json:"userName,omitempty"`
Password string `json:"password,omitempty"`
}
// APIKey provides API key based authentication to a request passed via context using ContextAPIKey
type APIKey struct {
Key string
Prefix string
}
// ServerVariable stores the information about a server variable
type ServerVariable struct {
Description string
DefaultValue string
EnumValues []string
}
// ServerConfiguration stores the information about a server
type ServerConfiguration struct {
URL string
Description string
Variables map[string]ServerVariable
}
// ServerConfigurations stores multiple ServerConfiguration items
type ServerConfigurations []ServerConfiguration
// Configuration stores the configuration of the API client
type Configuration struct {
Host string `json:"host,omitempty"`
Scheme string `json:"scheme,omitempty"`
DefaultHeader map[string]string `json:"defaultHeader,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
Debug bool `json:"debug,omitempty"`
Servers ServerConfigurations
OperationServers map[string]ServerConfigurations
HTTPClient *http.Client
}
// NewConfiguration returns a new Configuration object
func NewConfiguration() *Configuration {
cfg := &Configuration{
DefaultHeader: make(map[string]string),
UserAgent: "OpenAPI-Generator/1.0.0/go",
Debug: false,
Servers: ServerConfigurations{
{
URL: "",
Description: "No description provided",
},
},
OperationServers: map[string]ServerConfigurations{},
}
return cfg
}
// AddDefaultHeader adds a new HTTP header to the default header in the request
func (c *Configuration) AddDefaultHeader(key string, value string) {
c.DefaultHeader[key] = value
}
// URL formats template on a index using given variables
func (sc ServerConfigurations) URL(index int, variables map[string]string) (string, error) {
if index < 0 || len(sc) <= index {
return "", fmt.Errorf("Index %v out of range %v", index, len(sc)-1)
}
server := sc[index]
url := server.URL
// go through variables and replace placeholders
for name, variable := range server.Variables {
if value, ok := variables[name]; ok {
found := bool(len(variable.EnumValues) == 0)
for _, enumValue := range variable.EnumValues {
if value == enumValue {
found = true
}
}
if !found {
return "", fmt.Errorf("The variable %s in the server URL has invalid value %v. Must be %v", name, value, variable.EnumValues)
}
url = strings.Replace(url, "{"+name+"}", value, -1)
} else {
url = strings.Replace(url, "{"+name+"}", variable.DefaultValue, -1)
}
}
return url, nil
}
// ServerURL returns URL based on server settings
func (c *Configuration) ServerURL(index int, variables map[string]string) (string, error) {
return c.Servers.URL(index, variables)
}
func getServerIndex(ctx context.Context) (int, error) {
si := ctx.Value(ContextServerIndex)
if si != nil {
if index, ok := si.(int); ok {
return index, nil
}
return 0, reportError("Invalid type %T should be int", si)
}
return 0, nil
}
func getServerOperationIndex(ctx context.Context, endpoint string) (int, error) {
osi := ctx.Value(ContextOperationServerIndices)
if osi != nil {
if operationIndices, ok := osi.(map[string]int); !ok {
return 0, reportError("Invalid type %T should be map[string]int", osi)
} else {
index, ok := operationIndices[endpoint]
if ok {
return index, nil
}
}
}
return getServerIndex(ctx)
}
func getServerVariables(ctx context.Context) (map[string]string, error) {
sv := ctx.Value(ContextServerVariables)
if sv != nil {
if variables, ok := sv.(map[string]string); ok {
return variables, nil
}
return nil, reportError("ctx value of ContextServerVariables has invalid type %T should be map[string]string", sv)
}
return nil, nil
}
func getServerOperationVariables(ctx context.Context, endpoint string) (map[string]string, error) {
osv := ctx.Value(ContextOperationServerVariables)
if osv != nil {
if operationVariables, ok := osv.(map[string]map[string]string); !ok {
return nil, reportError("ctx value of ContextOperationServerVariables has invalid type %T should be map[string]map[string]string", osv)
} else {
variables, ok := operationVariables[endpoint]
if ok {
return variables, nil
}
}
}
return getServerVariables(ctx)
}
// ServerURLWithContext returns a new server URL given an endpoint
func (c *Configuration) ServerURLWithContext(ctx context.Context, endpoint string) (string, error) {
sc, ok := c.OperationServers[endpoint]
if !ok {
sc = c.Servers
}
if ctx == nil {
return sc.URL(0, nil)
}
index, err := getServerOperationIndex(ctx, endpoint)
if err != nil {
return "", err
}
variables, err := getServerOperationVariables(ctx, endpoint)
if err != nil {
return "", err
}
return sc.URL(index, variables)
}

View File

@@ -0,0 +1,56 @@
# BatchCheckPermissionBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Tuples** | Pointer to [**[]Relationship**](Relationship.md) | | [optional]
## Methods
### NewBatchCheckPermissionBody
`func NewBatchCheckPermissionBody() *BatchCheckPermissionBody`
NewBatchCheckPermissionBody instantiates a new BatchCheckPermissionBody object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewBatchCheckPermissionBodyWithDefaults
`func NewBatchCheckPermissionBodyWithDefaults() *BatchCheckPermissionBody`
NewBatchCheckPermissionBodyWithDefaults instantiates a new BatchCheckPermissionBody object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetTuples
`func (o *BatchCheckPermissionBody) GetTuples() []Relationship`
GetTuples returns the Tuples field if non-nil, zero value otherwise.
### GetTuplesOk
`func (o *BatchCheckPermissionBody) GetTuplesOk() (*[]Relationship, bool)`
GetTuplesOk returns a tuple with the Tuples field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetTuples
`func (o *BatchCheckPermissionBody) SetTuples(v []Relationship)`
SetTuples sets Tuples field to given value.
### HasTuples
`func (o *BatchCheckPermissionBody) HasTuples() bool`
HasTuples returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,51 @@
# BatchCheckPermissionResult
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Results** | [**[]CheckPermissionResultWithError**](CheckPermissionResultWithError.md) | An array of check results. The order aligns with the input order. |
## Methods
### NewBatchCheckPermissionResult
`func NewBatchCheckPermissionResult(results []CheckPermissionResultWithError, ) *BatchCheckPermissionResult`
NewBatchCheckPermissionResult instantiates a new BatchCheckPermissionResult object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewBatchCheckPermissionResultWithDefaults
`func NewBatchCheckPermissionResultWithDefaults() *BatchCheckPermissionResult`
NewBatchCheckPermissionResultWithDefaults instantiates a new BatchCheckPermissionResult object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetResults
`func (o *BatchCheckPermissionResult) GetResults() []CheckPermissionResultWithError`
GetResults returns the Results field if non-nil, zero value otherwise.
### GetResultsOk
`func (o *BatchCheckPermissionResult) GetResultsOk() (*[]CheckPermissionResultWithError, bool)`
GetResultsOk returns a tuple with the Results field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetResults
`func (o *BatchCheckPermissionResult) SetResults(v []CheckPermissionResultWithError)`
SetResults sets Results field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,56 @@
# CheckOplSyntaxResult
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Errors** | Pointer to [**[]ParseError**](ParseError.md) | The list of syntax errors | [optional]
## Methods
### NewCheckOplSyntaxResult
`func NewCheckOplSyntaxResult() *CheckOplSyntaxResult`
NewCheckOplSyntaxResult instantiates a new CheckOplSyntaxResult object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewCheckOplSyntaxResultWithDefaults
`func NewCheckOplSyntaxResultWithDefaults() *CheckOplSyntaxResult`
NewCheckOplSyntaxResultWithDefaults instantiates a new CheckOplSyntaxResult object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetErrors
`func (o *CheckOplSyntaxResult) GetErrors() []ParseError`
GetErrors returns the Errors field if non-nil, zero value otherwise.
### GetErrorsOk
`func (o *CheckOplSyntaxResult) GetErrorsOk() (*[]ParseError, bool)`
GetErrorsOk returns a tuple with the Errors field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetErrors
`func (o *CheckOplSyntaxResult) SetErrors(v []ParseError)`
SetErrors sets Errors field to given value.
### HasErrors
`func (o *CheckOplSyntaxResult) HasErrors() bool`
HasErrors returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,51 @@
# CheckPermissionResult
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Allowed** | **bool** | whether the relation tuple is allowed |
## Methods
### NewCheckPermissionResult
`func NewCheckPermissionResult(allowed bool, ) *CheckPermissionResult`
NewCheckPermissionResult instantiates a new CheckPermissionResult object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewCheckPermissionResultWithDefaults
`func NewCheckPermissionResultWithDefaults() *CheckPermissionResult`
NewCheckPermissionResultWithDefaults instantiates a new CheckPermissionResult object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetAllowed
`func (o *CheckPermissionResult) GetAllowed() bool`
GetAllowed returns the Allowed field if non-nil, zero value otherwise.
### GetAllowedOk
`func (o *CheckPermissionResult) GetAllowedOk() (*bool, bool)`
GetAllowedOk returns a tuple with the Allowed field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetAllowed
`func (o *CheckPermissionResult) SetAllowed(v bool)`
SetAllowed sets Allowed field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,77 @@
# CheckPermissionResultWithError
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Allowed** | **bool** | whether the relation tuple is allowed |
**Error** | Pointer to **string** | any error generated while checking the relation tuple | [optional]
## Methods
### NewCheckPermissionResultWithError
`func NewCheckPermissionResultWithError(allowed bool, ) *CheckPermissionResultWithError`
NewCheckPermissionResultWithError instantiates a new CheckPermissionResultWithError object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewCheckPermissionResultWithErrorWithDefaults
`func NewCheckPermissionResultWithErrorWithDefaults() *CheckPermissionResultWithError`
NewCheckPermissionResultWithErrorWithDefaults instantiates a new CheckPermissionResultWithError object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetAllowed
`func (o *CheckPermissionResultWithError) GetAllowed() bool`
GetAllowed returns the Allowed field if non-nil, zero value otherwise.
### GetAllowedOk
`func (o *CheckPermissionResultWithError) GetAllowedOk() (*bool, bool)`
GetAllowedOk returns a tuple with the Allowed field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetAllowed
`func (o *CheckPermissionResultWithError) SetAllowed(v bool)`
SetAllowed sets Allowed field to given value.
### GetError
`func (o *CheckPermissionResultWithError) GetError() string`
GetError returns the Error field if non-nil, zero value otherwise.
### GetErrorOk
`func (o *CheckPermissionResultWithError) GetErrorOk() (*string, bool)`
GetErrorOk returns a tuple with the Error field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetError
`func (o *CheckPermissionResultWithError) SetError(v string)`
SetError sets Error field to given value.
### HasError
`func (o *CheckPermissionResultWithError) HasError() bool`
HasError returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,160 @@
# CreateRelationshipBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Namespace** | Pointer to **string** | Namespace to query | [optional]
**Object** | Pointer to **string** | Object to query | [optional]
**Relation** | Pointer to **string** | Relation to query | [optional]
**SubjectId** | Pointer to **string** | SubjectID to query Either SubjectSet or SubjectID can be provided. | [optional]
**SubjectSet** | Pointer to [**SubjectSet**](SubjectSet.md) | | [optional]
## Methods
### NewCreateRelationshipBody
`func NewCreateRelationshipBody() *CreateRelationshipBody`
NewCreateRelationshipBody instantiates a new CreateRelationshipBody object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewCreateRelationshipBodyWithDefaults
`func NewCreateRelationshipBodyWithDefaults() *CreateRelationshipBody`
NewCreateRelationshipBodyWithDefaults instantiates a new CreateRelationshipBody object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetNamespace
`func (o *CreateRelationshipBody) GetNamespace() string`
GetNamespace returns the Namespace field if non-nil, zero value otherwise.
### GetNamespaceOk
`func (o *CreateRelationshipBody) GetNamespaceOk() (*string, bool)`
GetNamespaceOk returns a tuple with the Namespace field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetNamespace
`func (o *CreateRelationshipBody) SetNamespace(v string)`
SetNamespace sets Namespace field to given value.
### HasNamespace
`func (o *CreateRelationshipBody) HasNamespace() bool`
HasNamespace returns a boolean if a field has been set.
### GetObject
`func (o *CreateRelationshipBody) GetObject() string`
GetObject returns the Object field if non-nil, zero value otherwise.
### GetObjectOk
`func (o *CreateRelationshipBody) GetObjectOk() (*string, bool)`
GetObjectOk returns a tuple with the Object field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetObject
`func (o *CreateRelationshipBody) SetObject(v string)`
SetObject sets Object field to given value.
### HasObject
`func (o *CreateRelationshipBody) HasObject() bool`
HasObject returns a boolean if a field has been set.
### GetRelation
`func (o *CreateRelationshipBody) GetRelation() string`
GetRelation returns the Relation field if non-nil, zero value otherwise.
### GetRelationOk
`func (o *CreateRelationshipBody) GetRelationOk() (*string, bool)`
GetRelationOk returns a tuple with the Relation field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetRelation
`func (o *CreateRelationshipBody) SetRelation(v string)`
SetRelation sets Relation field to given value.
### HasRelation
`func (o *CreateRelationshipBody) HasRelation() bool`
HasRelation returns a boolean if a field has been set.
### GetSubjectId
`func (o *CreateRelationshipBody) GetSubjectId() string`
GetSubjectId returns the SubjectId field if non-nil, zero value otherwise.
### GetSubjectIdOk
`func (o *CreateRelationshipBody) GetSubjectIdOk() (*string, bool)`
GetSubjectIdOk returns a tuple with the SubjectId field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectId
`func (o *CreateRelationshipBody) SetSubjectId(v string)`
SetSubjectId sets SubjectId field to given value.
### HasSubjectId
`func (o *CreateRelationshipBody) HasSubjectId() bool`
HasSubjectId returns a boolean if a field has been set.
### GetSubjectSet
`func (o *CreateRelationshipBody) GetSubjectSet() SubjectSet`
GetSubjectSet returns the SubjectSet field if non-nil, zero value otherwise.
### GetSubjectSetOk
`func (o *CreateRelationshipBody) GetSubjectSetOk() (*SubjectSet, bool)`
GetSubjectSetOk returns a tuple with the SubjectSet field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectSet
`func (o *CreateRelationshipBody) SetSubjectSet(v SubjectSet)`
SetSubjectSet sets SubjectSet field to given value.
### HasSubjectSet
`func (o *CreateRelationshipBody) HasSubjectSet() bool`
HasSubjectSet returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

51
internal/httpclient/docs/ErrorGeneric.md generated Normal file
View File

@@ -0,0 +1,51 @@
# ErrorGeneric
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Error** | [**GenericError**](GenericError.md) | |
## Methods
### NewErrorGeneric
`func NewErrorGeneric(error_ GenericError, ) *ErrorGeneric`
NewErrorGeneric instantiates a new ErrorGeneric object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewErrorGenericWithDefaults
`func NewErrorGenericWithDefaults() *ErrorGeneric`
NewErrorGenericWithDefaults instantiates a new ErrorGeneric object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetError
`func (o *ErrorGeneric) GetError() GenericError`
GetError returns the Error field if non-nil, zero value otherwise.
### GetErrorOk
`func (o *ErrorGeneric) GetErrorOk() (*GenericError, bool)`
GetErrorOk returns a tuple with the Error field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetError
`func (o *ErrorGeneric) SetError(v GenericError)`
SetError sets Error field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,103 @@
# ExpandedPermissionTree
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Children** | Pointer to [**[]ExpandedPermissionTree**](ExpandedPermissionTree.md) | The children of the node, possibly none. | [optional]
**Tuple** | Pointer to [**Relationship**](Relationship.md) | | [optional]
**Type** | **string** | The type of the node. union TreeNodeUnion exclusion TreeNodeExclusion intersection TreeNodeIntersection leaf TreeNodeLeaf tuple_to_subject_set TreeNodeTupleToSubjectSet computed_subject_set TreeNodeComputedSubjectSet not TreeNodeNot unspecified TreeNodeUnspecified |
## Methods
### NewExpandedPermissionTree
`func NewExpandedPermissionTree(type_ string, ) *ExpandedPermissionTree`
NewExpandedPermissionTree instantiates a new ExpandedPermissionTree object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewExpandedPermissionTreeWithDefaults
`func NewExpandedPermissionTreeWithDefaults() *ExpandedPermissionTree`
NewExpandedPermissionTreeWithDefaults instantiates a new ExpandedPermissionTree object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetChildren
`func (o *ExpandedPermissionTree) GetChildren() []ExpandedPermissionTree`
GetChildren returns the Children field if non-nil, zero value otherwise.
### GetChildrenOk
`func (o *ExpandedPermissionTree) GetChildrenOk() (*[]ExpandedPermissionTree, bool)`
GetChildrenOk returns a tuple with the Children field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetChildren
`func (o *ExpandedPermissionTree) SetChildren(v []ExpandedPermissionTree)`
SetChildren sets Children field to given value.
### HasChildren
`func (o *ExpandedPermissionTree) HasChildren() bool`
HasChildren returns a boolean if a field has been set.
### GetTuple
`func (o *ExpandedPermissionTree) GetTuple() Relationship`
GetTuple returns the Tuple field if non-nil, zero value otherwise.
### GetTupleOk
`func (o *ExpandedPermissionTree) GetTupleOk() (*Relationship, bool)`
GetTupleOk returns a tuple with the Tuple field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetTuple
`func (o *ExpandedPermissionTree) SetTuple(v Relationship)`
SetTuple sets Tuple field to given value.
### HasTuple
`func (o *ExpandedPermissionTree) HasTuple() bool`
HasTuple returns a boolean if a field has been set.
### GetType
`func (o *ExpandedPermissionTree) GetType() string`
GetType returns the Type field if non-nil, zero value otherwise.
### GetTypeOk
`func (o *ExpandedPermissionTree) GetTypeOk() (*string, bool)`
GetTypeOk returns a tuple with the Type field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetType
`func (o *ExpandedPermissionTree) SetType(v string)`
SetType sets Type field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

233
internal/httpclient/docs/GenericError.md generated Normal file
View File

@@ -0,0 +1,233 @@
# GenericError
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Code** | Pointer to **int64** | The status code | [optional]
**Debug** | Pointer to **string** | Debug information This field is often not exposed to protect against leaking sensitive information. | [optional]
**Details** | Pointer to **map[string]interface{}** | Further error details | [optional]
**Id** | Pointer to **string** | The error ID Useful when trying to identify various errors in application logic. | [optional]
**Message** | **string** | Error message The error&#39;s message. |
**Reason** | Pointer to **string** | A human-readable reason for the error | [optional]
**Request** | Pointer to **string** | The request ID The request ID is often exposed internally in order to trace errors across service architectures. This is often a UUID. | [optional]
**Status** | Pointer to **string** | The status description | [optional]
## Methods
### NewGenericError
`func NewGenericError(message string, ) *GenericError`
NewGenericError instantiates a new GenericError object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewGenericErrorWithDefaults
`func NewGenericErrorWithDefaults() *GenericError`
NewGenericErrorWithDefaults instantiates a new GenericError object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetCode
`func (o *GenericError) GetCode() int64`
GetCode returns the Code field if non-nil, zero value otherwise.
### GetCodeOk
`func (o *GenericError) GetCodeOk() (*int64, bool)`
GetCodeOk returns a tuple with the Code field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetCode
`func (o *GenericError) SetCode(v int64)`
SetCode sets Code field to given value.
### HasCode
`func (o *GenericError) HasCode() bool`
HasCode returns a boolean if a field has been set.
### GetDebug
`func (o *GenericError) GetDebug() string`
GetDebug returns the Debug field if non-nil, zero value otherwise.
### GetDebugOk
`func (o *GenericError) GetDebugOk() (*string, bool)`
GetDebugOk returns a tuple with the Debug field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetDebug
`func (o *GenericError) SetDebug(v string)`
SetDebug sets Debug field to given value.
### HasDebug
`func (o *GenericError) HasDebug() bool`
HasDebug returns a boolean if a field has been set.
### GetDetails
`func (o *GenericError) GetDetails() map[string]interface{}`
GetDetails returns the Details field if non-nil, zero value otherwise.
### GetDetailsOk
`func (o *GenericError) GetDetailsOk() (*map[string]interface{}, bool)`
GetDetailsOk returns a tuple with the Details field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetDetails
`func (o *GenericError) SetDetails(v map[string]interface{})`
SetDetails sets Details field to given value.
### HasDetails
`func (o *GenericError) HasDetails() bool`
HasDetails returns a boolean if a field has been set.
### GetId
`func (o *GenericError) GetId() string`
GetId returns the Id field if non-nil, zero value otherwise.
### GetIdOk
`func (o *GenericError) GetIdOk() (*string, bool)`
GetIdOk returns a tuple with the Id field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetId
`func (o *GenericError) SetId(v string)`
SetId sets Id field to given value.
### HasId
`func (o *GenericError) HasId() bool`
HasId returns a boolean if a field has been set.
### GetMessage
`func (o *GenericError) GetMessage() string`
GetMessage returns the Message field if non-nil, zero value otherwise.
### GetMessageOk
`func (o *GenericError) GetMessageOk() (*string, bool)`
GetMessageOk returns a tuple with the Message field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetMessage
`func (o *GenericError) SetMessage(v string)`
SetMessage sets Message field to given value.
### GetReason
`func (o *GenericError) GetReason() string`
GetReason returns the Reason field if non-nil, zero value otherwise.
### GetReasonOk
`func (o *GenericError) GetReasonOk() (*string, bool)`
GetReasonOk returns a tuple with the Reason field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetReason
`func (o *GenericError) SetReason(v string)`
SetReason sets Reason field to given value.
### HasReason
`func (o *GenericError) HasReason() bool`
HasReason returns a boolean if a field has been set.
### GetRequest
`func (o *GenericError) GetRequest() string`
GetRequest returns the Request field if non-nil, zero value otherwise.
### GetRequestOk
`func (o *GenericError) GetRequestOk() (*string, bool)`
GetRequestOk returns a tuple with the Request field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetRequest
`func (o *GenericError) SetRequest(v string)`
SetRequest sets Request field to given value.
### HasRequest
`func (o *GenericError) HasRequest() bool`
HasRequest returns a boolean if a field has been set.
### GetStatus
`func (o *GenericError) GetStatus() string`
GetStatus returns the Status field if non-nil, zero value otherwise.
### GetStatusOk
`func (o *GenericError) GetStatusOk() (*string, bool)`
GetStatusOk returns a tuple with the Status field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetStatus
`func (o *GenericError) SetStatus(v string)`
SetStatus sets Status field to given value.
### HasStatus
`func (o *GenericError) HasStatus() bool`
HasStatus returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,51 @@
# GetVersion200Response
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Version** | **string** | The version of Ory Keto. |
## Methods
### NewGetVersion200Response
`func NewGetVersion200Response(version string, ) *GetVersion200Response`
NewGetVersion200Response instantiates a new GetVersion200Response object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewGetVersion200ResponseWithDefaults
`func NewGetVersion200ResponseWithDefaults() *GetVersion200Response`
NewGetVersion200ResponseWithDefaults instantiates a new GetVersion200Response object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetVersion
`func (o *GetVersion200Response) GetVersion() string`
GetVersion returns the Version field if non-nil, zero value otherwise.
### GetVersionOk
`func (o *GetVersion200Response) GetVersionOk() (*string, bool)`
GetVersionOk returns a tuple with the Version field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetVersion
`func (o *GetVersion200Response) SetVersion(v string)`
SetVersion sets Version field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,56 @@
# HealthNotReadyStatus
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Errors** | Pointer to **map[string]string** | Errors contains a list of errors that caused the not ready status. | [optional]
## Methods
### NewHealthNotReadyStatus
`func NewHealthNotReadyStatus() *HealthNotReadyStatus`
NewHealthNotReadyStatus instantiates a new HealthNotReadyStatus object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewHealthNotReadyStatusWithDefaults
`func NewHealthNotReadyStatusWithDefaults() *HealthNotReadyStatus`
NewHealthNotReadyStatusWithDefaults instantiates a new HealthNotReadyStatus object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetErrors
`func (o *HealthNotReadyStatus) GetErrors() map[string]string`
GetErrors returns the Errors field if non-nil, zero value otherwise.
### GetErrorsOk
`func (o *HealthNotReadyStatus) GetErrorsOk() (*map[string]string, bool)`
GetErrorsOk returns a tuple with the Errors field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetErrors
`func (o *HealthNotReadyStatus) SetErrors(v map[string]string)`
SetErrors sets Errors field to given value.
### HasErrors
`func (o *HealthNotReadyStatus) HasErrors() bool`
HasErrors returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

56
internal/httpclient/docs/HealthStatus.md generated Normal file
View File

@@ -0,0 +1,56 @@
# HealthStatus
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Status** | Pointer to **string** | Status always contains \&quot;ok\&quot;. | [optional]
## Methods
### NewHealthStatus
`func NewHealthStatus() *HealthStatus`
NewHealthStatus instantiates a new HealthStatus object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewHealthStatusWithDefaults
`func NewHealthStatusWithDefaults() *HealthStatus`
NewHealthStatusWithDefaults instantiates a new HealthStatus object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetStatus
`func (o *HealthStatus) GetStatus() string`
GetStatus returns the Status field if non-nil, zero value otherwise.
### GetStatusOk
`func (o *HealthStatus) GetStatusOk() (*string, bool)`
GetStatusOk returns a tuple with the Status field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetStatus
`func (o *HealthStatus) SetStatus(v string)`
SetStatus sets Status field to given value.
### HasStatus
`func (o *HealthStatus) HasStatus() bool`
HasStatus returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,51 @@
# IsAlive200Response
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Status** | **string** | Always \&quot;ok\&quot;. |
## Methods
### NewIsAlive200Response
`func NewIsAlive200Response(status string, ) *IsAlive200Response`
NewIsAlive200Response instantiates a new IsAlive200Response object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewIsAlive200ResponseWithDefaults
`func NewIsAlive200ResponseWithDefaults() *IsAlive200Response`
NewIsAlive200ResponseWithDefaults instantiates a new IsAlive200Response object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetStatus
`func (o *IsAlive200Response) GetStatus() string`
GetStatus returns the Status field if non-nil, zero value otherwise.
### GetStatusOk
`func (o *IsAlive200Response) GetStatusOk() (*string, bool)`
GetStatusOk returns a tuple with the Status field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetStatus
`func (o *IsAlive200Response) SetStatus(v string)`
SetStatus sets Status field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,51 @@
# IsReady503Response
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Errors** | **map[string]string** | Errors contains a list of errors that caused the not ready status. |
## Methods
### NewIsReady503Response
`func NewIsReady503Response(errors map[string]string, ) *IsReady503Response`
NewIsReady503Response instantiates a new IsReady503Response object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewIsReady503ResponseWithDefaults
`func NewIsReady503ResponseWithDefaults() *IsReady503Response`
NewIsReady503ResponseWithDefaults instantiates a new IsReady503Response object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetErrors
`func (o *IsReady503Response) GetErrors() map[string]string`
GetErrors returns the Errors field if non-nil, zero value otherwise.
### GetErrorsOk
`func (o *IsReady503Response) GetErrorsOk() (*map[string]string, bool)`
GetErrorsOk returns a tuple with the Errors field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetErrors
`func (o *IsReady503Response) SetErrors(v map[string]string)`
SetErrors sets Errors field to given value.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

194
internal/httpclient/docs/MetadataApi.md generated Normal file
View File

@@ -0,0 +1,194 @@
# \MetadataApi
All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**GetVersion**](MetadataApi.md#GetVersion) | **Get** /version | Return Running Software Version.
[**IsAlive**](MetadataApi.md#IsAlive) | **Get** /health/alive | Check HTTP Server Status
[**IsReady**](MetadataApi.md#IsReady) | **Get** /health/ready | Check HTTP Server and Database Status
## GetVersion
> GetVersion200Response GetVersion(ctx).Execute()
Return Running Software Version.
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.MetadataApi.GetVersion(context.Background()).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `MetadataApi.GetVersion``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `GetVersion`: GetVersion200Response
fmt.Fprintf(os.Stdout, "Response from `MetadataApi.GetVersion`: %v\n", resp)
}
```
### Path Parameters
This endpoint does not need any parameter.
### Other Parameters
Other parameters are passed through a pointer to a apiGetVersionRequest struct via the builder pattern
### Return type
[**GetVersion200Response**](GetVersion200Response.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## IsAlive
> IsAlive200Response IsAlive(ctx).Execute()
Check HTTP Server Status
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.MetadataApi.IsAlive(context.Background()).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `MetadataApi.IsAlive``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `IsAlive`: IsAlive200Response
fmt.Fprintf(os.Stdout, "Response from `MetadataApi.IsAlive`: %v\n", resp)
}
```
### Path Parameters
This endpoint does not need any parameter.
### Other Parameters
Other parameters are passed through a pointer to a apiIsAliveRequest struct via the builder pattern
### Return type
[**IsAlive200Response**](IsAlive200Response.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, text/plain
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## IsReady
> IsAlive200Response IsReady(ctx).Execute()
Check HTTP Server and Database Status
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.MetadataApi.IsReady(context.Background()).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `MetadataApi.IsReady``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `IsReady`: IsAlive200Response
fmt.Fprintf(os.Stdout, "Response from `MetadataApi.IsReady`: %v\n", resp)
}
```
### Path Parameters
This endpoint does not need any parameter.
### Other Parameters
Other parameters are passed through a pointer to a apiIsReadyRequest struct via the builder pattern
### Return type
[**IsAlive200Response**](IsAlive200Response.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, text/plain
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)

56
internal/httpclient/docs/Namespace.md generated Normal file
View File

@@ -0,0 +1,56 @@
# Namespace
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Name** | Pointer to **string** | Name of the namespace. | [optional]
## Methods
### NewNamespace
`func NewNamespace() *Namespace`
NewNamespace instantiates a new Namespace object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewNamespaceWithDefaults
`func NewNamespaceWithDefaults() *Namespace`
NewNamespaceWithDefaults instantiates a new Namespace object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetName
`func (o *Namespace) GetName() string`
GetName returns the Name field if non-nil, zero value otherwise.
### GetNameOk
`func (o *Namespace) GetNameOk() (*string, bool)`
GetNameOk returns a tuple with the Name field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetName
`func (o *Namespace) SetName(v string)`
SetName sets Name field to given value.
### HasName
`func (o *Namespace) HasName() bool`
HasName returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

108
internal/httpclient/docs/ParseError.md generated Normal file
View File

@@ -0,0 +1,108 @@
# ParseError
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**End** | Pointer to [**SourcePosition**](SourcePosition.md) | | [optional]
**Message** | Pointer to **string** | | [optional]
**Start** | Pointer to [**SourcePosition**](SourcePosition.md) | | [optional]
## Methods
### NewParseError
`func NewParseError() *ParseError`
NewParseError instantiates a new ParseError object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewParseErrorWithDefaults
`func NewParseErrorWithDefaults() *ParseError`
NewParseErrorWithDefaults instantiates a new ParseError object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetEnd
`func (o *ParseError) GetEnd() SourcePosition`
GetEnd returns the End field if non-nil, zero value otherwise.
### GetEndOk
`func (o *ParseError) GetEndOk() (*SourcePosition, bool)`
GetEndOk returns a tuple with the End field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetEnd
`func (o *ParseError) SetEnd(v SourcePosition)`
SetEnd sets End field to given value.
### HasEnd
`func (o *ParseError) HasEnd() bool`
HasEnd returns a boolean if a field has been set.
### GetMessage
`func (o *ParseError) GetMessage() string`
GetMessage returns the Message field if non-nil, zero value otherwise.
### GetMessageOk
`func (o *ParseError) GetMessageOk() (*string, bool)`
GetMessageOk returns a tuple with the Message field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetMessage
`func (o *ParseError) SetMessage(v string)`
SetMessage sets Message field to given value.
### HasMessage
`func (o *ParseError) HasMessage() bool`
HasMessage returns a boolean if a field has been set.
### GetStart
`func (o *ParseError) GetStart() SourcePosition`
GetStart returns the Start field if non-nil, zero value otherwise.
### GetStartOk
`func (o *ParseError) GetStartOk() (*SourcePosition, bool)`
GetStartOk returns a tuple with the Start field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetStart
`func (o *ParseError) SetStart(v SourcePosition)`
SetStart sets Start field to given value.
### HasStart
`func (o *ParseError) HasStart() bool`
HasStart returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

450
internal/httpclient/docs/PermissionApi.md generated Normal file
View File

@@ -0,0 +1,450 @@
# \PermissionApi
All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**BatchCheckPermission**](PermissionApi.md#BatchCheckPermission) | **Post** /relation-tuples/batch/check | Batch check permissions
[**CheckPermission**](PermissionApi.md#CheckPermission) | **Get** /relation-tuples/check/openapi | Check a permission
[**CheckPermissionOrError**](PermissionApi.md#CheckPermissionOrError) | **Get** /relation-tuples/check | Check a permission
[**ExpandPermissions**](PermissionApi.md#ExpandPermissions) | **Get** /relation-tuples/expand | Expand a Relationship into permissions.
[**PostCheckPermission**](PermissionApi.md#PostCheckPermission) | **Post** /relation-tuples/check/openapi | Check a permission
[**PostCheckPermissionOrError**](PermissionApi.md#PostCheckPermissionOrError) | **Post** /relation-tuples/check | Check a permission
## BatchCheckPermission
> BatchCheckPermissionResult BatchCheckPermission(ctx).MaxDepth(maxDepth).BatchCheckPermissionBody(batchCheckPermissionBody).Execute()
Batch check permissions
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
maxDepth := int64(789) // int64 | (optional)
batchCheckPermissionBody := *openapiclient.NewBatchCheckPermissionBody() // BatchCheckPermissionBody | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.PermissionApi.BatchCheckPermission(context.Background()).MaxDepth(maxDepth).BatchCheckPermissionBody(batchCheckPermissionBody).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `PermissionApi.BatchCheckPermission``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `BatchCheckPermission`: BatchCheckPermissionResult
fmt.Fprintf(os.Stdout, "Response from `PermissionApi.BatchCheckPermission`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiBatchCheckPermissionRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**maxDepth** | **int64** | |
**batchCheckPermissionBody** | [**BatchCheckPermissionBody**](BatchCheckPermissionBody.md) | |
### Return type
[**BatchCheckPermissionResult**](BatchCheckPermissionResult.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## CheckPermission
> CheckPermissionResult CheckPermission(ctx).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).MaxDepth(maxDepth).Execute()
Check a permission
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
namespace := "namespace_example" // string | Namespace of the Relationship (optional)
object := "object_example" // string | Object of the Relationship (optional)
relation := "relation_example" // string | Relation of the Relationship (optional)
subjectId := "subjectId_example" // string | SubjectID of the Relationship (optional)
subjectSetNamespace := "subjectSetNamespace_example" // string | Namespace of the Subject Set (optional)
subjectSetObject := "subjectSetObject_example" // string | Object of the Subject Set (optional)
subjectSetRelation := "subjectSetRelation_example" // string | Relation of the Subject Set (optional)
maxDepth := int64(789) // int64 | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.PermissionApi.CheckPermission(context.Background()).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).MaxDepth(maxDepth).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `PermissionApi.CheckPermission``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `CheckPermission`: CheckPermissionResult
fmt.Fprintf(os.Stdout, "Response from `PermissionApi.CheckPermission`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiCheckPermissionRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**namespace** | **string** | Namespace of the Relationship |
**object** | **string** | Object of the Relationship |
**relation** | **string** | Relation of the Relationship |
**subjectId** | **string** | SubjectID of the Relationship |
**subjectSetNamespace** | **string** | Namespace of the Subject Set |
**subjectSetObject** | **string** | Object of the Subject Set |
**subjectSetRelation** | **string** | Relation of the Subject Set |
**maxDepth** | **int64** | |
### Return type
[**CheckPermissionResult**](CheckPermissionResult.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## CheckPermissionOrError
> CheckPermissionResult CheckPermissionOrError(ctx).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).MaxDepth(maxDepth).Execute()
Check a permission
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
namespace := "namespace_example" // string | Namespace of the Relationship (optional)
object := "object_example" // string | Object of the Relationship (optional)
relation := "relation_example" // string | Relation of the Relationship (optional)
subjectId := "subjectId_example" // string | SubjectID of the Relationship (optional)
subjectSetNamespace := "subjectSetNamespace_example" // string | Namespace of the Subject Set (optional)
subjectSetObject := "subjectSetObject_example" // string | Object of the Subject Set (optional)
subjectSetRelation := "subjectSetRelation_example" // string | Relation of the Subject Set (optional)
maxDepth := int64(789) // int64 | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.PermissionApi.CheckPermissionOrError(context.Background()).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).MaxDepth(maxDepth).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `PermissionApi.CheckPermissionOrError``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `CheckPermissionOrError`: CheckPermissionResult
fmt.Fprintf(os.Stdout, "Response from `PermissionApi.CheckPermissionOrError`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiCheckPermissionOrErrorRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**namespace** | **string** | Namespace of the Relationship |
**object** | **string** | Object of the Relationship |
**relation** | **string** | Relation of the Relationship |
**subjectId** | **string** | SubjectID of the Relationship |
**subjectSetNamespace** | **string** | Namespace of the Subject Set |
**subjectSetObject** | **string** | Object of the Subject Set |
**subjectSetRelation** | **string** | Relation of the Subject Set |
**maxDepth** | **int64** | |
### Return type
[**CheckPermissionResult**](CheckPermissionResult.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## ExpandPermissions
> ExpandedPermissionTree ExpandPermissions(ctx).Namespace(namespace).Object(object).Relation(relation).MaxDepth(maxDepth).Execute()
Expand a Relationship into permissions.
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
namespace := "namespace_example" // string | Namespace of the Subject Set
object := "object_example" // string | Object of the Subject Set
relation := "relation_example" // string | Relation of the Subject Set
maxDepth := int64(789) // int64 | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.PermissionApi.ExpandPermissions(context.Background()).Namespace(namespace).Object(object).Relation(relation).MaxDepth(maxDepth).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `PermissionApi.ExpandPermissions``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `ExpandPermissions`: ExpandedPermissionTree
fmt.Fprintf(os.Stdout, "Response from `PermissionApi.ExpandPermissions`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiExpandPermissionsRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**namespace** | **string** | Namespace of the Subject Set |
**object** | **string** | Object of the Subject Set |
**relation** | **string** | Relation of the Subject Set |
**maxDepth** | **int64** | |
### Return type
[**ExpandedPermissionTree**](ExpandedPermissionTree.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## PostCheckPermission
> CheckPermissionResult PostCheckPermission(ctx).MaxDepth(maxDepth).PostCheckPermissionBody(postCheckPermissionBody).Execute()
Check a permission
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
maxDepth := int64(789) // int64 | (optional)
postCheckPermissionBody := *openapiclient.NewPostCheckPermissionBody() // PostCheckPermissionBody | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.PermissionApi.PostCheckPermission(context.Background()).MaxDepth(maxDepth).PostCheckPermissionBody(postCheckPermissionBody).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `PermissionApi.PostCheckPermission``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `PostCheckPermission`: CheckPermissionResult
fmt.Fprintf(os.Stdout, "Response from `PermissionApi.PostCheckPermission`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiPostCheckPermissionRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**maxDepth** | **int64** | |
**postCheckPermissionBody** | [**PostCheckPermissionBody**](PostCheckPermissionBody.md) | |
### Return type
[**CheckPermissionResult**](CheckPermissionResult.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## PostCheckPermissionOrError
> CheckPermissionResult PostCheckPermissionOrError(ctx).MaxDepth(maxDepth).PostCheckPermissionOrErrorBody(postCheckPermissionOrErrorBody).Execute()
Check a permission
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
maxDepth := int64(789) // int64 | (optional)
postCheckPermissionOrErrorBody := *openapiclient.NewPostCheckPermissionOrErrorBody() // PostCheckPermissionOrErrorBody | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.PermissionApi.PostCheckPermissionOrError(context.Background()).MaxDepth(maxDepth).PostCheckPermissionOrErrorBody(postCheckPermissionOrErrorBody).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `PermissionApi.PostCheckPermissionOrError``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `PostCheckPermissionOrError`: CheckPermissionResult
fmt.Fprintf(os.Stdout, "Response from `PermissionApi.PostCheckPermissionOrError`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiPostCheckPermissionOrErrorRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**maxDepth** | **int64** | |
**postCheckPermissionOrErrorBody** | [**PostCheckPermissionOrErrorBody**](PostCheckPermissionOrErrorBody.md) | |
### Return type
[**CheckPermissionResult**](CheckPermissionResult.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)

View File

@@ -0,0 +1,160 @@
# PostCheckPermissionBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Namespace** | Pointer to **string** | Namespace to query | [optional]
**Object** | Pointer to **string** | Object to query | [optional]
**Relation** | Pointer to **string** | Relation to query | [optional]
**SubjectId** | Pointer to **string** | SubjectID to query Either SubjectSet or SubjectID can be provided. | [optional]
**SubjectSet** | Pointer to [**SubjectSet**](SubjectSet.md) | | [optional]
## Methods
### NewPostCheckPermissionBody
`func NewPostCheckPermissionBody() *PostCheckPermissionBody`
NewPostCheckPermissionBody instantiates a new PostCheckPermissionBody object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewPostCheckPermissionBodyWithDefaults
`func NewPostCheckPermissionBodyWithDefaults() *PostCheckPermissionBody`
NewPostCheckPermissionBodyWithDefaults instantiates a new PostCheckPermissionBody object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetNamespace
`func (o *PostCheckPermissionBody) GetNamespace() string`
GetNamespace returns the Namespace field if non-nil, zero value otherwise.
### GetNamespaceOk
`func (o *PostCheckPermissionBody) GetNamespaceOk() (*string, bool)`
GetNamespaceOk returns a tuple with the Namespace field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetNamespace
`func (o *PostCheckPermissionBody) SetNamespace(v string)`
SetNamespace sets Namespace field to given value.
### HasNamespace
`func (o *PostCheckPermissionBody) HasNamespace() bool`
HasNamespace returns a boolean if a field has been set.
### GetObject
`func (o *PostCheckPermissionBody) GetObject() string`
GetObject returns the Object field if non-nil, zero value otherwise.
### GetObjectOk
`func (o *PostCheckPermissionBody) GetObjectOk() (*string, bool)`
GetObjectOk returns a tuple with the Object field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetObject
`func (o *PostCheckPermissionBody) SetObject(v string)`
SetObject sets Object field to given value.
### HasObject
`func (o *PostCheckPermissionBody) HasObject() bool`
HasObject returns a boolean if a field has been set.
### GetRelation
`func (o *PostCheckPermissionBody) GetRelation() string`
GetRelation returns the Relation field if non-nil, zero value otherwise.
### GetRelationOk
`func (o *PostCheckPermissionBody) GetRelationOk() (*string, bool)`
GetRelationOk returns a tuple with the Relation field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetRelation
`func (o *PostCheckPermissionBody) SetRelation(v string)`
SetRelation sets Relation field to given value.
### HasRelation
`func (o *PostCheckPermissionBody) HasRelation() bool`
HasRelation returns a boolean if a field has been set.
### GetSubjectId
`func (o *PostCheckPermissionBody) GetSubjectId() string`
GetSubjectId returns the SubjectId field if non-nil, zero value otherwise.
### GetSubjectIdOk
`func (o *PostCheckPermissionBody) GetSubjectIdOk() (*string, bool)`
GetSubjectIdOk returns a tuple with the SubjectId field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectId
`func (o *PostCheckPermissionBody) SetSubjectId(v string)`
SetSubjectId sets SubjectId field to given value.
### HasSubjectId
`func (o *PostCheckPermissionBody) HasSubjectId() bool`
HasSubjectId returns a boolean if a field has been set.
### GetSubjectSet
`func (o *PostCheckPermissionBody) GetSubjectSet() SubjectSet`
GetSubjectSet returns the SubjectSet field if non-nil, zero value otherwise.
### GetSubjectSetOk
`func (o *PostCheckPermissionBody) GetSubjectSetOk() (*SubjectSet, bool)`
GetSubjectSetOk returns a tuple with the SubjectSet field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectSet
`func (o *PostCheckPermissionBody) SetSubjectSet(v SubjectSet)`
SetSubjectSet sets SubjectSet field to given value.
### HasSubjectSet
`func (o *PostCheckPermissionBody) HasSubjectSet() bool`
HasSubjectSet returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,160 @@
# PostCheckPermissionOrErrorBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Namespace** | Pointer to **string** | Namespace to query | [optional]
**Object** | Pointer to **string** | Object to query | [optional]
**Relation** | Pointer to **string** | Relation to query | [optional]
**SubjectId** | Pointer to **string** | SubjectID to query Either SubjectSet or SubjectID can be provided. | [optional]
**SubjectSet** | Pointer to [**SubjectSet**](SubjectSet.md) | | [optional]
## Methods
### NewPostCheckPermissionOrErrorBody
`func NewPostCheckPermissionOrErrorBody() *PostCheckPermissionOrErrorBody`
NewPostCheckPermissionOrErrorBody instantiates a new PostCheckPermissionOrErrorBody object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewPostCheckPermissionOrErrorBodyWithDefaults
`func NewPostCheckPermissionOrErrorBodyWithDefaults() *PostCheckPermissionOrErrorBody`
NewPostCheckPermissionOrErrorBodyWithDefaults instantiates a new PostCheckPermissionOrErrorBody object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetNamespace
`func (o *PostCheckPermissionOrErrorBody) GetNamespace() string`
GetNamespace returns the Namespace field if non-nil, zero value otherwise.
### GetNamespaceOk
`func (o *PostCheckPermissionOrErrorBody) GetNamespaceOk() (*string, bool)`
GetNamespaceOk returns a tuple with the Namespace field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetNamespace
`func (o *PostCheckPermissionOrErrorBody) SetNamespace(v string)`
SetNamespace sets Namespace field to given value.
### HasNamespace
`func (o *PostCheckPermissionOrErrorBody) HasNamespace() bool`
HasNamespace returns a boolean if a field has been set.
### GetObject
`func (o *PostCheckPermissionOrErrorBody) GetObject() string`
GetObject returns the Object field if non-nil, zero value otherwise.
### GetObjectOk
`func (o *PostCheckPermissionOrErrorBody) GetObjectOk() (*string, bool)`
GetObjectOk returns a tuple with the Object field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetObject
`func (o *PostCheckPermissionOrErrorBody) SetObject(v string)`
SetObject sets Object field to given value.
### HasObject
`func (o *PostCheckPermissionOrErrorBody) HasObject() bool`
HasObject returns a boolean if a field has been set.
### GetRelation
`func (o *PostCheckPermissionOrErrorBody) GetRelation() string`
GetRelation returns the Relation field if non-nil, zero value otherwise.
### GetRelationOk
`func (o *PostCheckPermissionOrErrorBody) GetRelationOk() (*string, bool)`
GetRelationOk returns a tuple with the Relation field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetRelation
`func (o *PostCheckPermissionOrErrorBody) SetRelation(v string)`
SetRelation sets Relation field to given value.
### HasRelation
`func (o *PostCheckPermissionOrErrorBody) HasRelation() bool`
HasRelation returns a boolean if a field has been set.
### GetSubjectId
`func (o *PostCheckPermissionOrErrorBody) GetSubjectId() string`
GetSubjectId returns the SubjectId field if non-nil, zero value otherwise.
### GetSubjectIdOk
`func (o *PostCheckPermissionOrErrorBody) GetSubjectIdOk() (*string, bool)`
GetSubjectIdOk returns a tuple with the SubjectId field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectId
`func (o *PostCheckPermissionOrErrorBody) SetSubjectId(v string)`
SetSubjectId sets SubjectId field to given value.
### HasSubjectId
`func (o *PostCheckPermissionOrErrorBody) HasSubjectId() bool`
HasSubjectId returns a boolean if a field has been set.
### GetSubjectSet
`func (o *PostCheckPermissionOrErrorBody) GetSubjectSet() SubjectSet`
GetSubjectSet returns the SubjectSet field if non-nil, zero value otherwise.
### GetSubjectSetOk
`func (o *PostCheckPermissionOrErrorBody) GetSubjectSetOk() (*SubjectSet, bool)`
GetSubjectSetOk returns a tuple with the SubjectSet field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectSet
`func (o *PostCheckPermissionOrErrorBody) SetSubjectSet(v SubjectSet)`
SetSubjectSet sets SubjectSet field to given value.
### HasSubjectSet
`func (o *PostCheckPermissionOrErrorBody) HasSubjectSet() bool`
HasSubjectSet returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

160
internal/httpclient/docs/RelationQuery.md generated Normal file
View File

@@ -0,0 +1,160 @@
# RelationQuery
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Namespace** | Pointer to **string** | Namespace to query | [optional]
**Object** | Pointer to **string** | Object to query | [optional]
**Relation** | Pointer to **string** | Relation to query | [optional]
**SubjectId** | Pointer to **string** | SubjectID to query Either SubjectSet or SubjectID can be provided. | [optional]
**SubjectSet** | Pointer to [**SubjectSet**](SubjectSet.md) | | [optional]
## Methods
### NewRelationQuery
`func NewRelationQuery() *RelationQuery`
NewRelationQuery instantiates a new RelationQuery object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewRelationQueryWithDefaults
`func NewRelationQueryWithDefaults() *RelationQuery`
NewRelationQueryWithDefaults instantiates a new RelationQuery object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetNamespace
`func (o *RelationQuery) GetNamespace() string`
GetNamespace returns the Namespace field if non-nil, zero value otherwise.
### GetNamespaceOk
`func (o *RelationQuery) GetNamespaceOk() (*string, bool)`
GetNamespaceOk returns a tuple with the Namespace field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetNamespace
`func (o *RelationQuery) SetNamespace(v string)`
SetNamespace sets Namespace field to given value.
### HasNamespace
`func (o *RelationQuery) HasNamespace() bool`
HasNamespace returns a boolean if a field has been set.
### GetObject
`func (o *RelationQuery) GetObject() string`
GetObject returns the Object field if non-nil, zero value otherwise.
### GetObjectOk
`func (o *RelationQuery) GetObjectOk() (*string, bool)`
GetObjectOk returns a tuple with the Object field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetObject
`func (o *RelationQuery) SetObject(v string)`
SetObject sets Object field to given value.
### HasObject
`func (o *RelationQuery) HasObject() bool`
HasObject returns a boolean if a field has been set.
### GetRelation
`func (o *RelationQuery) GetRelation() string`
GetRelation returns the Relation field if non-nil, zero value otherwise.
### GetRelationOk
`func (o *RelationQuery) GetRelationOk() (*string, bool)`
GetRelationOk returns a tuple with the Relation field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetRelation
`func (o *RelationQuery) SetRelation(v string)`
SetRelation sets Relation field to given value.
### HasRelation
`func (o *RelationQuery) HasRelation() bool`
HasRelation returns a boolean if a field has been set.
### GetSubjectId
`func (o *RelationQuery) GetSubjectId() string`
GetSubjectId returns the SubjectId field if non-nil, zero value otherwise.
### GetSubjectIdOk
`func (o *RelationQuery) GetSubjectIdOk() (*string, bool)`
GetSubjectIdOk returns a tuple with the SubjectId field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectId
`func (o *RelationQuery) SetSubjectId(v string)`
SetSubjectId sets SubjectId field to given value.
### HasSubjectId
`func (o *RelationQuery) HasSubjectId() bool`
HasSubjectId returns a boolean if a field has been set.
### GetSubjectSet
`func (o *RelationQuery) GetSubjectSet() SubjectSet`
GetSubjectSet returns the SubjectSet field if non-nil, zero value otherwise.
### GetSubjectSetOk
`func (o *RelationQuery) GetSubjectSetOk() (*SubjectSet, bool)`
GetSubjectSetOk returns a tuple with the SubjectSet field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectSet
`func (o *RelationQuery) SetSubjectSet(v SubjectSet)`
SetSubjectSet sets SubjectSet field to given value.
### HasSubjectSet
`func (o *RelationQuery) HasSubjectSet() bool`
HasSubjectSet returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

145
internal/httpclient/docs/Relationship.md generated Normal file
View File

@@ -0,0 +1,145 @@
# Relationship
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Namespace** | **string** | Namespace of the Relation Tuple |
**Object** | **string** | Object of the Relation Tuple |
**Relation** | **string** | Relation of the Relation Tuple |
**SubjectId** | Pointer to **string** | SubjectID of the Relation Tuple Either SubjectSet or SubjectID can be provided. | [optional]
**SubjectSet** | Pointer to [**SubjectSet**](SubjectSet.md) | | [optional]
## Methods
### NewRelationship
`func NewRelationship(namespace string, object string, relation string, ) *Relationship`
NewRelationship instantiates a new Relationship object
This constructor will assign default values to properties that have it defined,
and makes sure properties required by API are set, but the set of arguments
will change when the set of required properties is changed
### NewRelationshipWithDefaults
`func NewRelationshipWithDefaults() *Relationship`
NewRelationshipWithDefaults instantiates a new Relationship object
This constructor will only assign default values to properties that have it defined,
but it doesn't guarantee that properties required by API are set
### GetNamespace
`func (o *Relationship) GetNamespace() string`
GetNamespace returns the Namespace field if non-nil, zero value otherwise.
### GetNamespaceOk
`func (o *Relationship) GetNamespaceOk() (*string, bool)`
GetNamespaceOk returns a tuple with the Namespace field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetNamespace
`func (o *Relationship) SetNamespace(v string)`
SetNamespace sets Namespace field to given value.
### GetObject
`func (o *Relationship) GetObject() string`
GetObject returns the Object field if non-nil, zero value otherwise.
### GetObjectOk
`func (o *Relationship) GetObjectOk() (*string, bool)`
GetObjectOk returns a tuple with the Object field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetObject
`func (o *Relationship) SetObject(v string)`
SetObject sets Object field to given value.
### GetRelation
`func (o *Relationship) GetRelation() string`
GetRelation returns the Relation field if non-nil, zero value otherwise.
### GetRelationOk
`func (o *Relationship) GetRelationOk() (*string, bool)`
GetRelationOk returns a tuple with the Relation field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetRelation
`func (o *Relationship) SetRelation(v string)`
SetRelation sets Relation field to given value.
### GetSubjectId
`func (o *Relationship) GetSubjectId() string`
GetSubjectId returns the SubjectId field if non-nil, zero value otherwise.
### GetSubjectIdOk
`func (o *Relationship) GetSubjectIdOk() (*string, bool)`
GetSubjectIdOk returns a tuple with the SubjectId field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectId
`func (o *Relationship) SetSubjectId(v string)`
SetSubjectId sets SubjectId field to given value.
### HasSubjectId
`func (o *Relationship) HasSubjectId() bool`
HasSubjectId returns a boolean if a field has been set.
### GetSubjectSet
`func (o *Relationship) GetSubjectSet() SubjectSet`
GetSubjectSet returns the SubjectSet field if non-nil, zero value otherwise.
### GetSubjectSetOk
`func (o *Relationship) GetSubjectSetOk() (*SubjectSet, bool)`
GetSubjectSetOk returns a tuple with the SubjectSet field if it's non-nil, zero value otherwise
and a boolean to check if the value has been set.
### SetSubjectSet
`func (o *Relationship) SetSubjectSet(v SubjectSet)`
SetSubjectSet sets SubjectSet field to given value.
### HasSubjectSet
`func (o *Relationship) HasSubjectSet() bool`
HasSubjectSet returns a boolean if a field has been set.
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,429 @@
# \RelationshipApi
All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**CheckOplSyntax**](RelationshipApi.md#CheckOplSyntax) | **Post** /opl/syntax/check | Check the syntax of an OPL file
[**CreateRelationship**](RelationshipApi.md#CreateRelationship) | **Put** /admin/relation-tuples | Create a Relationship
[**DeleteRelationships**](RelationshipApi.md#DeleteRelationships) | **Delete** /admin/relation-tuples | Delete Relationships
[**GetRelationships**](RelationshipApi.md#GetRelationships) | **Get** /relation-tuples | Query relationships
[**ListRelationshipNamespaces**](RelationshipApi.md#ListRelationshipNamespaces) | **Get** /namespaces | Query namespaces
[**PatchRelationships**](RelationshipApi.md#PatchRelationships) | **Patch** /admin/relation-tuples | Patch Multiple Relationships
## CheckOplSyntax
> CheckOplSyntaxResult CheckOplSyntax(ctx).Body(body).Execute()
Check the syntax of an OPL file
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
body := "body_example" // string | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.RelationshipApi.CheckOplSyntax(context.Background()).Body(body).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `RelationshipApi.CheckOplSyntax``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `CheckOplSyntax`: CheckOplSyntaxResult
fmt.Fprintf(os.Stdout, "Response from `RelationshipApi.CheckOplSyntax`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiCheckOplSyntaxRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**body** | **string** | |
### Return type
[**CheckOplSyntaxResult**](CheckOplSyntaxResult.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: text/plain
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## CreateRelationship
> Relationship CreateRelationship(ctx).CreateRelationshipBody(createRelationshipBody).Execute()
Create a Relationship
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
createRelationshipBody := *openapiclient.NewCreateRelationshipBody() // CreateRelationshipBody | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.RelationshipApi.CreateRelationship(context.Background()).CreateRelationshipBody(createRelationshipBody).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `RelationshipApi.CreateRelationship``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `CreateRelationship`: Relationship
fmt.Fprintf(os.Stdout, "Response from `RelationshipApi.CreateRelationship`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiCreateRelationshipRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**createRelationshipBody** | [**CreateRelationshipBody**](CreateRelationshipBody.md) | |
### Return type
[**Relationship**](Relationship.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## DeleteRelationships
> DeleteRelationships(ctx).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).Execute()
Delete Relationships
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
namespace := "namespace_example" // string | Namespace of the Relationship (optional)
object := "object_example" // string | Object of the Relationship (optional)
relation := "relation_example" // string | Relation of the Relationship (optional)
subjectId := "subjectId_example" // string | SubjectID of the Relationship (optional)
subjectSetNamespace := "subjectSetNamespace_example" // string | Namespace of the Subject Set (optional)
subjectSetObject := "subjectSetObject_example" // string | Object of the Subject Set (optional)
subjectSetRelation := "subjectSetRelation_example" // string | Relation of the Subject Set (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.RelationshipApi.DeleteRelationships(context.Background()).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `RelationshipApi.DeleteRelationships``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiDeleteRelationshipsRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**namespace** | **string** | Namespace of the Relationship |
**object** | **string** | Object of the Relationship |
**relation** | **string** | Relation of the Relationship |
**subjectId** | **string** | SubjectID of the Relationship |
**subjectSetNamespace** | **string** | Namespace of the Subject Set |
**subjectSetObject** | **string** | Object of the Subject Set |
**subjectSetRelation** | **string** | Relation of the Subject Set |
### Return type
(empty response body)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## GetRelationships
> Relationships GetRelationships(ctx).PageSize(pageSize).PageToken(pageToken).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).Execute()
Query relationships
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
pageSize := int64(789) // int64 | Items per Page This is the number of items per page to return. For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination). (optional) (default to 250)
pageToken := "pageToken_example" // string | Next Page Token The next page token. For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination). (optional)
namespace := "namespace_example" // string | Namespace of the Relationship (optional)
object := "object_example" // string | Object of the Relationship (optional)
relation := "relation_example" // string | Relation of the Relationship (optional)
subjectId := "subjectId_example" // string | SubjectID of the Relationship (optional)
subjectSetNamespace := "subjectSetNamespace_example" // string | Namespace of the Subject Set (optional)
subjectSetObject := "subjectSetObject_example" // string | Object of the Subject Set (optional)
subjectSetRelation := "subjectSetRelation_example" // string | Relation of the Subject Set (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.RelationshipApi.GetRelationships(context.Background()).PageSize(pageSize).PageToken(pageToken).Namespace(namespace).Object(object).Relation(relation).SubjectId(subjectId).SubjectSetNamespace(subjectSetNamespace).SubjectSetObject(subjectSetObject).SubjectSetRelation(subjectSetRelation).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `RelationshipApi.GetRelationships``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `GetRelationships`: Relationships
fmt.Fprintf(os.Stdout, "Response from `RelationshipApi.GetRelationships`: %v\n", resp)
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiGetRelationshipsRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**pageSize** | **int64** | Items per Page This is the number of items per page to return. For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination). | [default to 250]
**pageToken** | **string** | Next Page Token The next page token. For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination). |
**namespace** | **string** | Namespace of the Relationship |
**object** | **string** | Object of the Relationship |
**relation** | **string** | Relation of the Relationship |
**subjectId** | **string** | SubjectID of the Relationship |
**subjectSetNamespace** | **string** | Namespace of the Subject Set |
**subjectSetObject** | **string** | Object of the Subject Set |
**subjectSetRelation** | **string** | Relation of the Subject Set |
### Return type
[**Relationships**](Relationships.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## ListRelationshipNamespaces
> RelationshipNamespaces ListRelationshipNamespaces(ctx).Execute()
Query namespaces
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.RelationshipApi.ListRelationshipNamespaces(context.Background()).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `RelationshipApi.ListRelationshipNamespaces``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
// response from `ListRelationshipNamespaces`: RelationshipNamespaces
fmt.Fprintf(os.Stdout, "Response from `RelationshipApi.ListRelationshipNamespaces`: %v\n", resp)
}
```
### Path Parameters
This endpoint does not need any parameter.
### Other Parameters
Other parameters are passed through a pointer to a apiListRelationshipNamespacesRequest struct via the builder pattern
### Return type
[**RelationshipNamespaces**](RelationshipNamespaces.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)
## PatchRelationships
> PatchRelationships(ctx).RelationshipPatch(relationshipPatch).Execute()
Patch Multiple Relationships
### Example
```go
package main
import (
"context"
"fmt"
"os"
openapiclient "./openapi"
)
func main() {
relationshipPatch := []openapiclient.RelationshipPatch{*openapiclient.NewRelationshipPatch()} // []RelationshipPatch | (optional)
configuration := openapiclient.NewConfiguration()
apiClient := openapiclient.NewAPIClient(configuration)
resp, r, err := apiClient.RelationshipApi.PatchRelationships(context.Background()).RelationshipPatch(relationshipPatch).Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "Error when calling `RelationshipApi.PatchRelationships``: %v\n", err)
fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r)
}
}
```
### Path Parameters
### Other Parameters
Other parameters are passed through a pointer to a apiPatchRelationshipsRequest struct via the builder pattern
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**relationshipPatch** | [**[]RelationshipPatch**](RelationshipPatch.md) | |
### Return type
(empty response body)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints)
[[Back to Model list]](../README.md#documentation-for-models)
[[Back to README]](../README.md)

Some files were not shown because too many files have changed in this diff Show More