diff --git a/internal/check/engine.go b/internal/check/engine.go index 2e232b839..f8205b92a 100644 --- a/internal/check/engine.go +++ b/internal/check/engine.go @@ -35,104 +35,74 @@ type ( Query = relationtuple.RelationQuery ) +const WildcardRelation = "..." + func NewEngine(d EngineDependencies) *Engine { return &Engine{ d: d, } } -func (e *Engine) isIncluded( - ctx context.Context, - requested *RelationTuple, - rels []*RelationTuple, - restDepth int, -) checkgroup.CheckFunc { - if restDepth < 0 { - e.d.Logger().Debug("reached max-depth, therefore this query will not be further expanded") - return checkgroup.UnknownMemberFunc +// 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 } + return result.Membership == checkgroup.IsMember, nil +} - // This is the same as the graph problem "can requested.Subject be reached - // from requested.Object through the first outgoing edge requested.Relation" - // - // We implement recursive depth-first search here. - // TODO replace by more performant algorithm: - // https://github.com/ory/keto/issues/483 - - ctx = graph.InitVisited(ctx) - g := checkgroup.New(ctx) - - for _, sr := range rels { - var wasAlreadyVisited bool - ctx, wasAlreadyVisited = graph.CheckAndAddVisited(ctx, sr.Subject) - if wasAlreadyVisited { - continue - } - - // we only have to check Subject here as we know that sr was reached - // from requested.ObjectID, requested.Relation through 0...n - // indirections - if requested.Subject.Equals(sr.Subject) { - // found the requested relation - g.SetIsMember() - break - } - - sub, isSubjectSet := sr.Subject.(*relationtuple.SubjectSet) - if !isSubjectSet { - continue - } +// 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) checkgroup.Result { + // 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 + } - g.Add(e.subQuery( - requested, - &Query{Object: sub.Object, Relation: sub.Relation, Namespace: sub.Namespace}, - restDepth, - )) + resultCh := make(chan checkgroup.Result) + go e.checkIsAllowed(ctx, r, restDepth)(ctx, resultCh) + select { + case result := <-resultCh: + return result + case <-ctx.Done(): + return checkgroup.Result{Err: errors.WithStack(ctx.Err())} } - return checkgroup.WithEdge(checkgroup.Edge{ - Tuple: *requested, - Type: expand.Union, - }, g.CheckFunc()) } -func (e *Engine) subQuery( - requested *RelationTuple, - query *Query, - restDepth int, -) checkgroup.CheckFunc { +// checkExpandSubject checks the expansions of the subject set of the tuple. +// +// For a relation tuple n:obj#rel@user, checkExpandSubject first queries for all +// subjects that match n:obj#rel@* (arbirary subjects), and then for each +// subject checks subject@user. +func (e *Engine) checkExpandSubject(ctx context.Context, r *RelationTuple, restDepth int) checkgroup.CheckFunc { if restDepth < 0 { e.d.Logger(). - WithFields(requested.ToLoggerFields()). + WithFields(r.ToLoggerFields()). Debug("reached max-depth, therefore this query will not be further expanded") return checkgroup.UnknownMemberFunc } - return func(ctx context.Context, resultCh chan<- checkgroup.Result) { e.d.Logger(). - WithField("request", requested.String()). - WithField("query", query.String()). - Trace("check one indirection further") - - // an empty page token denotes the first page (as tokens are opaque) - var prevPage string - - // Special case: check if we can find the subject id directly - if rels, _, err := e.d.RelationTupleManager().GetRelationTuples(ctx, requested.ToQuery()); err == nil && len(rels) > 0 { - resultCh <- checkgroup.Result{ - Membership: checkgroup.IsMember, - Tree: &expand.Tree{ - Type: expand.Leaf, - Tuple: requested, - }, - } - return - } + WithField("request", r.String()). + Trace("check expand subject") g := checkgroup.New(ctx) + var ( + subjects []*RelationTuple + pageToken string + err error + visited bool + innerCtx = graph.InitVisited(ctx) + query = &Query{Namespace: r.Namespace, Object: r.Object, Relation: r.Relation} + ) for { - nextRels, nextPage, err := e.d.RelationTupleManager().GetRelationTuples(ctx, query, x.WithToken(prevPage)) - // herodot.ErrNotFound occurs when the namespace is unknown + subjects, pageToken, err = e.d.RelationTupleManager().GetRelationTuples(innerCtx, query, x.WithToken(pageToken)) if errors.Is(err, herodot.ErrNotFound) { g.Add(checkgroup.NotMemberFunc) break @@ -140,48 +110,71 @@ func (e *Engine) subQuery( g.Add(checkgroup.ErrorFunc(err)) break } - - g.Add(e.isIncluded(ctx, requested, nextRels, restDepth-1)) - - // loop through pages until either allowed, end of pages, or an error occurred - if nextPage == "" || g.Done() { + for _, s := range subjects { + innerCtx, visited = graph.CheckAndAddVisited(innerCtx, s.Subject) + if visited { + continue + } + if s.Subject.SubjectSet() == nil || s.Subject.SubjectSet().Relation == WildcardRelation { + continue + } + g.Add(e.checkIsAllowed( + innerCtx, + &RelationTuple{ + Namespace: s.Subject.SubjectSet().Namespace, + Object: s.Subject.SubjectSet().Object, + Relation: s.Subject.SubjectSet().Relation, + Subject: r.Subject, + }, + restDepth-1, + )) + } + if pageToken == "" || g.Done() { break } - prevPage = nextPage } resultCh <- g.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 - } - return result.Membership == checkgroup.IsMember, nil -} - -func (e *Engine) CheckRelationTuple(ctx context.Context, r *RelationTuple, restDepth int) checkgroup.Result { - // 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 +// checkDirect checks if the relation tuple is in the database directly. +func (e *Engine) checkDirect(ctx context.Context, r *RelationTuple, restDepth int) checkgroup.CheckFunc { + if restDepth < 0 { + e.d.Logger(). + WithField("method", "checkDirect"). + Debug("reached max-depth, therefore this query will not be further expanded") + return checkgroup.UnknownMemberFunc } - - resultCh := make(chan checkgroup.Result) - go e.checkIsAllowed(ctx, r, restDepth)(ctx, resultCh) - select { - case result := <-resultCh: - return result - case <-ctx.Done(): - return checkgroup.Result{Err: errors.WithStack(ctx.Err())} + return func(ctx context.Context, resultCh chan<- checkgroup.Result) { + e.d.Logger(). + WithField("request", r.String()). + Trace("check direct") + if rels, _, err := e.d.RelationTupleManager().GetRelationTuples(ctx, r.ToQuery()); err == nil && len(rels) > 0 { + resultCh <- checkgroup.Result{ + Membership: checkgroup.IsMember, + Tree: &expand.Tree{ + Type: expand.Leaf, + Tuple: r, + }, + } + } else { + resultCh <- checkgroup.Result{ + Membership: checkgroup.NotMember, + } + } } } +// checkIsAllowed checks if the relation tuple is allowed (there is a path from +// the relation tuple subject to the namespace, object and relation) either +// directly (in the database), or through subject-set expansions, or through +// user-set rewrites. func (e *Engine) checkIsAllowed(ctx context.Context, r *RelationTuple, restDepth int) checkgroup.CheckFunc { if restDepth < 0 { - e.d.Logger().Debug("reached max-depth, therefore this query will not be further expanded") + e.d.Logger(). + WithField("method", "checkIsAllowed"). + Debug("reached max-depth, therefore this query will not be further expanded") return checkgroup.UnknownMemberFunc } @@ -190,13 +183,8 @@ func (e *Engine) checkIsAllowed(ctx context.Context, r *RelationTuple, restDepth Trace("check is allowed") g := checkgroup.New(ctx) - g.Add(e.subQuery(r, - &Query{ - Object: r.Object, - Relation: r.Relation, - Namespace: r.Namespace, - }, restDepth), - ) + g.Add(e.checkDirect(ctx, r, restDepth-1)) + g.Add(e.checkExpandSubject(ctx, r, restDepth)) relation, err := e.astRelationFor(ctx, r) if err != nil { diff --git a/internal/check/rewrites.go b/internal/check/rewrites.go index d7bbd9cb0..af90308bd 100644 --- a/internal/check/rewrites.go +++ b/internal/check/rewrites.go @@ -157,6 +157,12 @@ func (e *Engine) checkInverted( } } +// checkComputedUserset rewrites the relation tuple to use the userset relation +// instead of the the relation from the tuple. +// +// A relation tuple n:obj#original_rel@user is rewritten to +// n:obj#userset@user, where the 'userset' relation is taken from the +// userset.Relation. func (e *Engine) checkComputedUserset( ctx context.Context, r *RelationTuple, @@ -185,8 +191,18 @@ func (e *Engine) checkComputedUserset( ) } +// checkTupleToUserset rewrites the relation tuple to use the userset relation. +// +// Given a relation tuple like docs:readme#editor@user, and a tuple-to-userset +// rewrite with the relation "parent" and the computed userset relation +// "owner", the following checks will be performed: +// +// * query for all tuples like docs:readme#parent@??? to get a list of subjects +// that have the parent relation on docs:readme +// +// * For each matching subject, then check if subject#owner@user. func (e *Engine) checkTupleToUserset( - r *RelationTuple, + tuple *RelationTuple, userset *ast.TupleToUserset, restDepth int, ) checkgroup.CheckFunc { @@ -196,7 +212,7 @@ func (e *Engine) checkTupleToUserset( } e.d.Logger(). - WithField("request", r.String()). + WithField("request", tuple.String()). WithField("tuple to userset relation", userset.Relation). WithField("tuple to userset computed", userset.ComputedUsersetRelation). Trace("check tuple to userset") @@ -204,16 +220,16 @@ func (e *Engine) checkTupleToUserset( return func(ctx context.Context, resultCh chan<- checkgroup.Result) { var ( prevPage, nextPage string - rts []*RelationTuple + tuples []*RelationTuple err error ) g := checkgroup.New(ctx) for nextPage = "x"; nextPage != "" && !g.Done(); prevPage = nextPage { - rts, nextPage, err = e.d.RelationTupleManager().GetRelationTuples( + tuples, nextPage, err = e.d.RelationTupleManager().GetRelationTuples( ctx, &Query{ - Namespace: r.Namespace, - Object: r.Object, + Namespace: tuple.Namespace, + Object: tuple.Object, Relation: userset.Relation, }, x.WithToken(prevPage)) @@ -222,17 +238,17 @@ func (e *Engine) checkTupleToUserset( return } - for _, rt := range rts { - if rt.Subject.SubjectSet() == nil { + for _, t := range tuples { + if t.Subject.SubjectSet() == nil { continue } g.Add(e.checkIsAllowed( ctx, &RelationTuple{ - Namespace: rt.Subject.SubjectSet().Namespace, - Object: rt.Subject.SubjectSet().Object, + Namespace: t.Subject.SubjectSet().Namespace, + Object: t.Subject.SubjectSet().Object, Relation: userset.ComputedUsersetRelation, - Subject: r.Subject, + Subject: tuple.Subject, }, restDepth-1, ))