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:
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, ""))
|
||||
}
|
||||
Reference in New Issue
Block a user