snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
7
internal/check/2023-01-09-benchtest.txt
Normal file
7
internal/check/2023-01-09-benchtest.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/ory/keto/internal/check
|
||||
cpu: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
|
||||
BenchmarkComputedUsersets/Computed_userset-8 2155 583306 ns/op 9.000 queries/op 109370 B/op 1556 allocs/op
|
||||
PASS
|
||||
ok github.com/ory/keto/internal/check 1.351s
|
||||
190
internal/check/bench_test.go
Normal file
190
internal/check/bench_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
func wideNamespace(width int) *namespace.Namespace {
|
||||
wideNS := &namespace.Namespace{
|
||||
Name: fmt.Sprintf("%d_wide", width),
|
||||
Relations: []ast.Relation{{Name: "editor"}},
|
||||
}
|
||||
viewerRelation := &ast.Relation{
|
||||
Name: "viewer",
|
||||
SubjectSetRewrite: &ast.SubjectSetRewrite{
|
||||
Operation: ast.OperatorOr,
|
||||
Children: ast.Children{},
|
||||
},
|
||||
}
|
||||
for i := 0; i < width; i++ {
|
||||
relation := fmt.Sprintf("relation-%d", i)
|
||||
viewerRelation.SubjectSetRewrite.Children = append(
|
||||
viewerRelation.SubjectSetRewrite.Children,
|
||||
&ast.ComputedSubjectSet{Relation: relation},
|
||||
)
|
||||
wideNS.Relations = append(wideNS.Relations, ast.Relation{Name: relation})
|
||||
}
|
||||
viewerRelation.SubjectSetRewrite.Children = append(
|
||||
viewerRelation.SubjectSetRewrite.Children,
|
||||
&ast.ComputedSubjectSet{Relation: "editor"},
|
||||
)
|
||||
wideNS.Relations = append(wideNS.Relations, *viewerRelation)
|
||||
|
||||
return wideNS
|
||||
}
|
||||
|
||||
func BenchmarkCheckEngine(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
var (
|
||||
depths = []int{2, 4, 8, 16, 32}
|
||||
widths = []int{10, 20, 40, 80, 100}
|
||||
maxDepth = depths[len(depths)-1]
|
||||
)
|
||||
|
||||
namespaces := []*namespace.Namespace{
|
||||
{
|
||||
Name: "deep",
|
||||
Relations: []ast.Relation{
|
||||
{Name: "owner"},
|
||||
{
|
||||
Name: "editor",
|
||||
SubjectSetRewrite: &ast.SubjectSetRewrite{
|
||||
Children: ast.Children{&ast.ComputedSubjectSet{
|
||||
Relation: "owner",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "viewer",
|
||||
SubjectSetRewrite: &ast.SubjectSetRewrite{
|
||||
Children: ast.Children{
|
||||
&ast.ComputedSubjectSet{
|
||||
Relation: "editor",
|
||||
},
|
||||
&ast.TupleToSubjectSet{
|
||||
Relation: "parent",
|
||||
ComputedSubjectSetRelation: "viewer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(b, driver.WithNamespaces(namespaces))
|
||||
reg.Logger().Logger.SetLevel(logrus.InfoLevel)
|
||||
|
||||
tuples := []string{
|
||||
"deep:deep_file#parent@deep:folder_1#...",
|
||||
}
|
||||
for i := 1; i < maxDepth; i++ {
|
||||
tuples = append(tuples, fmt.Sprintf("deep:folder_%d#parent@deep:folder_%d#...", i, i+1))
|
||||
}
|
||||
for _, d := range depths {
|
||||
tuples = append(tuples, fmt.Sprintf("deep:folder_%d#owner@user_%d", d, d))
|
||||
}
|
||||
for _, w := range widths {
|
||||
namespaces = append(namespaces, wideNamespace(w))
|
||||
tuples = append(tuples, fmt.Sprintf("%d-wide:wide_file#editor@user", w))
|
||||
}
|
||||
testhelpers.MapAndInsertTuplesFromString(b, reg, tuples)
|
||||
|
||||
require.NoError(b, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 100*maxDepth))
|
||||
e := check.NewEngine(reg)
|
||||
|
||||
b.Run("case=deep tree", func(b *testing.B) {
|
||||
for _, depth := range depths {
|
||||
b.Run(fmt.Sprintf("depth=%03d", depth), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
rt := testhelpers.TupleFromString(b, fmt.Sprintf("deep:deep_file#viewer@user_%d", depth))
|
||||
res := e.CheckRelationTuple(ctx, rt, 2*depth)
|
||||
assert.NoError(b, res.Err)
|
||||
if res.Membership != check.IsMember {
|
||||
b.Error("user should be able to view 'deep_file'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("case=wide tree", func(b *testing.B) {
|
||||
for _, width := range widths {
|
||||
b.Run(fmt.Sprintf("width=%03d", width), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
rt := testhelpers.TupleFromString(b, fmt.Sprintf("%d-wide:wide_file#editor@user", width))
|
||||
res := e.CheckRelationTuple(ctx, rt, 2*width)
|
||||
assert.NoError(b, res.Err)
|
||||
if res.Membership != check.IsMember {
|
||||
b.Error("user should be able to view 'wide_file'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//go:embed testfixtures/project_opl.ts
|
||||
var ProjectOPLConfig string
|
||||
|
||||
func BenchmarkComputedUsersets(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
|
||||
spans := tracetest.NewSpanRecorder()
|
||||
tracer := trace.NewTracerProvider(trace.WithSpanProcessor(spans)).Tracer("")
|
||||
reg := driver.NewSqliteTestRegistry(b, driver.WithLogLevel("debug"), driver.WithOPL(ProjectOPLConfig), driver.WithTracer(tracer), driver.WithConfig(config.KeyFeatureFlagStrictMode, true))
|
||||
reg.Logger().Logger.SetLevel(logrus.DebugLevel)
|
||||
|
||||
testhelpers.MapAndInsertTuplesFromString(b, reg, []string{
|
||||
"Project:Ory#owner@User:Admin",
|
||||
"Project:Ory#developer@User:Dev",
|
||||
})
|
||||
|
||||
e := check.NewEngine(reg)
|
||||
|
||||
query := testhelpers.TupleFromString(b, "Project:Ory#readProject@User:Dev")
|
||||
|
||||
b.Run("Computed userset", func(b *testing.B) {
|
||||
initialDBSpans := dbSpans(spans)
|
||||
for i := 0; i < b.N; i++ {
|
||||
res := e.CheckRelationTuple(ctx, query, 0)
|
||||
assert.NoError(b, res.Err)
|
||||
if res.Err != nil {
|
||||
b.Errorf("got unexpected error: %v", res.Err)
|
||||
}
|
||||
if res.Membership != check.IsMember {
|
||||
b.Error("check failed")
|
||||
}
|
||||
}
|
||||
b.ReportMetric((float64(dbSpans(spans)-initialDBSpans))/float64(b.N), "queries/op")
|
||||
})
|
||||
}
|
||||
|
||||
func dbSpans(spans *tracetest.SpanRecorder) (count int) {
|
||||
for _, s := range spans.Started() {
|
||||
if strings.HasPrefix(s.Name(), "sql-conn-query") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
157
internal/check/call_limit_test.go
Normal file
157
internal/check/call_limit_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/schema"
|
||||
)
|
||||
|
||||
func newRegistryForOPL(t *testing.T, opl string, opts ...driver.TestRegistryOption) *driver.RegistryDefault {
|
||||
t.Helper()
|
||||
parsed, errs := schema.Parse(opl)
|
||||
require.Empty(t, errs)
|
||||
nss := make([]*namespace.Namespace, len(parsed))
|
||||
for i := range parsed {
|
||||
nss[i] = &parsed[i]
|
||||
}
|
||||
return driver.NewSqliteTestRegistry(t, append([]driver.TestRegistryOption{driver.WithNamespaces(nss)}, opts...)...)
|
||||
}
|
||||
|
||||
// requireCyclicError asserts err is the call-limit backstop, not just any error
|
||||
// (e.g. a context cancellation), by checking its reason.
|
||||
func requireCyclicError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var re interface{ Reason() string }
|
||||
require.ErrorAs(t, err, &re)
|
||||
require.Contains(t, re.Reason(), "cyclic permit reference")
|
||||
}
|
||||
|
||||
func TestCallLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type outcome int
|
||||
const (
|
||||
cyclicError outcome = iota
|
||||
member
|
||||
depthLimited
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
opl string
|
||||
opts []driver.TestRegistryOption
|
||||
tuples []string
|
||||
check string
|
||||
restDepth int
|
||||
want outcome
|
||||
}{
|
||||
{
|
||||
name: "invert cycle aborts regardless of configured depth",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class Resource implements Namespace {
|
||||
permits = {
|
||||
edit: (ctx: Context): boolean => !this.permits.view(ctx),
|
||||
view: (ctx: Context): boolean => !this.permits.edit(ctx),
|
||||
}
|
||||
}`,
|
||||
opts: []driver.TestRegistryOption{driver.WithConfig("limit.max_read_depth", 65535)},
|
||||
check: "Resource:doc#edit@User:alice",
|
||||
restDepth: 65535,
|
||||
want: cyclicError,
|
||||
},
|
||||
{
|
||||
name: "branching cycle aborts decisively",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class R implements Namespace {
|
||||
permits = {
|
||||
a: (ctx: Context): boolean => this.permits.b(ctx) && this.permits.c(ctx),
|
||||
b: (ctx: Context): boolean => this.permits.a(ctx) && this.permits.a(ctx),
|
||||
c: (ctx: Context): boolean => this.permits.a(ctx) && this.permits.a(ctx),
|
||||
}
|
||||
}`,
|
||||
check: "R:x#a@User:u",
|
||||
restDepth: 5,
|
||||
want: cyclicError,
|
||||
},
|
||||
{
|
||||
name: "self-loop is bounded by RestDepth",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class Resource implements Namespace {
|
||||
permits = {
|
||||
edit: (ctx: Context): boolean => this.permits.edit(ctx),
|
||||
}
|
||||
}`,
|
||||
check: "Resource:doc#edit@User:alice",
|
||||
restDepth: 5,
|
||||
want: depthLimited,
|
||||
},
|
||||
{
|
||||
name: "legit role chain resolves",
|
||||
opl: `
|
||||
class User implements Namespace {}
|
||||
class Doc implements Namespace {
|
||||
related: {
|
||||
l0: User[]; l1: User[]; l2: User[]; l3: User[];
|
||||
}
|
||||
permits = {
|
||||
p0: (ctx: Context): boolean => this.permits.p1(ctx) || this.related.l0.includes(ctx.subject),
|
||||
p1: (ctx: Context): boolean => this.permits.p2(ctx) || this.related.l1.includes(ctx.subject),
|
||||
p2: (ctx: Context): boolean => this.permits.p3(ctx) || this.related.l2.includes(ctx.subject),
|
||||
p3: (ctx: Context): boolean => this.related.l3.includes(ctx.subject),
|
||||
}
|
||||
}`,
|
||||
tuples: []string{"Doc:d#l3@User:alice"},
|
||||
check: "Doc:d#p0@User:alice",
|
||||
restDepth: 5,
|
||||
want: member,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := newRegistryForOPL(t, tc.opl, tc.opts...)
|
||||
if len(tc.tuples) > 0 {
|
||||
testhelpers.MapAndInsertTuplesFromString(t, reg, tc.tuples)
|
||||
}
|
||||
rt := testhelpers.TupleFromString(t, tc.check)
|
||||
|
||||
assertResult := func(t *testing.T, res check.Result) {
|
||||
switch tc.want {
|
||||
case cyclicError:
|
||||
requireCyclicError(t, res.Err)
|
||||
require.Equal(t, check.MembershipUnknown, res.Membership)
|
||||
case member:
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, check.IsMember, res.Membership)
|
||||
case depthLimited:
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, check.MembershipUnknown, res.Membership)
|
||||
require.Equal(t, check.LimitationMaxDepthExceeded, res.Limitation)
|
||||
}
|
||||
}
|
||||
|
||||
// Both executors wire the middleware separately.
|
||||
t.Run("check engine", func(t *testing.T) {
|
||||
assertResult(t, check.NewEngine(reg).CheckRelationTuple(t.Context(), rt, tc.restDepth))
|
||||
})
|
||||
t.Run("trace engine", func(t *testing.T) {
|
||||
res, tree := trace.NewEngine(reg).CheckRelationTupleWithTrace(t.Context(), rt, tc.restDepth)
|
||||
require.NotNil(t, tree)
|
||||
assertResult(t, res)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
141
internal/check/definitions.go
Normal file
141
internal/check/definitions.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type (
|
||||
// Result holds the outcome of a single permission check.
|
||||
// Limitation is non-empty only when Membership is MembershipUnknown.
|
||||
Result struct {
|
||||
Membership Membership
|
||||
Err error
|
||||
Limitation LimitationKind
|
||||
}
|
||||
|
||||
Membership int
|
||||
|
||||
// LimitationKind identifies which traversal constraint was hit. A non-empty
|
||||
// value on a Result means the membership answer is indeterminate.
|
||||
LimitationKind string
|
||||
)
|
||||
|
||||
//go:generate stringer -type Membership
|
||||
const (
|
||||
MembershipUnknown Membership = iota
|
||||
IsMember
|
||||
NotMember
|
||||
)
|
||||
|
||||
const (
|
||||
LimitationMaxDepthExceeded LimitationKind = "max_depth_exceeded"
|
||||
LimitationMaxWidthExceeded LimitationKind = "max_width_exceeded"
|
||||
)
|
||||
|
||||
var (
|
||||
ResultUnknown = Result{Membership: MembershipUnknown}
|
||||
ResultIsMember = Result{Membership: IsMember}
|
||||
ResultNotMember = Result{Membership: NotMember}
|
||||
)
|
||||
|
||||
// CheckRequest carries all inputs for a single check evaluation step.
|
||||
type CheckRequest struct {
|
||||
Tuple *relationtuple.RelationTuple
|
||||
RestDepth int
|
||||
}
|
||||
|
||||
func (r CheckRequest) WithDepth(d int) CheckRequest {
|
||||
r.RestDepth = d
|
||||
return r
|
||||
}
|
||||
|
||||
// PlannedStep pairs a Step with the CheckRequest it should run against.
|
||||
type PlannedStep struct {
|
||||
Step Step
|
||||
Req CheckRequest
|
||||
}
|
||||
|
||||
// StepRunner orchestrates execution of multiple steps with short-circuit semantics.
|
||||
// Typical usage:
|
||||
//
|
||||
// runner := ex.NewUnionRunner(ctx)
|
||||
// for _, ps := range steps {
|
||||
// if runner.Done() { break }
|
||||
// runner.Add(ps)
|
||||
// }
|
||||
// return runner.Result()
|
||||
type StepRunner interface {
|
||||
// Add enqueues a step for execution. Returns early if Done() is true.
|
||||
// No-op if the runner has already reached a decisive result or been cancelled.
|
||||
Add(PlannedStep)
|
||||
|
||||
// Done reports whether a decisive result was reached or context cancelled.
|
||||
// Use to short-circuit the enqueue loop.
|
||||
Done() bool
|
||||
|
||||
// Ctx returns the runner's context, usable for shared state like visited tracking.
|
||||
// The context is cancelled when a decisive result is reached (to signal siblings).
|
||||
Ctx() context.Context
|
||||
|
||||
// AddLimitation records that some branches were skipped due to given [LimitationKind] constraint.
|
||||
AddLimitation(LimitationKind)
|
||||
|
||||
// Result waits for all in-flight steps to complete and returns the aggregated result.
|
||||
// Call it after the Add loop finishes (or breaks early on Done()).
|
||||
Result() Result
|
||||
}
|
||||
|
||||
// Executor coordinates permission check execution with middleware support.
|
||||
// It runs individual steps (Run), sequences steps with union/intersection logic
|
||||
// (RunUnion, RunIntersection), and supports custom execution strategies via
|
||||
// middleware. All Executor implementations are stateless; dependencies come
|
||||
// from EngineDependencies.
|
||||
//
|
||||
// Typical usage:
|
||||
//
|
||||
// result := executor.Run(ctx, DirectStep{}, req)
|
||||
// unionResult := executor.RunUnion(ctx, step1, step2)
|
||||
// intersectionResult := executor.RunIntersection(ctx, step1, step2)
|
||||
type Executor interface {
|
||||
// Run executes a single step through the middleware chain.
|
||||
Run(ctx context.Context, step Step, req CheckRequest) Result
|
||||
|
||||
// RunUnion runs steps with OR semantics: returns IsMember on first match,
|
||||
// short-circuits siblings, falls through to NotMember if none match.
|
||||
RunUnion(ctx context.Context, steps ...PlannedStep) Result
|
||||
|
||||
// RunIntersection runs steps with AND semantics: returns NotMember or Error
|
||||
// on first failure, short-circuits siblings.
|
||||
RunIntersection(ctx context.Context, steps ...PlannedStep) Result
|
||||
|
||||
// NewUnionRunner creates a StepRunner for streaming union execution.
|
||||
// It is useful when Steps are created dynamically and the full list is not known upfront.
|
||||
NewUnionRunner(ctx context.Context) StepRunner
|
||||
|
||||
Deps() EngineDependencies
|
||||
}
|
||||
|
||||
// Middleware intercepts a step execution within the pipeline. next continues
|
||||
// down the remaining middlewares and ultimately calls step.Execute.
|
||||
type Middleware func(
|
||||
ctx context.Context,
|
||||
step Step,
|
||||
req CheckRequest,
|
||||
next func(context.Context, Step, CheckRequest) Result,
|
||||
) Result
|
||||
|
||||
// Checker performs a full permission check for a single relation tuple.
|
||||
// Implemented by Executor in the step package.
|
||||
type (
|
||||
CheckerProvider interface {
|
||||
Checker() Checker
|
||||
}
|
||||
Checker interface {
|
||||
CheckRelationTuple(ctx context.Context, r *relationtuple.RelationTuple, restDepth int) Result
|
||||
}
|
||||
)
|
||||
25
internal/check/definitions_string.go
Normal file
25
internal/check/definitions_string.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Code generated by "stringer -type Membership"; DO NOT EDIT.
|
||||
|
||||
package check
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[MembershipUnknown-0]
|
||||
_ = x[IsMember-1]
|
||||
_ = x[NotMember-2]
|
||||
}
|
||||
|
||||
const _Membership_name = "MembershipUnknownIsMemberNotMember"
|
||||
|
||||
var _Membership_index = [...]uint8{0, 17, 25, 34}
|
||||
|
||||
func (i Membership) String() string {
|
||||
if i < 0 || i >= Membership(len(_Membership_index)-1) {
|
||||
return "Membership(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _Membership_name[_Membership_index[i]:_Membership_index[i+1]]
|
||||
}
|
||||
127
internal/check/engine.go
Normal file
127
internal/check/engine.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
||||
"github.com/ory/keto/x/events"
|
||||
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
EngineProvider interface {
|
||||
PermissionEngine() *Engine
|
||||
}
|
||||
|
||||
Engine struct {
|
||||
dep EngineDependencies
|
||||
}
|
||||
|
||||
EngineDependencies interface {
|
||||
relationtuple.ManagerProvider
|
||||
relationtuple.MapperProvider
|
||||
CheckerProvider
|
||||
config.Provider
|
||||
logrusx.Provider
|
||||
otelx.Provider
|
||||
}
|
||||
|
||||
// Type alias for shorter signatures.
|
||||
relationTuple = relationtuple.RelationTuple
|
||||
)
|
||||
|
||||
func errLimitationExceeded() *herodot.DefaultError {
|
||||
return &herodot.DefaultError{
|
||||
StatusField: http.StatusText(http.StatusUnprocessableEntity),
|
||||
ErrorField: "The check could not be completed",
|
||||
CodeField: http.StatusUnprocessableEntity,
|
||||
GRPCCodeField: codes.FailedPrecondition,
|
||||
}
|
||||
}
|
||||
|
||||
// NewEngine creates an Engine that delegates checks to checker.
|
||||
func NewEngine(d EngineDependencies) *Engine {
|
||||
return &Engine{dep: d}
|
||||
}
|
||||
|
||||
// CheckIsMember checks if the relation tuple's subject has the relation on the
|
||||
// object in the namespace either directly or indirectly and returns a boolean
|
||||
// result.
|
||||
func (e *Engine) CheckIsMember(ctx context.Context, r *relationTuple, restDepth int) (bool, error) {
|
||||
result := e.CheckRelationTuple(ctx, r, restDepth)
|
||||
if result.Err != nil {
|
||||
return false, result.Err
|
||||
}
|
||||
if result.Membership == MembershipUnknown && result.Limitation != "" && e.dep.Config(ctx).StrictMode() {
|
||||
return false, errLimitationExceeded().WithReason(string(result.Limitation))
|
||||
}
|
||||
return result.Membership == IsMember, nil
|
||||
}
|
||||
|
||||
// CheckRelationTuple checks if the relation tuple's subject has the relation on
|
||||
// the object in the namespace either directly or indirectly and returns a check
|
||||
// result.
|
||||
func (e *Engine) CheckRelationTuple(ctx context.Context, r *relationTuple, restDepth int) (res Result) {
|
||||
ctx, span := e.dep.Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTuple")
|
||||
defer otelx.End(span, &res.Err)
|
||||
|
||||
txErr := e.dep.RelationTupleManager().WithLatestSnapshot(ctx, func(ctx context.Context) error {
|
||||
res = e.dep.Checker().CheckRelationTuple(ctx, r, restDepth)
|
||||
return res.Err
|
||||
})
|
||||
if txErr != nil && res.Err == nil {
|
||||
res.Membership = MembershipUnknown
|
||||
res.Err = herodot.ErrInternalServerError().WithWrap(txErr)
|
||||
|
||||
e.dep.Logger().WithError(txErr).WithField("tuple", r.String()).Error("could not open a consistent read snapshot for the check")
|
||||
}
|
||||
if res.Err == nil {
|
||||
span.AddEvent(events.NewPermissionsChecked(ctx))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// BatchCheck makes parallelized check requests for tuples. The check results
|
||||
// are returned as a slice where the result index matches the input tuple index.
|
||||
func (e *Engine) BatchCheck(
|
||||
ctx context.Context,
|
||||
tuples []*ketoapi.RelationTuple,
|
||||
maxDepth int,
|
||||
) ([]Result, error) {
|
||||
eg := &errgroup.Group{}
|
||||
eg.SetLimit(e.dep.Config(ctx).BatchCheckParallelizationLimit())
|
||||
|
||||
mapper := e.dep.ReadOnlyMapper()
|
||||
results := make([]Result, len(tuples))
|
||||
for i, tuple := range tuples {
|
||||
eg.Go(func() error {
|
||||
internalTuple, err := mapper.FromTuple(ctx, tuple)
|
||||
if err != nil {
|
||||
results[i] = Result{
|
||||
Membership: MembershipUnknown,
|
||||
Err: err,
|
||||
}
|
||||
} else {
|
||||
results[i] = e.CheckRelationTuple(ctx, internalTuple[0], maxDepth)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
845
internal/check/engine_test.go
Normal file
845
internal/check/engine_test.go
Normal file
@@ -0,0 +1,845 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/internal/x/dbx"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
// ExpansionRecursive is a three-level group hierarchy where engineering branches
|
||||
// into two sibling sub-groups. Safe to run with WithStrict(true).
|
||||
//
|
||||
// company ⊃ engineering ⊃ (frontend, backend)
|
||||
// company ⊃ User:cto
|
||||
// engineering ⊃ User:manager
|
||||
// backend ⊃ User:Alice, Alice
|
||||
// frontend ⊃ User:Bob, Bob
|
||||
var ExpansionRecursive = testhelpers.Scenario{
|
||||
Name: "Expansion Recursive",
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Group:company#members@Group:engineering#members",
|
||||
|
||||
"Group:company#members@User:cto",
|
||||
"Group:engineering#members@User:manager",
|
||||
|
||||
"Group:engineering#members@Group:frontend#members",
|
||||
"Group:engineering#members@Group:backend#members",
|
||||
|
||||
"Group:backend#members@User:Alice",
|
||||
"Group:frontend#members@User:Bob",
|
||||
|
||||
"Group:backend#members@Alice", // SubjectID
|
||||
"Group:frontend#members@Bob", // SubjectID
|
||||
},
|
||||
}
|
||||
|
||||
var ExpansionRecursiveNamespaceOnly = testhelpers.Scenario{
|
||||
Name: ExpansionRecursive.Name + " (namespaces only)",
|
||||
Opl: `class User implements Namespace{} class Group implements Namespace{}`,
|
||||
InputTuples: ExpansionRecursive.InputTuples,
|
||||
}
|
||||
|
||||
// SubjectSetExpansion3Hop combines cross-namespace and within-namespace SubjectSet expansion.
|
||||
//
|
||||
// File:team-report#viewers ⊃ File:team-report#editors ⊃ Group:finance#member → (User:Alice, User:Bob).
|
||||
// File:personal-note#viewers ⊃ User:Alice, User:Bob
|
||||
var SubjectSetExpansion3Hop = testhelpers.Scenario{
|
||||
Name: "SubjectSet Expansion 3 Hops",
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
editors: (User | SubjectSet<Group, "member">)[]
|
||||
viewers: (User | SubjectSet<File, "editors">)[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Group:finance#member@User:Alice",
|
||||
"Group:finance#member@User:Bob",
|
||||
|
||||
"File:team-report#editors@Group:finance#member", // group members are editors
|
||||
"File:team-report#viewers@File:team-report#editors", // editors are also viewers
|
||||
|
||||
"File:personal-note#viewers@User:Alice",
|
||||
"File:personal-note#viewers@User:Bob",
|
||||
},
|
||||
}
|
||||
|
||||
var SubjectSetExpansion3HopNamespacesOnly = testhelpers.Scenario{
|
||||
Name: SubjectSetExpansion3Hop.Name + " (namespaces only)",
|
||||
Opl: `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`,
|
||||
InputTuples: SubjectSetExpansion3Hop.InputTuples,
|
||||
}
|
||||
|
||||
// CircularGraphNamespacesOnly covers a fully circular SubjectSet graph with no leaf subjects.
|
||||
//
|
||||
// A → B → C → A.
|
||||
var CircularGraphNamespacesOnly = testhelpers.Scenario{
|
||||
Name: "Circular Graph (namespaces only)",
|
||||
Opl: `
|
||||
class Folder implements Namespace{}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Folder:a#parent@Folder:b#parent",
|
||||
"Folder:b#parent@Folder:c#parent",
|
||||
"Folder:c#parent@Folder:a#parent",
|
||||
},
|
||||
}
|
||||
|
||||
// FileWithNonConformingTuples covers a schema where a client incorrectly stores
|
||||
// tuples directly under permit names (canEdit, canView) instead of the backing
|
||||
// related fields (editors, viewers).
|
||||
//
|
||||
// In strict mode the engine ignores those tuples and resolves access only via
|
||||
// OPL rewrites. In non-strict mode the direct tuples are visible alongside the
|
||||
// rewrites.
|
||||
var FileWithNonConformingTuples = testhelpers.Scenario{
|
||||
Name: "File With Non-Conforming Tuples",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
editors: User[]
|
||||
viewers: User[]
|
||||
}
|
||||
permits = {
|
||||
canEdit: (ctx: Context) => this.related.editors.includes(ctx.subject),
|
||||
canView: (ctx: Context) => this.permits.canEdit(ctx) || this.related.viewers.includes(ctx.subject),
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"File:secret-doc#editors@User:Alice",
|
||||
"File:secret-doc#viewers@User:Bob",
|
||||
|
||||
// Bad client data: permit names used as direct relation targets.
|
||||
"File:secret-doc#canEdit@User:Eve",
|
||||
"File:secret-doc#canEdit@Eve", // subjectID
|
||||
},
|
||||
}
|
||||
|
||||
// NotionPage models a simplified Notion-like workspace with nested pages.
|
||||
//
|
||||
// canView requires direct or parent access.
|
||||
// canEdit requires direct editor access AND workspace membership.
|
||||
var NotionPage = testhelpers.Scenario{
|
||||
Name: "Notion Page",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class Workspace implements Namespace {
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
class Page implements Namespace {
|
||||
related: {
|
||||
workspace: Workspace[]
|
||||
parent: Page[]
|
||||
viewer: User[]
|
||||
editor: User[]
|
||||
}
|
||||
permits = {
|
||||
canView: (ctx: Context) =>
|
||||
this.related.viewer.includes(ctx.subject) ||
|
||||
this.related.editor.includes(ctx.subject) ||
|
||||
this.related.parent.traverse(p => p.permits.canView(ctx)),
|
||||
canEdit: (ctx: Context) =>
|
||||
this.related.editor.includes(ctx.subject) &&
|
||||
this.related.workspace.traverse(w => w.related.member.includes(ctx.subject)),
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Workspace:acme#member@User:Alice",
|
||||
"Workspace:acme#member@User:Bob",
|
||||
|
||||
"Page:home#workspace@Workspace:acme",
|
||||
"Page:home#viewer@User:Alice",
|
||||
"Page:home#editor@User:Bob",
|
||||
"Page:home#editor@User:Eve", // Eve is not workspace member
|
||||
|
||||
"Page:intro#workspace@Workspace:acme",
|
||||
"Page:intro#parent@Page:home", // intro inherits canView from home
|
||||
"Page:intro#editor@User:Alice",
|
||||
},
|
||||
}
|
||||
|
||||
var testcases = []struct {
|
||||
scenarios []testhelpers.Scenario
|
||||
expectedMembers []string
|
||||
expectedNonMembers []string
|
||||
}{
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{ExpansionRecursive, ExpansionRecursiveNamespaceOnly},
|
||||
expectedMembers: []string{
|
||||
// direct: SubjectSet and SubjectID members
|
||||
"Group:backend#members@User:Alice", "Group:backend#members@Alice",
|
||||
"Group:frontend#members@User:Bob", "Group:frontend#members@Bob",
|
||||
|
||||
// engineering: own direct member plus frontend and backend
|
||||
"Group:engineering#members@User:manager",
|
||||
"Group:engineering#members@User:Alice", "Group:engineering#members@Alice",
|
||||
"Group:engineering#members@User:Bob", "Group:engineering#members@Bob",
|
||||
|
||||
// company: own direct member plus everything from engineering
|
||||
"Group:company#members@User:cto",
|
||||
"Group:company#members@User:manager",
|
||||
"Group:company#members@User:Alice", "Group:company#members@Alice",
|
||||
"Group:company#members@User:Bob", "Group:company#members@Bob",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
// frontend and backend members do not cross.
|
||||
"Group:frontend#members@User:Alice",
|
||||
"Group:backend#members@User:Bob",
|
||||
|
||||
// Membership flows upward only.
|
||||
"Group:backend#members@User:manager", "Group:backend#members@User:cto",
|
||||
"Group:frontend#members@User:manager", "Group:frontend#members@User:cto",
|
||||
"Group:engineering#members@User:cto",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{SubjectSetExpansion3Hop, SubjectSetExpansion3HopNamespacesOnly},
|
||||
expectedMembers: []string{
|
||||
// direct
|
||||
"Group:finance#member@User:Alice", "Group:finance#member@User:Bob",
|
||||
|
||||
// via group expansion
|
||||
"File:team-report#editors@User:Alice", "File:team-report#editors@User:Bob",
|
||||
|
||||
// via editors are viewers
|
||||
"File:team-report#viewers@User:Alice", "File:team-report#viewers@User:Bob",
|
||||
|
||||
// direct viewers on personal note
|
||||
"File:personal-note#viewers@User:Alice", "File:personal-note#viewers@User:Bob",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
// File:personal-note is missing subject-set relationship;
|
||||
// Therefore editors are not viewers;
|
||||
"File:personal-note#editors@User:Alice",
|
||||
"File:personal-note#editors@User:Bob",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{CircularGraphNamespacesOnly},
|
||||
expectedMembers: []string{
|
||||
"Folder:a#parent@Folder:a#parent",
|
||||
"Folder:a#parent@Folder:b#parent",
|
||||
"Folder:a#parent@Folder:c#parent",
|
||||
|
||||
"Folder:b#parent@Folder:a#parent",
|
||||
"Folder:b#parent@Folder:b#parent",
|
||||
"Folder:b#parent@Folder:c#parent",
|
||||
|
||||
"Folder:c#parent@Folder:a#parent",
|
||||
"Folder:c#parent@Folder:b#parent",
|
||||
"Folder:c#parent@Folder:c#parent",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{{
|
||||
Name: "rejects transitive relation",
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
class Directory implements Namespace{}
|
||||
class File implements Namespace{}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"File:report#parent@Directory:docs",
|
||||
"Directory:docs#access@User:Alice",
|
||||
},
|
||||
}},
|
||||
expectedNonMembers: []string{
|
||||
"File:report#access@User:Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples.WithStrict(true)},
|
||||
expectedMembers: []string{
|
||||
"File:secret-doc#viewers@User:Bob",
|
||||
"File:secret-doc#editors@User:Alice",
|
||||
|
||||
// via OPL
|
||||
"File:secret-doc#canView@User:Bob",
|
||||
"File:secret-doc#canEdit@User:Alice",
|
||||
"File:secret-doc#canView@User:Alice",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
"File:secret-doc#canEdit@User:Eve",
|
||||
"File:secret-doc#canView@User:Eve",
|
||||
|
||||
"File:secret-doc#canEdit@Eve",
|
||||
"File:secret-doc#canView@Eve",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{NotionPage},
|
||||
expectedMembers: []string{
|
||||
// direct viewer/editor on home
|
||||
"Page:home#canView@User:Alice",
|
||||
"Page:home#canView@User:Bob", // editor also satisfies canView
|
||||
"Page:home#canView@User:Eve", // stale editor tuple still satisfies canView (no workspace check)
|
||||
// canEdit requires editor AND workspace member
|
||||
"Page:home#canEdit@User:Bob",
|
||||
// intro: direct editor + workspace member
|
||||
"Page:intro#canEdit@User:Alice",
|
||||
// intro canView via TTU: intro → parent → home → Alice/Bob/Eve
|
||||
"Page:intro#canView@User:Alice",
|
||||
"Page:intro#canView@User:Bob",
|
||||
"Page:intro#canView@User:Eve",
|
||||
},
|
||||
expectedNonMembers: []string{
|
||||
// Eve has editor tuple but is not a workspace member — AND fails
|
||||
"Page:home#canEdit@User:Eve",
|
||||
// Bob is editor of home, not intro — canEdit requires explicit editor tuple
|
||||
"Page:intro#canEdit@User:Bob",
|
||||
// Alice is only a viewer on home, not an editor
|
||||
"Page:home#canEdit@User:Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenarios: []testhelpers.Scenario{FileWithNonConformingTuples},
|
||||
expectedMembers: []string{
|
||||
"File:secret-doc#viewers@User:Bob",
|
||||
"File:secret-doc#editors@User:Alice",
|
||||
|
||||
// via OPL
|
||||
"File:secret-doc#canView@User:Bob",
|
||||
"File:secret-doc#canEdit@User:Alice",
|
||||
"File:secret-doc#canView@User:Alice",
|
||||
|
||||
// canEdit is a direct tuple, visible in non-strict mode
|
||||
"File:secret-doc#canEdit@User:Eve",
|
||||
// canView is visible via OPL rewrite in non-strict mode
|
||||
"File:secret-doc#canView@User:Eve",
|
||||
},
|
||||
expectedNonMembers: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
t.Run(`membership tests`, func(t *testing.T) {
|
||||
for _, tc := range testcases {
|
||||
for _, scenario := range tc.scenarios {
|
||||
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := check.NewEngine(reg)
|
||||
ctx := t.Context()
|
||||
|
||||
for _, tuple := range tc.expectedMembers {
|
||||
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
|
||||
require.NoError(t, err)
|
||||
assert.Truef(t, res, "expected %q to be a member", tuple)
|
||||
}
|
||||
|
||||
for _, tuple := range tc.expectedNonMembers {
|
||||
res, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tuple), 5)
|
||||
require.NoError(t, err)
|
||||
assert.Falsef(t, res, "expected %q to not be a member", tuple)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respects max-depth", func(t *testing.T) {
|
||||
ExpansionRecursive.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := check.NewEngine(reg)
|
||||
ctx := t.Context()
|
||||
|
||||
// company → engineering → backend → User:Alice requires exactly 2 hops.
|
||||
tuple := testhelpers.TupleFromString(t, "Group:company#members@User:Alice")
|
||||
|
||||
assert.Equal(t, 5, reg.Config(ctx).MaxReadDepth())
|
||||
|
||||
res, err := e.CheckIsMember(ctx, tuple, 1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res, "depth=1 is not enough for a 2-hop chain")
|
||||
|
||||
res, err = e.CheckIsMember(ctx, tuple, 2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res, "depth=2 is enough for a 2-hop chain")
|
||||
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 1))
|
||||
res, err = e.CheckIsMember(ctx, tuple, 5)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res, "global max-depth=1 overrides request depth=5")
|
||||
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, 3))
|
||||
res, err = e.CheckIsMember(ctx, tuple, 0)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res, "global max-depth=3 is enough")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("batch check", func(t *testing.T) {
|
||||
SubjectSetExpansion3Hop.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := check.NewEngine(reg)
|
||||
ctx := t.Context()
|
||||
|
||||
targetTuples := []*ketoapi.RelationTuple{
|
||||
testhelpers.APITupleFromString(t, "Group:finance#member@User:Alice"), // direct
|
||||
testhelpers.APITupleFromString(t, "File:team-report#editors@User:Alice"), // 1-hop
|
||||
testhelpers.APITupleFromString(t, "File:team-report#viewers@User:Alice"), // 2-hop
|
||||
testhelpers.APITupleFromString(t, "NonExistent:x#rel@User:Alice"), // non-existent namespace
|
||||
testhelpers.APITupleFromString(t, "Group:finance#member@User:Eve"), // unknown subject
|
||||
testhelpers.APITupleFromString(t, "File:team-report#viewers@File:team-report#editors"), // via subject set
|
||||
}
|
||||
|
||||
// At depth=1, the 2-hop viewers chain is not resolved.
|
||||
results, err := e.BatchCheck(ctx, targetTuples, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, check.IsMember, results[0].Membership)
|
||||
require.Equal(t, check.IsMember, results[1].Membership)
|
||||
require.Equal(t, check.MembershipUnknown, results[2].Membership)
|
||||
require.Equal(t, check.LimitationMaxDepthExceeded, results[2].Limitation)
|
||||
require.Equal(t, check.MembershipUnknown, results[3].Membership)
|
||||
require.EqualError(t, results[3].Err, herodot.ErrNotFound().Error())
|
||||
require.Equal(t, check.MembershipUnknown, results[4].Membership)
|
||||
require.Equal(t, check.LimitationMaxDepthExceeded, results[4].Limitation)
|
||||
require.Equal(t, check.IsMember, results[5].Membership)
|
||||
|
||||
// At depth=2, the 2-hop viewers chain resolves
|
||||
results, err = e.BatchCheck(ctx, targetTuples, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, check.IsMember, results[2].Membership)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// widthLimitScenario fans out Resource:doc#readers into three groups.
|
||||
// With width=1 only Group:alpha is explored; the rest are truncated.
|
||||
var widthLimitScenario = testhelpers.Scenario{
|
||||
Name: "Width Limit",
|
||||
OrderedShardID: true,
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
member: (User | SubjectSet<Group, "member">)[]
|
||||
}
|
||||
}
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
readers: (User | SubjectSet<Group, "member">)[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
// Resource:doc#readers
|
||||
// ├── Group:alpha#member ──► Group:alpha-sub#member ──► User:Alice (explored first at width=1)
|
||||
// ├── Group:beta#member ──► Group:beta-sub#member ──► User:Bob (truncated at width=1)
|
||||
// └── Group:gamma#member ──► Group:gamma-sub#member (truncated at width=1)
|
||||
InputTuples: []string{
|
||||
// Insertion order determines DB return order for expand
|
||||
"Resource:doc#readers@Group:alpha#member",
|
||||
"Resource:doc#readers@Group:beta#member",
|
||||
"Resource:doc#readers@Group:gamma#member",
|
||||
|
||||
"Group:alpha#member@Group:alpha-sub#member",
|
||||
"Group:beta#member@Group:beta-sub#member",
|
||||
"Group:gamma#member@Group:gamma-sub#member",
|
||||
|
||||
"Group:alpha-sub#member@User:Alice",
|
||||
"Group:beta-sub#member@User:Bob",
|
||||
// Eve is in none of the groups.
|
||||
},
|
||||
}
|
||||
|
||||
func TestWidthLimit(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
subject string
|
||||
width int // 0 means default 100
|
||||
expectedMembership check.Membership
|
||||
expectedLimitation check.LimitationKind
|
||||
expectedIsMember bool
|
||||
}
|
||||
|
||||
tests := []tc{
|
||||
{
|
||||
name: "no limit: Alice is IsMember",
|
||||
subject: "User:Alice",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "no limit: Eve is NotMember",
|
||||
subject: "User:Eve",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "width=1: Eve is indeterminate",
|
||||
subject: "User:Eve",
|
||||
width: 1,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxWidthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
// Bob is only in beta-sub, which is always truncated at width=1.
|
||||
name: "width=1: member only in truncated branch is indeterminate",
|
||||
subject: "User:Bob",
|
||||
width: 1,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxWidthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
widthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.width > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
})
|
||||
|
||||
// in strict mode, expect Error when limitation is hit
|
||||
widthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.width > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadWidth, tc.width))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
if tc.expectedLimitation != "" {
|
||||
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.Error(t, err)
|
||||
var he *herodot.DefaultError
|
||||
require.ErrorAs(t, err, &he)
|
||||
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
|
||||
} else {
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, "Resource:doc#readers@"+tc.subject), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Alice is in both deep and short chains.
|
||||
// Depth of 3 is needed to reach Alice via shortRel.
|
||||
// Depth of 5 is needed to reach Alice via deepRel.
|
||||
var depthLimitScenario = testhelpers.Scenario{
|
||||
Name: "Depth Limit",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
deepRel: (User | SubjectSet<Group, "members">)[]
|
||||
shortRel: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
permits = {
|
||||
union: (ctx:Context) =>
|
||||
this.related.deepRel.includes(ctx.subject)
|
||||
|| this.related.shortRel.includes(ctx.subject),
|
||||
intersection: (ctx:Context) =>
|
||||
this.related.deepRel.includes(ctx.subject)
|
||||
&& this.related.shortRel.includes(ctx.subject),
|
||||
shortButNotDeep: (ctx:Context) => this.related.shortRel.includes(ctx.subject)
|
||||
&& !this.related.deepRel.includes(ctx.subject),
|
||||
|
||||
// notEither simulates a client that authorizes users when Keto returns NotMember
|
||||
notEither: (ctx:Context) => !this.permits.union(ctx),
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
"Resource:doc#deepRel@Group:deep#members",
|
||||
"Resource:doc#shortRel@Group:short#members",
|
||||
|
||||
"Group:deep#members@Group:deep1#members",
|
||||
"Group:deep1#members@Group:deep2#members",
|
||||
"Group:deep2#members@Group:deep3#members",
|
||||
"Group:deep3#members@Group:deep4#members",
|
||||
"Group:deep4#members@User:Alice",
|
||||
|
||||
"Group:short#members@Group:short1#members",
|
||||
"Group:short1#members@Group:short2#members",
|
||||
"Group:short2#members@User:Alice",
|
||||
"Group:short2#members@User:Charlie",
|
||||
},
|
||||
}
|
||||
|
||||
func TestDepthLimit(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
tuple string
|
||||
depth int
|
||||
expectedMembership check.Membership
|
||||
expectedLimitation check.LimitationKind
|
||||
expectedIsMember bool
|
||||
}
|
||||
|
||||
tests := []tc{
|
||||
{
|
||||
name: "union: too shallow depth for both branches yields NotMember",
|
||||
tuple: "Resource:doc#union@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "union: enough depth for 1 Member branch yields Member",
|
||||
tuple: "Resource:doc#union@User:Alice",
|
||||
depth: 4,
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "union: enough depth for 1 NotMember branch yields Unknown",
|
||||
tuple: "Resource:doc#union@User:Bob", // Bob is in no branch
|
||||
depth: 4,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "intersection: too shallow depth for both branches yields Unknown",
|
||||
tuple: "Resource:doc#intersection@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "intersection: enough depth for only 1 branch yields Unknown",
|
||||
tuple: "Resource:doc#intersection@User:Alice",
|
||||
depth: 4,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "intersection: enough depth for both branches yields Member",
|
||||
tuple: "Resource:doc#intersection@User:Alice",
|
||||
depth: 5,
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "shortButNotDeep: too shallow depth for both branches yields Unknown",
|
||||
tuple: "Resource:doc#shortButNotDeep@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "shortButNotDeep: enough depth for only 1 branch yields Unknown",
|
||||
tuple: "Resource:doc#shortButNotDeep@User:Alice",
|
||||
depth: 4,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
{
|
||||
name: "shortButNotDeep: user only in shortRel yields Member at sufficient depth",
|
||||
tuple: "Resource:doc#shortButNotDeep@User:Charlie",
|
||||
depth: 7,
|
||||
expectedMembership: check.IsMember,
|
||||
expectedIsMember: true,
|
||||
},
|
||||
{
|
||||
name: "notEither: not enough depth for any branch yields Unknown",
|
||||
tuple: "Resource:doc#notEither@User:Alice",
|
||||
depth: 2,
|
||||
expectedMembership: check.MembershipUnknown,
|
||||
expectedLimitation: check.LimitationMaxDepthExceeded,
|
||||
expectedIsMember: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
depthLimitScenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.depth > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
})
|
||||
|
||||
// in strict mode, expect Error when limitation is hit
|
||||
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
ctx := t.Context()
|
||||
e := check.NewEngine(reg)
|
||||
if tc.depth > 0 {
|
||||
require.NoError(t, reg.Config(ctx).Set(config.KeyLimitMaxReadDepth, tc.depth))
|
||||
}
|
||||
|
||||
res := e.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expectedMembership, res.Membership)
|
||||
assert.Equal(t, tc.expectedLimitation, res.Limitation)
|
||||
|
||||
if tc.expectedLimitation != "" {
|
||||
_, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.Error(t, err)
|
||||
var he *herodot.DefaultError
|
||||
require.ErrorAs(t, err, &he)
|
||||
assert.Contains(t, he.Reason(), string(tc.expectedLimitation))
|
||||
} else {
|
||||
isMember, err := e.CheckIsMember(ctx, testhelpers.TupleFromString(t, tc.tuple), 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedIsMember, isMember)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSnapshotIsolation verifies that a concurrent write committed between
|
||||
// two SQL queries within the same check does not affect the result.
|
||||
func TestCheckSnapshotIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opl = `
|
||||
class User implements Namespace {}
|
||||
class Folder implements Namespace {
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
permits = {
|
||||
isMember: (ctx: Context) => this.related.members.includes(ctx.subject),
|
||||
}
|
||||
}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
parent: Folder[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.parent.traverse(p => p.permits.isMember(ctx)),
|
||||
}
|
||||
}
|
||||
`
|
||||
inputTuples := []string{
|
||||
"File:readme#parent@Folder:docs",
|
||||
"Folder:docs#members@User:u1", // this tuple will be deleted during check execution
|
||||
}
|
||||
|
||||
var snapshotDSNs []*dbx.DsnT
|
||||
for _, dsn := range dbx.GetDSNs(t) {
|
||||
switch dsn.Name {
|
||||
case "cockroach", "mysql", "postgres":
|
||||
snapshotDSNs = append(snapshotDSNs, dsn)
|
||||
}
|
||||
}
|
||||
if len(snapshotDSNs) == 0 {
|
||||
// SQLite is excluded. pop serialises all SQLite transactions with a mutex,
|
||||
// so the middleware's concurrent delete gets deadlocked against the outer check
|
||||
// transaction
|
||||
t.Skip("sqlite is excluded from snapshot isolation test; run without -short to start CockroachDB/MySQL/PostgreSQL")
|
||||
}
|
||||
|
||||
for _, dsn := range snapshotDSNs {
|
||||
t.Run("dsn="+dsn.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := driver.NewTestRegistry(t, dsn,
|
||||
driver.WithOPL(opl),
|
||||
driver.WithMapperNamespace(testhelpers.CustomMapperNamespace),
|
||||
driver.WithConfig(config.KeyFeatureFlagStrictMode, true),
|
||||
)
|
||||
testhelpers.MapAndInsertTuplesFromString(t, reg, inputTuples)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// The delete must happen after the first SQL query in the transaction so
|
||||
// the snapshot is already established in PostgreSQL/MySQL REPEATABLE READ
|
||||
// (which snapshots at first statement, not at BEGIN).
|
||||
var once sync.Once
|
||||
middleware := func(mCtx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
||||
if s.Kind() == check.StepIsAllowed && req.Tuple.Namespace == "Folder" && req.Tuple.Relation == "isMember" {
|
||||
once.Do(func() {
|
||||
assert.NoError(t, reg.RelationTupleManager().DeleteRelationTuples(t.Context(),
|
||||
testhelpers.TupleFromString(t, "Folder:docs#members@User:u1"),
|
||||
))
|
||||
})
|
||||
}
|
||||
return next(mCtx, s, req)
|
||||
}
|
||||
|
||||
engine := check.NewEngine(&checkerOverrideRegistry{Registry: reg, mw: middleware})
|
||||
|
||||
result := engine.CheckRelationTuple(ctx, testhelpers.TupleFromString(t, "File:readme#view@User:u1"), 5)
|
||||
require.NoError(t, result.Err)
|
||||
assert.Equal(t, check.IsMember, result.Membership,
|
||||
"check must reflect the database state at the start of the evaluation")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// checkerOverrideRegistry wraps a Registry and replaces its Checker.
|
||||
// mw can be used to inject a middleware to the Executor.
|
||||
type checkerOverrideRegistry struct {
|
||||
driver.Registry
|
||||
mw check.Middleware
|
||||
}
|
||||
|
||||
func (r *checkerOverrideRegistry) Checker() check.Checker {
|
||||
return step.NewExecutor(r.Registry, r.mw)
|
||||
}
|
||||
526
internal/check/handler.go
Normal file
526
internal/check/handler.go
Normal file
@@ -0,0 +1,526 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/internal/x"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
type (
|
||||
handlerDependencies interface {
|
||||
EngineProvider
|
||||
relationtuple.ManagerProvider
|
||||
relationtuple.MapperProvider
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
config.Provider
|
||||
x.HandlerOptionsProvider
|
||||
}
|
||||
Handler struct {
|
||||
relationtuplesconnect.UnimplementedCheckServiceHandler
|
||||
d handlerDependencies
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ relationtuplesconnect.CheckServiceHandler = (*Handler)(nil)
|
||||
_ *checkPermission = nil
|
||||
)
|
||||
|
||||
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
|
||||
|
||||
const (
|
||||
RouteBase = "/relation-tuples/check"
|
||||
OpenAPIRouteBase = RouteBase + "/openapi"
|
||||
BatchRoute = "/relation-tuples/batch/check"
|
||||
)
|
||||
|
||||
func (h *Handler) RegisterReadRoutes(r *httprouterx.RouterPublic) {
|
||||
r.GET(RouteBase, h.getCheckMirrorStatus)
|
||||
r.GET(OpenAPIRouteBase, h.getCheckNoStatus)
|
||||
r.POST(RouteBase, h.postCheckMirrorStatus)
|
||||
r.POST(OpenAPIRouteBase, h.postCheckNoStatus)
|
||||
r.POST(BatchRoute, h.batchCheck)
|
||||
|
||||
checkServiceMethods := rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto.Services().ByName("CheckService").Methods()
|
||||
|
||||
checkHandler := connect.NewUnaryHandler(
|
||||
relationtuplesconnect.CheckServiceCheckProcedure,
|
||||
h.Check,
|
||||
connect.WithSchema(checkServiceMethods.ByName("Check")),
|
||||
connect.WithHandlerOptions(h.d.HandlerOptions()...),
|
||||
)
|
||||
r.Handle(relationtuplesconnect.CheckServiceCheckProcedure, checkHandler)
|
||||
|
||||
batchCheckHandler := connect.NewUnaryHandler(
|
||||
relationtuplesconnect.CheckServiceBatchCheckProcedure,
|
||||
h.BatchCheck,
|
||||
connect.WithSchema(checkServiceMethods.ByName("BatchCheck")),
|
||||
connect.WithHandlerOptions(h.d.HandlerOptions()...),
|
||||
)
|
||||
r.Handle(relationtuplesconnect.CheckServiceBatchCheckProcedure, batchCheckHandler)
|
||||
}
|
||||
|
||||
func (h *Handler) ProtoFiles() []protoreflect.FileDescriptor {
|
||||
return []protoreflect.FileDescriptor{
|
||||
rts.File_ory_keto_relation_tuples_v1alpha2_check_service_proto,
|
||||
rts.File_ory_keto_relation_tuples_v1alpha2_relation_tuples_proto,
|
||||
}
|
||||
}
|
||||
|
||||
// Check Permission Result
|
||||
//
|
||||
// The content of the allowed field is mirrored in the HTTP status code.
|
||||
//
|
||||
// swagger:model checkPermissionResult
|
||||
type CheckPermissionResult struct {
|
||||
// whether the relation tuple is allowed
|
||||
//
|
||||
// required: true
|
||||
Allowed bool `json:"allowed"`
|
||||
}
|
||||
|
||||
// Check Permission Result With Error
|
||||
//
|
||||
// swagger:model checkPermissionResultWithError
|
||||
type CheckPermissionResultWithError struct {
|
||||
// whether the relation tuple is allowed
|
||||
//
|
||||
// required: true
|
||||
Allowed bool `json:"allowed"`
|
||||
// any error generated while checking the relation tuple
|
||||
//
|
||||
// required: false
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Check Permission Request Parameters
|
||||
//
|
||||
// swagger:parameters checkPermission
|
||||
type checkPermission struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
}
|
||||
|
||||
// swagger:route GET /relation-tuples/check/openapi permission checkPermission
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) getCheckNoStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.getCheck(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
// Check Permission Or Error Request Parameters
|
||||
//
|
||||
// swagger:parameters checkPermissionOrError
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type checkPermissionOrError struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
}
|
||||
|
||||
// swagger:route GET /relation-tuples/check permission checkPermissionOrError
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// 403: checkPermissionResult
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) getCheckMirrorStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.getCheck(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if allowed {
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
func (h *Handler) getCheck(ctx context.Context, q url.Values) (bool, error) {
|
||||
maxDepth, err := x.GetMaxDepthFromQuery(q)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
tuple, err := (&ketoapi.RelationTuple{}).FromURLQuery(q)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
it, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple)
|
||||
// herodot.ErrNotFound occurs when the namespace is unknown
|
||||
if errors.Is(err, herodot.ErrNotFound()) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return h.d.PermissionEngine().CheckIsMember(ctx, it[0], maxDepth)
|
||||
}
|
||||
|
||||
// Check Permission using Post Request Parameters
|
||||
//
|
||||
// swagger:parameters postCheckPermission
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermission struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
|
||||
// in: body
|
||||
Payload postCheckPermissionBody
|
||||
}
|
||||
|
||||
// Check Permission using Post Request Body
|
||||
//
|
||||
// swagger:model postCheckPermissionBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermissionBody struct {
|
||||
ketoapi.RelationQuery
|
||||
}
|
||||
|
||||
// swagger:route POST /relation-tuples/check/openapi permission postCheckPermission
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) postCheckNoStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
// Post Check Permission Or Error Request Parameters
|
||||
//
|
||||
// swagger:parameters postCheckPermissionOrError
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermissionOrError struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
|
||||
// in: body
|
||||
Body postCheckPermissionOrErrorBody
|
||||
}
|
||||
|
||||
// Post Check Permission Or Error Body
|
||||
//
|
||||
// swagger:model postCheckPermissionOrErrorBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type postCheckPermissionOrErrorBody struct {
|
||||
ketoapi.RelationQuery
|
||||
}
|
||||
|
||||
// swagger:route POST /relation-tuples/check permission postCheckPermissionOrError
|
||||
//
|
||||
// # Check a permission
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: checkPermissionResult
|
||||
// 400: errorGeneric
|
||||
// 403: checkPermissionResult
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) postCheckMirrorStatus(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := h.postCheck(r.Context(), r.Body, r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if allowed {
|
||||
h.d.Writer().Write(w, r, &CheckPermissionResult{Allowed: allowed})
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().WriteCode(w, r, http.StatusForbidden, &CheckPermissionResult{Allowed: allowed})
|
||||
}
|
||||
|
||||
func (h *Handler) postCheck(ctx context.Context, body io.Reader, query url.Values) (bool, error) {
|
||||
maxDepth, err := x.GetMaxDepthFromQuery(query)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var tuple ketoapi.RelationTuple
|
||||
if err := json.NewDecoder(body).Decode(&tuple); err != nil {
|
||||
return false, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error()))
|
||||
}
|
||||
t, err := h.d.ReadOnlyMapper().FromTuple(ctx, &tuple)
|
||||
// herodot.ErrNotFound occurs when the namespace is unknown
|
||||
if errors.Is(err, herodot.ErrNotFound()) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return h.d.PermissionEngine().CheckIsMember(ctx, t[0], maxDepth)
|
||||
}
|
||||
|
||||
func (h *Handler) Check(ctx context.Context, req *connect.Request[rts.CheckRequest]) (*connect.Response[rts.CheckResponse], error) {
|
||||
var src ketoapi.TupleData
|
||||
if req.Msg.Tuple != nil {
|
||||
src = req.Msg.Tuple
|
||||
} else {
|
||||
src = req.Msg
|
||||
}
|
||||
|
||||
tuple, err := (&ketoapi.RelationTuple{}).FromDataProvider(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
internalTuple, err := h.d.ReadOnlyMapper().FromTuple(ctx, tuple)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed, err := h.d.PermissionEngine().CheckIsMember(ctx, internalTuple[0], int(req.Msg.MaxDepth))
|
||||
// TODO add content change handling
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rts.CheckResponse{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value.
|
||||
Allowed: allowed,
|
||||
Snaptoken: "not yet implemented",
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Batch Check Permission Request Parameters
|
||||
//
|
||||
// swagger:parameters batchCheckPermission
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type batchCheckPermission struct {
|
||||
// in: query
|
||||
MaxDepth int `json:"max-depth"`
|
||||
|
||||
// in: body
|
||||
Body batchCheckPermissionBody
|
||||
}
|
||||
|
||||
// Batch Check Permission Body
|
||||
//
|
||||
// swagger:model batchCheckPermissionBody
|
||||
//
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type batchCheckPermissionBody struct {
|
||||
Tuples []*ketoapi.RelationTuple `json:"tuples"`
|
||||
}
|
||||
|
||||
// Batch Check Permission Result
|
||||
//
|
||||
// swagger:model batchCheckPermissionResult
|
||||
type BatchCheckPermissionResult struct {
|
||||
// An array of check results. The order aligns with the input order.
|
||||
//
|
||||
// required: true
|
||||
Results []*CheckPermissionResultWithError `json:"results"`
|
||||
}
|
||||
|
||||
// swagger:route POST /relation-tuples/batch/check permission batchCheckPermission
|
||||
//
|
||||
// # Batch check permissions
|
||||
//
|
||||
// To learn how relationship tuples and the check works, head over to [the documentation](https://www.ory.com/docs/keto/concepts/api-overview).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: batchCheckPermissionResult
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: keto-public-high
|
||||
func (h *Handler) batchCheck(w http.ResponseWriter, r *http.Request) {
|
||||
results, err := h.doBatchCheck(r.Context(), r.Body, r.URL.Query())
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, &BatchCheckPermissionResult{Results: results})
|
||||
}
|
||||
|
||||
// doBatchCheck is the HTTP entry point for checking batches of tuples
|
||||
func (h *Handler) doBatchCheck(ctx context.Context, body io.Reader, query url.Values) ([]*CheckPermissionResultWithError, error) {
|
||||
maxDepth, err := x.GetMaxDepthFromQuery(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var request batchCheckPermissionBody
|
||||
if err := json.NewDecoder(body).Decode(&request); err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrBadRequest().WithErrorf("could not unmarshal json: %s", err.Error()))
|
||||
}
|
||||
|
||||
if len(request.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() {
|
||||
return nil, herodot.ErrBadRequest().WithErrorf("batch exceeds max size of %v",
|
||||
h.d.Config(ctx).BatchCheckMaxBatchSize())
|
||||
}
|
||||
|
||||
results, err := h.d.PermissionEngine().BatchCheck(ctx, request.Tuples, maxDepth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strict := h.d.Config(ctx).StrictMode()
|
||||
responses := make([]*CheckPermissionResultWithError, len(request.Tuples))
|
||||
for i, result := range results {
|
||||
responses[i] = &CheckPermissionResultWithError{
|
||||
Allowed: result.Membership == IsMember,
|
||||
Error: batchResultErrMsg(result, strict),
|
||||
}
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// BatchCheck is the gRPC entry point for checking batches of tuples
|
||||
func (h *Handler) BatchCheck(ctx context.Context, req *connect.Request[rts.BatchCheckRequest]) (*connect.Response[rts.BatchCheckResponse], error) {
|
||||
if len(req.Msg.Tuples) > h.d.Config(ctx).BatchCheckMaxBatchSize() {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf(
|
||||
"batch exceeds max size of %v", h.d.Config(ctx).BatchCheckMaxBatchSize()))
|
||||
}
|
||||
|
||||
ketoTuples := make([]*ketoapi.RelationTuple, len(req.Msg.Tuples))
|
||||
for i, tuple := range req.Msg.Tuples {
|
||||
ketoTuples[i] = (&ketoapi.RelationTuple{}).FromProto(tuple)
|
||||
}
|
||||
|
||||
results, err := h.d.PermissionEngine().BatchCheck(ctx, ketoTuples, int(req.Msg.MaxDepth))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strict := h.d.Config(ctx).StrictMode()
|
||||
responses := make([]*rts.CheckResponseWithError, len(results))
|
||||
for i, result := range results {
|
||||
responses[i] = &rts.CheckResponseWithError{ //nolint:gosec // Snaptoken is not a credential, just a placeholder value.
|
||||
Allowed: result.Membership == IsMember,
|
||||
Error: batchResultErrMsg(result, strict),
|
||||
Snaptoken: "not yet implemented",
|
||||
}
|
||||
}
|
||||
|
||||
return connect.NewResponse(&rts.BatchCheckResponse{
|
||||
Results: responses,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// batchResultErrMsg returns an error string for a single batch check result.
|
||||
// In strict mode a limitation is surfaced so callers can distinguish
|
||||
// "definitely not a member" from "could not determine membership".
|
||||
func batchResultErrMsg(r Result, strict bool) string {
|
||||
if r.Err != nil {
|
||||
return r.Err.Error()
|
||||
}
|
||||
if r.Membership == MembershipUnknown && r.Limitation != "" && strict {
|
||||
return string(r.Limitation)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
415
internal/check/handler_test.go
Normal file
415
internal/check/handler_test.go
Normal file
@@ -0,0 +1,415 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
client "github.com/ory/keto/internal/httpclient"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/ketoapi"
|
||||
)
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
defer func() { assert.NoError(t, resp.Body.Close()) }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
func assertAllowed(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
body := readBody(t, resp)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
|
||||
allowed := gjson.GetBytes(body, "allowed")
|
||||
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
||||
require.True(t, allowed.Bool())
|
||||
}
|
||||
|
||||
type responseAssertion func(t *testing.T, resp *http.Response)
|
||||
|
||||
func baseAssertDenied(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
body := readBody(t, resp)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s", body)
|
||||
allowed := gjson.GetBytes(body, "allowed")
|
||||
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
||||
require.False(t, allowed.Bool())
|
||||
}
|
||||
|
||||
// For OpenAPI clients, we want to always return a 200 status code even if the
|
||||
// check returned "denied" to not cause exceptions etc. in the generated clients.
|
||||
func openAPIAssertDenied(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
body := readBody(t, resp)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body)
|
||||
allowed := gjson.GetBytes(body, "allowed")
|
||||
require.True(t, allowed.Exists(), "response missing 'allowed' field: %s", body)
|
||||
require.False(t, allowed.Bool())
|
||||
}
|
||||
|
||||
func newTestGRPCCheckClient(t *testing.T, h *check.Handler) rts.CheckServiceClient {
|
||||
t.Helper()
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewUnstartedServer(router)
|
||||
ts.EnableHTTP2 = true
|
||||
ts.StartTLS()
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
rootCA := x509.NewCertPool()
|
||||
rootCA.AddCert(ts.Certificate())
|
||||
|
||||
conn, err := grpc.NewClient(ts.Listener.Addr().String(),
|
||||
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{RootCAs: rootCA})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
||||
return rts.NewCheckServiceClient(conn)
|
||||
}
|
||||
|
||||
func buildBatchURL(baseURL, maxDepth string) string {
|
||||
return fmt.Sprintf("%s%s?max-depth=%s", baseURL, check.BatchRoute, maxDepth)
|
||||
}
|
||||
|
||||
func TestCheckRESTHandler(t *testing.T) {
|
||||
nspaces := []*namespace.Namespace{{
|
||||
Name: "ns",
|
||||
}}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
|
||||
h := check.NewHandler(reg)
|
||||
r := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(r)
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
for _, suite := range []struct {
|
||||
name string
|
||||
base string
|
||||
assertDenied responseAssertion
|
||||
}{
|
||||
{name: "base", base: check.RouteBase, assertDenied: baseAssertDenied},
|
||||
{name: "openapi", base: check.OpenAPIRouteBase, assertDenied: openAPIAssertDenied},
|
||||
} {
|
||||
t.Run("suite="+suite.name, func(t *testing.T) {
|
||||
assertDenied := suite.assertDenied
|
||||
|
||||
t.Run("case=returns bad request on malformed int", func(t *testing.T) {
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?max-depth=foo")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
|
||||
})
|
||||
|
||||
t.Run("case=returns bad request on malformed input", func(t *testing.T) {
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + url.Values{
|
||||
"subject": {"not#a valid userset rewrite"},
|
||||
}.Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=returns bad request on missing subject", func(t *testing.T) {
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "subject is not allowed to be nil")
|
||||
})
|
||||
|
||||
t.Run("case=returns denied on unknown namespace", func(t *testing.T) {
|
||||
rt := testhelpers.APITupleFromString(t, "not:o#r@s")
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertDenied(t, resp)
|
||||
})
|
||||
|
||||
t.Run("case=returns allowed", func(t *testing.T) {
|
||||
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
|
||||
testhelpers.MapAndInsertTuples(t, reg, rt)
|
||||
|
||||
q := rt.ToURLQuery()
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + q.Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertAllowed(t, resp)
|
||||
})
|
||||
|
||||
t.Run("case=returns denied", func(t *testing.T) {
|
||||
rt := testhelpers.APITupleFromString(t, "ns:foo#r@s")
|
||||
resp, err := ts.Client().Get(ts.URL + suite.base + "?" + rt.ToURLQuery().Encode())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertDenied(t, resp)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCheckHandler(t *testing.T) {
|
||||
nspaces := []*namespace.Namespace{{Name: "ns"}}
|
||||
reg := driver.NewSqliteTestRegistry(t, driver.WithNamespaces(nspaces))
|
||||
h := check.NewHandler(reg)
|
||||
|
||||
rt := testhelpers.APITupleFromString(t, "ns:o#r@s")
|
||||
testhelpers.MapAndInsertTuples(t, reg, rt)
|
||||
|
||||
// REST setup.
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// gRPC setup.
|
||||
checkClient := newTestGRPCCheckClient(t, h)
|
||||
|
||||
t.Run("case=REST: validates request format", func(t *testing.T) {
|
||||
t.Run("non-int max depth", func(t *testing.T) {
|
||||
resp, err := ts.Client().Post(ts.URL+check.BatchRoute+"?max-depth=foo",
|
||||
"application/json", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "invalid syntax")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON body", func(t *testing.T) {
|
||||
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"),
|
||||
"application/json", strings.NewReader("not-json"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "could not unmarshal json")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=validates max batch size", func(t *testing.T) {
|
||||
t.Run("protocol=REST", func(t *testing.T) {
|
||||
tuples := make([]client.Relationship, 11)
|
||||
for i := range tuples {
|
||||
tuples[i] = relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s"))
|
||||
}
|
||||
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: tuples})
|
||||
require.NoError(t, err)
|
||||
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Contains(t, string(readBody(t, resp)), "batch exceeds max size of 10")
|
||||
})
|
||||
|
||||
t.Run("protocol=gRPC", func(t *testing.T) {
|
||||
tuples := make([]*rts.RelationTuple, 11)
|
||||
for i := range tuples {
|
||||
tuples[i] = testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto()
|
||||
}
|
||||
_, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: tuples, MaxDepth: 5})
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.InvalidArgument, st.Code())
|
||||
require.Equal(t, "batch exceeds max size of 10", st.Message())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=returns correct results per tuple", func(t *testing.T) {
|
||||
// Three tuples: allowed, not-allowed, unknown namespace.
|
||||
restTuples := []client.Relationship{
|
||||
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o#r@s")), // allowed
|
||||
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "ns:o2#r@s")), // not allowed
|
||||
relationshipFromAPITuple(testhelpers.APITupleFromString(t, "n2:o#r@s")), // unknown namespace
|
||||
}
|
||||
grpcTuples := []*rts.RelationTuple{
|
||||
testhelpers.APITupleFromString(t, "ns:o#r@s").ToProto(), // allowed
|
||||
testhelpers.APITupleFromString(t, "ns:o2#r@s").ToProto(), // not allowed
|
||||
testhelpers.APITupleFromString(t, "n2:o#r@s").ToProto(), // unknown namespace
|
||||
}
|
||||
|
||||
t.Run("protocol=REST", func(t *testing.T) {
|
||||
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{Tuples: restTuples})
|
||||
require.NoError(t, err)
|
||||
resp, err := ts.Client().Post(buildBatchURL(ts.URL, "5"), "application/json", bytes.NewReader(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result client.BatchCheckPermissionResult
|
||||
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
|
||||
require.Equal(t, client.BatchCheckPermissionResult{
|
||||
Results: []client.CheckPermissionResultWithError{
|
||||
{Allowed: true},
|
||||
{Allowed: false},
|
||||
{Allowed: false, Error: new("The requested resource could not be found")},
|
||||
},
|
||||
}, result)
|
||||
})
|
||||
|
||||
t.Run("protocol=gRPC", func(t *testing.T) {
|
||||
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{Tuples: grpcTuples, MaxDepth: 5})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Results, 3)
|
||||
require.True(t, resp.Results[0].Allowed)
|
||||
require.Empty(t, resp.Results[0].Error)
|
||||
require.False(t, resp.Results[1].Allowed)
|
||||
require.Empty(t, resp.Results[1].Error)
|
||||
require.False(t, resp.Results[2].Allowed)
|
||||
require.Equal(t, "The requested resource could not be found", resp.Results[2].Error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// relationshipFromAPITuple converts a ketoapi.RelationTuple to a client.Relationship
|
||||
// for use in REST batch check requests.
|
||||
func relationshipFromAPITuple(tuple *ketoapi.RelationTuple) client.Relationship {
|
||||
rel := client.Relationship{
|
||||
Namespace: tuple.Namespace,
|
||||
Object: tuple.Object,
|
||||
Relation: tuple.Relation,
|
||||
}
|
||||
if tuple.SubjectID != nil {
|
||||
rel.SubjectId = tuple.SubjectID
|
||||
} else if tuple.SubjectSet != nil {
|
||||
rel.SubjectSet = &client.SubjectSet{
|
||||
Namespace: tuple.SubjectSet.Namespace,
|
||||
Object: tuple.SubjectSet.Object,
|
||||
Relation: tuple.SubjectSet.Relation,
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func TestCheckStrictModeLimit(t *testing.T) {
|
||||
depthLimitScenario.WithStrict(true).Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadDepth, 2))
|
||||
h := check.NewHandler(reg)
|
||||
|
||||
// This tuple has a depth of 3, which exceeds the configured limit of 2, causing to hit the limitation.
|
||||
tupleQuery := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice").ToURLQuery()
|
||||
|
||||
t.Run("case=HTTP: returns 422 with limitation kind", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
resp, err := ts.Client().Get(ts.URL + check.RouteBase + "?max-depth=2&" + tupleQuery.Encode())
|
||||
require.NoError(t, err)
|
||||
body := readBody(t, resp)
|
||||
|
||||
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "%s", body)
|
||||
require.Equal(t, "The check could not be completed", gjson.GetBytes(body, "error.message").String())
|
||||
require.Contains(t, gjson.GetBytes(body, "error.reason").String(), string(check.LimitationMaxDepthExceeded))
|
||||
})
|
||||
|
||||
t.Run("case=gRPC: returns FailedPrecondition with limitation kind", func(t *testing.T) {
|
||||
checkClient := newTestGRPCCheckClient(t, h)
|
||||
tuple := testhelpers.APITupleFromString(t, "Resource:doc#union@User:Alice")
|
||||
_, err := checkClient.Check(t.Context(), &rts.CheckRequest{
|
||||
Tuple: tuple.ToProto(),
|
||||
MaxDepth: 2,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.FailedPrecondition, st.Code())
|
||||
require.Equal(t, "The check could not be completed", st.Message())
|
||||
|
||||
var ei *errdetails.ErrorInfo
|
||||
for _, d := range st.Details() {
|
||||
if info, ok := d.(*errdetails.ErrorInfo); ok {
|
||||
ei = info
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNilf(t, ei, "%+v", st.Details())
|
||||
require.Contains(t, ei.Reason, string(check.LimitationMaxDepthExceeded))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchCheckLimitHandler(t *testing.T) {
|
||||
for _, scenario := range []testhelpers.Scenario{
|
||||
widthLimitScenario,
|
||||
widthLimitScenario.WithStrict(true),
|
||||
} {
|
||||
scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
require.NoError(t, reg.Config(t.Context()).Set(config.KeyLimitMaxReadWidth, 1))
|
||||
h := check.NewHandler(reg)
|
||||
strict := scenario.Strict
|
||||
|
||||
// Bob is only in the truncated branch → MembershipUnknown at width=1.
|
||||
tuple := testhelpers.APITupleFromString(t, "Resource:doc#readers@User:Bob")
|
||||
|
||||
t.Run("case=REST: strict mode surfaces limitation in error field", func(t *testing.T) {
|
||||
router := httprouterx.NewRouterPublic()
|
||||
h.RegisterReadRoutes(router)
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
bodyBytes, err := json.Marshal(client.BatchCheckPermissionBody{
|
||||
Tuples: []client.Relationship{relationshipFromAPITuple(tuple)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := ts.Client().Post(
|
||||
fmt.Sprintf("%s%s?max-depth=10", ts.URL, check.BatchRoute),
|
||||
"application/json", bytes.NewReader(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result client.BatchCheckPermissionResult
|
||||
require.NoError(t, json.Unmarshal(readBody(t, resp), &result))
|
||||
require.Len(t, result.Results, 1)
|
||||
require.False(t, result.Results[0].Allowed)
|
||||
if strict {
|
||||
require.NotNil(t, result.Results[0].Error)
|
||||
require.Contains(t, *result.Results[0].Error, string(check.LimitationMaxWidthExceeded))
|
||||
} else {
|
||||
require.Empty(t, result.Results[0].Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=gRPC: strict mode surfaces limitation in error field", func(t *testing.T) {
|
||||
checkClient := newTestGRPCCheckClient(t, h)
|
||||
resp, err := checkClient.BatchCheck(t.Context(), &rts.BatchCheckRequest{
|
||||
Tuples: []*rts.RelationTuple{tuple.ToProto()},
|
||||
MaxDepth: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Results, 1)
|
||||
require.False(t, resp.Results[0].Allowed)
|
||||
if strict {
|
||||
require.Contains(t, resp.Results[0].Error, string(check.LimitationMaxWidthExceeded))
|
||||
} else {
|
||||
require.Empty(t, resp.Results[0].Error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
284
internal/check/rewrites_test.go
Normal file
284
internal/check/rewrites_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
var rewrites = testhelpers.Scenario{
|
||||
Name: "userset rewrites",
|
||||
Opl: `
|
||||
class User implements Namespace {}
|
||||
|
||||
class Doc implements Namespace {
|
||||
related: {
|
||||
owner: User[]
|
||||
parent: Doc[]
|
||||
}
|
||||
permits = {
|
||||
editor: (ctx: Context) => this.related.owner.includes(ctx.subject),
|
||||
viewer: (ctx: Context) => this.permits.editor(ctx) ||
|
||||
this.related.parent.traverse(p => p.permits.viewer(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class Level implements Namespace {
|
||||
related: {
|
||||
member: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
level: Level[]
|
||||
owner: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewer: (ctx: Context) => this.related.owner.traverse(g => g.related.member.includes(ctx.subject)),
|
||||
isOwner: (ctx: Context) => this.related.owner.traverse(g => g.related.member.includes(ctx.subject)),
|
||||
|
||||
read: (ctx: Context) => this.permits.viewer(ctx) || this.permits.isOwner(ctx),
|
||||
update: (ctx: Context) => this.permits.isOwner(ctx),
|
||||
delete: (ctx: Context) => this.permits.isOwner(ctx) && this.related.level.traverse(l => l.related.member.includes(ctx.subject)),
|
||||
}
|
||||
}
|
||||
|
||||
class Acl implements Namespace {
|
||||
related: {
|
||||
allow: User[]
|
||||
deny: User[]
|
||||
}
|
||||
permits = {
|
||||
access: (ctx: Context) => this.related.allow.includes(ctx.subject) && !this.related.deny.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
InputTuples: []string{
|
||||
// Direct ownership — plain and namespace-qualified subjects both work.
|
||||
"Doc:document#owner@plain_user",
|
||||
"Doc:document#owner@User:user",
|
||||
|
||||
// Single-level parent: doc_in_folder inherits viewer from its parent folder.
|
||||
"Doc:folder#owner@plain_user",
|
||||
"Doc:folder#owner@User:user",
|
||||
"Doc:doc_in_folder#parent@Doc:folder",
|
||||
|
||||
// Deep hierarchy: user owns folder_a; viewer propagates down 4 levels to file.
|
||||
"Doc:folder_a#owner@user",
|
||||
"Doc:folder_b#parent@Doc:folder_a",
|
||||
"Doc:folder_c#parent@Doc:folder_b",
|
||||
"Doc:file#parent@Doc:folder_c",
|
||||
|
||||
// Group + level gating: delete requires owner-group membership AND correct level.
|
||||
"Group:editors#member@mark",
|
||||
"Group:editors#member@mike",
|
||||
"Level:superadmin#member@mark",
|
||||
"Level:superadmin#member@sandy",
|
||||
"Resource:topsecret#owner@Group:editors#",
|
||||
"Resource:topsecret#level@Level:superadmin#",
|
||||
|
||||
// Allow/deny ACL: deny overrides allow (mallory is on both lists).
|
||||
"Acl:document#allow@alice",
|
||||
"Acl:document#allow@bob",
|
||||
"Acl:document#allow@mallory",
|
||||
"Acl:document#deny@mallory",
|
||||
},
|
||||
}
|
||||
|
||||
type path []string
|
||||
|
||||
func TestUsersetRewrites(t *testing.T) {
|
||||
testCases := []struct {
|
||||
query string
|
||||
expected check.Result
|
||||
expectedPaths []path
|
||||
}{
|
||||
{
|
||||
// direct
|
||||
query: "Doc:document#owner@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// userset rewrite
|
||||
query: "Doc:document#editor@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// userset rewrite
|
||||
query: "Doc:document#editor@plain_user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// transitive userset rewrite
|
||||
query: "Doc:document#viewer@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Doc:document#editor@nobody",
|
||||
expected: check.ResultNotMember,
|
||||
},
|
||||
{
|
||||
query: "Doc:folder#viewer@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:doc_in_folder#viewer@User:user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:doc_in_folder#viewer@plain_user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:doc_in_folder#viewer@nobody",
|
||||
expected: check.ResultNotMember,
|
||||
},
|
||||
{
|
||||
// tuple to userset
|
||||
query: "Doc:another_doc#viewer@user",
|
||||
expected: check.ResultNotMember,
|
||||
},
|
||||
{
|
||||
query: "Doc:file#viewer@user",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Level:superadmin#member@mark",
|
||||
expected: check.ResultIsMember, // mark is both editor and has correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#isOwner@mark",
|
||||
expected: check.ResultIsMember, // mark is both editor and has correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#delete@mark",
|
||||
expected: check.ResultIsMember, // mark is both editor and has correct level
|
||||
expectedPaths: []path{
|
||||
{"*", "Resource:topsecret#delete@mark", "*", "Level:superadmin#member@mark"},
|
||||
{"*", "Resource:topsecret#delete@mark", "Resource:topsecret#isOwner@mark", "*", "*", "*", "Group:editors#member@mark"},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#update@mike",
|
||||
expected: check.ResultIsMember, // mike owns the resource
|
||||
},
|
||||
{
|
||||
query: "Level:superadmin#member@mike",
|
||||
expected: check.ResultNotMember, // mike does not have correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#delete@mike",
|
||||
expected: check.ResultNotMember, // mike does not have correct level
|
||||
},
|
||||
{
|
||||
query: "Resource:topsecret#delete@sandy",
|
||||
expected: check.ResultNotMember, // sandy is not in the editor Group
|
||||
},
|
||||
{
|
||||
query: "Acl:document#access@alice",
|
||||
expected: check.ResultIsMember,
|
||||
expectedPaths: []path{{"*", "Acl:document#access@alice", "Acl:document#allow@alice"}},
|
||||
},
|
||||
{
|
||||
query: "Acl:document#access@bob",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Acl:document#allow@mallory",
|
||||
expected: check.ResultIsMember,
|
||||
},
|
||||
{
|
||||
query: "Acl:document#access@mallory",
|
||||
expected: check.ResultNotMember, // mallory is also on deny-list
|
||||
},
|
||||
}
|
||||
|
||||
rewrites.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
e := trace.NewEngine(reg)
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
||||
|
||||
t.Run("suite=testcases", func(t *testing.T) {
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run("case="+tc.query, func(t *testing.T) {
|
||||
rt := testhelpers.TupleFromString(t, tc.query)
|
||||
|
||||
res, tree := e.CheckRelationTupleWithTrace(t.Context(), rt, 100)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, tc.expected.Membership.String(), res.Membership.String())
|
||||
|
||||
if len(tc.expectedPaths) > 0 {
|
||||
for _, path := range tc.expectedPaths {
|
||||
assertPath(t, path, tree)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Run("suite=one worker", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
e := trace.NewEngine(reg)
|
||||
|
||||
rt := testhelpers.TupleFromString(t, "Doc:file#viewer@user")
|
||||
res, _ := e.CheckRelationTupleWithTrace(ctx, rt, 100)
|
||||
require.NoError(t, res.Err)
|
||||
assert.Equal(t, check.ResultIsMember.Membership, res.Membership)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// assertPath asserts that the given path can be found in the tree.
|
||||
func assertPath(t *testing.T, path path, tree *trace.Node) {
|
||||
require.NotNil(t, tree)
|
||||
assert.True(t, hasPath(t, path, tree), "could not find path %s in tree:\n%s", path, tree)
|
||||
}
|
||||
|
||||
func hasPath(t *testing.T, path path, tree *trace.Node) bool {
|
||||
if len(path) == 0 {
|
||||
return true
|
||||
}
|
||||
treeLabel := tree.Tuple.String()
|
||||
if path[0] != "*" {
|
||||
// use testhelpers.TupleFromString to compare against paths with UUIDs.
|
||||
tuple := testhelpers.TupleFromString(t, path[0])
|
||||
tupleStr := tuple.String()
|
||||
if tupleStr != treeLabel {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(path) == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, child := range tree.Children {
|
||||
if hasPath(t, path[1:], child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
32
internal/check/step.go
Normal file
32
internal/check/step.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// StepKind identifies the execution model type of a Step.
|
||||
type StepKind string
|
||||
|
||||
const (
|
||||
StepIsAllowed StepKind = "is_allowed"
|
||||
StepDirect StepKind = "direct"
|
||||
StepDirectMulti StepKind = "direct_multi"
|
||||
StepExpand StepKind = "expand_subject"
|
||||
StepComputed StepKind = "computed"
|
||||
StepTraverse StepKind = "traverse"
|
||||
StepRewrite StepKind = "rewrite"
|
||||
StepInvert StepKind = "invert"
|
||||
)
|
||||
|
||||
// Step is a single unit of execution within the permission check engine.
|
||||
type Step interface {
|
||||
// Kind returns the execution-model kind of this step.
|
||||
Kind() StepKind
|
||||
|
||||
// Execute performs the step's work and returns the membership result.
|
||||
// Child steps must be dispatched through ex, not called directly.
|
||||
Execute(ctx context.Context, req CheckRequest, ex Executor) Result
|
||||
}
|
||||
55
internal/check/step/call_limit.go
Normal file
55
internal/check/step/call_limit.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
)
|
||||
|
||||
type callBudgetKeyType struct{}
|
||||
|
||||
var callBudgetKey callBudgetKeyType
|
||||
|
||||
// maxCallSteps bounds the number of steps along a single resolution path. Most
|
||||
// recursion is already bounded by RestDepth, but same-object computed hops
|
||||
// (ComputedUsersetStep, InvertStep) don't decrement it, so a cyclic config
|
||||
// (e.g. two permits that reference each other) would otherwise recurse until
|
||||
// the stack overflows and crashes the process. This is a blanket per-path
|
||||
// ceiling, well above any real config and well below a stack overflow.
|
||||
const maxCallSteps = 1000
|
||||
|
||||
func errCallBudgetExceeded() error {
|
||||
return errors.WithStack(&herodot.DefaultError{
|
||||
StatusField: http.StatusText(http.StatusUnprocessableEntity),
|
||||
ErrorField: "The check could not be completed",
|
||||
ReasonField: "The check was aborted after too many resolution steps. This usually indicates a cyclic permit reference in the namespace configuration.",
|
||||
CodeField: http.StatusUnprocessableEntity,
|
||||
GRPCCodeField: codes.FailedPrecondition,
|
||||
})
|
||||
}
|
||||
|
||||
// CallLimitMiddleware aborts a check once any single resolution path exceeds
|
||||
// maxCallSteps. It returns a decisive error so the whole check stops
|
||||
// immediately, rather than a limitation (which is non-decisive and would let a
|
||||
// cyclic config keep exploring exponentially many branches). The budget is
|
||||
// carried per-path in the context and seeded lazily.
|
||||
func CallLimitMiddleware() check.Middleware {
|
||||
return func(ctx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
||||
remaining, ok := ctx.Value(callBudgetKey).(int)
|
||||
if !ok {
|
||||
remaining = maxCallSteps
|
||||
}
|
||||
if remaining <= 0 {
|
||||
return check.Result{Err: errCallBudgetExceeded()}
|
||||
}
|
||||
return next(context.WithValue(ctx, callBudgetKey, remaining-1), s, req)
|
||||
}
|
||||
}
|
||||
37
internal/check/step/computed.go
Normal file
37
internal/check/step/computed.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// ComputedUsersetStep rewrites the tuple's relation to the computed relation
|
||||
// and delegates to IsAllowedStep.
|
||||
type ComputedUsersetStep struct {
|
||||
Relation string
|
||||
}
|
||||
|
||||
func (ComputedUsersetStep) Kind() check.StepKind { return check.StepComputed }
|
||||
|
||||
func (s ComputedUsersetStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
WithField("computed subjectSet relation", s.Relation).
|
||||
Trace("check computed subjectSet")
|
||||
|
||||
childReq := check.CheckRequest{
|
||||
Tuple: &relationtuple.RelationTuple{
|
||||
Namespace: req.Tuple.Namespace,
|
||||
Object: req.Tuple.Object,
|
||||
Relation: s.Relation,
|
||||
Subject: req.Tuple.Subject,
|
||||
},
|
||||
RestDepth: req.RestDepth,
|
||||
}
|
||||
return ex.Run(ctx, IsAllowedStep{}, childReq)
|
||||
}
|
||||
41
internal/check/step/direct.go
Normal file
41
internal/check/step/direct.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
)
|
||||
|
||||
// DirectStep checks whether the exact relation tuple exists in the database.
|
||||
type DirectStep struct{}
|
||||
|
||||
func (DirectStep) Kind() check.StepKind { return check.StepDirect }
|
||||
|
||||
func (DirectStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
if req.RestDepth <= 0 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
Trace("check direct")
|
||||
|
||||
found, err := ex.Deps().RelationTupleManager().ExistsRelationTuples(ctx, req.Tuple.ToQuery())
|
||||
switch {
|
||||
case err != nil:
|
||||
ex.Deps().Logger().
|
||||
WithField("method", "DirectStep").
|
||||
WithError(err).
|
||||
Error("failed to look up direct access in db")
|
||||
return check.Result{Err: errors.WithStack(err)}
|
||||
case found:
|
||||
return check.ResultIsMember
|
||||
default:
|
||||
return check.ResultNotMember
|
||||
}
|
||||
}
|
||||
40
internal/check/step/direct_multi.go
Normal file
40
internal/check/step/direct_multi.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
)
|
||||
|
||||
// DirectMultiStep checks whether any of the given relations exist directly in
|
||||
// the database for the tuple.
|
||||
type DirectMultiStep struct {
|
||||
Relations []string
|
||||
}
|
||||
|
||||
func (DirectMultiStep) Kind() check.StepKind { return check.StepDirectMulti }
|
||||
|
||||
func (s DirectMultiStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
if req.RestDepth <= 0 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
WithField("relations", s.Relations).
|
||||
Trace("check direct multi")
|
||||
|
||||
res, err := ex.Deps().RelationTupleManager().FindTupleWithRelations(ctx, req.Tuple, s.Relations)
|
||||
if err != nil {
|
||||
return check.Result{Err: errors.WithStack(err)}
|
||||
}
|
||||
if res != nil {
|
||||
return check.ResultIsMember
|
||||
}
|
||||
return check.ResultNotMember
|
||||
}
|
||||
91
internal/check/step/dispatch.go
Normal file
91
internal/check/step/dispatch.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
)
|
||||
|
||||
// IsAllowedStep is the central semantic dispatcher. It reads the AST rule for
|
||||
// the tuple and determines what checks are applicable via RunUnion.
|
||||
type IsAllowedStep struct {
|
||||
skipDirect bool
|
||||
}
|
||||
|
||||
func (IsAllowedStep) Kind() check.StepKind { return check.StepIsAllowed }
|
||||
|
||||
func (s IsAllowedStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
if req.RestDepth <= 0 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
Trace("check is allowed")
|
||||
|
||||
nm, err := ex.Deps().Config(ctx).NamespaceManager()
|
||||
if err != nil {
|
||||
return check.Result{Err: err}
|
||||
}
|
||||
|
||||
relation, err := astRelationFor(ctx, nm, req.Tuple)
|
||||
if err != nil {
|
||||
return check.Result{Err: err}
|
||||
}
|
||||
|
||||
hasRewrite := relation != nil && relation.SubjectSetRewrite != nil
|
||||
strictMode := ex.Deps().Config(ctx).StrictMode()
|
||||
|
||||
// In strict mode, only run steps that OPL explicitly permits.
|
||||
// In non-strict mode, run all applicable steps for backwards compatibility.
|
||||
canHaveDirect := !strictMode || AllowsDirectMember(relation, req.Tuple.Subject)
|
||||
|
||||
steps := make([]check.PlannedStep, 0, 3)
|
||||
|
||||
if hasRewrite {
|
||||
steps = append(steps, check.PlannedStep{
|
||||
Step: RewriteStep{Rewrite: relation.SubjectSetRewrite},
|
||||
Req: req,
|
||||
})
|
||||
}
|
||||
|
||||
if canHaveDirect && !s.skipDirect {
|
||||
steps = append(steps, check.PlannedStep{
|
||||
Step: DirectStep{},
|
||||
Req: req,
|
||||
})
|
||||
}
|
||||
|
||||
// In strict mode, only expand when OPL explicitly declares subject-set types, and
|
||||
// filter expansion to only those declared types.
|
||||
// In non-strict mode, always expand to preserve backwards compatibility.
|
||||
if strictMode {
|
||||
subjectSetTypes, err := subjectSetTypesFor(ctx, nm, req.Tuple.Subject, relation)
|
||||
if err != nil {
|
||||
return check.Result{Err: err}
|
||||
}
|
||||
if len(subjectSetTypes) > 0 {
|
||||
steps = append(steps, check.PlannedStep{
|
||||
Step: ExpandSubjectStep{SubjectSetTypes: subjectSetTypes},
|
||||
Req: req,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
steps = append(steps, check.PlannedStep{
|
||||
Step: ExpandSubjectStep{},
|
||||
Req: req,
|
||||
})
|
||||
}
|
||||
|
||||
switch len(steps) {
|
||||
case 0:
|
||||
return check.ResultNotMember
|
||||
case 1:
|
||||
return ex.Run(ctx, steps[0].Step, steps[0].Req)
|
||||
default:
|
||||
return ex.RunUnion(ctx, steps...)
|
||||
}
|
||||
}
|
||||
277
internal/check/step/executor.go
Normal file
277
internal/check/step/executor.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type (
|
||||
relationTuple = relationtuple.RelationTuple
|
||||
query = relationtuple.RelationQuery
|
||||
)
|
||||
|
||||
// runMode defines the decisiveness and default result semantics for an execution mode.
|
||||
//
|
||||
// IsDecisive determines whether a step result ends execution early (short-circuit).
|
||||
// - Union: IsMember or any Error is decisive.
|
||||
// - Intersection: NotMember or any Error is decisive.
|
||||
//
|
||||
// NoDecisionResult is returned when all steps ran without triggering a short-circuit.
|
||||
// - Union: NotMember (no member found among alternatives).
|
||||
// - Intersection: IsMember (all conditions met).
|
||||
//
|
||||
// NoStepsResult is returned when no steps were added (empty input slice).
|
||||
// - Both: NotMember (no tuples to check).
|
||||
type runMode interface {
|
||||
IsDecisive(check.Result) bool
|
||||
NoDecisionResult() check.Result
|
||||
NoStepsResult() check.Result
|
||||
}
|
||||
|
||||
// unionRunMode implements OR semantics: first IsMember or Error wins.
|
||||
type unionRunMode struct{}
|
||||
|
||||
func (unionRunMode) IsDecisive(r check.Result) bool {
|
||||
return r.Membership == check.IsMember || r.Err != nil
|
||||
}
|
||||
func (unionRunMode) NoDecisionResult() check.Result { return check.ResultNotMember }
|
||||
func (unionRunMode) NoStepsResult() check.Result { return check.ResultNotMember }
|
||||
|
||||
// intersectionRunMode implements AND semantics: first NotMember or Error wins.
|
||||
type intersectionRunMode struct{}
|
||||
|
||||
func (intersectionRunMode) IsDecisive(r check.Result) bool {
|
||||
return r.Membership == check.NotMember || r.Err != nil
|
||||
}
|
||||
func (intersectionRunMode) NoDecisionResult() check.Result { return check.ResultIsMember }
|
||||
func (intersectionRunMode) NoStepsResult() check.Result { return check.ResultNotMember }
|
||||
|
||||
var (
|
||||
unionMode runMode = unionRunMode{}
|
||||
intersectionMode runMode = intersectionRunMode{}
|
||||
)
|
||||
|
||||
// Executor implements check.Executor and runs Steps with short-circuit
|
||||
// union/intersection semantics and a middleware chain.
|
||||
type Executor struct {
|
||||
dep check.EngineDependencies
|
||||
middlewares []check.Middleware
|
||||
}
|
||||
|
||||
func NewExecutor(dep check.EngineDependencies, middlewares ...check.Middleware) *Executor {
|
||||
return &Executor{dep: dep, middlewares: middlewares}
|
||||
}
|
||||
|
||||
func (e *Executor) Deps() check.EngineDependencies { return e.dep }
|
||||
|
||||
func (e *Executor) Run(ctx context.Context, s check.Step, req check.CheckRequest) check.Result {
|
||||
return e.runAt(ctx, s, req, 0)
|
||||
}
|
||||
|
||||
// runAt executes the middleware chain at position i, then the step's Execute method.
|
||||
// It implements a functional middleware chain: each middleware receives a next() function
|
||||
// that continues the chain.
|
||||
func (e *Executor) runAt(ctx context.Context, s check.Step, req check.CheckRequest, i int) check.Result {
|
||||
if i >= len(e.middlewares) {
|
||||
return s.Execute(ctx, req, e)
|
||||
}
|
||||
return e.middlewares[i](ctx, s, req, func(ctx context.Context, s check.Step, req check.CheckRequest) check.Result {
|
||||
return e.runAt(ctx, s, req, i+1)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Executor) RunUnion(ctx context.Context, steps ...check.PlannedStep) check.Result {
|
||||
return runAll(ctx, e, unionMode, 1, steps...)
|
||||
}
|
||||
|
||||
func (e *Executor) RunIntersection(ctx context.Context, steps ...check.PlannedStep) check.Result {
|
||||
return runAll(ctx, e, intersectionMode, 1, steps...)
|
||||
}
|
||||
|
||||
func (e *Executor) NewUnionRunner(ctx context.Context) check.StepRunner {
|
||||
return newStepGroup(ctx, 1, unionMode, e)
|
||||
}
|
||||
|
||||
// CheckRelationTuple checks if the tuple's subject has the relation on the object.
|
||||
func (e *Executor) CheckRelationTuple(ctx context.Context, r *relationTuple, restDepth int) check.Result {
|
||||
restDepth = clampDepth(ctx, e.dep, restDepth)
|
||||
return e.Run(ctx, IsAllowedStep{}, check.CheckRequest{
|
||||
Tuple: r,
|
||||
RestDepth: restDepth,
|
||||
})
|
||||
}
|
||||
|
||||
func clampDepth(ctx context.Context, c config.Provider, restDepth int) int {
|
||||
if globalMaxDepth := c.Config(ctx).MaxReadDepth(); restDepth <= 0 || globalMaxDepth < restDepth {
|
||||
return globalMaxDepth
|
||||
}
|
||||
return restDepth
|
||||
}
|
||||
|
||||
func runAll(ctx context.Context, ex check.Executor, mode runMode, limit int, steps ...check.PlannedStep) check.Result {
|
||||
if len(steps) == 0 {
|
||||
return mode.NoStepsResult()
|
||||
}
|
||||
g := newStepGroup(ctx, limit, mode, ex)
|
||||
|
||||
for _, ps := range steps {
|
||||
if g.Done() {
|
||||
break
|
||||
}
|
||||
g.Add(ps)
|
||||
}
|
||||
return g.Result()
|
||||
}
|
||||
|
||||
// stepGroup runs sub-checks with short-circuit semantics. When sequential (limit=1)
|
||||
// steps run inline with no goroutine overhead. When limit>1 steps run
|
||||
// concurrently, cancelling siblings on the first decisive result.
|
||||
// stepGroup implements check.StepRunner.
|
||||
type stepGroup struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
ex check.Executor
|
||||
sequential bool
|
||||
|
||||
sem chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
result check.Result
|
||||
set bool
|
||||
limitation check.LimitationKind
|
||||
|
||||
mode runMode
|
||||
}
|
||||
|
||||
// newStepGroup creates a StepGroup that runs steps with the given concurrencyLimit and mode.
|
||||
// If concurrencyLimit < 1, it's clamped to 1 (sequential). Sequential execution (concurrencyLimit=1)
|
||||
// runs steps inline without goroutines; concurrent execution spawns workers up to
|
||||
// concurrencyLimit. The returned context is derived from ctx via WithCancel and is cancelled
|
||||
// when a decisive result is reached (triggering sibling cancellation).
|
||||
func newStepGroup(ctx context.Context, concurrencyLimit int, mode runMode, ex check.Executor) *stepGroup {
|
||||
if concurrencyLimit < 1 {
|
||||
concurrencyLimit = 1
|
||||
}
|
||||
g := &stepGroup{
|
||||
ex: ex,
|
||||
sequential: concurrencyLimit == 1,
|
||||
mode: mode,
|
||||
}
|
||||
g.ctx, g.cancel = context.WithCancel(ctx)
|
||||
if !g.sequential {
|
||||
g.sem = make(chan struct{}, concurrencyLimit)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Add submits ps for execution. No-op if Done returns true.
|
||||
func (g *stepGroup) Add(ps check.PlannedStep) {
|
||||
g.add(func(ctx context.Context) check.Result {
|
||||
return g.ex.Run(ctx, ps.Step, ps.Req)
|
||||
})
|
||||
}
|
||||
|
||||
func (g *stepGroup) add(f func(context.Context) check.Result) {
|
||||
if g.sequential {
|
||||
if g.Done() {
|
||||
return
|
||||
}
|
||||
g.handle(f(g.ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// currently dead path as we always use concurrentLimit = 1
|
||||
select {
|
||||
case g.sem <- struct{}{}:
|
||||
case <-g.ctx.Done():
|
||||
return
|
||||
}
|
||||
if g.Done() {
|
||||
<-g.sem
|
||||
return
|
||||
}
|
||||
g.wg.Add(1)
|
||||
go func() {
|
||||
defer g.wg.Done()
|
||||
defer func() { <-g.sem }()
|
||||
g.handle(f(g.ctx))
|
||||
}()
|
||||
}
|
||||
|
||||
func (g *stepGroup) AddLimitation(l check.LimitationKind) {
|
||||
g.mu.Lock()
|
||||
if !g.set && g.limitation == "" {
|
||||
g.limitation = l
|
||||
}
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *stepGroup) handle(r check.Result) {
|
||||
if g.mode.IsDecisive(r) && g.trySetResult(r) {
|
||||
g.cancel()
|
||||
return
|
||||
}
|
||||
if r.Membership == check.MembershipUnknown && r.Limitation != "" {
|
||||
g.AddLimitation(r.Limitation)
|
||||
}
|
||||
}
|
||||
|
||||
// Done reports whether a decisive result has been reached.
|
||||
func (g *stepGroup) Done() bool {
|
||||
select {
|
||||
case <-g.ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ctx returns the group's context.
|
||||
func (g *stepGroup) Ctx() context.Context { return g.ctx }
|
||||
|
||||
// Result waits for all in-flight steps to complete and returns the aggregated result.
|
||||
// Safe to call multiple times; subsequent calls return the same value.
|
||||
func (g *stepGroup) Result() check.Result {
|
||||
g.wg.Wait()
|
||||
// Capture ctx.Err() before g.cancel() to distinguish an external cancellation
|
||||
// (deadline exceeded, parent cancelled) from the internal cancel used to signal siblings.
|
||||
ctxErr := g.ctx.Err()
|
||||
g.cancel()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// No decisive result was set. Return in priority order:
|
||||
// 1. Context cancelled by parent or sibling — propagate the error.
|
||||
// 2. A limitation was recorded — result is indeterminate.
|
||||
// 3. Neither — no step was decisive, so fall back to the mode default.
|
||||
if !g.set {
|
||||
if ctxErr != nil {
|
||||
g.result = check.Result{Membership: check.MembershipUnknown, Err: ctxErr}
|
||||
} else if g.limitation != "" {
|
||||
g.result = check.Result{Membership: check.MembershipUnknown, Limitation: g.limitation}
|
||||
} else {
|
||||
g.result = g.mode.NoDecisionResult()
|
||||
}
|
||||
g.set = true
|
||||
}
|
||||
return g.result
|
||||
}
|
||||
|
||||
func (g *stepGroup) trySetResult(r check.Result) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.set {
|
||||
return false
|
||||
}
|
||||
g.set = true
|
||||
g.result = r
|
||||
return true
|
||||
}
|
||||
587
internal/check/step/executor_test.go
Normal file
587
internal/check/step/executor_test.go
Normal file
@@ -0,0 +1,587 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
)
|
||||
|
||||
func TestBaseExecutorRunSequential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sentinel := errors.New("planted error")
|
||||
unknownErr := check.Result{Membership: check.MembershipUnknown, Err: sentinel}
|
||||
unknownLimit := check.Result{Membership: check.MembershipUnknown, Limitation: check.LimitationMaxDepthExceeded}
|
||||
|
||||
t.Run("RunUnion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NotMember when there is no step", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := NewExecutor(nil).RunUnion(t.Context())
|
||||
assert.Equal(t, check.ResultNotMember, result)
|
||||
})
|
||||
|
||||
t.Run("return error when called after context is canceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
calls := make([]int, 0, 1)
|
||||
steps := plannedSteps([]check.Result{check.ResultIsMember}, &calls)
|
||||
result := NewExecutor(nil).RunUnion(ctx, steps...)
|
||||
|
||||
// context is cancelled, therefore steps did not run, and the result Unknown
|
||||
assert.Equal(t, check.MembershipUnknown, result.Membership)
|
||||
assert.ErrorIs(t, result.Err, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
results []check.Result
|
||||
expectedResult check.Result
|
||||
expectedErr error
|
||||
expectedCalls []int
|
||||
}
|
||||
|
||||
for _, tc := range []testCase{
|
||||
{
|
||||
name: "runs all steps when none is member",
|
||||
results: []check.Result{check.ResultUnknown, check.ResultNotMember, check.ResultUnknown, check.ResultNotMember, check.ResultNotMember},
|
||||
// todo: ideally, this should be Unknown
|
||||
expectedResult: check.ResultNotMember,
|
||||
expectedCalls: []int{0, 1, 2, 3, 4},
|
||||
},
|
||||
{
|
||||
name: "stops on first Member",
|
||||
results: []check.Result{check.ResultNotMember, check.ResultIsMember, check.ResultNotMember, check.ResultNotMember},
|
||||
expectedResult: check.ResultIsMember,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "stops on first Member; last step is Member",
|
||||
results: []check.Result{check.ResultNotMember, check.ResultNotMember, check.ResultNotMember, check.ResultIsMember},
|
||||
expectedResult: check.ResultIsMember,
|
||||
expectedCalls: []int{0, 1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "stops on first Member; therefore doesn't run error after Member",
|
||||
results: []check.Result{check.ResultNotMember, check.ResultIsMember, {Err: sentinel}, check.ResultNotMember},
|
||||
expectedResult: check.ResultIsMember,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "error short-circuits",
|
||||
results: []check.Result{check.ResultNotMember, {Err: sentinel}, check.ResultNotMember, check.ResultNotMember},
|
||||
expectedResult: check.ResultUnknown,
|
||||
expectedErr: sentinel,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "NotMember + Error = Unknown+error",
|
||||
results: []check.Result{check.ResultNotMember, unknownErr},
|
||||
expectedResult: check.ResultUnknown,
|
||||
expectedErr: sentinel,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "should stop on first error",
|
||||
results: []check.Result{unknownErr, check.ResultIsMember},
|
||||
expectedResult: check.ResultUnknown,
|
||||
expectedErr: sentinel,
|
||||
expectedCalls: []int{0},
|
||||
},
|
||||
{
|
||||
name: "NotMember + limit = Unknown",
|
||||
results: []check.Result{check.ResultNotMember, unknownLimit},
|
||||
expectedResult: unknownLimit,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "Limit + NotMember = Unknown",
|
||||
results: []check.Result{unknownLimit, check.ResultNotMember},
|
||||
expectedResult: unknownLimit,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "Limit + Member = Member",
|
||||
results: []check.Result{unknownLimit, check.ResultIsMember},
|
||||
expectedResult: check.ResultIsMember,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := make([]int, 0, len(tc.results))
|
||||
steps := plannedSteps(tc.results, &calls)
|
||||
|
||||
result := NewExecutor(nil).RunUnion(t.Context(), steps...)
|
||||
assert.Equal(t, tc.expectedResult.Membership, result.Membership)
|
||||
assert.ErrorIs(t, result.Err, tc.expectedErr)
|
||||
assert.Equal(t, tc.expectedCalls, calls)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RunIntersection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NotMember when there is no step", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := NewExecutor(nil).RunIntersection(t.Context())
|
||||
assert.Equal(t, check.ResultNotMember, result)
|
||||
})
|
||||
|
||||
t.Run("return error when called after context is canceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
calls := make([]int, 0, 1)
|
||||
steps := plannedSteps([]check.Result{check.ResultIsMember}, &calls)
|
||||
result := NewExecutor(nil).RunIntersection(ctx, steps...)
|
||||
|
||||
// context is cancelled, therefore steps did not run, and the result Unknown
|
||||
assert.Equal(t, check.MembershipUnknown, result.Membership)
|
||||
assert.ErrorIs(t, result.Err, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
results []check.Result
|
||||
expectedResult check.Result
|
||||
expectedErr error
|
||||
expectedCalls []int
|
||||
}
|
||||
|
||||
for _, tc := range []testCase{
|
||||
{
|
||||
name: "runs all steps when no NotMember",
|
||||
results: []check.Result{check.ResultIsMember, check.ResultIsMember, check.ResultIsMember},
|
||||
expectedResult: check.ResultIsMember,
|
||||
expectedCalls: []int{0, 1, 2},
|
||||
},
|
||||
{
|
||||
name: "stops on first NotMember",
|
||||
results: []check.Result{check.ResultIsMember, check.ResultNotMember, check.ResultIsMember, check.ResultIsMember},
|
||||
expectedResult: check.ResultNotMember,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "stops on first NotMember; last step is NotMember",
|
||||
results: []check.Result{check.ResultIsMember, check.ResultIsMember, check.ResultIsMember, check.ResultNotMember},
|
||||
expectedResult: check.ResultNotMember,
|
||||
expectedCalls: []int{0, 1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "error short-circuits",
|
||||
results: []check.Result{check.ResultIsMember, {Err: sentinel}, check.ResultIsMember, check.ResultIsMember},
|
||||
expectedResult: check.ResultUnknown,
|
||||
expectedErr: sentinel,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "Member + Error = Unknown+error",
|
||||
results: []check.Result{check.ResultIsMember, unknownErr},
|
||||
expectedResult: check.ResultUnknown,
|
||||
expectedErr: sentinel,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "should stop on first error",
|
||||
results: []check.Result{unknownErr, check.ResultIsMember},
|
||||
expectedResult: check.ResultUnknown,
|
||||
expectedErr: sentinel,
|
||||
expectedCalls: []int{0},
|
||||
},
|
||||
{
|
||||
name: "Member + limit = Unknown",
|
||||
results: []check.Result{check.ResultIsMember, unknownLimit},
|
||||
expectedResult: unknownLimit,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "Limit + Member = Unknown",
|
||||
results: []check.Result{unknownLimit, check.ResultIsMember},
|
||||
expectedResult: unknownLimit,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
{
|
||||
name: "Limit + NotMember = NotMember",
|
||||
results: []check.Result{unknownLimit, check.ResultNotMember},
|
||||
expectedResult: check.ResultNotMember,
|
||||
expectedCalls: []int{0, 1},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := make([]int, 0, len(tc.results))
|
||||
steps := plannedSteps(tc.results, &calls)
|
||||
|
||||
result := NewExecutor(nil).RunIntersection(t.Context(), steps...)
|
||||
assert.Equal(t, tc.expectedResult.Membership, result.Membership)
|
||||
assert.Equal(t, tc.expectedResult.Limitation, result.Limitation)
|
||||
assert.ErrorIs(t, result.Err, tc.expectedErr)
|
||||
assert.Equal(t, tc.expectedCalls, calls)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupSequential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Add after decisive result is no-op", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := make([]int, 0, 3)
|
||||
runner := newStepGroup(t.Context(), 1, unionMode, NewExecutor(nil))
|
||||
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls = append(calls, 0)
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
require.False(t, runner.Done())
|
||||
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls = append(calls, 1)
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
require.True(t, runner.Done())
|
||||
|
||||
// this step is no-op because the runner is already done with a decisive result
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls = append(calls, 2)
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
|
||||
result := runner.Result()
|
||||
require.Equal(t, check.IsMember, result.Membership)
|
||||
assert.Equal(t, []int{0, 1}, calls)
|
||||
})
|
||||
|
||||
t.Run("Add after cancelled context does not execute the step", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := 0
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
runner := newStepGroup(ctx, 1, unionMode, NewExecutor(nil))
|
||||
|
||||
require.True(t, runner.Done())
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls++
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
|
||||
assert.Equal(t, 0, calls)
|
||||
})
|
||||
|
||||
t.Run("Result after cancelled context returns MembershipUnknown and context.Canceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
runner := newStepGroup(ctx, 1, unionMode, NewExecutor(nil))
|
||||
result := runner.Result()
|
||||
assert.Equal(t, check.MembershipUnknown, result.Membership)
|
||||
assert.ErrorIs(t, result.Err, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("limit lower than one is clamped to sequential", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, limit := range []int{0, -1} {
|
||||
t.Run("limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
calls := make([]int, 0, 3)
|
||||
runner := newStepGroup(t.Context(), limit, unionMode, NewExecutor(nil))
|
||||
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls = append(calls, 0)
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls = append(calls, 1)
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls = append(calls, 2)
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
|
||||
result := runner.Result()
|
||||
require.Equal(t, check.IsMember, result.Membership)
|
||||
assert.Equal(t, []int{0, 1}, calls)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Result on empty group uses mode default", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unionRunner := newStepGroup(t.Context(), 1, unionMode, NewExecutor(nil))
|
||||
assert.Equal(t, check.ResultNotMember, unionRunner.Result())
|
||||
|
||||
intersectionRunner := newStepGroup(t.Context(), 1, intersectionMode, NewExecutor(nil))
|
||||
assert.Equal(t, check.ResultIsMember, intersectionRunner.Result())
|
||||
})
|
||||
|
||||
t.Run("Result called twice returns the same value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("decisive result", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := newStepGroup(t.Context(), 1, unionMode, NewExecutor(nil))
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
assert.Equal(t, check.IsMember, runner.Result().Membership)
|
||||
assert.Equal(t, check.IsMember, runner.Result().Membership)
|
||||
})
|
||||
|
||||
t.Run("no decisive result", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// When g.set is false after all steps complete, Result() commits the final value.
|
||||
// A second call must not return context.Canceled due to the internal g.cancel() from the first call.
|
||||
runner := newStepGroup(t.Context(), 1, intersectionMode, NewExecutor(nil))
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
assert.Equal(t, check.IsMember, runner.Result().Membership)
|
||||
assert.Equal(t, check.IsMember, runner.Result().Membership)
|
||||
})
|
||||
|
||||
t.Run("cancelled context", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
runner := newStepGroup(ctx, 1, unionMode, NewExecutor(nil))
|
||||
assert.ErrorIs(t, runner.Result().Err, context.Canceled)
|
||||
assert.ErrorIs(t, runner.Result().Err, context.Canceled)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("union", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("siblings gets context cancellation when one sibling returns a decisive result", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var ran [3]atomic.Bool
|
||||
var blockedStepCancelled atomic.Bool
|
||||
|
||||
runner := newStepGroup(t.Context(), 2, unionMode, NewExecutor(nil))
|
||||
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
|
||||
ran[0].Store(true)
|
||||
<-ctx.Done()
|
||||
blockedStepCancelled.Store(true)
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
ran[1].Store(true)
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
ran[2].Store(true)
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
|
||||
result := runner.Result()
|
||||
|
||||
require.Equal(t, check.IsMember, result.Membership)
|
||||
assert.True(t, ran[0].Load())
|
||||
assert.True(t, ran[1].Load())
|
||||
assert.True(t, blockedStepCancelled.Load())
|
||||
assert.False(t, ran[2].Load())
|
||||
})
|
||||
|
||||
t.Run("internal sibling cancellation does not leak as error when decisive result exists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := newStepGroup(t.Context(), 2, unionMode, NewExecutor(nil))
|
||||
|
||||
// Step 1 propagates its context error on cancellation.
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
|
||||
<-ctx.Done()
|
||||
return check.Result{Err: ctx.Err()}
|
||||
})})
|
||||
// Step 2 returns a decisive result, which internally cancels step 1.
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
|
||||
result := runner.Result()
|
||||
assert.Equal(t, check.IsMember, result.Membership)
|
||||
assert.NoError(t, result.Err)
|
||||
})
|
||||
|
||||
t.Run("Result blocks until all in-flight goroutines finish", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stepCompleted atomic.Bool
|
||||
|
||||
runner := newStepGroup(t.Context(), 2, unionMode, NewExecutor(nil))
|
||||
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
|
||||
<-ctx.Done()
|
||||
stepCompleted.Store(true)
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
|
||||
result := runner.Result()
|
||||
require.Equal(t, check.IsMember, result.Membership)
|
||||
assert.True(t, stepCompleted.Load(), "Result() returned before the in-flight goroutine finished")
|
||||
})
|
||||
|
||||
t.Run("Add after cancelled context does not execute the step", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int64
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
runner := newStepGroup(ctx, 2, unionMode, NewExecutor(nil))
|
||||
|
||||
require.True(t, runner.Done())
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
calls.Add(1)
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
|
||||
assert.Equal(t, int64(0), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("Result after cancelled context returns MembershipUnknown and context.Canceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
runner := newStepGroup(ctx, 2, unionMode, NewExecutor(nil))
|
||||
result := runner.Result()
|
||||
assert.Equal(t, check.MembershipUnknown, result.Membership)
|
||||
assert.ErrorIs(t, result.Err, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("context cancelled mid-step: error propagates to Result", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
runner := newStepGroup(ctx, 2, unionMode, NewExecutor(nil))
|
||||
|
||||
// Step 1 blocks until the context is cancelled, then returns the cancellation error.
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
|
||||
<-ctx.Done()
|
||||
return check.Result{Err: ctx.Err()}
|
||||
})})
|
||||
cancel()
|
||||
|
||||
// Step 2 is a no-op: g.ctx is already cancelled so Add returns immediately without running it.
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(ctx context.Context) check.Result {
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
|
||||
result := runner.Result()
|
||||
assert.ErrorIs(t, result.Err, context.Canceled)
|
||||
assert.Equal(t, check.MembershipUnknown, result.Membership)
|
||||
})
|
||||
|
||||
t.Run("returns NotMember when no step is decisive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := newStepGroup(t.Context(), 3, unionMode, NewExecutor(nil))
|
||||
for range 3 {
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
return check.ResultNotMember
|
||||
})})
|
||||
}
|
||||
|
||||
result := runner.Result()
|
||||
assert.Equal(t, check.NotMember, result.Membership)
|
||||
assert.NoError(t, result.Err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("intersection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("all steps IsMember returns IsMember without error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// For intersection, IsMember is not decisive so g.set stays false throughout.
|
||||
// Result() relies on ctxErr being nil (no internal g.cancel() fires) to reach NoDecisionResult().
|
||||
// This test documents that the success path produces no spurious error.
|
||||
runner := newStepGroup(t.Context(), 3, intersectionMode, NewExecutor(nil))
|
||||
for range 3 {
|
||||
runner.Add(check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
return check.ResultIsMember
|
||||
})})
|
||||
}
|
||||
|
||||
result := runner.Result()
|
||||
assert.Equal(t, check.IsMember, result.Membership)
|
||||
assert.NoError(t, result.Err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// plannedSteps is a test helper that creates a slice of PlannedStep from a slice of Results.
|
||||
// Each step records its index in calls when executed, allowing tests to verify execution order and count.
|
||||
func plannedSteps(results []check.Result, calls *[]int) []check.PlannedStep {
|
||||
var mu sync.Mutex
|
||||
steps := make([]check.PlannedStep, 0, len(results))
|
||||
for i, result := range results {
|
||||
steps = append(steps, check.PlannedStep{Step: stepFunc(func(_ context.Context) check.Result {
|
||||
mu.Lock()
|
||||
*calls = append(*calls, i)
|
||||
mu.Unlock()
|
||||
return result
|
||||
})})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// stepFunc is a Step whose Execute calls f directly, ignoring req and executor.
|
||||
type stepFunc func(ctx context.Context) check.Result
|
||||
|
||||
func (stepFunc) Kind() check.StepKind { return check.StepIsAllowed }
|
||||
func (f stepFunc) Execute(ctx context.Context, _ check.CheckRequest, _ check.Executor) check.Result {
|
||||
return f(ctx)
|
||||
}
|
||||
100
internal/check/step/expand.go
Normal file
100
internal/check/step/expand.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/internal/x/graph"
|
||||
)
|
||||
|
||||
// ExpandSubjectStep expands subject-set pointers under the tuple's relation and recursively checks each one.
|
||||
type ExpandSubjectStep struct {
|
||||
// SubjectSetTypes are the OPL-declared subject-set types for this relation.
|
||||
// When empty, all subject-set pointers are expanded.
|
||||
// When non-empty, these types can be used to narrow down the expansion.
|
||||
SubjectSetTypes []relationtuple.SubjectSetType
|
||||
}
|
||||
|
||||
func (ExpandSubjectStep) Kind() check.StepKind { return check.StepExpand }
|
||||
|
||||
func (s ExpandSubjectStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
if req.RestDepth <= 0 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
Trace("check expand subject")
|
||||
|
||||
results, err := ex.Deps().RelationTupleManager().TraverseSubjectSetExpansion(ctx, req.Tuple, s.SubjectSetTypes)
|
||||
if errors.Is(err, herodot.ErrNotFound()) {
|
||||
return check.ResultNotMember
|
||||
} else if err != nil {
|
||||
return check.Result{Err: errors.WithStack(err)}
|
||||
}
|
||||
|
||||
check.ReportTuplesLoaded(ctx, len(results))
|
||||
|
||||
// Check whether the current hop already resolved the query.
|
||||
for _, result := range results {
|
||||
if result.Found {
|
||||
check.ReportSubjectFound(ctx, result.To)
|
||||
return check.ResultIsMember
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return check.ResultNotMember
|
||||
}
|
||||
|
||||
// If we have results, but no direct hits, we need to check the next level of expansion.
|
||||
// We check the depth here to prevent planning a large number of child checks when they will already be at max depth.
|
||||
if req.RestDepth <= 1 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
// Build the visited-tracking context from the current ctx so that child
|
||||
// checks share the same visited set and prevent infinite loops.
|
||||
var visited bool
|
||||
visitCtx := graph.InitVisited(ctx)
|
||||
|
||||
runner := ex.NewUnionRunner(visitCtx)
|
||||
if maxWidth := ex.Deps().Config(ctx).MaxReadWidth(); len(results) > maxWidth {
|
||||
ex.Deps().Logger().
|
||||
WithField("method", "ExpandSubjectStep").
|
||||
WithField("request", req.Tuple.String()).
|
||||
WithField("max_width", maxWidth).
|
||||
WithField("results", len(results)).
|
||||
Debug("too many results, truncating to width limit")
|
||||
results = results[:maxWidth]
|
||||
|
||||
runner.AddLimitation(check.LimitationMaxWidthExceeded)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if runner.Done() {
|
||||
break
|
||||
}
|
||||
sub := &relationtuple.SubjectSet{
|
||||
Namespace: result.To.Namespace,
|
||||
Object: result.To.Object,
|
||||
Relation: result.To.Relation,
|
||||
}
|
||||
visitCtx, visited = graph.CheckAndAddVisited(visitCtx, sub)
|
||||
if visited {
|
||||
continue
|
||||
}
|
||||
runner.Add(check.PlannedStep{
|
||||
Step: IsAllowedStep{skipDirect: true},
|
||||
Req: check.CheckRequest{Tuple: result.To, RestDepth: req.RestDepth - 1},
|
||||
})
|
||||
}
|
||||
return runner.Result()
|
||||
}
|
||||
479
internal/check/step/expand_test.go
Normal file
479
internal/check/step/expand_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
// TestExpandSubjectStep_MemberFound verifies that ExpandSubjectStep produces
|
||||
// the correct short-circuit trace when the subject is found via subject-set expansion.
|
||||
func TestExpandSubjectStep_MemberFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
namespaceOnlyOPL := `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`
|
||||
|
||||
tests := []struct {
|
||||
scenario testhelpers.Scenario
|
||||
checkInput string
|
||||
expectedMembership check.Membership
|
||||
expectedTrace func(t testing.TB) *trace.Node
|
||||
}{
|
||||
{
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "1-hop subject-set hit resolves without recursive sub-check",
|
||||
Opl: namespaceOnlyOPL,
|
||||
InputTuples: []string{"File:f1#viewers@Group:g1#members", "Group:g1#members@User:Alice"},
|
||||
},
|
||||
checkInput: "File:f1#viewers@User:Alice",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Alice",
|
||||
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice"),
|
||||
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Alice", 1,
|
||||
trace.FoundNode(t, "Group:g1#members@User:Alice"),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "2-hop subject-set hit resolves without recursive sub-check",
|
||||
Opl: namespaceOnlyOPL,
|
||||
InputTuples: []string{
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
"Group:g1#members@Group:g2#members",
|
||||
"Group:g2#members@User:Alice",
|
||||
},
|
||||
},
|
||||
checkInput: "File:f1#viewers@User:Alice",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Alice",
|
||||
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice"),
|
||||
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Alice", 1,
|
||||
trace.CheckNode(t, trace.ResultMember, "Group:g1#members@User:Alice",
|
||||
trace.ExpandNode(t, trace.ResultMember, "Group:g1#members@User:Alice", 1,
|
||||
trace.FoundNode(t, "Group:g2#members@User:Alice"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
t.Parallel()
|
||||
|
||||
e := trace.NewEngine(reg)
|
||||
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, tt.checkInput), 100)
|
||||
require.Equal(t, tt.expectedMembership, res.Membership)
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, trace.SortNode(tt.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandSubjectStep_NotMember documents the not-member traces across the three
|
||||
// OPL modes (namespace-only; typed OPL; typed OPL with strict mode).
|
||||
//
|
||||
// Without typed OPL, the engine follows every subject-set pointer in the DB, including
|
||||
// leaf subjects stored as subject-sets with an empty relation. With typed OPL the engine
|
||||
// filters expansion to only the subject-set types declared in the schema, skipping leaf
|
||||
// subjects that are not valid subject-set types for the relation.
|
||||
func TestExpandSubjectStep_NotMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
typedOPL := `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: (User|SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: SubjectSet<Group, "members">[]
|
||||
}
|
||||
}
|
||||
`
|
||||
inputTuples := []string{
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
"Group:g1#members@User:Alice",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
scenario testhelpers.Scenario
|
||||
checkInput string
|
||||
expectedMembership check.Membership
|
||||
expectedTrace func(t testing.TB) *trace.Node
|
||||
}{
|
||||
{
|
||||
// The engine follows every subject-set pointer including leaf subjects stored as SubjectSets.
|
||||
// In only-namespaces setup, everything in DB is treated as a valid.
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "namespace-only-OPL: no filter available, recurses into leaf subject-sets",
|
||||
Opl: `class User implements Namespace{} class Group implements Namespace{} class File implements Namespace{}`,
|
||||
InputTuples: inputTuples,
|
||||
},
|
||||
checkInput: "File:f1#viewers@User:Bob",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob",
|
||||
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob"),
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob", 1,
|
||||
// Redundant: the engine doesn't know Alice is a leaf subject,
|
||||
// so it creates a sub-check that always returns not_member.
|
||||
trace.CheckNode(t, trace.ResultNotMember, "User:Alice#@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "User:Alice#@User:Bob", 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// In non-strict mode the OPL filter is not applied, so the engine recurses
|
||||
// into every subject-set pointer including leaf Users stored as empty-relation
|
||||
// SubjectSets. This matches the namespace-only behavior.
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "valid-OPL: without filter, recurses into leaf subject-sets",
|
||||
Opl: typedOPL,
|
||||
InputTuples: inputTuples,
|
||||
},
|
||||
checkInput: "File:f1#viewers@User:Bob",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob",
|
||||
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob"),
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "User:Alice#@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "User:Alice#@User:Bob", 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "valid-OPL-strict: with filter, skips leaf subject-sets",
|
||||
Strict: true,
|
||||
Opl: typedOPL,
|
||||
InputTuples: inputTuples,
|
||||
},
|
||||
checkInput: "File:f1#viewers@User:Bob",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
// In strict mode, File#viewers only accepts SubjectSet<Group, "members">.
|
||||
// User:Bob# (namespace=User) is not a declared direct type for "viewers" relation,
|
||||
// so the direct check is skipped entirely. Only the subject-set expansion runs.
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Bob", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Bob", 0),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
t.Parallel()
|
||||
|
||||
e := trace.NewEngine(reg)
|
||||
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, tt.checkInput), 100)
|
||||
require.Equal(t, tt.expectedMembership, res.Membership)
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, trace.SortNode(tt.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandSubjectStep_StaleSubjectSet shows how stale subject-set tuples behave
|
||||
// differently in strict vs non-strict mode.
|
||||
//
|
||||
// Scenario: OPL declares viewers: SubjectSet<Group, "admins">[], but the DB still
|
||||
// contains a tuple for the old "members" relation. In strict mode the OPL filter
|
||||
// blocks the stale pointer and access is denied. In non-strict mode no filter is
|
||||
// applied, so the stale pointer is followed and access is incorrectly granted.
|
||||
// Clients should migrate to strict mode to prevent stale tuples from granting access.
|
||||
func TestExpandSubjectStep_StaleSubjectSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opl := `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
admins: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: SubjectSet<Group, "admins">[]
|
||||
}
|
||||
}
|
||||
`
|
||||
inputTuples := []string{
|
||||
// "members" was removed from OPL but the tuple was not cleaned up in the DB.
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
"Group:g1#members@User:Alice",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
scenario testhelpers.Scenario
|
||||
expectedMembership check.Membership
|
||||
expectedTrace func(t testing.TB) *trace.Node
|
||||
}{
|
||||
{
|
||||
// Without the OPL filter, the stale "members" pointer is followed and
|
||||
// Alice is found as a direct member of Group:g1#members.
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "valid-OPL: stale subject-set tuples are followed without OPL filter",
|
||||
Opl: opl,
|
||||
InputTuples: inputTuples,
|
||||
},
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Alice",
|
||||
trace.DirectNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice"),
|
||||
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Alice", 1,
|
||||
trace.FoundNode(t, "Group:g1#members@User:Alice"),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// In strict mode the OPL filter is applied: only SubjectSet<Group,"admins">
|
||||
// pointers are expanded. The stale "members" tuple is filtered out.
|
||||
// The direct step is also skipped because File#viewers does not declare
|
||||
// User as a direct type — only SubjectSet<Group,"admins"> is allowed.
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "valid-OPL-strict: stale subject-set tuples are filtered by OPL",
|
||||
Strict: true,
|
||||
Opl: opl,
|
||||
InputTuples: inputTuples,
|
||||
},
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice",
|
||||
// tuples_loaded=0: expand found no valid subject-set tuples with "admins" relation.
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
t.Parallel()
|
||||
|
||||
e := trace.NewEngine(reg)
|
||||
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, "File:f1#viewers@User:Alice"), 100)
|
||||
require.Equal(t, tt.expectedMembership, res.Membership)
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, trace.SortNode(tt.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectSetExpansionOPLFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
scenario testhelpers.Scenario
|
||||
checks []struct {
|
||||
name string
|
||||
input string
|
||||
expectedMembership check.Membership
|
||||
expectedTrace func(t testing.TB) *trace.Node
|
||||
}
|
||||
}{
|
||||
{
|
||||
scenario: testhelpers.Scenario{
|
||||
Name: "nested SubjectSet chain with Direct User at the very end",
|
||||
Strict: true,
|
||||
Opl: `
|
||||
class User implements Namespace{}
|
||||
|
||||
class Subgroup implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: SubjectSet<Subgroup, "members">[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: SubjectSet<Group, "members">[]
|
||||
}
|
||||
}
|
||||
`,
|
||||
// According to OPL, the only valid viewer is User:Bob, as it follows
|
||||
// the subjectset chain declared in OPL.
|
||||
InputTuples: []string{
|
||||
// tuple exists even though "viewers" cannot accept direct-User by OPL
|
||||
"File:f1#viewers@User:Alice",
|
||||
|
||||
// File:f1#viewers c Group:g1#members c Subgroup:sg1#members c User:Bob
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
"Group:g1#members@Subgroup:sg1#members",
|
||||
"Subgroup:sg1#members@User:Bob",
|
||||
|
||||
// Group:g1#members c User:Eve
|
||||
"Group:g1#members@User:Eve",
|
||||
|
||||
// SubjectID: File:f2#viewers c Group:g2#members c Alice
|
||||
"File:f2#viewers@Group:g2#members",
|
||||
"Group:g2#members@Alice",
|
||||
},
|
||||
},
|
||||
checks: []struct {
|
||||
name string
|
||||
input string
|
||||
expectedMembership check.Membership
|
||||
expectedTrace func(t testing.TB) *trace.Node
|
||||
}{
|
||||
{
|
||||
name: "User:Alice is not a viewer (no path through subject-set chain)",
|
||||
input: "File:f1#viewers@User:Alice",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Alice", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Alice",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Alice", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Subgroup:sg1#members@User:Alice"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Eve is stored as a direct member of Group:g1#members, but OPL declares
|
||||
// Group#members as SubjectSet<Subgroup,"members"> only — no direct subjects.
|
||||
name: "User:Eve stored directly in Group but OPL forbids direct subjects there",
|
||||
input: "File:f1#viewers@User:Eve",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "File:f1#viewers@User:Eve",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "File:f1#viewers@User:Eve", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Group:g1#members@User:Eve",
|
||||
trace.ExpandNode(t, trace.ResultNotMember, "Group:g1#members@User:Eve", 1,
|
||||
trace.CheckNode(t, trace.ResultNotMember, "Subgroup:sg1#members@User:Eve"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Subgroup#members declares User[] only. SubjectSet with a non-empty relation
|
||||
// must not satisfy that direct-type check, so no steps are taken.
|
||||
name: "SubjectSet pointer rejected by direct-only User[] declaration",
|
||||
input: "Subgroup:sg1#members@Subgroup:sg2#members",
|
||||
expectedMembership: check.NotMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultNotMember, "Subgroup:sg1#members@Subgroup:sg2#members")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "User:Bob is a viewer via nested subject-set chain",
|
||||
input: "File:f1#viewers@User:Bob",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@User:Bob", 1,
|
||||
trace.CheckNode(t, trace.ResultMember, "Group:g1#members@User:Bob",
|
||||
trace.ExpandNode(t, trace.ResultMember, "Group:g1#members@User:Bob", 1,
|
||||
trace.FoundNode(t, "Subgroup:sg1#members@User:Bob"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Group:g1#members is a viewer via direct tuple",
|
||||
input: "File:f1#viewers@Group:g1#members",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@Group:g1#members",
|
||||
trace.DirectNode(t, trace.ResultMember, "File:f1#viewers@Group:g1#members"),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Subgroup:sg1#members is a viewer via Group:g1#members pointer",
|
||||
input: "File:f1#viewers@Subgroup:sg1#members",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f1#viewers@Subgroup:sg1#members",
|
||||
trace.ExpandNode(t, trace.ResultMember, "File:f1#viewers@Subgroup:sg1#members", 1,
|
||||
trace.FoundNode(t, "Group:g1#members@Subgroup:sg1#members"),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// SubjectID has no namespace, so OPL type constraints cannot apply to it
|
||||
name: "SubjectID Alice bypasses OPL type constraints and is member",
|
||||
input: "File:f2#viewers@Alice",
|
||||
expectedMembership: check.IsMember,
|
||||
expectedTrace: func(t testing.TB) *trace.Node {
|
||||
return trace.CheckNode(t, trace.ResultMember, "File:f2#viewers@Alice",
|
||||
// in non-strict mode, the engine would follow the direct SubjectID pointer here and
|
||||
// incorrectly find Alice as a member of Group:g1#members, even though OPL only allows
|
||||
// SubjectSet<Team,"members">.
|
||||
// We don't expect SubjectID to be used in strict mode, but if it is, it should be allowed to bypass
|
||||
// OPL for backward compatibility.
|
||||
trace.DirectNode(t, trace.ResultNotMember, "File:f2#viewers@Alice"),
|
||||
trace.ExpandNode(t, trace.ResultMember, "File:f2#viewers@Alice", 1,
|
||||
trace.FoundNode(t, "Group:g2#members@Alice"),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt.scenario.Run(t, func(t *testing.T, reg driver.Registry) {
|
||||
for _, c := range tt.checks {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := trace.NewEngine(reg)
|
||||
res, tree := e.CheckRelationTupleWithTrace(t.Context(), testhelpers.TupleFromString(t, c.input), 100)
|
||||
require.Equal(t, c.expectedMembership, res.Membership)
|
||||
require.NoError(t, res.Err)
|
||||
require.Equal(t, trace.SortNode(c.expectedTrace(t)).String(), trace.SortNode(trace.StripTiming(tree)).String())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
78
internal/check/step/helpers.go
Normal file
78
internal/check/step/helpers.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// astRelationFor looks up the AST Relation for the given tuple's namespace and
|
||||
// relation. Returns nil (not an error) when no namespace config is present.
|
||||
func astRelationFor(ctx context.Context, nm namespace.Manager, r *relationTuple) (*ast.Relation, error) {
|
||||
return namespace.ASTRelationFor(ctx, nm, r.Namespace, r.Relation)
|
||||
}
|
||||
|
||||
// maxDepthReached logs a debug message and returns MembershipUnknown.
|
||||
// Call it when RestDepth is exhausted and the check cannot be expanded further.
|
||||
func maxDepthReached(ex check.Executor, req check.CheckRequest) check.Result {
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
Debug("reached max-depth, therefore this query will not be further expanded")
|
||||
|
||||
return check.Result{Membership: check.MembershipUnknown, Limitation: check.LimitationMaxDepthExceeded}
|
||||
}
|
||||
|
||||
// subjectSetTypesFor returns the OPL-declared subject-set types for the relation.
|
||||
// It looks up the AST for each next-level (namespace, relation) to determine
|
||||
// whether the Subject can be direct-match there.
|
||||
func subjectSetTypesFor(ctx context.Context, nm namespace.Manager, subject relationtuple.Subject, relation *ast.Relation) ([]relationtuple.SubjectSetType, error) {
|
||||
if relation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var types []relationtuple.SubjectSetType
|
||||
for _, t := range relation.Types {
|
||||
// empty relation means direct membership, so not a SubjectSet<> declaration
|
||||
if t.Relation == "" {
|
||||
continue
|
||||
}
|
||||
nextRel, err := namespace.ASTRelationFor(ctx, nm, t.Namespace, t.Relation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types = append(types, relationtuple.SubjectSetType{
|
||||
Namespace: t.Namespace,
|
||||
Relation: t.Relation,
|
||||
AllowsDirect: AllowsDirectMember(nextRel, subject),
|
||||
})
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
// AllowsDirectMember reports whether OPL allows a direct subject match for the given relation.
|
||||
// A relation with a SubjectSetRewrite is computed, so no direct tuple conforms to OPL,
|
||||
// regardless of the subject kind.
|
||||
// SubjectID subjects have no namespace, so declared types cannot constrain them and true
|
||||
// is returned conservatively.
|
||||
// For SubjectSet subjects, both namespace and relation must match a declared type exactly.
|
||||
func AllowsDirectMember(relation *ast.Relation, subject relationtuple.Subject) bool {
|
||||
s, isSubjectSet := subject.(*relationtuple.SubjectSet)
|
||||
if !isSubjectSet {
|
||||
// For SubjectID, allow direct when the relation is not computed via a rewrite.
|
||||
return relation == nil || relation.SubjectSetRewrite == nil
|
||||
}
|
||||
if relation == nil || relation.SubjectSetRewrite != nil {
|
||||
return false
|
||||
}
|
||||
for _, t := range relation.Types {
|
||||
if t.Namespace == s.Namespace && t.Relation == s.Relation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
107
internal/check/step/helpers_test.go
Normal file
107
internal/check/step/helpers_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
func TestAllowsDirectMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
rewriteRel = &ast.Relation{SubjectSetRewrite: &ast.SubjectSetRewrite{}}
|
||||
directRel = &ast.Relation{Name: "users", Types: []ast.RelationType{{Namespace: "User"}}} // users: User[]
|
||||
anotherRel = &ast.Relation{Name: "keys", Types: []ast.RelationType{{Namespace: "ApiKey"}}} // keys: ApiKey[]
|
||||
subjectSetRel = &ast.Relation{Name: "members", Types: []ast.RelationType{{Namespace: "Group", Relation: "members"}}} // members: SubjectSet<Group,"members">
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
relation *ast.Relation
|
||||
subjectStr string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "SubjectID always returns true (conservative)",
|
||||
relation: directRel,
|
||||
subjectStr: "fixedID",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "SubjectID with nil relation returns true (conservative)",
|
||||
relation: nil,
|
||||
subjectStr: "fixedID",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "SubjectID with SubjectSetRewrite returns false (computed relation has no direct tuples)",
|
||||
relation: rewriteRel,
|
||||
subjectStr: "fixedID",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "SubjectSet with nil relation returns false",
|
||||
relation: nil,
|
||||
subjectStr: "User:u1",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "SubjectSet with SubjectSetRewrite returns false",
|
||||
relation: rewriteRel,
|
||||
subjectStr: "User:u1",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "SubjectSet namespace matches direct type",
|
||||
relation: directRel,
|
||||
subjectStr: "User:u1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subject namespace does not match any direct type",
|
||||
relation: anotherRel,
|
||||
subjectStr: "User:u1",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "relation doesn't even have any User type",
|
||||
relation: subjectSetRel,
|
||||
subjectStr: "User:u1",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "subject is a single object, but relation is a SubjectSet<Group,'members'>, so no direct match allowed",
|
||||
relation: subjectSetRel,
|
||||
subjectStr: "Group:g1",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "subject is group#member, and relation is also SubjectSet<Group,'members'>, so direct match is allowed",
|
||||
relation: subjectSetRel,
|
||||
subjectStr: "Group:g1#members",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subject is group#member, but relation is a Direct",
|
||||
relation: &ast.Relation{Name: "groups", Types: []ast.RelationType{{Namespace: "Group"}}},
|
||||
subjectStr: "Group:g1#members",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
subject := testhelpers.SubjectFromString(t, tc.subjectStr)
|
||||
assert.Equal(t, tc.want, step.AllowsDirectMember(tc.relation, subject))
|
||||
})
|
||||
}
|
||||
}
|
||||
40
internal/check/step/invert.go
Normal file
40
internal/check/step/invert.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
)
|
||||
|
||||
// InvertStep evaluates its child and inverts the membership result.
|
||||
// IsMember becomes NotMember and vice versa; Unknown and Error pass through.
|
||||
type InvertStep struct {
|
||||
Child ast.Child
|
||||
}
|
||||
|
||||
func (InvertStep) Kind() check.StepKind { return check.StepInvert }
|
||||
|
||||
func (s InvertStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
Trace("invert check")
|
||||
|
||||
childStep, err := rewriteChildToStep(s.Child)
|
||||
if err != nil {
|
||||
return check.Result{Err: err}
|
||||
}
|
||||
|
||||
result := ex.Run(ctx, childStep, req)
|
||||
|
||||
switch result.Membership {
|
||||
case check.IsMember:
|
||||
result.Membership = check.NotMember
|
||||
case check.NotMember:
|
||||
result.Membership = check.IsMember
|
||||
}
|
||||
return result
|
||||
}
|
||||
148
internal/check/step/rewrite.go
Normal file
148
internal/check/step/rewrite.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// RewriteStep evaluates a SubjectSetRewrite node (OR or AND).
|
||||
// It translates the rewrite's children into PlannedSteps via planRewrite and
|
||||
// then delegates to RunUnion (OR) or RunIntersection (AND).
|
||||
type RewriteStep struct {
|
||||
Rewrite *ast.SubjectSetRewrite
|
||||
}
|
||||
|
||||
func (RewriteStep) Kind() check.StepKind { return check.StepRewrite }
|
||||
|
||||
func (s RewriteStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) check.Result {
|
||||
if req.RestDepth <= 0 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
Trace("check subject-set rewrite")
|
||||
|
||||
steps, err := planRewrite(ctx, ex, req, s.Rewrite)
|
||||
if err != nil {
|
||||
return check.Result{Err: err}
|
||||
}
|
||||
|
||||
if s.Rewrite.Operation == ast.OperatorAnd {
|
||||
return ex.RunIntersection(ctx, steps...)
|
||||
}
|
||||
return ex.RunUnion(ctx, steps...)
|
||||
}
|
||||
|
||||
// planRewrite translates a SubjectSetRewrite AST node into a flat list of
|
||||
// PlannedSteps ready for execution. It applies the OR-shortcut optimization:
|
||||
// ComputedSubjectSet children in an OR are batched into a single
|
||||
// DirectMultiStep followed by individual IsAllowedStep calls.
|
||||
func planRewrite(ctx context.Context, ex check.Executor, req check.CheckRequest, rewrite *ast.SubjectSetRewrite) ([]check.PlannedStep, error) {
|
||||
var steps []check.PlannedStep
|
||||
handled := make(map[int]struct{}, len(rewrite.Children))
|
||||
|
||||
// OR shortcut: batch all ComputedSubjectSet children into one DirectMulti
|
||||
// check plus individual IsAllowed calls (matching the current engine's
|
||||
// OR-of-computed-usersets optimization).
|
||||
if rewrite.Operation == ast.OperatorOr {
|
||||
var computedRelations []string
|
||||
for i, child := range rewrite.Children {
|
||||
if c, ok := child.(*ast.ComputedSubjectSet); ok {
|
||||
handled[i] = struct{}{}
|
||||
computedRelations = append(computedRelations, c.Relation)
|
||||
}
|
||||
}
|
||||
|
||||
if len(computedRelations) > 0 {
|
||||
namespaceManager, err := ex.Deps().Config(ctx).NamespaceManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// In strict mode, skip direct checks for relations where OPL does not
|
||||
// allow a direct subject match, mirroring the DirectStep gate in
|
||||
// IsAllowedStep so nested and top-level checks agree.
|
||||
var directRelations []string
|
||||
isStrictMode := ex.Deps().Config(ctx).StrictMode()
|
||||
for _, rel := range computedRelations {
|
||||
astRel, _ := namespace.ASTRelationFor(ctx, namespaceManager, req.Tuple.Namespace, rel)
|
||||
if isStrictMode && !AllowsDirectMember(astRel, req.Tuple.Subject) {
|
||||
continue
|
||||
}
|
||||
directRelations = append(directRelations, rel)
|
||||
}
|
||||
|
||||
if len(directRelations) > 0 {
|
||||
steps = append(steps, check.PlannedStep{
|
||||
Step: DirectMultiStep{Relations: directRelations},
|
||||
Req: req,
|
||||
})
|
||||
}
|
||||
|
||||
// Each computed relation also gets a full IsAllowed check
|
||||
// (skipDirect=true since DirectMulti already covers direct hits).
|
||||
for _, rel := range computedRelations {
|
||||
steps = append(steps, check.PlannedStep{
|
||||
Step: IsAllowedStep{skipDirect: true},
|
||||
Req: check.CheckRequest{
|
||||
Tuple: &relationtuple.RelationTuple{
|
||||
Namespace: req.Tuple.Namespace,
|
||||
Object: req.Tuple.Object,
|
||||
Relation: rel,
|
||||
Subject: req.Tuple.Subject,
|
||||
},
|
||||
RestDepth: req.RestDepth - 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining (non-shortcut) children.
|
||||
for i, child := range rewrite.Children {
|
||||
if _, ok := handled[i]; ok {
|
||||
continue
|
||||
}
|
||||
step, err := rewriteChildToStep(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childReq := req
|
||||
switch child.(type) {
|
||||
case *ast.SubjectSetRewrite:
|
||||
childReq = req.WithDepth(req.RestDepth - 1)
|
||||
}
|
||||
|
||||
steps = append(steps, check.PlannedStep{Step: step, Req: childReq})
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// rewriteChildToStep converts a single rewrite AST child node into an
|
||||
// executable Step. Returns an error for unrecognised node types.
|
||||
func rewriteChildToStep(child ast.Child) (check.Step, error) {
|
||||
switch c := child.(type) {
|
||||
case *ast.TupleToSubjectSet:
|
||||
return TraverseStep{TTU: c}, nil
|
||||
case *ast.ComputedSubjectSet:
|
||||
return ComputedUsersetStep{Relation: c.Relation}, nil
|
||||
case *ast.SubjectSetRewrite:
|
||||
return RewriteStep{Rewrite: c}, nil
|
||||
case *ast.InvertResult:
|
||||
return InvertStep{Child: c.Child}, nil
|
||||
default:
|
||||
return nil, errors.New("not implemented: unknown rewrite child type")
|
||||
}
|
||||
}
|
||||
17
internal/check/step/testmain_test.go
Normal file
17
internal/check/step/testmain_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*defaultPolicy[...]).processItems"),
|
||||
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*Cache[...]).processItems"),
|
||||
)
|
||||
}
|
||||
84
internal/check/step/traverse.go
Normal file
84
internal/check/step/traverse.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package step
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
|
||||
"github.com/pkg/errors"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// TraverseStep implements the tuple-to-subject-set (TTU) rewrite.
|
||||
// It loads all tuples matching the TTU relation on the request object, then
|
||||
// for each subject-set subject checks membership under the computed relation.
|
||||
type TraverseStep struct {
|
||||
TTU *ast.TupleToSubjectSet
|
||||
}
|
||||
|
||||
func (TraverseStep) Kind() check.StepKind { return check.StepTraverse }
|
||||
|
||||
func (s TraverseStep) Execute(ctx context.Context, req check.CheckRequest, ex check.Executor) (res check.Result) {
|
||||
ctx, span := ex.Deps().Tracer(ctx).Tracer().Start(ctx, "check.step.TraverseStep")
|
||||
var rowCount int
|
||||
defer func() {
|
||||
span.SetAttributes(attribute.Int("tuples_loaded", rowCount))
|
||||
otelx.End(span, &res.Err)
|
||||
}()
|
||||
|
||||
if req.RestDepth <= 0 {
|
||||
return maxDepthReached(ex, req)
|
||||
}
|
||||
|
||||
ex.Deps().Logger().
|
||||
WithField("request", req.Tuple.String()).
|
||||
WithField("tuple to subject-set relation", s.TTU.Relation).
|
||||
WithField("tuple to subject-set computed", s.TTU.ComputedSubjectSetRelation).
|
||||
Trace("check tuple to subjectSet")
|
||||
|
||||
runner := ex.NewUnionRunner(ctx)
|
||||
for nextPage, _ := keysetpagination.NewPaginator(); !nextPage.IsLast() && !runner.Done(); {
|
||||
var tuples []*relationtuple.RelationTuple
|
||||
var err error
|
||||
tuples, nextPage, err = ex.Deps().RelationTupleManager().GetRelationTuples(runner.Ctx(), &query{
|
||||
Namespace: &req.Tuple.Namespace,
|
||||
Object: &req.Tuple.Object,
|
||||
Relation: &s.TTU.Relation,
|
||||
}, nextPage.ToOptions()...)
|
||||
if err != nil {
|
||||
return check.Result{Err: errors.WithStack(err)}
|
||||
}
|
||||
rowCount += len(tuples)
|
||||
check.ReportTuplesLoaded(runner.Ctx(), len(tuples))
|
||||
|
||||
for _, t := range tuples {
|
||||
subSet, ok := t.Subject.(*relationtuple.SubjectSet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
runner.Add(check.PlannedStep{
|
||||
Step: IsAllowedStep{},
|
||||
Req: check.CheckRequest{
|
||||
Tuple: &relationtuple.RelationTuple{
|
||||
Namespace: subSet.Namespace,
|
||||
Object: subSet.Object,
|
||||
Relation: s.TTU.ComputedSubjectSetRelation,
|
||||
Subject: req.Tuple.Subject,
|
||||
},
|
||||
RestDepth: req.RestDepth - 1,
|
||||
},
|
||||
})
|
||||
if runner.Done() {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return runner.Result()
|
||||
}
|
||||
29
internal/check/testfixtures/project_opl.ts
Normal file
29
internal/check/testfixtures/project_opl.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {Namespace, Context} from '@ory/keto-namespace-types'
|
||||
|
||||
class User implements Namespace {}
|
||||
|
||||
class Project implements Namespace {
|
||||
related: {
|
||||
owner: User[]
|
||||
developer: User[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
isOwner: (ctx: Context) => this.related.owner.includes(ctx.subject),
|
||||
isOwnerOrDeveloper: (ctx: Context) =>
|
||||
this.related.owner.includes(ctx.subject) ||
|
||||
this.related.developer.includes(ctx.subject),
|
||||
writeCollaborator: (ctx: Context) =>
|
||||
this.permits.isOwner(ctx),
|
||||
readCollaborator: (ctx: Context) =>
|
||||
this.permits.isOwnerOrDeveloper(ctx),
|
||||
deleteProject: (ctx: Context) => this.permits.isOwner(ctx),
|
||||
writeProject: (ctx: Context) =>
|
||||
this.permits.isOwnerOrDeveloper(ctx),
|
||||
readProject: (ctx: Context) =>
|
||||
this.permits.isOwnerOrDeveloper(ctx),
|
||||
}
|
||||
}
|
||||
20
internal/check/testmain_test.go
Normal file
20
internal/check/testmain_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m,
|
||||
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*defaultPolicy[...]).processItems"),
|
||||
goleak.IgnoreTopFunction("github.com/dgraph-io/ristretto/v2.(*Cache[...]).processItems"),
|
||||
goleak.IgnoreTopFunction("net/http.(*persistConn).readLoop"),
|
||||
goleak.IgnoreTopFunction("net/http.(*persistConn).writeLoop"),
|
||||
goleak.IgnoreTopFunction("database/sql.(*DB).connectionOpener"),
|
||||
)
|
||||
}
|
||||
50
internal/check/trace/engine.go
Normal file
50
internal/check/trace/engine.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
"github.com/ory/keto/x/events"
|
||||
)
|
||||
|
||||
// Engine builds a runtime with tracing enabled and returns the debug Node
|
||||
// tree alongside the check result.
|
||||
type Engine struct {
|
||||
deps check.EngineDependencies
|
||||
}
|
||||
|
||||
func NewEngine(d check.EngineDependencies) *Engine {
|
||||
return &Engine{deps: d}
|
||||
}
|
||||
|
||||
func (e *Engine) d() check.EngineDependencies { return e.deps }
|
||||
|
||||
// CheckIsMemberWithTrace is like Engine.CheckIsMember but also returns a debug
|
||||
// tree that traces every sub-check performed.
|
||||
func (e *Engine) CheckIsMemberWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (bool, *Node, error) {
|
||||
res, tree := e.CheckRelationTupleWithTrace(ctx, tuple, restDepth)
|
||||
return res.Membership == check.IsMember, tree, res.Err
|
||||
}
|
||||
|
||||
// CheckRelationTupleWithTrace returns the check result and the debug tree.
|
||||
func (e *Engine) CheckRelationTupleWithTrace(ctx context.Context, tuple *relationtuple.RelationTuple, restDepth int) (res check.Result, tree *Node) {
|
||||
ctx, span := e.d().Tracer(ctx).Tracer().Start(ctx, "Engine.CheckRelationTupleWithTrace")
|
||||
defer otelx.End(span, &res.Err)
|
||||
|
||||
session := NewSession()
|
||||
executor := step.NewExecutor(e.deps, session.Middleware(), step.CallLimitMiddleware())
|
||||
|
||||
res = executor.CheckRelationTuple(ctx, tuple, restDepth)
|
||||
session.Wait()
|
||||
if res.Err == nil {
|
||||
span.AddEvent(events.NewPermissionsChecked(ctx))
|
||||
}
|
||||
return res, session.Root()
|
||||
}
|
||||
607
internal/check/trace/engine_test.go
Normal file
607
internal/check/trace/engine_test.go
Normal file
@@ -0,0 +1,607 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/keto/internal/check/trace"
|
||||
"github.com/ory/keto/internal/driver"
|
||||
"github.com/ory/keto/internal/driver/config"
|
||||
"github.com/ory/keto/internal/namespace"
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
"github.com/ory/keto/schema"
|
||||
)
|
||||
|
||||
// # Non-determinism constraint
|
||||
//
|
||||
// Expand and traverse (tuple-to-subject-set) children are ordered by
|
||||
// shard_id (random UUIDv4) and require sortNode normalization.
|
||||
//
|
||||
// As a result, we cannot write tests where multiple Expand or Traverse siblings
|
||||
// compete to produce member; which one wins first is non-deterministic, as the
|
||||
// child tuples are not executed in the order we create them.
|
||||
// Therefore:
|
||||
// - sortNode is used to sort the children of an Expand or Traverse nodes
|
||||
// - Tests where all branches return not-member are always safe: every branch
|
||||
// runs to completion before not-member is returned.
|
||||
// - Do not write tests where Expand or Traverse has multiple children, and one child
|
||||
// returns member, the other one notMember; which one runs first is non-deterministic;
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
opl string
|
||||
inputTuples []string
|
||||
maxDepth int
|
||||
|
||||
checkInput string
|
||||
expected func(t testing.TB) *trace.Node
|
||||
strict bool
|
||||
only bool
|
||||
}
|
||||
|
||||
func TestTraceEngine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []testcase{
|
||||
// ── no-OPL mode ──────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// Engine finds the tuple immediately via direct lookup.
|
||||
// Expand is registered eagerly, but the Executor already has a decisive
|
||||
// result from the direct match, so the expansion work is skipped.
|
||||
name: "no-OPL: direct hit",
|
||||
inputTuples: []string{
|
||||
"Group:eng#members@User:1",
|
||||
},
|
||||
checkInput: "Group:eng#members@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "Group:eng#members@User:1",
|
||||
directNode(t, member, "Group:eng#members@User:1"),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Engine follows a three-hop SubjectSet chain to find a member.
|
||||
name: "no-OPL: multi-hop SubjectSet expansion",
|
||||
inputTuples: []string{
|
||||
"Group:a#members@User:1",
|
||||
"Group:b#members@Group:a#members",
|
||||
"Group:c#members@Group:b#members",
|
||||
},
|
||||
checkInput: "Group:c#members@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "Group:c#members@User:1",
|
||||
directNode(t, notMember, "Group:c#members@User:1"),
|
||||
expandNode(t, member, "Group:c#members@User:1", 1,
|
||||
checkNode(t, member, "Group:b#members@User:1",
|
||||
expandNode(t, member, "Group:b#members@User:1", 1,
|
||||
foundNode(t, "Group:a#members@User:1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Engine hits the depth cap at restDepth=0 and returns unknown.
|
||||
name: "no-OPL: depth limit stops expansion",
|
||||
maxDepth: 2,
|
||||
inputTuples: []string{
|
||||
"Group:a#members@User:1",
|
||||
"Group:b1#members@Group:a#members",
|
||||
"Group:c#members@Group:b1#members",
|
||||
},
|
||||
checkInput: "Group:c#members@User:99",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, unknown, "Group:c#members@User:99",
|
||||
directNode(t, notMember, "Group:c#members@User:99"),
|
||||
expandNode(t, unknown, "Group:c#members@User:99", 1,
|
||||
checkNode(t, unknown, "Group:b1#members@User:99",
|
||||
expandNode(t, unknown, "Group:b1#members@User:99", 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── strict OPL mode ───────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// In strict mode the only type-safe checks are fired;
|
||||
// no useless direct/expand on relations that are "permit";
|
||||
// Subject-set fires only when OPL field declares a SubjectSet<..>;
|
||||
name: "strict OPL: SubjectSet expand fires only for declared types",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace {
|
||||
related: {
|
||||
viewers: SubjectSet<Group, "members">[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@Group:g1#members",
|
||||
},
|
||||
checkInput: "File:f1#view@User:u1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:u1",
|
||||
unionNode(t, notMember, "File:f1#view@User:u1",
|
||||
// viewers only declares SubjectSet<Group,"members">, so User:u1 can
|
||||
// never be a direct match, so no direct or multi-direct check runs.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:u1",
|
||||
// expand fires because viewers declares SubjectSet<Group,"members">.
|
||||
// expand found 1 node, which is Group:g1#members, which is not a direct hit → not-member.
|
||||
expandNode(t, notMember, "File:f1#viewers@User:u1", 1,
|
||||
// skipDirect=true + no SubjectSet types on members → empty check
|
||||
checkNode(t, notMember, "Group:g1#members@User:u1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Strict OR with multiple computed-usersets and a TTU.
|
||||
// The OR shortcut batches all plain computed-usersets into a single multiDirect check.
|
||||
// Plain related fields (viewers, viewers2) with no SubjectSet types produce
|
||||
// empty checks because multiDirect already covers them, and sets skipDirect=true.
|
||||
name: "strict OPL: OR batches computed-usersets, suppresses spurious permit checks",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewers2: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)) ||
|
||||
this.related.viewers2.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
|
||||
"File:f1#viewers2@User:10",
|
||||
"File:f1#viewers2@User:11",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
},
|
||||
checkInput: "File:f1#view@User:100",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:100",
|
||||
unionNode(t, notMember, "File:f1#view@User:100",
|
||||
// OR shortcut batches viewers + viewers2 into one multi-relation direct check.
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewers2"}),
|
||||
// viewers and viewers2 are plain User[] fields: empty checks in strict mode.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:100"),
|
||||
checkNode(t, notMember, "File:f1#viewers2@User:100"),
|
||||
// TTU traverses viewerGroups; Group:g1 has no User:100 → not-member.
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 1,
|
||||
checkNode(t, notMember, "Group:g1#members@User:100",
|
||||
directNode(t, notMember, "Group:g1#members@User:100"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Strict OR where the TTU branch finds a member.
|
||||
name: "strict OPL: OR with TTU finds member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) ||
|
||||
this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject))
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"Group:g1#members@User:4",
|
||||
},
|
||||
checkInput: "File:f1#view@User:4",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "File:f1#view@User:4",
|
||||
unionNode(t, member, "File:f1#view@User:4",
|
||||
// multiDirect for viewers: User:4 is not a direct viewer.
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:4", []string{"viewers"}),
|
||||
// viewers is User[]: empty check in strict+skipDirect.
|
||||
checkNode(t, notMember, "File:f1#viewers@User:4"),
|
||||
// TTU loads Group:g1; User:4 is a direct member → member.
|
||||
// strict: members is User[] (no SubjectSet types) → no expand registered.
|
||||
traverseNode(t, member, "File:f1#viewerGroups@User:4", 1,
|
||||
checkNode(t, member, "Group:g1#members@User:4",
|
||||
directNode(t, member, "Group:g1#members@User:4"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// NOT inverts an inner member result to not-member.
|
||||
name: "strict OPL: NOT inverts member to not-member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
blocklist: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#blocklist@User:blocked",
|
||||
},
|
||||
checkInput: "File:f1#view@User:blocked",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:blocked",
|
||||
unionNode(t, notMember, "File:f1#view@User:blocked",
|
||||
invertNode(t, notMember, "File:f1#view@User:blocked",
|
||||
computedNode(t, member, "File:f1#blocklist@User:blocked",
|
||||
checkNode(t, member, "File:f1#blocklist@User:blocked",
|
||||
directNode(t, member, "File:f1#blocklist@User:blocked"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// NOT inverts an inner not-member result to member.
|
||||
name: "strict OPL: NOT inverts not-member to member",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
blocklist: User[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => !this.related.blocklist.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#blocklist@User:blocked",
|
||||
},
|
||||
checkInput: "File:f1#view@User:free",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, member, "File:f1#view@User:free",
|
||||
unionNode(t, member, "File:f1#view@User:free",
|
||||
invertNode(t, member, "File:f1#view@User:free",
|
||||
computedNode(t, notMember, "File:f1#blocklist@User:free",
|
||||
checkNode(t, notMember, "File:f1#blocklist@User:free",
|
||||
directNode(t, notMember, "File:f1#blocklist@User:free"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strict OPL: TTU with permit call inside traverse",
|
||||
strict: true,
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
permits = {
|
||||
isMember: (ctx: Context) => this.related.members.includes(ctx.subject)
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
view: (ctx: Context) => this.related.viewerGroups.traverse(g => g.permits.isMember(ctx))
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"Group:g1#members@User:1",
|
||||
"Group:g1#members@User:2",
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
},
|
||||
checkInput: "File:f1#view@User:99",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
return checkNode(t, notMember, "File:f1#view@User:99",
|
||||
unionNode(t, notMember, "File:f1#view@User:99",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:99", 1,
|
||||
checkNode(t, notMember, "Group:g1#isMember@User:99",
|
||||
unionNode(t, notMember, "Group:g1#isMember@User:99",
|
||||
multiDirectNode(t, notMember, "Group:g1#members@User:99", []string{"members"}),
|
||||
checkNode(t, notMember, "Group:g1#members@User:99"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── non-strict OPL mode ───────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
// Non-strict mode fires the rewrite, a direct check, AND a subject-set
|
||||
// expand concurrently even for a permit relation (view).
|
||||
name: "non-strict OPL: OR on permit reference emits spurious direct and expand",
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) || this.permits.viewGroup(ctx)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
"Group:g2#members@User:6",
|
||||
"Group:g2#members@User:7",
|
||||
"Group:g3#members@User:8",
|
||||
"Group:g3#members@User:9",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"File:f1#viewerGroups@Group:g2",
|
||||
"File:f1#viewerGroups@Group:g3",
|
||||
},
|
||||
checkInput: "File:f1#view@User:100",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
leafCheck := func(s string) *trace.Node {
|
||||
return checkNode(t, notMember, s,
|
||||
expandNode(t, notMember, s, 0),
|
||||
)
|
||||
}
|
||||
groupCheck := func(g string, users ...string) *trace.Node {
|
||||
members := make([]*trace.Node, len(users))
|
||||
for i, u := range users {
|
||||
members[i] = leafCheck("User:" + u + "#@User:100")
|
||||
}
|
||||
return checkNode(t, notMember, "Group:"+g+"#members@User:100",
|
||||
directNode(t, notMember, "Group:"+g+"#members@User:100"),
|
||||
expandNode(t, notMember, "Group:"+g+"#members@User:100", len(users), members...),
|
||||
)
|
||||
}
|
||||
return checkNode(t, notMember, "File:f1#view@User:100",
|
||||
unionNode(t, notMember, "File:f1#view@User:100",
|
||||
multiDirectNode(t, notMember, "File:f1#view@User:100", []string{"viewers", "viewGroup"}),
|
||||
checkNode(t, notMember, "File:f1#viewers@User:100",
|
||||
expandNode(t, notMember, "File:f1#viewers@User:100", 3,
|
||||
leafCheck("User:1#@User:100"),
|
||||
leafCheck("User:2#@User:100"),
|
||||
leafCheck("User:3#@User:100"),
|
||||
),
|
||||
),
|
||||
checkNode(t, notMember, "File:f1#viewGroup@User:100",
|
||||
unionNode(t, notMember, "File:f1#viewGroup@User:100",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:100", 3,
|
||||
groupCheck("g1", "4", "5"),
|
||||
groupCheck("g2", "6", "7"),
|
||||
groupCheck("g3", "8", "9"),
|
||||
),
|
||||
),
|
||||
expandNode(t, notMember, "File:f1#viewGroup@User:100", 0),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#view@User:100"),
|
||||
expandNode(t, notMember, "File:f1#view@User:100", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// AND: both arms must complete before the result is known, so
|
||||
// the full tree is always explored regardless of intermediate results.
|
||||
name: "non-strict OPL: AND requires both arms; permit-calls-permit",
|
||||
opl: `
|
||||
class User implements Namespace{}
|
||||
class Group implements Namespace{
|
||||
related: {
|
||||
members: User[]
|
||||
}
|
||||
}
|
||||
class File implements Namespace{
|
||||
related: {
|
||||
viewers: User[]
|
||||
viewerGroups: Group[]
|
||||
}
|
||||
permits = {
|
||||
viewGroup: (ctx: Context) => this.related.viewerGroups.traverse(g => g.related.members.includes(ctx.subject)),
|
||||
|
||||
view: (ctx: Context) => this.related.viewers.includes(ctx.subject) &&
|
||||
this.permits.viewGroup(ctx)
|
||||
}
|
||||
}
|
||||
`,
|
||||
inputTuples: []string{
|
||||
"File:f1#viewers@User:1",
|
||||
"File:f1#viewers@User:2",
|
||||
"File:f1#viewers@User:3",
|
||||
|
||||
"Group:g1#members@User:4",
|
||||
"Group:g1#members@User:5",
|
||||
"Group:g2#members@User:6",
|
||||
"Group:g2#members@User:7",
|
||||
"Group:g3#members@User:8",
|
||||
"Group:g3#members@User:9",
|
||||
|
||||
"File:f1#viewerGroups@Group:g1",
|
||||
"File:f1#viewerGroups@Group:g2",
|
||||
"File:f1#viewerGroups@Group:g3",
|
||||
},
|
||||
checkInput: "File:f1#view@User:1",
|
||||
expected: func(t testing.TB) *trace.Node {
|
||||
leafCheck := func(s string) *trace.Node {
|
||||
return checkNode(t, notMember, s,
|
||||
expandNode(t, notMember, s, 0),
|
||||
)
|
||||
}
|
||||
groupCheck := func(g string, users ...string) *trace.Node {
|
||||
members := make([]*trace.Node, len(users))
|
||||
for i, u := range users {
|
||||
members[i] = leafCheck("User:" + u + "#@User:1")
|
||||
}
|
||||
|
||||
return checkNode(t, notMember, "Group:"+g+"#members@User:1",
|
||||
directNode(t, notMember, "Group:"+g+"#members@User:1"),
|
||||
expandNode(t, notMember, "Group:"+g+"#members@User:1", len(users), members...),
|
||||
)
|
||||
}
|
||||
return checkNode(t, notMember, "File:f1#view@User:1",
|
||||
intersectionNode(t, notMember, "File:f1#view@User:1",
|
||||
computedNode(t, member, "File:f1#viewers@User:1",
|
||||
checkNode(t, member, "File:f1#viewers@User:1",
|
||||
directNode(t, member, "File:f1#viewers@User:1"),
|
||||
),
|
||||
),
|
||||
computedNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
checkNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
unionNode(t, notMember, "File:f1#viewGroup@User:1",
|
||||
traverseNode(t, notMember, "File:f1#viewerGroups@User:1", 3,
|
||||
groupCheck("g1", "4", "5"),
|
||||
groupCheck("g2", "6", "7"),
|
||||
groupCheck("g3", "8", "9"),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#viewGroup@User:1"),
|
||||
expandNode(t, notMember, "File:f1#viewGroup@User:1", 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
directNode(t, notMember, "File:f1#view@User:1"),
|
||||
expandNode(t, notMember, "File:f1#view@User:1", 0),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
focused := slices.ContainsFunc(tests, func(tt testcase) bool { return tt.only })
|
||||
for _, tt := range tests {
|
||||
if focused && !tt.only {
|
||||
continue
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts := []driver.TestRegistryOption{driver.WithLogLevel("info")}
|
||||
|
||||
if tt.opl != "" {
|
||||
_, errs := schema.Parse(tt.opl)
|
||||
require.Empty(t, errs, "invalid OPL in test case: %v", errs)
|
||||
}
|
||||
|
||||
if tt.opl != "" {
|
||||
opts = append(opts, driver.WithOPL(tt.opl))
|
||||
} else {
|
||||
opts = append(opts, driver.WithConfig(config.KeyNamespaces, []*namespace.Namespace{}))
|
||||
}
|
||||
|
||||
if tt.maxDepth != 0 {
|
||||
opts = append(opts, driver.WithConfig(config.KeyLimitMaxReadDepth, tt.maxDepth))
|
||||
}
|
||||
|
||||
if tt.strict {
|
||||
opts = append(opts, driver.WithConfig(config.KeyFeatureFlagStrictMode, tt.strict))
|
||||
}
|
||||
|
||||
reg := driver.NewSqliteTestRegistry(t, opts...)
|
||||
e := trace.NewEngine(reg)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, row := range tt.inputTuples {
|
||||
require.NoError(t, reg.RelationTupleManager().WriteRelationTuples(ctx, testhelpers.TupleFromString(t, row)))
|
||||
}
|
||||
|
||||
_, tree, err := e.CheckIsMemberWithTrace(ctx, testhelpers.TupleFromString(t, tt.checkInput), 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := trace.SortNode(tt.expected(t))
|
||||
actual := trace.SortNode(trace.StripTiming(tree))
|
||||
require.Equal(t, expected.String(), actual.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// result helpers — used as the first argument to every node builder.
|
||||
const (
|
||||
member = trace.ResultMember
|
||||
notMember = trace.ResultNotMember
|
||||
unknown = trace.ResultUnknown
|
||||
)
|
||||
|
||||
// Local aliases for the exported helpers in trace so test cases stay concise.
|
||||
var (
|
||||
checkNode = trace.CheckNode
|
||||
directNode = trace.DirectNode
|
||||
expandNode = trace.ExpandNode
|
||||
foundNode = trace.FoundNode
|
||||
unionNode = trace.UnionNode
|
||||
intersectionNode = trace.IntersectionNode
|
||||
invertNode = trace.InvertNode
|
||||
computedNode = trace.ComputedNode
|
||||
traverseNode = trace.TraverseNode
|
||||
multiDirectNode = trace.MultiDirectNode
|
||||
)
|
||||
199
internal/check/trace/node.go
Normal file
199
internal/check/trace/node.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type (
|
||||
NodeKind string
|
||||
NodeResult string
|
||||
)
|
||||
|
||||
const (
|
||||
NodeIsAllowed NodeKind = "is_allowed"
|
||||
NodeDirect NodeKind = "direct"
|
||||
NodeMultiDirect NodeKind = "direct_multi"
|
||||
NodeExpandSubject NodeKind = "expand_subject"
|
||||
NodeFoundSubject NodeKind = "expand_found"
|
||||
NodeComputed NodeKind = "computed"
|
||||
NodeTraverse NodeKind = "traverse"
|
||||
NodeUnion NodeKind = "union"
|
||||
NodeIntersection NodeKind = "intersection"
|
||||
NodeInvert NodeKind = "invert"
|
||||
)
|
||||
|
||||
func (k NodeKind) String() string { return string(k) }
|
||||
|
||||
const (
|
||||
// ResultMember indicates the subject is a member.
|
||||
ResultMember NodeResult = "member"
|
||||
// ResultNotMember indicates the subject is not a member.
|
||||
ResultNotMember NodeResult = "not_member"
|
||||
// ResultError indicates an error occurred during the check.
|
||||
ResultError NodeResult = "error"
|
||||
// ResultSkipped indicates the check was registered but never executed
|
||||
// because a sibling check resolved the parent before this one ran.
|
||||
ResultSkipped NodeResult = "skipped"
|
||||
// ResultCancelled indicates the check was cancelled mid-execution
|
||||
// because a sibling check resolved.
|
||||
ResultCancelled NodeResult = "cancelled"
|
||||
// ResultUnknown indicates the check was cut short because restDepth
|
||||
// reached zero; membership could not be determined.
|
||||
ResultUnknown NodeResult = "unknown"
|
||||
)
|
||||
|
||||
func (r NodeResult) prunable() bool {
|
||||
return r == ResultNotMember || r == ResultSkipped
|
||||
}
|
||||
|
||||
// MaxExpandChildren is the maximum number of non-significant children kept on
|
||||
// expand_subject and traverse nodes. Children whose result is member, error,
|
||||
// or cancelled are always kept regardless of this limit.
|
||||
const MaxExpandChildren = 5
|
||||
|
||||
// Node is one step in the debug tree produced by a permission check. It
|
||||
// records what kind of operation was performed, what the input tuple was, what
|
||||
// the result was, how many database records were fetched, and any sub-steps
|
||||
// that were spawned.
|
||||
type Node struct {
|
||||
Kind NodeKind
|
||||
Tuple *relationtuple.RelationTuple
|
||||
Result NodeResult
|
||||
|
||||
// Relations holds the batched relation names for NodeMultiDirect nodes.
|
||||
Relations []string
|
||||
|
||||
// TuplesLoaded is the number of database records fetched by this node.
|
||||
TuplesLoaded int
|
||||
|
||||
// TruncatedChildren is the number of children that were dropped to keep
|
||||
// the tree size manageable.
|
||||
TruncatedChildren int
|
||||
|
||||
Children []*Node
|
||||
|
||||
// Duration is the wall-clock time spent in this node (including children).
|
||||
Duration time.Duration
|
||||
|
||||
// parent is set when this node is appended to a parent, so that
|
||||
// dropIfOverLimit can remove it after the result is known.
|
||||
parent *Node
|
||||
|
||||
// mu protects Children, TruncatedChildren, TuplesLoaded, and prunableCount.
|
||||
mu sync.Mutex
|
||||
|
||||
// prunableCount counts how many children have a prunable result.
|
||||
prunableCount int
|
||||
}
|
||||
|
||||
func (n *Node) setResult(result check.Result) {
|
||||
if result.Err != nil {
|
||||
n.Result = ResultError
|
||||
if errors.Is(result.Err, context.Canceled) {
|
||||
n.Result = ResultCancelled
|
||||
}
|
||||
return
|
||||
}
|
||||
switch result.Membership {
|
||||
case check.IsMember:
|
||||
n.Result = ResultMember
|
||||
case check.NotMember:
|
||||
n.Result = ResultNotMember
|
||||
default:
|
||||
n.Result = ResultUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// dropIfOverLimit removes this node from its parent when the parent is an
|
||||
// expand or TTU node that already has MaxExpandChildren prunable children.
|
||||
func (n *Node) dropIfOverLimit() {
|
||||
parent := n.parent
|
||||
if parent == nil {
|
||||
return
|
||||
}
|
||||
if parent.Kind != NodeExpandSubject && parent.Kind != NodeTraverse {
|
||||
return
|
||||
}
|
||||
if !n.Result.prunable() {
|
||||
return
|
||||
}
|
||||
parent.mu.Lock()
|
||||
defer parent.mu.Unlock()
|
||||
parent.prunableCount++
|
||||
if parent.prunableCount <= MaxExpandChildren {
|
||||
return
|
||||
}
|
||||
for i, c := range parent.Children {
|
||||
if c == n {
|
||||
parent.Children = append(parent.Children[:i], parent.Children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
parent.TruncatedChildren++
|
||||
}
|
||||
|
||||
func (n *Node) recordTuplesLoaded(count int) {
|
||||
n.mu.Lock()
|
||||
n.TuplesLoaded += count
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Node) recordFoundSubject(tuple *relationtuple.RelationTuple) {
|
||||
child := &Node{Kind: NodeFoundSubject, Tuple: tuple, Result: ResultMember}
|
||||
n.mu.Lock()
|
||||
n.Children = append(n.Children, child)
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// String is a human-friendly representation of the debug tree, useful during testing.
|
||||
func (n *Node) String() string {
|
||||
if n == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
var walk func(cur *Node, depth int)
|
||||
walk = func(cur *Node, depth int) {
|
||||
for range depth {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
|
||||
input := ""
|
||||
if cur.Tuple != nil {
|
||||
input = cur.Tuple.String()
|
||||
if len(cur.Relations) > 0 {
|
||||
input = strings.Replace(input, "#"+cur.Tuple.Relation+"@", "#{"+strings.Join(cur.Relations, ",")+"}"+"@", 1)
|
||||
}
|
||||
}
|
||||
b.WriteString(string(cur.Kind))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.Quote(input))
|
||||
b.WriteString(" -> ")
|
||||
b.WriteString(string(cur.Result))
|
||||
|
||||
if cur.TuplesLoaded > 0 {
|
||||
b.WriteString(" tuples_loaded=")
|
||||
b.WriteString(strconv.Itoa(cur.TuplesLoaded))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
|
||||
for _, child := range cur.Children {
|
||||
walk(child, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(n, 0)
|
||||
return strings.TrimSuffix(b.String(), "\n")
|
||||
}
|
||||
147
internal/check/trace/session.go
Normal file
147
internal/check/trace/session.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ory/keto/internal/check"
|
||||
"github.com/ory/keto/internal/check/step"
|
||||
"github.com/ory/keto/internal/namespace/ast"
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
// traceNodeKey is the context key used to store and retrieve the current
|
||||
// trace node as execution descends through the step tree.
|
||||
type traceNodeKey struct{}
|
||||
|
||||
func nodeFromCtx(ctx context.Context) *Node {
|
||||
n, _ := ctx.Value(traceNodeKey{}).(*Node)
|
||||
return n
|
||||
}
|
||||
|
||||
func withTraceNode(ctx context.Context, n *Node) context.Context {
|
||||
return context.WithValue(ctx, traceNodeKey{}, n)
|
||||
}
|
||||
|
||||
// Session collects the debug tree for a single runtime execution.
|
||||
type Session struct {
|
||||
mu sync.Mutex
|
||||
root *Node
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewSession() *Session {
|
||||
return &Session{}
|
||||
}
|
||||
|
||||
func (t *Session) Root() *Node {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.root
|
||||
}
|
||||
|
||||
func (t *Session) Wait() {
|
||||
t.wg.Wait()
|
||||
}
|
||||
|
||||
// nodeKindForStep maps a Step's execution-model kind to the trace-model NodeKind.
|
||||
func nodeKindForStep(s check.Step) NodeKind {
|
||||
if s, ok := s.(step.RewriteStep); ok {
|
||||
if s.Rewrite.Operation == ast.OperatorOr {
|
||||
return NodeUnion
|
||||
}
|
||||
return NodeIntersection
|
||||
}
|
||||
switch s.Kind() {
|
||||
case check.StepIsAllowed:
|
||||
return NodeIsAllowed
|
||||
case check.StepDirect:
|
||||
return NodeDirect
|
||||
case check.StepDirectMulti:
|
||||
return NodeMultiDirect
|
||||
case check.StepExpand:
|
||||
return NodeExpandSubject
|
||||
case check.StepComputed:
|
||||
return NodeComputed
|
||||
case check.StepTraverse:
|
||||
return NodeTraverse
|
||||
case check.StepInvert:
|
||||
return NodeInvert
|
||||
default:
|
||||
return NodeKind(s.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns a Middleware that records a Node for every step execution,
|
||||
// building the debug trace tree as execution unfolds.
|
||||
func (t *Session) Middleware() check.Middleware {
|
||||
return func(ctx context.Context, s check.Step, req check.CheckRequest, next func(context.Context, check.Step, check.CheckRequest) check.Result) check.Result {
|
||||
t.wg.Add(1)
|
||||
defer t.wg.Done()
|
||||
|
||||
tuple := req.Tuple
|
||||
var relations []string
|
||||
|
||||
switch s := s.(type) {
|
||||
case step.ComputedUsersetStep:
|
||||
clone := *req.Tuple
|
||||
clone.Relation = s.Relation
|
||||
tuple = &clone
|
||||
case step.TraverseStep:
|
||||
clone := *req.Tuple
|
||||
clone.Relation = s.TTU.Relation
|
||||
tuple = &clone
|
||||
case step.DirectMultiStep:
|
||||
relations = s.Relations
|
||||
}
|
||||
|
||||
node, ctx := t.newTraceNode(ctx, nodeKindForStep(s), tuple, relations...)
|
||||
switch s.Kind() {
|
||||
case check.StepExpand:
|
||||
ctx = check.WithTraceHooks(ctx, check.TraceHooks{
|
||||
TuplesLoaded: node.recordTuplesLoaded,
|
||||
SubjectFound: node.recordFoundSubject,
|
||||
})
|
||||
case check.StepTraverse:
|
||||
ctx = check.WithTraceHooks(ctx, check.TraceHooks{TuplesLoaded: node.recordTuplesLoaded})
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := next(ctx, s, req)
|
||||
node.Duration = time.Since(start)
|
||||
node.setResult(result)
|
||||
node.dropIfOverLimit()
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// newTraceNode creates a Node, links it as a child of the current parent node
|
||||
// in ctx (or sets it as the root if there is none), and returns both the node
|
||||
// and a derived context with the new node set as the current parent.
|
||||
func (t *Session) newTraceNode(ctx context.Context, kind NodeKind, tuple *relationtuple.RelationTuple, relations ...string) (*Node, context.Context) {
|
||||
node := &Node{
|
||||
Kind: kind,
|
||||
Tuple: tuple,
|
||||
Relations: relations,
|
||||
|
||||
// Initial Result is Skipped, as it's possible that the step won't run at all
|
||||
// due to other sibling branch finishing with success.
|
||||
Result: ResultSkipped,
|
||||
}
|
||||
if parent := nodeFromCtx(ctx); parent != nil {
|
||||
node.parent = parent
|
||||
parent.mu.Lock()
|
||||
parent.Children = append(parent.Children, node)
|
||||
parent.mu.Unlock()
|
||||
} else {
|
||||
t.mu.Lock()
|
||||
t.root = node
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return node, withTraceNode(ctx, node)
|
||||
}
|
||||
115
internal/check/trace/testhelpers.go
Normal file
115
internal/check/trace/testhelpers.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package trace
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/keto/internal/testhelpers"
|
||||
)
|
||||
|
||||
// SortNode sorts children of NodeExpandSubject and NodeTraverse nodes by
|
||||
// Tuple.String() recursively. Both node kinds have non-deterministic child
|
||||
// order because the underlying queries order by shard_id (a random UUIDv4
|
||||
// assigned at write time). All other sibling orderings are engine-controlled
|
||||
// and must be asserted as-is.
|
||||
func SortNode(n *Node) *Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
if n.Kind == NodeExpandSubject || n.Kind == NodeTraverse {
|
||||
slices.SortFunc(n.Children, func(a, b *Node) int {
|
||||
as, bs := "", ""
|
||||
if a.Tuple != nil {
|
||||
as = a.Tuple.String()
|
||||
}
|
||||
if b.Tuple != nil {
|
||||
bs = b.Tuple.String()
|
||||
}
|
||||
if c := strings.Compare(as, bs); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(string(a.Kind), string(b.Kind))
|
||||
})
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
SortNode(child)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// StripTiming zeroes out Duration on every node in the tree so that test
|
||||
// assertions are not sensitive to wall-clock time.
|
||||
func StripTiming(n *Node) *Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
n.Duration = 0
|
||||
for _, child := range n.Children {
|
||||
StripTiming(child)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CheckNode builds a NodeIsAllowed node.
|
||||
func CheckNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeIsAllowed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// DirectNode builds a NodeDirect leaf (no children).
|
||||
func DirectNode(t testing.TB, result NodeResult, tuple string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeDirect, Tuple: testhelpers.TupleFromString(t, tuple), Result: result}
|
||||
}
|
||||
|
||||
// ExpandNode builds a NodeExpandSubject node.
|
||||
func ExpandNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeExpandSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
|
||||
}
|
||||
|
||||
// FoundNode builds a NodeFoundSubject leaf (no children).
|
||||
func FoundNode(t testing.TB, tuple string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeFoundSubject, Tuple: testhelpers.TupleFromString(t, tuple), Result: ResultMember}
|
||||
}
|
||||
|
||||
// UnionNode builds a NodeUnion node.
|
||||
func UnionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeUnion, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// IntersectionNode builds a NodeIntersection node.
|
||||
func IntersectionNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeIntersection, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// InvertNode builds a NodeInvert node.
|
||||
func InvertNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeInvert, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// ComputedNode builds a NodeComputed node.
|
||||
func ComputedNode(t testing.TB, result NodeResult, tuple string, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeComputed, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, Children: children}
|
||||
}
|
||||
|
||||
// TraverseNode builds a NodeTraverse node.
|
||||
func TraverseNode(t testing.TB, result NodeResult, tuple string, tuplesLoaded int, children ...*Node) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeTraverse, Tuple: testhelpers.TupleFromString(t, tuple), Result: result, TuplesLoaded: tuplesLoaded, Children: children}
|
||||
}
|
||||
|
||||
// MultiDirectNode builds a NodeMultiDirect leaf (no children).
|
||||
func MultiDirectNode(t testing.TB, result NodeResult, tuple string, relations []string) *Node {
|
||||
t.Helper()
|
||||
return &Node{Kind: NodeMultiDirect, Tuple: testhelpers.TupleFromString(t, tuple), Relations: relations, Result: result}
|
||||
}
|
||||
44
internal/check/trace_hook.go
Normal file
44
internal/check/trace_hook.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/keto/internal/relationtuple"
|
||||
)
|
||||
|
||||
type traceHooksKey struct{}
|
||||
|
||||
// TraceHooks carries optional instrumentation callbacks injected into context
|
||||
// by tracing middleware. Step implementations can emit events through helpers
|
||||
// in this file without importing the trace package.
|
||||
type TraceHooks struct {
|
||||
TuplesLoaded func(n int)
|
||||
SubjectFound func(tuple *relationtuple.RelationTuple)
|
||||
}
|
||||
|
||||
// WithTraceHooks returns a context that carries the given trace hooks.
|
||||
func WithTraceHooks(ctx context.Context, hooks TraceHooks) context.Context {
|
||||
return context.WithValue(ctx, traceHooksKey{}, hooks)
|
||||
}
|
||||
|
||||
func traceHooksFromContext(ctx context.Context) (TraceHooks, bool) {
|
||||
hooks, ok := ctx.Value(traceHooksKey{}).(TraceHooks)
|
||||
return hooks, ok
|
||||
}
|
||||
|
||||
// ReportTuplesLoaded calls the tuples-loaded hook stored in ctx, if any.
|
||||
func ReportTuplesLoaded(ctx context.Context, n int) {
|
||||
if hooks, ok := traceHooksFromContext(ctx); ok && hooks.TuplesLoaded != nil {
|
||||
hooks.TuplesLoaded(n)
|
||||
}
|
||||
}
|
||||
|
||||
// ReportSubjectFound calls the subject-found hook stored in ctx, if any.
|
||||
func ReportSubjectFound(ctx context.Context, tuple *relationtuple.RelationTuple) {
|
||||
if hooks, ok := traceHooksFromContext(ctx); ok && hooks.SubjectFound != nil {
|
||||
hooks.SubjectFound(tuple)
|
||||
}
|
||||
}
|
||||
10
internal/driver/config/buildinfo.go
Normal file
10
internal/driver/config/buildinfo.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
var (
|
||||
Version = "master"
|
||||
Date = "undefined"
|
||||
Commit = "undefined"
|
||||
)
|
||||
87
internal/driver/config/namespace_memory.go
Normal file
87
internal/driver/config/namespace_memory.go
Normal 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
|
||||
}
|
||||
}
|
||||
25
internal/driver/config/namespace_memory_test.go
Normal file
25
internal/driver/config/namespace_memory_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
295
internal/driver/config/namespace_watcher.go
Normal file
295
internal/driver/config/namespace_watcher.go
Normal 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()
|
||||
}
|
||||
}
|
||||
250
internal/driver/config/namespace_watcher_test.go
Normal file
250
internal/driver/config/namespace_watcher_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
193
internal/driver/config/opl_cache_test.go
Normal file
193
internal/driver/config/opl_cache_test.go
Normal 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) }
|
||||
181
internal/driver/config/opl_config_namespace_watcher.go
Normal file
181
internal/driver/config/opl_config_namespace_watcher.go
Normal 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
|
||||
}
|
||||
397
internal/driver/config/provider.go
Normal file
397
internal/driver/config/provider.go
Normal 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
|
||||
}
|
||||
302
internal/driver/config/provider_test.go
Normal file
302
internal/driver/config/provider_test.go
Normal 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
570
internal/driver/daemon.go
Normal 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
|
||||
}
|
||||
182
internal/driver/daemon_test.go
Normal file
182
internal/driver/daemon_test.go
Normal 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
|
||||
}
|
||||
40
internal/driver/logrus_interceptor.go
Normal file
40
internal/driver/logrus_interceptor.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
84
internal/driver/pop_connection.go
Normal file
84
internal/driver/pop_connection.go
Normal 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
|
||||
}
|
||||
63
internal/driver/registry.go
Normal file
63
internal/driver/registry.go
Normal 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"
|
||||
)
|
||||
392
internal/driver/registry_default.go
Normal file
392
internal/driver/registry_default.go
Normal 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
|
||||
}
|
||||
242
internal/driver/registry_factory.go
Normal file
242
internal/driver/registry_factory.go
Normal 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...)
|
||||
}
|
||||
41
internal/driver/selfsigned_cert_bench_test.go
Normal file
41
internal/driver/selfsigned_cert_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
internal/driver/testhelpers.go
Normal file
84
internal/driver/testhelpers.go
Normal 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})
|
||||
}
|
||||
155
internal/e2e/cli_client_test.go
Normal file
155
internal/e2e/cli_client_test.go
Normal 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")...)
|
||||
}
|
||||
160
internal/e2e/full_suit_test.go
Normal file
160
internal/e2e/full_suit_test.go
Normal 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)
|
||||
}
|
||||
285
internal/e2e/grpc_client_test.go
Normal file
285
internal/e2e/grpc_client_test.go
Normal 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
164
internal/e2e/helpers.go
Normal 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
|
||||
}
|
||||
222
internal/e2e/rest_client_test.go
Normal file
222
internal/e2e/rest_client_test.go
Normal 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)
|
||||
}
|
||||
360
internal/e2e/sdk_client_test.go
Normal file
360
internal/e2e/sdk_client_test.go
Normal 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
|
||||
}
|
||||
401
internal/e2e/testcases_test.go
Normal file
401
internal/e2e/testcases_test.go
Normal 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)
|
||||
}
|
||||
232
internal/e2e/transaction_cases_test.go
Normal file
232
internal/e2e/transaction_cases_test.go
Normal 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
397
internal/expand/engine.go
Normal 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
|
||||
}
|
||||
}
|
||||
1377
internal/expand/engine_test.go
Normal file
1377
internal/expand/engine_test.go
Normal file
File diff suppressed because it is too large
Load Diff
174
internal/expand/handler.go
Normal file
174
internal/expand/handler.go
Normal 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
|
||||
}
|
||||
131
internal/expand/handler_test.go
Normal file
131
internal/expand/handler_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
127
internal/expand/testhelper.go
Normal file
127
internal/expand/testhelper.go
Normal 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
24
internal/httpclient/.gitignore
generated
vendored
Normal 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
|
||||
23
internal/httpclient/.openapi-generator-ignore
generated
Normal file
23
internal/httpclient/.openapi-generator-ignore
generated
Normal 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
|
||||
70
internal/httpclient/.openapi-generator/FILES
generated
Normal file
70
internal/httpclient/.openapi-generator/FILES
generated
Normal 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
|
||||
1
internal/httpclient/.openapi-generator/VERSION
generated
Normal file
1
internal/httpclient/.openapi-generator/VERSION
generated
Normal file
@@ -0,0 +1 @@
|
||||
7.2.0
|
||||
7
internal/httpclient/.travis.yml
generated
Normal file
7
internal/httpclient/.travis.yml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
language: go
|
||||
|
||||
install:
|
||||
- go get -d -v .
|
||||
|
||||
script:
|
||||
- go build -v ./
|
||||
153
internal/httpclient/README.md
generated
Normal file
153
internal/httpclient/README.md
generated
Normal 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
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
442
internal/httpclient/api_metadata.go
generated
Normal 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
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
953
internal/httpclient/api_relationship.go
generated
Normal 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
547
internal/httpclient/client.go
generated
Normal 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
230
internal/httpclient/configuration.go
generated
Normal 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)
|
||||
}
|
||||
56
internal/httpclient/docs/BatchCheckPermissionBody.md
generated
Normal file
56
internal/httpclient/docs/BatchCheckPermissionBody.md
generated
Normal 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)
|
||||
|
||||
|
||||
51
internal/httpclient/docs/BatchCheckPermissionResult.md
generated
Normal file
51
internal/httpclient/docs/BatchCheckPermissionResult.md
generated
Normal 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)
|
||||
|
||||
|
||||
56
internal/httpclient/docs/CheckOplSyntaxResult.md
generated
Normal file
56
internal/httpclient/docs/CheckOplSyntaxResult.md
generated
Normal 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)
|
||||
|
||||
|
||||
51
internal/httpclient/docs/CheckPermissionResult.md
generated
Normal file
51
internal/httpclient/docs/CheckPermissionResult.md
generated
Normal 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)
|
||||
|
||||
|
||||
77
internal/httpclient/docs/CheckPermissionResultWithError.md
generated
Normal file
77
internal/httpclient/docs/CheckPermissionResultWithError.md
generated
Normal 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)
|
||||
|
||||
|
||||
160
internal/httpclient/docs/CreateRelationshipBody.md
generated
Normal file
160
internal/httpclient/docs/CreateRelationshipBody.md
generated
Normal 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
51
internal/httpclient/docs/ErrorGeneric.md
generated
Normal 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)
|
||||
|
||||
|
||||
103
internal/httpclient/docs/ExpandedPermissionTree.md
generated
Normal file
103
internal/httpclient/docs/ExpandedPermissionTree.md
generated
Normal 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
233
internal/httpclient/docs/GenericError.md
generated
Normal 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'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)
|
||||
|
||||
|
||||
51
internal/httpclient/docs/GetVersion200Response.md
generated
Normal file
51
internal/httpclient/docs/GetVersion200Response.md
generated
Normal 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)
|
||||
|
||||
|
||||
56
internal/httpclient/docs/HealthNotReadyStatus.md
generated
Normal file
56
internal/httpclient/docs/HealthNotReadyStatus.md
generated
Normal 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
56
internal/httpclient/docs/HealthStatus.md
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
# HealthStatus
|
||||
|
||||
## Properties
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**Status** | Pointer to **string** | Status always contains \"ok\". | [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)
|
||||
|
||||
|
||||
51
internal/httpclient/docs/IsAlive200Response.md
generated
Normal file
51
internal/httpclient/docs/IsAlive200Response.md
generated
Normal file
@@ -0,0 +1,51 @@
|
||||
# IsAlive200Response
|
||||
|
||||
## Properties
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**Status** | **string** | Always \"ok\". |
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
51
internal/httpclient/docs/IsReady503Response.md
generated
Normal file
51
internal/httpclient/docs/IsReady503Response.md
generated
Normal 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
194
internal/httpclient/docs/MetadataApi.md
generated
Normal 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
56
internal/httpclient/docs/Namespace.md
generated
Normal 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
108
internal/httpclient/docs/ParseError.md
generated
Normal 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
450
internal/httpclient/docs/PermissionApi.md
generated
Normal 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)
|
||||
|
||||
160
internal/httpclient/docs/PostCheckPermissionBody.md
generated
Normal file
160
internal/httpclient/docs/PostCheckPermissionBody.md
generated
Normal 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)
|
||||
|
||||
|
||||
160
internal/httpclient/docs/PostCheckPermissionOrErrorBody.md
generated
Normal file
160
internal/httpclient/docs/PostCheckPermissionOrErrorBody.md
generated
Normal 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
160
internal/httpclient/docs/RelationQuery.md
generated
Normal 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
145
internal/httpclient/docs/Relationship.md
generated
Normal 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)
|
||||
|
||||
|
||||
429
internal/httpclient/docs/RelationshipApi.md
generated
Normal file
429
internal/httpclient/docs/RelationshipApi.md
generated
Normal 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
Reference in New Issue
Block a user