github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/graph/check.go (about)

     1  package graph
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"time"
     8  
     9  	"github.com/prometheus/client_golang/prometheus"
    10  	"github.com/samber/lo"
    11  	"go.opentelemetry.io/otel"
    12  	"go.opentelemetry.io/otel/trace"
    13  	"google.golang.org/protobuf/types/known/durationpb"
    14  
    15  	"github.com/authzed/spicedb/internal/dispatch"
    16  	log "github.com/authzed/spicedb/internal/logging"
    17  	datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
    18  	"github.com/authzed/spicedb/internal/namespace"
    19  	"github.com/authzed/spicedb/internal/taskrunner"
    20  	"github.com/authzed/spicedb/pkg/datastore"
    21  	"github.com/authzed/spicedb/pkg/genutil/mapz"
    22  	"github.com/authzed/spicedb/pkg/genutil/slicez"
    23  	nspkg "github.com/authzed/spicedb/pkg/namespace"
    24  	core "github.com/authzed/spicedb/pkg/proto/core/v1"
    25  	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
    26  	iv1 "github.com/authzed/spicedb/pkg/proto/impl/v1"
    27  	"github.com/authzed/spicedb/pkg/spiceerrors"
    28  	"github.com/authzed/spicedb/pkg/tuple"
    29  )
    30  
    31  var tracer = otel.Tracer("spicedb/internal/graph/check")
    32  
    33  var dispatchChunkCountHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
    34  	Name:    "spicedb_check_dispatch_chunk_count",
    35  	Help:    "number of chunks when dispatching in check",
    36  	Buckets: []float64{1, 2, 3, 5, 10, 25, 100, 250},
    37  })
    38  
    39  var directDispatchQueryHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
    40  	Name:    "spicedb_check_direct_dispatch_query_count",
    41  	Help:    "number of queries made per direct dispatch",
    42  	Buckets: []float64{1, 2},
    43  })
    44  
    45  func init() {
    46  	prometheus.MustRegister(directDispatchQueryHistogram)
    47  	prometheus.MustRegister(dispatchChunkCountHistogram)
    48  }
    49  
    50  // NewConcurrentChecker creates an instance of ConcurrentChecker.
    51  func NewConcurrentChecker(d dispatch.Check, concurrencyLimit uint16) *ConcurrentChecker {
    52  	return &ConcurrentChecker{d, concurrencyLimit}
    53  }
    54  
    55  // ConcurrentChecker exposes a method to perform Check requests, and delegates subproblems to the
    56  // provided dispatch.Check instance.
    57  type ConcurrentChecker struct {
    58  	d                dispatch.Check
    59  	concurrencyLimit uint16
    60  }
    61  
    62  // ValidatedCheckRequest represents a request after it has been validated and parsed for internal
    63  // consumption.
    64  type ValidatedCheckRequest struct {
    65  	*v1.DispatchCheckRequest
    66  	Revision datastore.Revision
    67  }
    68  
    69  // currentRequestContext holds context information for the current request being
    70  // processed.
    71  type currentRequestContext struct {
    72  	// parentReq is the parent request being processed.
    73  	parentReq ValidatedCheckRequest
    74  
    75  	// filteredResourceIDs are those resource IDs to be checked after filtering for
    76  	// any resource IDs found directly matching the incoming subject.
    77  	//
    78  	// For example, a check of resources `user:{tom,sarah,fred}` and subject `user:sarah` will
    79  	// result in this slice containing `tom` and `fred`, but not `sarah`, as she was found as a
    80  	// match.
    81  	//
    82  	// This check and filter occurs via the filterForFoundMemberResource function in the
    83  	// checkInternal function before the rest of the checking logic is run. This slice should never
    84  	// be empty.
    85  	filteredResourceIDs []string
    86  
    87  	// resultsSetting is the results setting to use for this request and all subsequent
    88  	// requests.
    89  	resultsSetting v1.DispatchCheckRequest_ResultsSetting
    90  
    91  	// maxDispatchCount is the maximum number of resource IDs that can be specified in each dispatch.
    92  	maxDispatchCount uint16
    93  }
    94  
    95  // Check performs a check request with the provided request and context
    96  func (cc *ConcurrentChecker) Check(ctx context.Context, req ValidatedCheckRequest, relation *core.Relation) (*v1.DispatchCheckResponse, error) {
    97  	var startTime *time.Time
    98  	if req.Debug != v1.DispatchCheckRequest_NO_DEBUG {
    99  		now := time.Now()
   100  		startTime = &now
   101  	}
   102  
   103  	resolved := cc.checkInternal(ctx, req, relation)
   104  	resolved.Resp.Metadata = addCallToResponseMetadata(resolved.Resp.Metadata)
   105  	if req.Debug == v1.DispatchCheckRequest_NO_DEBUG {
   106  		return resolved.Resp, resolved.Err
   107  	}
   108  
   109  	// Add debug information if requested.
   110  	debugInfo := resolved.Resp.Metadata.DebugInfo
   111  	if debugInfo == nil {
   112  		debugInfo = &v1.DebugInformation{
   113  			Check: &v1.CheckDebugTrace{},
   114  		}
   115  	}
   116  
   117  	debugInfo.Check.Request = req.DispatchCheckRequest
   118  	debugInfo.Check.Duration = durationpb.New(time.Since(*startTime))
   119  
   120  	if nspkg.GetRelationKind(relation) == iv1.RelationMetadata_PERMISSION {
   121  		debugInfo.Check.ResourceRelationType = v1.CheckDebugTrace_PERMISSION
   122  	} else if nspkg.GetRelationKind(relation) == iv1.RelationMetadata_RELATION {
   123  		debugInfo.Check.ResourceRelationType = v1.CheckDebugTrace_RELATION
   124  	}
   125  
   126  	// Build the results for the debug trace.
   127  	results := make(map[string]*v1.ResourceCheckResult, len(req.DispatchCheckRequest.ResourceIds))
   128  	for _, resourceID := range req.DispatchCheckRequest.ResourceIds {
   129  		if found, ok := resolved.Resp.ResultsByResourceId[resourceID]; ok {
   130  			results[resourceID] = found
   131  		}
   132  	}
   133  
   134  	debugInfo.Check.Results = results
   135  	resolved.Resp.Metadata.DebugInfo = debugInfo
   136  	return resolved.Resp, resolved.Err
   137  }
   138  
   139  func (cc *ConcurrentChecker) checkInternal(ctx context.Context, req ValidatedCheckRequest, relation *core.Relation) CheckResult {
   140  	// Ensure that we have proper type information for running the check. This is now required as of the deprecation and removal
   141  	// of the v0 API.
   142  	if relation.GetTypeInformation() == nil && relation.GetUsersetRewrite() == nil {
   143  		return checkResultError(
   144  			fmt.Errorf("found relation `%s` without type information; to fix, please re-write your schema", relation.Name),
   145  			emptyMetadata,
   146  		)
   147  	}
   148  
   149  	// Ensure that we have at least one resource ID for which to execute the check.
   150  	if len(req.ResourceIds) == 0 {
   151  		return checkResultError(
   152  			fmt.Errorf("empty resource IDs given to dispatched check"),
   153  			emptyMetadata,
   154  		)
   155  	}
   156  
   157  	// Ensure that we are not performing a check for a wildcard as the subject.
   158  	if req.Subject.ObjectId == tuple.PublicWildcard {
   159  		return checkResultError(NewErrInvalidArgument(errors.New("cannot perform check on wildcard")), emptyMetadata)
   160  	}
   161  
   162  	// Deduplicate any incoming resource IDs.
   163  	resourceIds := lo.Uniq(req.ResourceIds)
   164  
   165  	// Filter the incoming resource IDs for any which match the subject directly. For example, if we receive
   166  	// a check for resource `user:{tom, fred, sarah}#...` and a subject of `user:sarah#...`, then we know
   167  	// that `user:sarah#...` is a valid "member" of the resource, as it matches exactly.
   168  	//
   169  	// If the filtering results in no further resource IDs to check, or a result is found and a single
   170  	// result is allowed, we terminate early.
   171  	membershipSet, filteredResourcesIds := filterForFoundMemberResource(req.ResourceRelation, resourceIds, req.Subject)
   172  	if membershipSet.HasDeterminedMember() && req.DispatchCheckRequest.ResultsSetting == v1.DispatchCheckRequest_ALLOW_SINGLE_RESULT {
   173  		return checkResultsForMembership(membershipSet, emptyMetadata)
   174  	}
   175  
   176  	if len(filteredResourcesIds) == 0 {
   177  		return noMembers()
   178  	}
   179  
   180  	// NOTE: We can always allow a single result if we're only trying to find the results for a
   181  	// single resource ID. This "reset" allows for short circuiting of downstream dispatched calls.
   182  	resultsSetting := req.ResultsSetting
   183  	if len(filteredResourcesIds) == 1 {
   184  		resultsSetting = v1.DispatchCheckRequest_ALLOW_SINGLE_RESULT
   185  	}
   186  
   187  	crc := currentRequestContext{
   188  		parentReq:           req,
   189  		filteredResourceIDs: filteredResourcesIds,
   190  		resultsSetting:      resultsSetting,
   191  		maxDispatchCount:    maxDispatchChunkSize,
   192  	}
   193  
   194  	if req.Debug == v1.DispatchCheckRequest_ENABLE_TRACE_DEBUGGING {
   195  		crc.maxDispatchCount = 1
   196  	}
   197  
   198  	if relation.UsersetRewrite == nil {
   199  		return combineResultWithFoundResources(cc.checkDirect(ctx, crc, relation), membershipSet)
   200  	}
   201  
   202  	return combineResultWithFoundResources(cc.checkUsersetRewrite(ctx, crc, relation.UsersetRewrite), membershipSet)
   203  }
   204  
   205  type directDispatch struct {
   206  	resourceType *core.RelationReference
   207  	resourceIds  []string
   208  }
   209  
   210  func (cc *ConcurrentChecker) checkDirect(ctx context.Context, crc currentRequestContext, relation *core.Relation) CheckResult {
   211  	ctx, span := tracer.Start(ctx, "checkDirect")
   212  	defer span.End()
   213  
   214  	// Build a filter for finding the direct relationships for the check. There are three
   215  	// classes of relationships to be found:
   216  	// 1) the target subject itself, if allowed on this relation
   217  	// 2) the wildcard form of the target subject, if a wildcard is allowed on this relation
   218  	// 3) Otherwise, any non-terminal (non-`...`) subjects, if allowed on this relation, to be
   219  	//    redispatched outward
   220  	hasNonTerminals := false
   221  	hasDirectSubject := false
   222  	hasWildcardSubject := false
   223  
   224  	defer func() {
   225  		if hasNonTerminals {
   226  			span.SetName("non terminal")
   227  		} else if hasDirectSubject {
   228  			span.SetName("terminal")
   229  		} else {
   230  			span.SetName("wildcard subject")
   231  		}
   232  	}()
   233  	log.Ctx(ctx).Trace().Object("direct", crc.parentReq).Send()
   234  	ds := datastoremw.MustFromContext(ctx).SnapshotReader(crc.parentReq.Revision)
   235  
   236  	for _, allowedDirectRelation := range relation.GetTypeInformation().GetAllowedDirectRelations() {
   237  		// If the namespace of the allowed direct relation matches the subject type, there are two
   238  		// cases to optimize:
   239  		// 1) Finding the target subject itself, as a direct lookup
   240  		// 2) Finding a wildcard for the subject type+relation
   241  		if allowedDirectRelation.GetNamespace() == crc.parentReq.Subject.Namespace {
   242  			if allowedDirectRelation.GetPublicWildcard() != nil {
   243  				hasWildcardSubject = true
   244  			} else if allowedDirectRelation.GetRelation() == crc.parentReq.Subject.Relation {
   245  				hasDirectSubject = true
   246  			}
   247  		}
   248  
   249  		// If the relation found is not an ellipsis, then this is a nested relation that
   250  		// might need to be followed, so indicate that such relationships should be returned
   251  		//
   252  		// TODO(jschorr): Use type information to *further* optimize this query around which nested
   253  		// relations can reach the target subject type.
   254  		if allowedDirectRelation.GetRelation() != tuple.Ellipsis {
   255  			hasNonTerminals = true
   256  		}
   257  	}
   258  
   259  	foundResources := NewMembershipSet()
   260  
   261  	// If the direct subject or a wildcard form can be found, issue a query for just that
   262  	// subject.
   263  	var queryCount float64
   264  	defer func() {
   265  		directDispatchQueryHistogram.Observe(queryCount)
   266  	}()
   267  
   268  	if hasDirectSubject || hasWildcardSubject {
   269  		subjectSelectors := []datastore.SubjectsSelector{}
   270  
   271  		if hasDirectSubject {
   272  			subjectSelectors = append(subjectSelectors, datastore.SubjectsSelector{
   273  				OptionalSubjectType: crc.parentReq.Subject.Namespace,
   274  				OptionalSubjectIds:  []string{crc.parentReq.Subject.ObjectId},
   275  				RelationFilter:      datastore.SubjectRelationFilter{}.WithRelation(crc.parentReq.Subject.Relation),
   276  			})
   277  		}
   278  
   279  		if hasWildcardSubject {
   280  			subjectSelectors = append(subjectSelectors, datastore.SubjectsSelector{
   281  				OptionalSubjectType: crc.parentReq.Subject.Namespace,
   282  				OptionalSubjectIds:  []string{tuple.PublicWildcard},
   283  				RelationFilter:      datastore.SubjectRelationFilter{}.WithEllipsisRelation(),
   284  			})
   285  		}
   286  
   287  		filter := datastore.RelationshipsFilter{
   288  			OptionalResourceType:      crc.parentReq.ResourceRelation.Namespace,
   289  			OptionalResourceIds:       crc.filteredResourceIDs,
   290  			OptionalResourceRelation:  crc.parentReq.ResourceRelation.Relation,
   291  			OptionalSubjectsSelectors: subjectSelectors,
   292  		}
   293  
   294  		it, err := ds.QueryRelationships(ctx, filter)
   295  		if err != nil {
   296  			return checkResultError(NewCheckFailureErr(err), emptyMetadata)
   297  		}
   298  		defer it.Close()
   299  		queryCount += 1.0
   300  
   301  		// Find the matching subject(s).
   302  		for tpl := it.Next(); tpl != nil; tpl = it.Next() {
   303  			if it.Err() != nil {
   304  				return checkResultError(NewCheckFailureErr(it.Err()), emptyMetadata)
   305  			}
   306  
   307  			// If the subject of the relationship matches the target subject, then we've found
   308  			// a result.
   309  			if !tuple.OnrEqualOrWildcard(tpl.Subject, crc.parentReq.Subject) {
   310  				tplString, err := tuple.String(tpl)
   311  				if err != nil {
   312  					return checkResultError(err, emptyMetadata)
   313  				}
   314  
   315  				return checkResultError(
   316  					NewCheckFailureErr(
   317  						fmt.Errorf("somehow got invalid ONR for direct check matching: %s vs %s", tuple.StringONR(crc.parentReq.Subject), tplString),
   318  					),
   319  					emptyMetadata,
   320  				)
   321  			}
   322  
   323  			foundResources.AddDirectMember(tpl.ResourceAndRelation.ObjectId, tpl.Caveat)
   324  			if crc.resultsSetting == v1.DispatchCheckRequest_ALLOW_SINGLE_RESULT && foundResources.HasDeterminedMember() {
   325  				return checkResultsForMembership(foundResources, emptyMetadata)
   326  			}
   327  		}
   328  		it.Close()
   329  	}
   330  
   331  	// Filter down the resource IDs for further dispatch based on whether they exist as found
   332  	// subjects in the existing membership set.
   333  	furtherFilteredResourceIDs := make([]string, 0, len(crc.filteredResourceIDs)-foundResources.Size())
   334  	for _, resourceID := range crc.filteredResourceIDs {
   335  		if foundResources.HasConcreteResourceID(resourceID) {
   336  			continue
   337  		}
   338  
   339  		furtherFilteredResourceIDs = append(furtherFilteredResourceIDs, resourceID)
   340  	}
   341  
   342  	// If there are no possible non-terminals, then the check is completed.
   343  	if !hasNonTerminals || len(furtherFilteredResourceIDs) == 0 {
   344  		return checkResultsForMembership(foundResources, emptyMetadata)
   345  	}
   346  
   347  	// Otherwise, for any remaining resource IDs, query for redispatch.
   348  	filter := datastore.RelationshipsFilter{
   349  		OptionalResourceType:     crc.parentReq.ResourceRelation.Namespace,
   350  		OptionalResourceIds:      furtherFilteredResourceIDs,
   351  		OptionalResourceRelation: crc.parentReq.ResourceRelation.Relation,
   352  		OptionalSubjectsSelectors: []datastore.SubjectsSelector{
   353  			{
   354  				RelationFilter: datastore.SubjectRelationFilter{}.WithOnlyNonEllipsisRelations(),
   355  			},
   356  		},
   357  	}
   358  
   359  	it, err := ds.QueryRelationships(ctx, filter)
   360  	if err != nil {
   361  		return checkResultError(NewCheckFailureErr(err), emptyMetadata)
   362  	}
   363  	defer it.Close()
   364  	queryCount += 1.0
   365  
   366  	// Find the subjects over which to dispatch.
   367  	subjectsToDispatch := tuple.NewONRByTypeSet()
   368  	relationshipsBySubjectONR := mapz.NewMultiMap[string, *core.RelationTuple]()
   369  
   370  	for tpl := it.Next(); tpl != nil; tpl = it.Next() {
   371  		if it.Err() != nil {
   372  			return checkResultError(NewCheckFailureErr(it.Err()), emptyMetadata)
   373  		}
   374  
   375  		// Add the subject as an object over which to dispatch.
   376  		if tpl.Subject.Relation == Ellipsis {
   377  			return checkResultError(NewCheckFailureErr(fmt.Errorf("got a terminal for a non-terminal query")), emptyMetadata)
   378  		}
   379  
   380  		subjectsToDispatch.Add(tpl.Subject)
   381  		relationshipsBySubjectONR.Add(tuple.StringONR(tpl.Subject), tpl)
   382  	}
   383  	it.Close()
   384  
   385  	// Convert the subjects into batched requests.
   386  	// To simplify the logic, +1 is added to account for the situation where
   387  	// the number of elements is less than the chunk size, and spare us some annoying code.
   388  	expectedNumberOfChunks := subjectsToDispatch.ValueLen()/int(crc.maxDispatchCount) + 1
   389  	toDispatch := make([]directDispatch, 0, expectedNumberOfChunks)
   390  	subjectsToDispatch.ForEachType(func(rr *core.RelationReference, resourceIds []string) {
   391  		chunkCount := 0.0
   392  		slicez.ForEachChunk(resourceIds, crc.maxDispatchCount, func(resourceIdChunk []string) {
   393  			chunkCount++
   394  			toDispatch = append(toDispatch, directDispatch{
   395  				resourceType: rr,
   396  				resourceIds:  resourceIdChunk,
   397  			})
   398  		})
   399  		dispatchChunkCountHistogram.Observe(chunkCount)
   400  	})
   401  
   402  	// Dispatch and map to the associated resource ID(s).
   403  	result := union(ctx, crc, toDispatch, func(ctx context.Context, crc currentRequestContext, dd directDispatch) CheckResult {
   404  		childResult := cc.dispatch(ctx, crc, ValidatedCheckRequest{
   405  			&v1.DispatchCheckRequest{
   406  				ResourceRelation: dd.resourceType,
   407  				ResourceIds:      dd.resourceIds,
   408  				Subject:          crc.parentReq.Subject,
   409  				ResultsSetting:   crc.resultsSetting,
   410  
   411  				Metadata: decrementDepth(crc.parentReq.Metadata),
   412  				Debug:    crc.parentReq.Debug,
   413  			},
   414  			crc.parentReq.Revision,
   415  		})
   416  		if childResult.Err != nil {
   417  			return childResult
   418  		}
   419  
   420  		return mapFoundResources(childResult, dd.resourceType, relationshipsBySubjectONR)
   421  	}, cc.concurrencyLimit)
   422  
   423  	return combineResultWithFoundResources(result, foundResources)
   424  }
   425  
   426  func mapFoundResources(result CheckResult, resourceType *core.RelationReference, relationshipsBySubjectONR *mapz.MultiMap[string, *core.RelationTuple]) CheckResult {
   427  	// Map any resources found to the parent resource IDs.
   428  	membershipSet := NewMembershipSet()
   429  	for foundResourceID, result := range result.Resp.ResultsByResourceId {
   430  		subjectKey := tuple.StringONR(&core.ObjectAndRelation{
   431  			Namespace: resourceType.Namespace,
   432  			ObjectId:  foundResourceID,
   433  			Relation:  resourceType.Relation,
   434  		})
   435  
   436  		tuples, _ := relationshipsBySubjectONR.Get(subjectKey)
   437  		for _, relationTuple := range tuples {
   438  			membershipSet.AddMemberViaRelationship(relationTuple.ResourceAndRelation.ObjectId, result.Expression, relationTuple)
   439  		}
   440  	}
   441  
   442  	if membershipSet.IsEmpty() {
   443  		return noMembersWithMetadata(result.Resp.Metadata)
   444  	}
   445  
   446  	return checkResultsForMembership(membershipSet, result.Resp.Metadata)
   447  }
   448  
   449  func (cc *ConcurrentChecker) checkUsersetRewrite(ctx context.Context, crc currentRequestContext, rewrite *core.UsersetRewrite) CheckResult {
   450  	switch rw := rewrite.RewriteOperation.(type) {
   451  	case *core.UsersetRewrite_Union:
   452  		if len(rw.Union.Child) > 1 {
   453  			var span trace.Span
   454  			ctx, span = tracer.Start(ctx, "+")
   455  			defer span.End()
   456  		}
   457  		return union(ctx, crc, rw.Union.Child, cc.runSetOperation, cc.concurrencyLimit)
   458  	case *core.UsersetRewrite_Intersection:
   459  		ctx, span := tracer.Start(ctx, "&")
   460  		defer span.End()
   461  		return all(ctx, crc, rw.Intersection.Child, cc.runSetOperation, cc.concurrencyLimit)
   462  	case *core.UsersetRewrite_Exclusion:
   463  		ctx, span := tracer.Start(ctx, "-")
   464  		defer span.End()
   465  		return difference(ctx, crc, rw.Exclusion.Child, cc.runSetOperation, cc.concurrencyLimit)
   466  	default:
   467  		return checkResultError(fmt.Errorf("unknown userset rewrite operator"), emptyMetadata)
   468  	}
   469  }
   470  
   471  func (cc *ConcurrentChecker) dispatch(ctx context.Context, _ currentRequestContext, req ValidatedCheckRequest) CheckResult {
   472  	log.Ctx(ctx).Trace().Object("dispatch", req).Send()
   473  	result, err := cc.d.DispatchCheck(ctx, req.DispatchCheckRequest)
   474  	return CheckResult{result, err}
   475  }
   476  
   477  func (cc *ConcurrentChecker) runSetOperation(ctx context.Context, crc currentRequestContext, childOneof *core.SetOperation_Child) CheckResult {
   478  	switch child := childOneof.ChildType.(type) {
   479  	case *core.SetOperation_Child_XThis:
   480  		return checkResultError(errors.New("use of _this is unsupported; please rewrite your schema"), emptyMetadata)
   481  	case *core.SetOperation_Child_ComputedUserset:
   482  		return cc.checkComputedUserset(ctx, crc, child.ComputedUserset, nil, nil)
   483  	case *core.SetOperation_Child_UsersetRewrite:
   484  		return cc.checkUsersetRewrite(ctx, crc, child.UsersetRewrite)
   485  	case *core.SetOperation_Child_TupleToUserset:
   486  		return cc.checkTupleToUserset(ctx, crc, child.TupleToUserset)
   487  	case *core.SetOperation_Child_XNil:
   488  		return noMembers()
   489  	default:
   490  		return checkResultError(fmt.Errorf("unknown set operation child `%T` in check", child), emptyMetadata)
   491  	}
   492  }
   493  
   494  func (cc *ConcurrentChecker) checkComputedUserset(ctx context.Context, crc currentRequestContext, cu *core.ComputedUserset, rr *core.RelationReference, resourceIds []string) CheckResult {
   495  	ctx, span := tracer.Start(ctx, cu.Relation)
   496  	defer span.End()
   497  
   498  	var startNamespace string
   499  	var targetResourceIds []string
   500  	if cu.Object == core.ComputedUserset_TUPLE_USERSET_OBJECT {
   501  		if rr == nil || len(resourceIds) == 0 {
   502  			return checkResultError(spiceerrors.MustBugf("computed userset for tupleset without tuples"), emptyMetadata)
   503  		}
   504  
   505  		startNamespace = rr.Namespace
   506  		targetResourceIds = resourceIds
   507  	} else if cu.Object == core.ComputedUserset_TUPLE_OBJECT {
   508  		if rr != nil {
   509  			return checkResultError(spiceerrors.MustBugf("computed userset for tupleset with wrong object type"), emptyMetadata)
   510  		}
   511  
   512  		startNamespace = crc.parentReq.ResourceRelation.Namespace
   513  		targetResourceIds = crc.filteredResourceIDs
   514  	}
   515  
   516  	targetRR := &core.RelationReference{
   517  		Namespace: startNamespace,
   518  		Relation:  cu.Relation,
   519  	}
   520  
   521  	// If we will be dispatching to the goal's ONR, then we know that the ONR is a member.
   522  	membershipSet, updatedTargetResourceIds := filterForFoundMemberResource(targetRR, targetResourceIds, crc.parentReq.Subject)
   523  	if (membershipSet.HasDeterminedMember() && crc.resultsSetting == v1.DispatchCheckRequest_ALLOW_SINGLE_RESULT) || len(updatedTargetResourceIds) == 0 {
   524  		return checkResultsForMembership(membershipSet, emptyMetadata)
   525  	}
   526  
   527  	// Check if the target relation exists. If not, return nothing. This is only necessary
   528  	// for TTU-based computed usersets, as directly computed ones reference relations within
   529  	// the same namespace as the caller, and thus must be fully typed checked.
   530  	if cu.Object == core.ComputedUserset_TUPLE_USERSET_OBJECT {
   531  		ds := datastoremw.MustFromContext(ctx).SnapshotReader(crc.parentReq.Revision)
   532  		err := namespace.CheckNamespaceAndRelation(ctx, targetRR.Namespace, targetRR.Relation, true, ds)
   533  		if err != nil {
   534  			if errors.As(err, &namespace.ErrRelationNotFound{}) {
   535  				return noMembers()
   536  			}
   537  
   538  			return checkResultError(err, emptyMetadata)
   539  		}
   540  	}
   541  
   542  	result := cc.dispatch(ctx, crc, ValidatedCheckRequest{
   543  		&v1.DispatchCheckRequest{
   544  			ResourceRelation: targetRR,
   545  			ResourceIds:      updatedTargetResourceIds,
   546  			Subject:          crc.parentReq.Subject,
   547  			ResultsSetting:   crc.resultsSetting,
   548  			Metadata:         decrementDepth(crc.parentReq.Metadata),
   549  			Debug:            crc.parentReq.Debug,
   550  		},
   551  		crc.parentReq.Revision,
   552  	})
   553  	return combineResultWithFoundResources(result, membershipSet)
   554  }
   555  
   556  func filterForFoundMemberResource(resourceRelation *core.RelationReference, resourceIds []string, subject *core.ObjectAndRelation) (*MembershipSet, []string) {
   557  	if resourceRelation.Namespace != subject.Namespace || resourceRelation.Relation != subject.Relation {
   558  		return nil, resourceIds
   559  	}
   560  
   561  	for index, resourceID := range resourceIds {
   562  		if subject.ObjectId == resourceID {
   563  			membershipSet := NewMembershipSet()
   564  			membershipSet.AddDirectMember(resourceID, nil)
   565  			return membershipSet, removeIndexFromSlice(resourceIds, index)
   566  		}
   567  	}
   568  
   569  	return nil, resourceIds
   570  }
   571  
   572  func removeIndexFromSlice[T any](s []T, index int) []T {
   573  	cpy := make([]T, 0, len(s)-1)
   574  	cpy = append(cpy, s[:index]...)
   575  	return append(cpy, s[index+1:]...)
   576  }
   577  
   578  func (cc *ConcurrentChecker) checkTupleToUserset(ctx context.Context, crc currentRequestContext, ttu *core.TupleToUserset) CheckResult {
   579  	ctx, span := tracer.Start(ctx, ttu.Tupleset.Relation+"->"+ttu.ComputedUserset.Relation)
   580  	defer span.End()
   581  
   582  	log.Ctx(ctx).Trace().Object("ttu", crc.parentReq).Send()
   583  	ds := datastoremw.MustFromContext(ctx).SnapshotReader(crc.parentReq.Revision)
   584  	it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{
   585  		OptionalResourceType:     crc.parentReq.ResourceRelation.Namespace,
   586  		OptionalResourceIds:      crc.filteredResourceIDs,
   587  		OptionalResourceRelation: ttu.Tupleset.Relation,
   588  	})
   589  	if err != nil {
   590  		return checkResultError(NewCheckFailureErr(err), emptyMetadata)
   591  	}
   592  	defer it.Close()
   593  
   594  	subjectsToDispatch := tuple.NewONRByTypeSet()
   595  	relationshipsBySubjectONR := mapz.NewMultiMap[string, *core.RelationTuple]()
   596  	for tpl := it.Next(); tpl != nil; tpl = it.Next() {
   597  		if it.Err() != nil {
   598  			return checkResultError(NewCheckFailureErr(it.Err()), emptyMetadata)
   599  		}
   600  
   601  		subjectsToDispatch.Add(tpl.Subject)
   602  		relationshipsBySubjectONR.Add(tuple.StringONR(tpl.Subject), tpl)
   603  	}
   604  	it.Close()
   605  
   606  	// Convert the subjects into batched requests.
   607  	// To simplify the logic, +1 is added to account for the situation where
   608  	// the number of elements is less than the chunk size, and spare us some annoying code.
   609  	expectedNumberOfChunks := subjectsToDispatch.ValueLen()/int(crc.maxDispatchCount) + 1
   610  	toDispatch := make([]directDispatch, 0, expectedNumberOfChunks)
   611  	subjectsToDispatch.ForEachType(func(rr *core.RelationReference, resourceIds []string) {
   612  		chunkCount := 0.0
   613  		slicez.ForEachChunk(resourceIds, crc.maxDispatchCount, func(resourceIdChunk []string) {
   614  			chunkCount++
   615  			toDispatch = append(toDispatch, directDispatch{
   616  				resourceType: rr,
   617  				resourceIds:  resourceIdChunk,
   618  			})
   619  		})
   620  		dispatchChunkCountHistogram.Observe(chunkCount)
   621  	})
   622  
   623  	return union(
   624  		ctx,
   625  		crc,
   626  		toDispatch,
   627  		func(ctx context.Context, crc currentRequestContext, dd directDispatch) CheckResult {
   628  			childResult := cc.checkComputedUserset(ctx, crc, ttu.ComputedUserset, dd.resourceType, dd.resourceIds)
   629  			if childResult.Err != nil {
   630  				return childResult
   631  			}
   632  
   633  			return mapFoundResources(childResult, dd.resourceType, relationshipsBySubjectONR)
   634  		},
   635  		cc.concurrencyLimit,
   636  	)
   637  }
   638  
   639  func withDistinctMetadata(result CheckResult) CheckResult {
   640  	// NOTE: This is necessary to ensure unique debug information on the request and that debug
   641  	// information from the child metadata is *not* copied over.
   642  	clonedResp := result.Resp.CloneVT()
   643  	clonedResp.Metadata = combineResponseMetadata(emptyMetadata, clonedResp.Metadata)
   644  	return CheckResult{
   645  		Resp: clonedResp,
   646  		Err:  result.Err,
   647  	}
   648  }
   649  
   650  // union returns whether any one of the lazy checks pass, and is used for union.
   651  func union[T any](
   652  	ctx context.Context,
   653  	crc currentRequestContext,
   654  	children []T,
   655  	handler func(ctx context.Context, crc currentRequestContext, child T) CheckResult,
   656  	concurrencyLimit uint16,
   657  ) CheckResult {
   658  	if len(children) == 0 {
   659  		return noMembers()
   660  	}
   661  
   662  	if len(children) == 1 {
   663  		return withDistinctMetadata(handler(ctx, crc, children[0]))
   664  	}
   665  
   666  	resultChan := make(chan CheckResult, len(children))
   667  	childCtx, cancelFn := context.WithCancel(ctx)
   668  	dispatchAllAsync(childCtx, crc, children, handler, resultChan, concurrencyLimit)
   669  	defer cancelFn()
   670  
   671  	responseMetadata := emptyMetadata
   672  	membershipSet := NewMembershipSet()
   673  
   674  	for i := 0; i < len(children); i++ {
   675  		select {
   676  		case result := <-resultChan:
   677  			log.Ctx(ctx).Trace().Object("anyResult", result.Resp).Send()
   678  			responseMetadata = combineResponseMetadata(responseMetadata, result.Resp.Metadata)
   679  			if result.Err != nil {
   680  				return checkResultError(result.Err, responseMetadata)
   681  			}
   682  
   683  			membershipSet.UnionWith(result.Resp.ResultsByResourceId)
   684  			if membershipSet.HasDeterminedMember() && crc.resultsSetting == v1.DispatchCheckRequest_ALLOW_SINGLE_RESULT {
   685  				return checkResultsForMembership(membershipSet, responseMetadata)
   686  			}
   687  
   688  		case <-ctx.Done():
   689  			log.Ctx(ctx).Trace().Msg("anyCanceled")
   690  			return checkResultError(context.Canceled, responseMetadata)
   691  		}
   692  	}
   693  
   694  	return checkResultsForMembership(membershipSet, responseMetadata)
   695  }
   696  
   697  // all returns whether all of the lazy checks pass, and is used for intersection.
   698  func all[T any](
   699  	ctx context.Context,
   700  	crc currentRequestContext,
   701  	children []T,
   702  	handler func(ctx context.Context, crc currentRequestContext, child T) CheckResult,
   703  	concurrencyLimit uint16,
   704  ) CheckResult {
   705  	if len(children) == 0 {
   706  		return noMembers()
   707  	}
   708  
   709  	if len(children) == 1 {
   710  		return withDistinctMetadata(handler(ctx, crc, children[0]))
   711  	}
   712  
   713  	responseMetadata := emptyMetadata
   714  
   715  	resultChan := make(chan CheckResult, len(children))
   716  	childCtx, cancelFn := context.WithCancel(ctx)
   717  	dispatchAllAsync(childCtx, currentRequestContext{
   718  		parentReq:           crc.parentReq,
   719  		filteredResourceIDs: crc.filteredResourceIDs,
   720  		resultsSetting:      v1.DispatchCheckRequest_REQUIRE_ALL_RESULTS,
   721  		maxDispatchCount:    crc.maxDispatchCount,
   722  	}, children, handler, resultChan, concurrencyLimit)
   723  	defer cancelFn()
   724  
   725  	var membershipSet *MembershipSet
   726  	for i := 0; i < len(children); i++ {
   727  		select {
   728  		case result := <-resultChan:
   729  			responseMetadata = combineResponseMetadata(responseMetadata, result.Resp.Metadata)
   730  			if result.Err != nil {
   731  				return checkResultError(result.Err, responseMetadata)
   732  			}
   733  
   734  			if membershipSet == nil {
   735  				membershipSet = NewMembershipSet()
   736  				membershipSet.UnionWith(result.Resp.ResultsByResourceId)
   737  			} else {
   738  				membershipSet.IntersectWith(result.Resp.ResultsByResourceId)
   739  			}
   740  
   741  			if membershipSet.IsEmpty() {
   742  				return noMembersWithMetadata(responseMetadata)
   743  			}
   744  		case <-ctx.Done():
   745  			return checkResultError(context.Canceled, responseMetadata)
   746  		}
   747  	}
   748  
   749  	return checkResultsForMembership(membershipSet, responseMetadata)
   750  }
   751  
   752  // difference returns whether the first lazy check passes and none of the supsequent checks pass.
   753  func difference[T any](
   754  	ctx context.Context,
   755  	crc currentRequestContext,
   756  	children []T,
   757  	handler func(ctx context.Context, crc currentRequestContext, child T) CheckResult,
   758  	concurrencyLimit uint16,
   759  ) CheckResult {
   760  	if len(children) == 0 {
   761  		return noMembers()
   762  	}
   763  
   764  	if len(children) == 1 {
   765  		return checkResultError(fmt.Errorf("difference requires more than a single child"), emptyMetadata)
   766  	}
   767  
   768  	childCtx, cancelFn := context.WithCancel(ctx)
   769  	baseChan := make(chan CheckResult, 1)
   770  	othersChan := make(chan CheckResult, len(children)-1)
   771  
   772  	go func() {
   773  		result := handler(childCtx, crc, children[0])
   774  		baseChan <- result
   775  	}()
   776  
   777  	dispatchAllAsync(childCtx, currentRequestContext{
   778  		parentReq:           crc.parentReq,
   779  		filteredResourceIDs: crc.filteredResourceIDs,
   780  		resultsSetting:      v1.DispatchCheckRequest_REQUIRE_ALL_RESULTS,
   781  		maxDispatchCount:    crc.maxDispatchCount,
   782  	}, children[1:], handler, othersChan, concurrencyLimit-1)
   783  	defer cancelFn()
   784  
   785  	responseMetadata := emptyMetadata
   786  	membershipSet := NewMembershipSet()
   787  
   788  	// Wait for the base set to return.
   789  	select {
   790  	case base := <-baseChan:
   791  		responseMetadata = combineResponseMetadata(responseMetadata, base.Resp.Metadata)
   792  
   793  		if base.Err != nil {
   794  			return checkResultError(base.Err, responseMetadata)
   795  		}
   796  
   797  		membershipSet.UnionWith(base.Resp.ResultsByResourceId)
   798  		if membershipSet.IsEmpty() {
   799  			return noMembersWithMetadata(responseMetadata)
   800  		}
   801  
   802  	case <-ctx.Done():
   803  		return checkResultError(context.Canceled, responseMetadata)
   804  	}
   805  
   806  	// Subtract the remaining sets.
   807  	for i := 1; i < len(children); i++ {
   808  		select {
   809  		case sub := <-othersChan:
   810  			responseMetadata = combineResponseMetadata(responseMetadata, sub.Resp.Metadata)
   811  
   812  			if sub.Err != nil {
   813  				return checkResultError(sub.Err, responseMetadata)
   814  			}
   815  
   816  			membershipSet.Subtract(sub.Resp.ResultsByResourceId)
   817  			if membershipSet.IsEmpty() {
   818  				return noMembersWithMetadata(responseMetadata)
   819  			}
   820  
   821  		case <-ctx.Done():
   822  			return checkResultError(context.Canceled, responseMetadata)
   823  		}
   824  	}
   825  
   826  	return checkResultsForMembership(membershipSet, responseMetadata)
   827  }
   828  
   829  func dispatchAllAsync[T any](
   830  	ctx context.Context,
   831  	crc currentRequestContext,
   832  	children []T,
   833  	handler func(ctx context.Context, crc currentRequestContext, child T) CheckResult,
   834  	resultChan chan<- CheckResult,
   835  	concurrencyLimit uint16,
   836  ) {
   837  	tr := taskrunner.NewPreloadedTaskRunner(ctx, concurrencyLimit, len(children))
   838  	for _, currentChild := range children {
   839  		currentChild := currentChild
   840  		tr.Add(func(ctx context.Context) error {
   841  			result := handler(ctx, crc, currentChild)
   842  			resultChan <- result
   843  			return result.Err
   844  		})
   845  	}
   846  
   847  	tr.Start()
   848  }
   849  
   850  func noMembers() CheckResult {
   851  	return CheckResult{
   852  		&v1.DispatchCheckResponse{
   853  			Metadata: emptyMetadata,
   854  		},
   855  		nil,
   856  	}
   857  }
   858  
   859  func noMembersWithMetadata(metadata *v1.ResponseMeta) CheckResult {
   860  	return CheckResult{
   861  		&v1.DispatchCheckResponse{
   862  			Metadata: metadata,
   863  		},
   864  		nil,
   865  	}
   866  }
   867  
   868  func checkResultsForMembership(foundMembership *MembershipSet, subProblemMetadata *v1.ResponseMeta) CheckResult {
   869  	return CheckResult{
   870  		&v1.DispatchCheckResponse{
   871  			Metadata:            ensureMetadata(subProblemMetadata),
   872  			ResultsByResourceId: foundMembership.AsCheckResultsMap(),
   873  		},
   874  		nil,
   875  	}
   876  }
   877  
   878  func checkResultError(err error, subProblemMetadata *v1.ResponseMeta) CheckResult {
   879  	return CheckResult{
   880  		&v1.DispatchCheckResponse{
   881  			Metadata: ensureMetadata(subProblemMetadata),
   882  		},
   883  		err,
   884  	}
   885  }
   886  
   887  func combineResultWithFoundResources(result CheckResult, foundResources *MembershipSet) CheckResult {
   888  	if result.Err != nil {
   889  		return result
   890  	}
   891  
   892  	if foundResources.IsEmpty() {
   893  		return result
   894  	}
   895  
   896  	foundResources.UnionWith(result.Resp.ResultsByResourceId)
   897  	return CheckResult{
   898  		Resp: &v1.DispatchCheckResponse{
   899  			ResultsByResourceId: foundResources.AsCheckResultsMap(),
   900  			Metadata:            result.Resp.Metadata,
   901  		},
   902  		Err: result.Err,
   903  	}
   904  }
   905  
   906  func combineResponseMetadata(existing *v1.ResponseMeta, responseMetadata *v1.ResponseMeta) *v1.ResponseMeta {
   907  	combined := &v1.ResponseMeta{
   908  		DispatchCount:       existing.DispatchCount + responseMetadata.DispatchCount,
   909  		DepthRequired:       max(existing.DepthRequired, responseMetadata.DepthRequired),
   910  		CachedDispatchCount: existing.CachedDispatchCount + responseMetadata.CachedDispatchCount,
   911  	}
   912  
   913  	if existing.DebugInfo == nil && responseMetadata.DebugInfo == nil {
   914  		return combined
   915  	}
   916  
   917  	debugInfo := &v1.DebugInformation{
   918  		Check: &v1.CheckDebugTrace{},
   919  	}
   920  
   921  	if existing.DebugInfo != nil {
   922  		if existing.DebugInfo.Check.Request != nil {
   923  			debugInfo.Check.SubProblems = append(debugInfo.Check.SubProblems, existing.DebugInfo.Check)
   924  		} else {
   925  			debugInfo.Check.SubProblems = append(debugInfo.Check.SubProblems, existing.DebugInfo.Check.SubProblems...)
   926  		}
   927  	}
   928  
   929  	if responseMetadata.DebugInfo != nil {
   930  		if responseMetadata.DebugInfo.Check.Request != nil {
   931  			debugInfo.Check.SubProblems = append(debugInfo.Check.SubProblems, responseMetadata.DebugInfo.Check)
   932  		} else {
   933  			debugInfo.Check.SubProblems = append(debugInfo.Check.SubProblems, responseMetadata.DebugInfo.Check.SubProblems...)
   934  		}
   935  	}
   936  
   937  	combined.DebugInfo = debugInfo
   938  	return combined
   939  }