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

     1  package graph
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"sort"
     8  
     9  	"golang.org/x/sync/errgroup"
    10  
    11  	"github.com/authzed/spicedb/internal/datasets"
    12  	"github.com/authzed/spicedb/internal/dispatch"
    13  	log "github.com/authzed/spicedb/internal/logging"
    14  	datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
    15  	"github.com/authzed/spicedb/internal/namespace"
    16  	"github.com/authzed/spicedb/pkg/datastore"
    17  	"github.com/authzed/spicedb/pkg/datastore/options"
    18  	"github.com/authzed/spicedb/pkg/genutil/mapz"
    19  	"github.com/authzed/spicedb/pkg/genutil/slicez"
    20  	core "github.com/authzed/spicedb/pkg/proto/core/v1"
    21  	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
    22  	"github.com/authzed/spicedb/pkg/spiceerrors"
    23  	"github.com/authzed/spicedb/pkg/tuple"
    24  	"github.com/authzed/spicedb/pkg/typesystem"
    25  )
    26  
    27  // lsDispatchVersion defines the "version" of this dispatcher. Must be incremented
    28  // anytime an incompatible change is made to the dispatcher itself or its cursor
    29  // production.
    30  const lsDispatchVersion = 1
    31  
    32  // CursorForFoundSubjectID returns an updated version of the afterResponseCursor (which must have been created
    33  // by this dispatcher), but with the specified subjectID as the starting point.
    34  func CursorForFoundSubjectID(subjectID string, afterResponseCursor *v1.Cursor) (*v1.Cursor, error) {
    35  	if afterResponseCursor == nil {
    36  		return &v1.Cursor{
    37  			DispatchVersion: lsDispatchVersion,
    38  			Sections:        []string{subjectID},
    39  		}, nil
    40  	}
    41  
    42  	if len(afterResponseCursor.Sections) != 1 {
    43  		return nil, spiceerrors.MustBugf("given an invalid afterResponseCursor (wrong number of sections)")
    44  	}
    45  
    46  	return &v1.Cursor{
    47  		DispatchVersion: lsDispatchVersion,
    48  		Sections:        []string{subjectID},
    49  	}, nil
    50  }
    51  
    52  // ValidatedLookupSubjectsRequest represents a request after it has been validated and parsed for internal
    53  // consumption.
    54  type ValidatedLookupSubjectsRequest struct {
    55  	*v1.DispatchLookupSubjectsRequest
    56  	Revision datastore.Revision
    57  }
    58  
    59  // NewConcurrentLookupSubjects creates an instance of ConcurrentLookupSubjects.
    60  func NewConcurrentLookupSubjects(d dispatch.LookupSubjects, concurrencyLimit uint16) *ConcurrentLookupSubjects {
    61  	return &ConcurrentLookupSubjects{d, concurrencyLimit}
    62  }
    63  
    64  // ConcurrentLookupSubjects performs the concurrent lookup subjects operation.
    65  type ConcurrentLookupSubjects struct {
    66  	d                dispatch.LookupSubjects
    67  	concurrencyLimit uint16
    68  }
    69  
    70  func (cl *ConcurrentLookupSubjects) LookupSubjects(
    71  	req ValidatedLookupSubjectsRequest,
    72  	stream dispatch.LookupSubjectsStream,
    73  ) error {
    74  	ctx := stream.Context()
    75  
    76  	if len(req.ResourceIds) == 0 {
    77  		return fmt.Errorf("no resources ids given to lookupsubjects dispatch")
    78  	}
    79  
    80  	limits := newLimitTracker(req.OptionalLimit)
    81  	ci, err := newCursorInformation(req.OptionalCursor, limits, lsDispatchVersion)
    82  	if err != nil {
    83  		return err
    84  	}
    85  
    86  	// Run both "branches" in parallel and union together to respect the cursors and limits.
    87  	return runInParallel(ctx, ci, stream, cl.concurrencyLimit,
    88  		unionOperation{
    89  			callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error {
    90  				return cl.yieldMatchingResources(ctx, ci.withClonedLimits(), req, cstream)
    91  			},
    92  			runIf: req.SubjectRelation.Namespace == req.ResourceRelation.Namespace && req.SubjectRelation.Relation == req.ResourceRelation.Relation,
    93  		},
    94  		unionOperation{
    95  			callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error {
    96  				return cl.yieldRelationSubjects(ctx, ci.withClonedLimits(), req, cstream, concurrencyLimit)
    97  			},
    98  			runIf: true,
    99  		},
   100  	)
   101  }
   102  
   103  // yieldMatchingResources yields the current resource IDs iff the resource matches the target
   104  // subject.
   105  func (cl *ConcurrentLookupSubjects) yieldMatchingResources(
   106  	_ context.Context,
   107  	ci cursorInformation,
   108  	req ValidatedLookupSubjectsRequest,
   109  	stream dispatch.LookupSubjectsStream,
   110  ) error {
   111  	if req.SubjectRelation.Namespace != req.ResourceRelation.Namespace ||
   112  		req.SubjectRelation.Relation != req.ResourceRelation.Relation {
   113  		return nil
   114  	}
   115  
   116  	subjectsMap, err := subjectsForConcreteIds(req.ResourceIds, ci)
   117  	if err != nil {
   118  		return err
   119  	}
   120  
   121  	return publishSubjects(stream, ci, subjectsMap)
   122  }
   123  
   124  // yieldRelationSubjects walks the relation, performing lookup subjects on the relation's data or
   125  // computed rewrite.
   126  func (cl *ConcurrentLookupSubjects) yieldRelationSubjects(
   127  	ctx context.Context,
   128  	ci cursorInformation,
   129  	req ValidatedLookupSubjectsRequest,
   130  	stream dispatch.LookupSubjectsStream,
   131  	concurrencyLimit uint16,
   132  ) error {
   133  	ds := datastoremw.MustFromContext(ctx)
   134  	reader := ds.SnapshotReader(req.Revision)
   135  
   136  	_, validatedTS, err := typesystem.ReadNamespaceAndTypes(ctx, req.ResourceRelation.Namespace, reader)
   137  	if err != nil {
   138  		return err
   139  	}
   140  
   141  	relation, err := validatedTS.GetRelationOrError(req.ResourceRelation.Relation)
   142  	if err != nil {
   143  		return err
   144  	}
   145  
   146  	if relation.UsersetRewrite == nil {
   147  		// As there is no rewrite here, perform direct lookup of subjects on the relation.
   148  		return cl.lookupDirectSubjects(ctx, ci, req, stream, validatedTS, reader, concurrencyLimit)
   149  	}
   150  
   151  	return cl.lookupViaRewrite(ctx, ci, req, stream, relation.UsersetRewrite, concurrencyLimit)
   152  }
   153  
   154  // subjectsForConcreteIds returns a FoundSubjects map for the given *concrete* subject IDs, filtered by the cursor (if applicable).
   155  func subjectsForConcreteIds(subjectIDs []string, ci cursorInformation) (map[string]*v1.FoundSubjects, error) {
   156  	// If the after subject ID is the wildcard, then no concrete subjects should be returned.
   157  	afterSubjectID, _ := ci.headSectionValue()
   158  	if afterSubjectID == tuple.PublicWildcard {
   159  		return nil, nil
   160  	}
   161  
   162  	foundSubjects := make(map[string]*v1.FoundSubjects, len(subjectIDs))
   163  	for _, subjectID := range subjectIDs {
   164  		if afterSubjectID != "" && subjectID <= afterSubjectID {
   165  			continue
   166  		}
   167  
   168  		foundSubjects[subjectID] = &v1.FoundSubjects{
   169  			FoundSubjects: []*v1.FoundSubject{
   170  				{
   171  					SubjectId:        subjectID,
   172  					CaveatExpression: nil, // Explicitly nil since this is a concrete found subject.
   173  				},
   174  			},
   175  		}
   176  	}
   177  	return foundSubjects, nil
   178  }
   179  
   180  // lookupDirectSubjects performs lookup of subjects directly on a relation.
   181  func (cl *ConcurrentLookupSubjects) lookupDirectSubjects(
   182  	ctx context.Context,
   183  	ci cursorInformation,
   184  	req ValidatedLookupSubjectsRequest,
   185  	stream dispatch.LookupSubjectsStream,
   186  	validatedTS *typesystem.ValidatedNamespaceTypeSystem,
   187  	reader datastore.Reader,
   188  	concurrencyLimit uint16,
   189  ) error {
   190  	// Check if the direct subject can be found on this relation and, if so, query for then.
   191  	directAllowed, err := validatedTS.IsAllowedDirectRelation(req.ResourceRelation.Relation, req.SubjectRelation.Namespace, req.SubjectRelation.Relation)
   192  	if err != nil {
   193  		return err
   194  	}
   195  
   196  	hasIndirectSubjects, err := validatedTS.HasIndirectSubjects(req.ResourceRelation.Relation)
   197  	if err != nil {
   198  		return err
   199  	}
   200  
   201  	wildcardAllowed, err := validatedTS.IsAllowedPublicNamespace(req.ResourceRelation.Relation, req.SubjectRelation.Namespace)
   202  	if err != nil {
   203  		return err
   204  	}
   205  
   206  	return runInParallel(ctx, ci, stream, concurrencyLimit,
   207  		// Direct subjects found on the relation.
   208  		unionOperation{
   209  			callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error {
   210  				return cl.lookupDirectSubjectsForRelation(ctx, ci.withClonedLimits(), req, cstream, validatedTS, reader)
   211  			},
   212  			runIf: directAllowed == typesystem.DirectRelationValid,
   213  		},
   214  
   215  		// Wildcard on the relation.
   216  		unionOperation{
   217  			callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error {
   218  				return cl.lookupWildcardSubjectForRelation(ctx, ci.withClonedLimits(), req, cstream, validatedTS, reader)
   219  			},
   220  
   221  			// Wildcards are only applicable on ellipsis subjects
   222  			runIf: req.SubjectRelation.Relation == tuple.Ellipsis && wildcardAllowed == typesystem.PublicSubjectAllowed,
   223  		},
   224  
   225  		// Dispatching over indirect subjects on the relation.
   226  		unionOperation{
   227  			callback: func(ctx context.Context, cstream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error {
   228  				return cl.dispatchIndirectSubjectsForRelation(ctx, ci.withClonedLimits(), req, cstream, reader)
   229  			},
   230  			runIf: hasIndirectSubjects,
   231  		},
   232  	)
   233  }
   234  
   235  // lookupDirectSubjectsForRelation finds all directly matching subjects on the request's relation, if applicable.
   236  func (cl *ConcurrentLookupSubjects) lookupDirectSubjectsForRelation(
   237  	ctx context.Context,
   238  	ci cursorInformation,
   239  	req ValidatedLookupSubjectsRequest,
   240  	stream dispatch.LookupSubjectsStream,
   241  	validatedTS *typesystem.ValidatedNamespaceTypeSystem,
   242  	reader datastore.Reader,
   243  ) error {
   244  	// Check if the direct subject can be found on this relation and, if so, query for then.
   245  	directAllowed, err := validatedTS.IsAllowedDirectRelation(req.ResourceRelation.Relation, req.SubjectRelation.Namespace, req.SubjectRelation.Relation)
   246  	if err != nil {
   247  		return err
   248  	}
   249  
   250  	if directAllowed == typesystem.DirectRelationNotValid {
   251  		return nil
   252  	}
   253  
   254  	var afterCursor options.Cursor
   255  	afterSubjectID, _ := ci.headSectionValue()
   256  
   257  	// If the cursor specifies the wildcard, then skip all further non-wildcard results.
   258  	if afterSubjectID == tuple.PublicWildcard {
   259  		return nil
   260  	}
   261  
   262  	if afterSubjectID != "" {
   263  		afterCursor = &core.RelationTuple{
   264  			// NOTE: since we fully specify the resource below, the resource should be ignored in this cursor.
   265  			ResourceAndRelation: &core.ObjectAndRelation{
   266  				Namespace: "",
   267  				ObjectId:  "",
   268  				Relation:  "",
   269  			},
   270  			Subject: &core.ObjectAndRelation{
   271  				Namespace: req.SubjectRelation.Namespace,
   272  				ObjectId:  afterSubjectID,
   273  				Relation:  req.SubjectRelation.Relation,
   274  			},
   275  		}
   276  	}
   277  
   278  	limit := ci.limits.currentLimit + 1 // +1 because there might be a matching wildcard too.
   279  	if !ci.limits.hasLimit {
   280  		limit = 0
   281  	}
   282  
   283  	foundSubjectsByResourceID := datasets.NewSubjectSetByResourceID()
   284  	if err := queryForDirectSubjects(ctx, req, datastore.SubjectsSelector{
   285  		OptionalSubjectType: req.SubjectRelation.Namespace,
   286  		RelationFilter:      datastore.SubjectRelationFilter{}.WithNonEllipsisRelation(req.SubjectRelation.Relation),
   287  	}, afterCursor, foundSubjectsByResourceID, reader, limit); err != nil {
   288  		return err
   289  	}
   290  
   291  	// Send the results to the stream.
   292  	if foundSubjectsByResourceID.IsEmpty() {
   293  		return nil
   294  	}
   295  	return publishSubjects(stream, ci, foundSubjectsByResourceID.AsMap())
   296  }
   297  
   298  // lookupWildcardSubjectForRelation finds the wildcard subject on the request's relation, if applicable.
   299  func (cl *ConcurrentLookupSubjects) lookupWildcardSubjectForRelation(
   300  	ctx context.Context,
   301  	ci cursorInformation,
   302  	req ValidatedLookupSubjectsRequest,
   303  	stream dispatch.LookupSubjectsStream,
   304  	validatedTS *typesystem.ValidatedNamespaceTypeSystem,
   305  	reader datastore.Reader,
   306  ) error {
   307  	// Check if a wildcard is possible and, if so, query directly for it without any cursoring. This is necessary because wildcards
   308  	// must *always* be returned, regardless of the cursor.
   309  	if req.SubjectRelation.Relation != tuple.Ellipsis {
   310  		return nil
   311  	}
   312  
   313  	wildcardAllowed, err := validatedTS.IsAllowedPublicNamespace(req.ResourceRelation.Relation, req.SubjectRelation.Namespace)
   314  	if err != nil {
   315  		return err
   316  	}
   317  	if wildcardAllowed == typesystem.PublicSubjectNotAllowed {
   318  		return nil
   319  	}
   320  
   321  	// NOTE: the cursor here is `nil` regardless of that passed in, to ensure wildcards are always returned.
   322  	foundSubjectsByResourceID := datasets.NewSubjectSetByResourceID()
   323  	if err := queryForDirectSubjects(ctx, req, datastore.SubjectsSelector{
   324  		OptionalSubjectType: req.SubjectRelation.Namespace,
   325  		OptionalSubjectIds:  []string{tuple.PublicWildcard},
   326  		RelationFilter:      datastore.SubjectRelationFilter{}.WithEllipsisRelation(),
   327  	}, nil, foundSubjectsByResourceID, reader, 1); err != nil {
   328  		return err
   329  	}
   330  
   331  	// Send the results to the stream.
   332  	if foundSubjectsByResourceID.IsEmpty() {
   333  		return nil
   334  	}
   335  
   336  	return publishSubjects(stream, ci, foundSubjectsByResourceID.AsMap())
   337  }
   338  
   339  // dispatchIndirectSubjectsForRelation looks up all non-ellipsis subjects on the relation and redispatches the LookupSubjects
   340  // operation over them.
   341  func (cl *ConcurrentLookupSubjects) dispatchIndirectSubjectsForRelation(
   342  	ctx context.Context,
   343  	ci cursorInformation,
   344  	req ValidatedLookupSubjectsRequest,
   345  	stream dispatch.LookupSubjectsStream,
   346  	reader datastore.Reader,
   347  ) error {
   348  	// TODO(jschorr): use reachability type information to skip subject relations that cannot reach the subject type.
   349  	// TODO(jschorr): Store the range of subjects found as a result of this call and store in the cursor to further optimize.
   350  
   351  	// Lookup indirect subjects for redispatching.
   352  	// TODO: limit to only the necessary columns. See: https://github.com/authzed/spicedb/issues/1527
   353  	it, err := reader.QueryRelationships(ctx, datastore.RelationshipsFilter{
   354  		OptionalResourceType:     req.ResourceRelation.Namespace,
   355  		OptionalResourceRelation: req.ResourceRelation.Relation,
   356  		OptionalResourceIds:      req.ResourceIds,
   357  		OptionalSubjectsSelectors: []datastore.SubjectsSelector{{
   358  			RelationFilter: datastore.SubjectRelationFilter{}.WithOnlyNonEllipsisRelations(),
   359  		}},
   360  	})
   361  	if err != nil {
   362  		return err
   363  	}
   364  	defer it.Close()
   365  
   366  	toDispatchByType := datasets.NewSubjectByTypeSet()
   367  	relationshipsBySubjectONR := mapz.NewMultiMap[string, *core.RelationTuple]()
   368  	for tpl := it.Next(); tpl != nil; tpl = it.Next() {
   369  		if it.Err() != nil {
   370  			return it.Err()
   371  		}
   372  
   373  		err := toDispatchByType.AddSubjectOf(tpl)
   374  		if err != nil {
   375  			return err
   376  		}
   377  
   378  		relationshipsBySubjectONR.Add(tuple.StringONR(tpl.Subject), tpl)
   379  	}
   380  	it.Close()
   381  
   382  	return cl.dispatchTo(ctx, ci, req, toDispatchByType, relationshipsBySubjectONR, stream)
   383  }
   384  
   385  // queryForDirectSubjects performs querying for direct subjects on the request's relation, with the specified
   386  // subjects selector. The found subjects (if any) are added to the foundSubjectsByResourceID dataset.
   387  func queryForDirectSubjects(
   388  	ctx context.Context,
   389  	req ValidatedLookupSubjectsRequest,
   390  	subjectsSelector datastore.SubjectsSelector,
   391  	afterCursor options.Cursor,
   392  	foundSubjectsByResourceID datasets.SubjectSetByResourceID,
   393  	reader datastore.Reader,
   394  	limit uint32,
   395  ) error {
   396  	queryOptions := []options.QueryOptionsOption{options.WithSort(options.BySubject), options.WithAfter(afterCursor)}
   397  	if limit > 0 {
   398  		limit64 := uint64(limit)
   399  		queryOptions = append(queryOptions, options.WithLimit(&limit64))
   400  	}
   401  
   402  	sit, err := reader.QueryRelationships(ctx, datastore.RelationshipsFilter{
   403  		OptionalResourceType:     req.ResourceRelation.Namespace,
   404  		OptionalResourceRelation: req.ResourceRelation.Relation,
   405  		OptionalResourceIds:      req.ResourceIds,
   406  		OptionalSubjectsSelectors: []datastore.SubjectsSelector{
   407  			subjectsSelector,
   408  		},
   409  	}, queryOptions...)
   410  	if err != nil {
   411  		return err
   412  	}
   413  	defer sit.Close()
   414  
   415  	for tpl := sit.Next(); tpl != nil; tpl = sit.Next() {
   416  		if sit.Err() != nil {
   417  			return sit.Err()
   418  		}
   419  		if err := foundSubjectsByResourceID.AddFromRelationship(tpl); err != nil {
   420  			return fmt.Errorf("failed to call AddFromRelationship in lookupDirectSubjects: %w", err)
   421  		}
   422  	}
   423  	if sit.Err() != nil {
   424  		return sit.Err()
   425  	}
   426  	sit.Close()
   427  	return nil
   428  }
   429  
   430  // lookupViaComputed redispatches LookupSubjects over a computed relation.
   431  func (cl *ConcurrentLookupSubjects) lookupViaComputed(
   432  	ctx context.Context,
   433  	ci cursorInformation,
   434  	parentRequest ValidatedLookupSubjectsRequest,
   435  	parentStream dispatch.LookupSubjectsStream,
   436  	cu *core.ComputedUserset,
   437  ) error {
   438  	ds := datastoremw.MustFromContext(ctx).SnapshotReader(parentRequest.Revision)
   439  	if err := namespace.CheckNamespaceAndRelation(ctx, parentRequest.ResourceRelation.Namespace, cu.Relation, true, ds); err != nil {
   440  		if errors.As(err, &namespace.ErrRelationNotFound{}) {
   441  			return nil
   442  		}
   443  
   444  		return err
   445  	}
   446  
   447  	stream := &dispatch.WrappedDispatchStream[*v1.DispatchLookupSubjectsResponse]{
   448  		Stream: parentStream,
   449  		Ctx:    ctx,
   450  		Processor: func(result *v1.DispatchLookupSubjectsResponse) (*v1.DispatchLookupSubjectsResponse, bool, error) {
   451  			return &v1.DispatchLookupSubjectsResponse{
   452  				FoundSubjectsByResourceId: result.FoundSubjectsByResourceId,
   453  				Metadata:                  addCallToResponseMetadata(result.Metadata),
   454  				AfterResponseCursor:       result.AfterResponseCursor,
   455  			}, true, nil
   456  		},
   457  	}
   458  
   459  	return cl.d.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{
   460  		ResourceRelation: &core.RelationReference{
   461  			Namespace: parentRequest.ResourceRelation.Namespace,
   462  			Relation:  cu.Relation,
   463  		},
   464  		ResourceIds:     parentRequest.ResourceIds,
   465  		SubjectRelation: parentRequest.SubjectRelation,
   466  		Metadata: &v1.ResolverMeta{
   467  			AtRevision:     parentRequest.Revision.String(),
   468  			DepthRemaining: parentRequest.Metadata.DepthRemaining - 1,
   469  		},
   470  		OptionalCursor: ci.currentCursor,
   471  		OptionalLimit:  ci.limits.currentLimit,
   472  	}, stream)
   473  }
   474  
   475  // lookupViaTupleToUserset redispatches LookupSubjects over those objects found from an arrow (TTU).
   476  func (cl *ConcurrentLookupSubjects) lookupViaTupleToUserset(
   477  	ctx context.Context,
   478  	ci cursorInformation,
   479  	parentRequest ValidatedLookupSubjectsRequest,
   480  	parentStream dispatch.LookupSubjectsStream,
   481  	ttu *core.TupleToUserset,
   482  ) error {
   483  	ds := datastoremw.MustFromContext(ctx).SnapshotReader(parentRequest.Revision)
   484  	it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{
   485  		OptionalResourceType:     parentRequest.ResourceRelation.Namespace,
   486  		OptionalResourceRelation: ttu.Tupleset.Relation,
   487  		OptionalResourceIds:      parentRequest.ResourceIds,
   488  	})
   489  	if err != nil {
   490  		return err
   491  	}
   492  	defer it.Close()
   493  
   494  	toDispatchByTuplesetType := datasets.NewSubjectByTypeSet()
   495  	relationshipsBySubjectONR := mapz.NewMultiMap[string, *core.RelationTuple]()
   496  	for tpl := it.Next(); tpl != nil; tpl = it.Next() {
   497  		if it.Err() != nil {
   498  			return it.Err()
   499  		}
   500  
   501  		// Add the subject to be dispatched.
   502  		err := toDispatchByTuplesetType.AddSubjectOf(tpl)
   503  		if err != nil {
   504  			return err
   505  		}
   506  
   507  		// Add the *rewritten* subject to the relationships multimap for mapping back to the associated
   508  		// relationship, as we will be mapping from the computed relation, not the tupleset relation.
   509  		relationshipsBySubjectONR.Add(tuple.StringONR(&core.ObjectAndRelation{
   510  			Namespace: tpl.Subject.Namespace,
   511  			ObjectId:  tpl.Subject.ObjectId,
   512  			Relation:  ttu.ComputedUserset.Relation,
   513  		}), tpl)
   514  	}
   515  	it.Close()
   516  
   517  	// Map the found subject types by the computed userset relation, so that we dispatch to it.
   518  	toDispatchByComputedRelationType, err := toDispatchByTuplesetType.Map(func(resourceType *core.RelationReference) (*core.RelationReference, error) {
   519  		if err := namespace.CheckNamespaceAndRelation(ctx, resourceType.Namespace, ttu.ComputedUserset.Relation, false, ds); err != nil {
   520  			if errors.As(err, &namespace.ErrRelationNotFound{}) {
   521  				return nil, nil
   522  			}
   523  
   524  			return nil, err
   525  		}
   526  
   527  		return &core.RelationReference{
   528  			Namespace: resourceType.Namespace,
   529  			Relation:  ttu.ComputedUserset.Relation,
   530  		}, nil
   531  	})
   532  	if err != nil {
   533  		return err
   534  	}
   535  
   536  	return cl.dispatchTo(ctx, ci, parentRequest, toDispatchByComputedRelationType, relationshipsBySubjectONR, parentStream)
   537  }
   538  
   539  // lookupViaRewrite performs LookupSubjects over a rewrite operation (union, intersection, exclusion).
   540  func (cl *ConcurrentLookupSubjects) lookupViaRewrite(
   541  	ctx context.Context,
   542  	ci cursorInformation,
   543  	req ValidatedLookupSubjectsRequest,
   544  	stream dispatch.LookupSubjectsStream,
   545  	usr *core.UsersetRewrite,
   546  	concurrencyLimit uint16,
   547  ) error {
   548  	switch rw := usr.RewriteOperation.(type) {
   549  	case *core.UsersetRewrite_Union:
   550  		log.Ctx(ctx).Trace().Msg("union")
   551  		return cl.lookupSetOperationForUnion(ctx, ci, req, stream, rw.Union, concurrencyLimit)
   552  	case *core.UsersetRewrite_Intersection:
   553  		log.Ctx(ctx).Trace().Msg("intersection")
   554  		return cl.lookupSetOperationInSequence(ctx, ci, req, rw.Intersection, newLookupSubjectsIntersection(stream, ci), concurrencyLimit)
   555  	case *core.UsersetRewrite_Exclusion:
   556  		log.Ctx(ctx).Trace().Msg("exclusion")
   557  		return cl.lookupSetOperationInSequence(ctx, ci, req, rw.Exclusion, newLookupSubjectsExclusion(stream, ci), concurrencyLimit)
   558  	default:
   559  		return fmt.Errorf("unknown kind of rewrite in lookup subjects")
   560  	}
   561  }
   562  
   563  func (cl *ConcurrentLookupSubjects) lookupSetOperationForUnion(
   564  	ctx context.Context,
   565  	ci cursorInformation,
   566  	req ValidatedLookupSubjectsRequest,
   567  	stream dispatch.LookupSubjectsStream,
   568  	so *core.SetOperation,
   569  	concurrencyLimit uint16,
   570  ) error {
   571  	// NOTE: unlike intersection or exclusion, union can run all of its branches in parallel, with the starting cursor
   572  	// and limit, as the results will be merged at completion of the operation and any "extra" results will be tossed.
   573  	reducer := newLookupSubjectsUnion(stream, ci)
   574  
   575  	runChild := func(cctx context.Context, cstream dispatch.LookupSubjectsStream, childOneof *core.SetOperation_Child) error {
   576  		switch child := childOneof.ChildType.(type) {
   577  		case *core.SetOperation_Child_XThis:
   578  			return errors.New("use of _this is unsupported; please rewrite your schema")
   579  
   580  		case *core.SetOperation_Child_ComputedUserset:
   581  			return cl.lookupViaComputed(cctx, ci, req, cstream, child.ComputedUserset)
   582  
   583  		case *core.SetOperation_Child_UsersetRewrite:
   584  			return cl.lookupViaRewrite(cctx, ci, req, cstream, child.UsersetRewrite, adjustConcurrencyLimit(concurrencyLimit, len(so.Child)))
   585  
   586  		case *core.SetOperation_Child_TupleToUserset:
   587  			return cl.lookupViaTupleToUserset(cctx, ci, req, cstream, child.TupleToUserset)
   588  
   589  		case *core.SetOperation_Child_XNil:
   590  			// Purposely do nothing.
   591  			return nil
   592  
   593  		default:
   594  			return fmt.Errorf("unknown set operation child `%T` in expand", child)
   595  		}
   596  	}
   597  
   598  	// Skip the goroutines when there is a single child, such as a direct aliasing of a permission (permission foo = bar)
   599  	if len(so.Child) == 1 {
   600  		if err := runChild(ctx, reducer.ForIndex(ctx, 0), so.Child[0]); err != nil {
   601  			return err
   602  		}
   603  	} else {
   604  		cancelCtx, cancel := context.WithCancel(ctx)
   605  		defer cancel()
   606  
   607  		g, subCtx := errgroup.WithContext(cancelCtx)
   608  		g.SetLimit(int(concurrencyLimit))
   609  
   610  		for index, childOneof := range so.Child {
   611  			stream := reducer.ForIndex(subCtx, index)
   612  			childOneof := childOneof
   613  			g.Go(func() error {
   614  				return runChild(subCtx, stream, childOneof)
   615  			})
   616  		}
   617  
   618  		// Wait for all dispatched operations to complete.
   619  		if err := g.Wait(); err != nil {
   620  			return err
   621  		}
   622  	}
   623  
   624  	return reducer.CompletedChildOperations()
   625  }
   626  
   627  func (cl *ConcurrentLookupSubjects) lookupSetOperationInSequence(
   628  	ctx context.Context,
   629  	ci cursorInformation,
   630  	req ValidatedLookupSubjectsRequest,
   631  	so *core.SetOperation,
   632  	reducer *dependentBranchReducer,
   633  	concurrencyLimit uint16,
   634  ) error {
   635  	// Run the intersection/exclusion until the limit is reached (if applicable) or until results are exhausted.
   636  	for {
   637  		if ci.limits.hasExhaustedLimit() {
   638  			return nil
   639  		}
   640  
   641  		// In order to run a cursored/limited intersection or exclusion, we need to ensure that the later branches represent
   642  		// the entire span of results from the first branch. Therefore, we run the first branch, gets its results, then run
   643  		// the later branches, looping until the entire span is computed. The span looping occurs within RunUntilSpanned based
   644  		// on the passed in `index`.
   645  		for index, childOneof := range so.Child {
   646  			stream := reducer.ForIndex(ctx, index)
   647  			err := reducer.RunUntilSpanned(ctx, index, func(ctx context.Context, current branchRunInformation) error {
   648  				switch child := childOneof.ChildType.(type) {
   649  				case *core.SetOperation_Child_XThis:
   650  					return errors.New("use of _this is unsupported; please rewrite your schema")
   651  
   652  				case *core.SetOperation_Child_ComputedUserset:
   653  					return cl.lookupViaComputed(ctx, current.ci, req, stream, child.ComputedUserset)
   654  
   655  				case *core.SetOperation_Child_UsersetRewrite:
   656  					return cl.lookupViaRewrite(ctx, current.ci, req, stream, child.UsersetRewrite, concurrencyLimit)
   657  
   658  				case *core.SetOperation_Child_TupleToUserset:
   659  					return cl.lookupViaTupleToUserset(ctx, current.ci, req, stream, child.TupleToUserset)
   660  
   661  				case *core.SetOperation_Child_XNil:
   662  					// Purposely do nothing.
   663  					return nil
   664  
   665  				default:
   666  					return fmt.Errorf("unknown set operation child `%T` in expand", child)
   667  				}
   668  			})
   669  			if err != nil {
   670  				return err
   671  			}
   672  		}
   673  
   674  		firstBranchConcreteCount, err := reducer.CompletedDependentChildOperations()
   675  		if err != nil {
   676  			return err
   677  		}
   678  
   679  		// If the first branch has no additional results, then we're done.
   680  		if firstBranchConcreteCount == 0 {
   681  			return nil
   682  		}
   683  	}
   684  }
   685  
   686  func (cl *ConcurrentLookupSubjects) dispatchTo(
   687  	ctx context.Context,
   688  	ci cursorInformation,
   689  	parentRequest ValidatedLookupSubjectsRequest,
   690  	toDispatchByType *datasets.SubjectByTypeSet,
   691  	relationshipsBySubjectONR *mapz.MultiMap[string, *core.RelationTuple],
   692  	parentStream dispatch.LookupSubjectsStream,
   693  ) error {
   694  	if toDispatchByType.IsEmpty() {
   695  		return nil
   696  	}
   697  
   698  	return toDispatchByType.ForEachTypeUntil(func(resourceType *core.RelationReference, foundSubjects datasets.SubjectSet) (bool, error) {
   699  		if ci.limits.hasExhaustedLimit() {
   700  			return false, nil
   701  		}
   702  
   703  		slice := foundSubjects.AsSlice()
   704  		resourceIds := make([]string, 0, len(slice))
   705  		for _, foundSubject := range slice {
   706  			resourceIds = append(resourceIds, foundSubject.SubjectId)
   707  		}
   708  
   709  		stream := &dispatch.WrappedDispatchStream[*v1.DispatchLookupSubjectsResponse]{
   710  			Stream: parentStream,
   711  			Ctx:    ctx,
   712  			Processor: func(result *v1.DispatchLookupSubjectsResponse) (*v1.DispatchLookupSubjectsResponse, bool, error) {
   713  				// For any found subjects, map them through their associated starting resources, to apply any caveats that were
   714  				// only those resources' relationships.
   715  				//
   716  				// For example, given relationships which formed the dispatch:
   717  				//   - document:firstdoc#viewer@group:group1#member
   718  				//   - document:firstdoc#viewer@group:group2#member[somecaveat]
   719  				//
   720  				//	And results:
   721  				//	 - group1 => {user:tom, user:sarah}
   722  				//   - group2 => {user:tom, user:fred}
   723  				//
   724  				//	This will produce:
   725  				//	 - firstdoc => {user:tom, user:sarah, user:fred[somecaveat]}
   726  				//
   727  				mappedFoundSubjects := make(map[string]*v1.FoundSubjects, len(result.FoundSubjectsByResourceId))
   728  				for childResourceID, foundSubjects := range result.FoundSubjectsByResourceId {
   729  					subjectKey := tuple.StringONR(&core.ObjectAndRelation{
   730  						Namespace: resourceType.Namespace,
   731  						ObjectId:  childResourceID,
   732  						Relation:  resourceType.Relation,
   733  					})
   734  
   735  					relationships, _ := relationshipsBySubjectONR.Get(subjectKey)
   736  					if len(relationships) == 0 {
   737  						return nil, false, fmt.Errorf("missing relationships for subject key %v; please report this error", subjectKey)
   738  					}
   739  
   740  					for _, relationship := range relationships {
   741  						existing := mappedFoundSubjects[relationship.ResourceAndRelation.ObjectId]
   742  
   743  						// If the relationship has no caveat, simply map the resource ID.
   744  						if relationship.GetCaveat() == nil {
   745  							combined, err := combineFoundSubjects(existing, foundSubjects)
   746  							if err != nil {
   747  								return nil, false, fmt.Errorf("could not combine caveat-less subjects: %w", err)
   748  							}
   749  							mappedFoundSubjects[relationship.ResourceAndRelation.ObjectId] = combined
   750  							continue
   751  						}
   752  
   753  						// Otherwise, apply the caveat to all found subjects for that resource and map to the resource ID.
   754  						foundSubjectSet := datasets.NewSubjectSet()
   755  						err := foundSubjectSet.UnionWith(foundSubjects.FoundSubjects)
   756  						if err != nil {
   757  							return nil, false, fmt.Errorf("could not combine subject sets: %w", err)
   758  						}
   759  
   760  						combined, err := combineFoundSubjects(
   761  							existing,
   762  							foundSubjectSet.WithParentCaveatExpression(wrapCaveat(relationship.Caveat)).AsFoundSubjects(),
   763  						)
   764  						if err != nil {
   765  							return nil, false, fmt.Errorf("could not combine caveated subjects: %w", err)
   766  						}
   767  
   768  						mappedFoundSubjects[relationship.ResourceAndRelation.ObjectId] = combined
   769  					}
   770  				}
   771  
   772  				// NOTE: this response does not need to be limited or filtered because the child dispatch has already done so.
   773  				return &v1.DispatchLookupSubjectsResponse{
   774  					FoundSubjectsByResourceId: mappedFoundSubjects,
   775  					Metadata:                  addCallToResponseMetadata(result.Metadata),
   776  					AfterResponseCursor:       result.AfterResponseCursor,
   777  				}, true, nil
   778  			},
   779  		}
   780  
   781  		// Dispatch the found subjects as the resources of the next step.
   782  		return slicez.ForEachChunkUntil(resourceIds, maxDispatchChunkSize, func(resourceIdChunk []string) (bool, error) {
   783  			err := cl.d.DispatchLookupSubjects(&v1.DispatchLookupSubjectsRequest{
   784  				ResourceRelation: resourceType,
   785  				ResourceIds:      resourceIdChunk,
   786  				SubjectRelation:  parentRequest.SubjectRelation,
   787  				Metadata: &v1.ResolverMeta{
   788  					AtRevision:     parentRequest.Revision.String(),
   789  					DepthRemaining: parentRequest.Metadata.DepthRemaining - 1,
   790  				},
   791  				OptionalCursor: ci.currentCursor,
   792  				OptionalLimit:  ci.limits.currentLimit,
   793  			}, stream)
   794  			if err != nil {
   795  				return false, err
   796  			}
   797  
   798  			return true, nil
   799  		})
   800  	})
   801  }
   802  
   803  type unionOperation struct {
   804  	callback func(ctx context.Context, stream dispatch.LookupSubjectsStream, concurrencyLimit uint16) error
   805  	runIf    bool
   806  }
   807  
   808  // runInParallel runs the given operations in parallel, union-ing together the results from the operations.
   809  func runInParallel(ctx context.Context, ci cursorInformation, stream dispatch.LookupSubjectsStream, concurrencyLimit uint16, operations ...unionOperation) error {
   810  	filteredOperations := make([]unionOperation, 0, len(operations))
   811  	for _, op := range operations {
   812  		if op.runIf {
   813  			filteredOperations = append(filteredOperations, op)
   814  		}
   815  	}
   816  
   817  	// If there is no work to be done, return.
   818  	if len(filteredOperations) == 0 {
   819  		return nil
   820  	}
   821  
   822  	// If there is only a single operation to run, just invoke it directly to avoid creating unnecessary goroutines and
   823  	// additional work.
   824  	if len(filteredOperations) == 1 {
   825  		return filteredOperations[0].callback(ctx, stream, concurrencyLimit)
   826  	}
   827  
   828  	// Otherwise, run each operation in parallel and union together the results via a reducer.
   829  	reducer := newLookupSubjectsUnion(stream, ci)
   830  
   831  	cancelCtx, cancel := context.WithCancel(ctx)
   832  	defer cancel()
   833  
   834  	g, subCtx := errgroup.WithContext(cancelCtx)
   835  	g.SetLimit(int(concurrencyLimit))
   836  
   837  	adjustedLimit := adjustConcurrencyLimit(concurrencyLimit, 1)
   838  	for index, fop := range filteredOperations {
   839  		opStream := reducer.ForIndex(subCtx, index)
   840  		fop := fop
   841  		adjustedLimit = adjustedLimit - 1
   842  		currentLimit := max(adjustedLimit, 1)
   843  		g.Go(func() error {
   844  			return fop.callback(subCtx, opStream, currentLimit)
   845  		})
   846  	}
   847  
   848  	if err := g.Wait(); err != nil {
   849  		return err
   850  	}
   851  
   852  	return reducer.CompletedChildOperations()
   853  }
   854  
   855  func combineFoundSubjects(existing *v1.FoundSubjects, toAdd *v1.FoundSubjects) (*v1.FoundSubjects, error) {
   856  	if existing == nil {
   857  		return toAdd, nil
   858  	}
   859  
   860  	if toAdd == nil {
   861  		return nil, fmt.Errorf("toAdd FoundSubject cannot be nil")
   862  	}
   863  
   864  	return &v1.FoundSubjects{
   865  		FoundSubjects: append(existing.FoundSubjects, toAdd.FoundSubjects...),
   866  	}, nil
   867  }
   868  
   869  // finalSubjectIDForResults returns the ID of the last subject (sorted) in the results, if any.
   870  // Returns empty string if none.
   871  func finalSubjectIDForResults(ci cursorInformation, results []*v1.DispatchLookupSubjectsResponse) (string, error) {
   872  	endingSubjectIDs := mapz.NewSet[string]()
   873  	for _, result := range results {
   874  		frc, err := newCursorInformation(result.AfterResponseCursor, ci.limits, lsDispatchVersion)
   875  		if err != nil {
   876  			return "", err
   877  		}
   878  
   879  		lastSubjectID, _ := frc.headSectionValue()
   880  		if lastSubjectID == "" {
   881  			return "", spiceerrors.MustBugf("got invalid cursor")
   882  		}
   883  
   884  		endingSubjectIDs.Add(lastSubjectID)
   885  	}
   886  
   887  	sortedSubjectIDs := endingSubjectIDs.AsSlice()
   888  	sort.Strings(sortedSubjectIDs)
   889  
   890  	if len(sortedSubjectIDs) == 0 {
   891  		return "", nil
   892  	}
   893  
   894  	return sortedSubjectIDs[len(sortedSubjectIDs)-1], nil
   895  }
   896  
   897  // createFilteredAndLimitedResponse creates a filtered and limited (as is necessary via the cursor and limits)
   898  // version of the subjects, returning a DispatchLookupSubjectsResponse ready for publishing with just that
   899  // subset of results.
   900  func createFilteredAndLimitedResponse(
   901  	ci cursorInformation,
   902  	subjects map[string]*v1.FoundSubjects,
   903  	metadata *v1.ResponseMeta,
   904  ) (*v1.DispatchLookupSubjectsResponse, func(), error) {
   905  	if subjects == nil {
   906  		return nil, func() {}, spiceerrors.MustBugf("nil subjects given to createFilteredAndLimitedResponse")
   907  	}
   908  
   909  	afterSubjectID, _ := ci.headSectionValue()
   910  
   911  	// Filter down the subjects found by the cursor (if applicable) and then apply a limit.
   912  	filteredSubjectIDs := mapz.NewSet[string]()
   913  	for _, foundSubjects := range subjects {
   914  		for _, foundSubject := range foundSubjects.FoundSubjects {
   915  			// NOTE: wildcard is always returned, because it is needed by all branches, at all times.
   916  			if foundSubject.SubjectId == tuple.PublicWildcard || (afterSubjectID == "" || foundSubject.SubjectId > afterSubjectID) {
   917  				filteredSubjectIDs.Add(foundSubject.SubjectId)
   918  			}
   919  		}
   920  	}
   921  
   922  	sortedSubjectIDs := filteredSubjectIDs.AsSlice()
   923  	sort.Strings(sortedSubjectIDs)
   924  
   925  	subjectIDsToPublish := make([]string, 0, len(sortedSubjectIDs))
   926  	lastSubjectIDToPublishWithoutWildcard := ""
   927  
   928  	done := func() {}
   929  	for _, subjectID := range sortedSubjectIDs {
   930  		// Wildcards are always published, regardless of the limit.
   931  		if subjectID == tuple.PublicWildcard {
   932  			subjectIDsToPublish = append(subjectIDsToPublish, subjectID)
   933  			continue
   934  		}
   935  
   936  		ok := ci.limits.prepareForPublishing()
   937  		if !ok {
   938  			break
   939  		}
   940  
   941  		subjectIDsToPublish = append(subjectIDsToPublish, subjectID)
   942  		lastSubjectIDToPublishWithoutWildcard = subjectID
   943  	}
   944  
   945  	if len(subjectIDsToPublish) == 0 {
   946  		return nil, done, nil
   947  	}
   948  
   949  	// Determine the subject ID for the cursor. If there are any concrete subject IDs, then the last
   950  	// one is used. Otherwise, the wildcard itself is published as a specialized cursor to indicate that
   951  	// all concrete subjects have been consumed.
   952  	cursorSubjectID := "*"
   953  	if len(lastSubjectIDToPublishWithoutWildcard) > 0 {
   954  		cursorSubjectID = lastSubjectIDToPublishWithoutWildcard
   955  	}
   956  
   957  	updatedCI, err := ci.withOutgoingSection(cursorSubjectID)
   958  	if err != nil {
   959  		return nil, done, err
   960  	}
   961  
   962  	return &v1.DispatchLookupSubjectsResponse{
   963  		FoundSubjectsByResourceId: filterSubjectsMap(subjects, subjectIDsToPublish),
   964  		Metadata:                  metadata,
   965  		AfterResponseCursor:       updatedCI.responsePartialCursor(),
   966  	}, done, nil
   967  }
   968  
   969  // publishSubjects publishes the given subjects to the stream, after applying filtering and limiting.
   970  func publishSubjects(stream dispatch.LookupSubjectsStream, ci cursorInformation, subjects map[string]*v1.FoundSubjects) error {
   971  	response, done, err := createFilteredAndLimitedResponse(ci, subjects, emptyMetadata)
   972  	defer done()
   973  	if err != nil {
   974  		return err
   975  	}
   976  
   977  	if response == nil {
   978  		return nil
   979  	}
   980  
   981  	return stream.Publish(response)
   982  }
   983  
   984  // filterSubjectsMap filters the subjects found in the subjects map to only those allowed, returning an updated map.
   985  func filterSubjectsMap(subjects map[string]*v1.FoundSubjects, allowedSubjectIds []string) map[string]*v1.FoundSubjects {
   986  	updated := make(map[string]*v1.FoundSubjects, len(subjects))
   987  	allowed := mapz.NewSet[string](allowedSubjectIds...)
   988  
   989  	for key, subjects := range subjects {
   990  		filtered := make([]*v1.FoundSubject, 0, len(subjects.FoundSubjects))
   991  
   992  		for _, subject := range subjects.FoundSubjects {
   993  			if !allowed.Has(subject.SubjectId) {
   994  				continue
   995  			}
   996  
   997  			filtered = append(filtered, subject)
   998  		}
   999  
  1000  		sort.Sort(bySubjectID(filtered))
  1001  		if len(filtered) > 0 {
  1002  			updated[key] = &v1.FoundSubjects{FoundSubjects: filtered}
  1003  		}
  1004  	}
  1005  
  1006  	return updated
  1007  }
  1008  
  1009  func adjustConcurrencyLimit(concurrencyLimit uint16, count int) uint16 {
  1010  	if int(concurrencyLimit)-count <= 0 {
  1011  		return 1
  1012  	}
  1013  
  1014  	return concurrencyLimit - uint16(count)
  1015  }
  1016  
  1017  type bySubjectID []*v1.FoundSubject
  1018  
  1019  func (u bySubjectID) Len() int {
  1020  	return len(u)
  1021  }
  1022  
  1023  func (u bySubjectID) Swap(i, j int) {
  1024  	u[i], u[j] = u[j], u[i]
  1025  }
  1026  
  1027  func (u bySubjectID) Less(i, j int) bool {
  1028  	return u[i].SubjectId < u[j].SubjectId
  1029  }