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

     1  package graph
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"sort"
     8  
     9  	"github.com/authzed/spicedb/internal/dispatch"
    10  	datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
    11  	"github.com/authzed/spicedb/pkg/datastore"
    12  	"github.com/authzed/spicedb/pkg/datastore/options"
    13  	core "github.com/authzed/spicedb/pkg/proto/core/v1"
    14  	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
    15  	"github.com/authzed/spicedb/pkg/spiceerrors"
    16  	"github.com/authzed/spicedb/pkg/tuple"
    17  	"github.com/authzed/spicedb/pkg/typesystem"
    18  )
    19  
    20  // rrDispatchVersion defines the "version" of this dispatcher. Must be incremented
    21  // anytime an incompatible change is made to the dispatcher itself or its cursor
    22  // production.
    23  const rrDispatchVersion = 1
    24  
    25  // NewCursoredReachableResources creates an instance of CursoredReachableResources.
    26  func NewCursoredReachableResources(d dispatch.ReachableResources, concurrencyLimit uint16) *CursoredReachableResources {
    27  	return &CursoredReachableResources{d, concurrencyLimit}
    28  }
    29  
    30  // CursoredReachableResources exposes a method to perform ReachableResources requests, and
    31  // delegates subproblems to the provided dispatch.ReachableResources instance.
    32  type CursoredReachableResources struct {
    33  	d                dispatch.ReachableResources
    34  	concurrencyLimit uint16
    35  }
    36  
    37  // ValidatedReachableResourcesRequest represents a request after it has been validated and parsed for internal
    38  // consumption.
    39  type ValidatedReachableResourcesRequest struct {
    40  	*v1.DispatchReachableResourcesRequest
    41  	Revision datastore.Revision
    42  }
    43  
    44  func (crr *CursoredReachableResources) ReachableResources(
    45  	req ValidatedReachableResourcesRequest,
    46  	stream dispatch.ReachableResourcesStream,
    47  ) error {
    48  	if len(req.SubjectIds) == 0 {
    49  		return fmt.Errorf("no subjects ids given to reachable resources dispatch")
    50  	}
    51  
    52  	// Sort for stability.
    53  	sort.Strings(req.SubjectIds)
    54  
    55  	ctx := stream.Context()
    56  	limits := newLimitTracker(req.OptionalLimit)
    57  	ci, err := newCursorInformation(req.OptionalCursor, limits, rrDispatchVersion)
    58  	if err != nil {
    59  		return err
    60  	}
    61  
    62  	return withSubsetInCursor(ci,
    63  		func(currentOffset int, nextCursorWith afterResponseCursor) error {
    64  			// If the resource type matches the subject type, yield directly as a one-to-one result
    65  			// for each subjectID.
    66  			if req.SubjectRelation.Namespace == req.ResourceRelation.Namespace &&
    67  				req.SubjectRelation.Relation == req.ResourceRelation.Relation {
    68  				for index, subjectID := range req.SubjectIds {
    69  					if index < currentOffset {
    70  						continue
    71  					}
    72  
    73  					if !ci.limits.prepareForPublishing() {
    74  						return nil
    75  					}
    76  
    77  					err := stream.Publish(&v1.DispatchReachableResourcesResponse{
    78  						Resource: &v1.ReachableResource{
    79  							ResourceId:    subjectID,
    80  							ResultStatus:  v1.ReachableResource_HAS_PERMISSION,
    81  							ForSubjectIds: []string{subjectID},
    82  						},
    83  						Metadata:            emptyMetadata,
    84  						AfterResponseCursor: nextCursorWith(index + 1),
    85  					})
    86  					if err != nil {
    87  						return err
    88  					}
    89  				}
    90  			}
    91  			return nil
    92  		}, func(ci cursorInformation) error {
    93  			// Once done checking for the matching subject type, yield by dispatching over entrypoints.
    94  			return crr.afterSameType(ctx, ci, req, stream)
    95  		})
    96  }
    97  
    98  func (crr *CursoredReachableResources) afterSameType(
    99  	ctx context.Context,
   100  	ci cursorInformation,
   101  	req ValidatedReachableResourcesRequest,
   102  	parentStream dispatch.ReachableResourcesStream,
   103  ) error {
   104  	dispatched := &syncONRSet{}
   105  
   106  	// Load the type system and reachability graph to find the entrypoints for the reachability.
   107  	ds := datastoremw.MustFromContext(ctx)
   108  	reader := ds.SnapshotReader(req.Revision)
   109  	_, typeSystem, err := typesystem.ReadNamespaceAndTypes(ctx, req.ResourceRelation.Namespace, reader)
   110  	if err != nil {
   111  		return err
   112  	}
   113  
   114  	rg := typesystem.ReachabilityGraphFor(typeSystem)
   115  	entrypoints, err := rg.OptimizedEntrypointsForSubjectToResource(ctx, &core.RelationReference{
   116  		Namespace: req.SubjectRelation.Namespace,
   117  		Relation:  req.SubjectRelation.Relation,
   118  	}, req.ResourceRelation)
   119  	if err != nil {
   120  		return err
   121  	}
   122  
   123  	// For each entrypoint, load the necessary data and re-dispatch if a subproblem was found.
   124  	return withParallelizedStreamingIterableInCursor(ctx, ci, entrypoints, parentStream, crr.concurrencyLimit,
   125  		func(ctx context.Context, ci cursorInformation, entrypoint typesystem.ReachabilityEntrypoint, stream dispatch.ReachableResourcesStream) error {
   126  			switch entrypoint.EntrypointKind() {
   127  			case core.ReachabilityEntrypoint_RELATION_ENTRYPOINT:
   128  				return crr.lookupRelationEntrypoint(ctx, ci, entrypoint, rg, reader, req, stream, dispatched)
   129  
   130  			case core.ReachabilityEntrypoint_COMPUTED_USERSET_ENTRYPOINT:
   131  				containingRelation := entrypoint.ContainingRelationOrPermission()
   132  				rewrittenSubjectRelation := &core.RelationReference{
   133  					Namespace: containingRelation.Namespace,
   134  					Relation:  containingRelation.Relation,
   135  				}
   136  
   137  				rsm := subjectIDsToResourcesMap(rewrittenSubjectRelation, req.SubjectIds)
   138  				drsm := rsm.asReadOnly()
   139  
   140  				return crr.redispatchOrReport(
   141  					ctx,
   142  					ci,
   143  					rewrittenSubjectRelation,
   144  					drsm,
   145  					rg,
   146  					entrypoint,
   147  					stream,
   148  					req,
   149  					dispatched,
   150  				)
   151  
   152  			case core.ReachabilityEntrypoint_TUPLESET_TO_USERSET_ENTRYPOINT:
   153  				return crr.lookupTTUEntrypoint(ctx, ci, entrypoint, rg, reader, req, stream, dispatched)
   154  
   155  			default:
   156  				return spiceerrors.MustBugf("Unknown kind of entrypoint: %v", entrypoint.EntrypointKind())
   157  			}
   158  		})
   159  }
   160  
   161  func (crr *CursoredReachableResources) lookupRelationEntrypoint(
   162  	ctx context.Context,
   163  	ci cursorInformation,
   164  	entrypoint typesystem.ReachabilityEntrypoint,
   165  	rg *typesystem.ReachabilityGraph,
   166  	reader datastore.Reader,
   167  	req ValidatedReachableResourcesRequest,
   168  	stream dispatch.ReachableResourcesStream,
   169  	dispatched *syncONRSet,
   170  ) error {
   171  	relationReference, err := entrypoint.DirectRelation()
   172  	if err != nil {
   173  		return err
   174  	}
   175  
   176  	_, relTypeSystem, err := typesystem.ReadNamespaceAndTypes(ctx, relationReference.Namespace, reader)
   177  	if err != nil {
   178  		return err
   179  	}
   180  
   181  	// Build the list of subjects to lookup based on the type information available.
   182  	isDirectAllowed, err := relTypeSystem.IsAllowedDirectRelation(
   183  		relationReference.Relation,
   184  		req.SubjectRelation.Namespace,
   185  		req.SubjectRelation.Relation,
   186  	)
   187  	if err != nil {
   188  		return err
   189  	}
   190  
   191  	subjectIds := make([]string, 0, len(req.SubjectIds)+1)
   192  	if isDirectAllowed == typesystem.DirectRelationValid {
   193  		subjectIds = append(subjectIds, req.SubjectIds...)
   194  	}
   195  
   196  	if req.SubjectRelation.Relation == tuple.Ellipsis {
   197  		isWildcardAllowed, err := relTypeSystem.IsAllowedPublicNamespace(relationReference.Relation, req.SubjectRelation.Namespace)
   198  		if err != nil {
   199  			return err
   200  		}
   201  
   202  		if isWildcardAllowed == typesystem.PublicSubjectAllowed {
   203  			subjectIds = append(subjectIds, "*")
   204  		}
   205  	}
   206  
   207  	// Lookup the subjects and then redispatch/report results.
   208  	relationFilter := datastore.SubjectRelationFilter{
   209  		NonEllipsisRelation: req.SubjectRelation.Relation,
   210  	}
   211  
   212  	if req.SubjectRelation.Relation == tuple.Ellipsis {
   213  		relationFilter = datastore.SubjectRelationFilter{
   214  			IncludeEllipsisRelation: true,
   215  		}
   216  	}
   217  
   218  	subjectsFilter := datastore.SubjectsFilter{
   219  		SubjectType:        req.SubjectRelation.Namespace,
   220  		OptionalSubjectIds: subjectIds,
   221  		RelationFilter:     relationFilter,
   222  	}
   223  
   224  	return crr.redispatchOrReportOverDatabaseQuery(
   225  		ctx,
   226  		redispatchOverDatabaseConfig{
   227  			ci:                 ci,
   228  			reader:             reader,
   229  			subjectsFilter:     subjectsFilter,
   230  			sourceResourceType: relationReference,
   231  			foundResourceType:  relationReference,
   232  			entrypoint:         entrypoint,
   233  			rg:                 rg,
   234  			concurrencyLimit:   crr.concurrencyLimit,
   235  			parentStream:       stream,
   236  			parentRequest:      req,
   237  			dispatched:         dispatched,
   238  		},
   239  	)
   240  }
   241  
   242  type redispatchOverDatabaseConfig struct {
   243  	ci cursorInformation
   244  
   245  	reader datastore.Reader
   246  
   247  	subjectsFilter     datastore.SubjectsFilter
   248  	sourceResourceType *core.RelationReference
   249  	foundResourceType  *core.RelationReference
   250  
   251  	entrypoint typesystem.ReachabilityEntrypoint
   252  	rg         *typesystem.ReachabilityGraph
   253  
   254  	concurrencyLimit uint16
   255  	parentStream     dispatch.ReachableResourcesStream
   256  	parentRequest    ValidatedReachableResourcesRequest
   257  	dispatched       *syncONRSet
   258  }
   259  
   260  func (crr *CursoredReachableResources) redispatchOrReportOverDatabaseQuery(
   261  	ctx context.Context,
   262  	config redispatchOverDatabaseConfig,
   263  ) error {
   264  	return withDatastoreCursorInCursor(ctx, config.ci, config.parentStream, config.concurrencyLimit,
   265  		// Find the target resources for the subject.
   266  		func(queryCursor options.Cursor) ([]itemAndPostCursor[dispatchableResourcesSubjectMap], error) {
   267  			it, err := config.reader.ReverseQueryRelationships(
   268  				ctx,
   269  				config.subjectsFilter,
   270  				options.WithResRelation(&options.ResourceRelation{
   271  					Namespace: config.sourceResourceType.Namespace,
   272  					Relation:  config.sourceResourceType.Relation,
   273  				}),
   274  				options.WithSortForReverse(options.BySubject),
   275  				options.WithAfterForReverse(queryCursor),
   276  			)
   277  			if err != nil {
   278  				return nil, err
   279  			}
   280  			defer it.Close()
   281  
   282  			// Chunk based on the FilterMaximumIDCount, to ensure we never send more than that amount of
   283  			// results to a downstream dispatch.
   284  			rsm := newResourcesSubjectMapWithCapacity(config.sourceResourceType, uint32(datastore.FilterMaximumIDCount))
   285  			toBeHandled := make([]itemAndPostCursor[dispatchableResourcesSubjectMap], 0)
   286  			currentCursor := queryCursor
   287  
   288  			for tpl := it.Next(); tpl != nil; tpl = it.Next() {
   289  				if it.Err() != nil {
   290  					return nil, it.Err()
   291  				}
   292  
   293  				if err := rsm.addRelationship(tpl); err != nil {
   294  					return nil, err
   295  				}
   296  
   297  				if rsm.len() == int(datastore.FilterMaximumIDCount) {
   298  					toBeHandled = append(toBeHandled, itemAndPostCursor[dispatchableResourcesSubjectMap]{
   299  						item:   rsm.asReadOnly(),
   300  						cursor: currentCursor,
   301  					})
   302  					rsm = newResourcesSubjectMapWithCapacity(config.sourceResourceType, uint32(datastore.FilterMaximumIDCount))
   303  					currentCursor = tpl
   304  				}
   305  			}
   306  			it.Close()
   307  
   308  			if rsm.len() > 0 {
   309  				toBeHandled = append(toBeHandled, itemAndPostCursor[dispatchableResourcesSubjectMap]{
   310  					item:   rsm.asReadOnly(),
   311  					cursor: currentCursor,
   312  				})
   313  			}
   314  
   315  			return toBeHandled, nil
   316  		},
   317  
   318  		// Redispatch or report the results.
   319  		func(
   320  			ctx context.Context,
   321  			ci cursorInformation,
   322  			drsm dispatchableResourcesSubjectMap,
   323  			currentStream dispatch.ReachableResourcesStream,
   324  		) error {
   325  			return crr.redispatchOrReport(
   326  				ctx,
   327  				ci,
   328  				config.foundResourceType,
   329  				drsm,
   330  				config.rg,
   331  				config.entrypoint,
   332  				currentStream,
   333  				config.parentRequest,
   334  				config.dispatched,
   335  			)
   336  		},
   337  	)
   338  }
   339  
   340  func (crr *CursoredReachableResources) lookupTTUEntrypoint(ctx context.Context,
   341  	ci cursorInformation,
   342  	entrypoint typesystem.ReachabilityEntrypoint,
   343  	rg *typesystem.ReachabilityGraph,
   344  	reader datastore.Reader,
   345  	req ValidatedReachableResourcesRequest,
   346  	stream dispatch.ReachableResourcesStream,
   347  	dispatched *syncONRSet,
   348  ) error {
   349  	containingRelation := entrypoint.ContainingRelationOrPermission()
   350  
   351  	_, ttuTypeSystem, err := typesystem.ReadNamespaceAndTypes(ctx, containingRelation.Namespace, reader)
   352  	if err != nil {
   353  		return err
   354  	}
   355  
   356  	tuplesetRelation, err := entrypoint.TuplesetRelation()
   357  	if err != nil {
   358  		return err
   359  	}
   360  
   361  	// Determine whether this TTU should be followed, which will be the case if the subject relation's namespace
   362  	// is allowed in any form on the relation; since arrows ignore the subject's relation (if any), we check
   363  	// for the subject namespace as a whole.
   364  	allowedRelations, err := ttuTypeSystem.GetAllowedDirectNamespaceSubjectRelations(tuplesetRelation, req.SubjectRelation.Namespace)
   365  	if err != nil {
   366  		return err
   367  	}
   368  
   369  	if allowedRelations == nil {
   370  		return nil
   371  	}
   372  
   373  	// Search for the resolved subjects in the tupleset of the TTU.
   374  	subjectsFilter := datastore.SubjectsFilter{
   375  		SubjectType:        req.SubjectRelation.Namespace,
   376  		OptionalSubjectIds: req.SubjectIds,
   377  	}
   378  
   379  	// Optimization: if there is a single allowed relation, pass it as a subject relation filter to make things faster
   380  	// on querying.
   381  	if allowedRelations.Len() == 1 {
   382  		allowedRelationName := allowedRelations.AsSlice()[0]
   383  		subjectsFilter.RelationFilter = datastore.SubjectRelationFilter{}.WithRelation(allowedRelationName)
   384  	}
   385  
   386  	tuplesetRelationReference := &core.RelationReference{
   387  		Namespace: containingRelation.Namespace,
   388  		Relation:  tuplesetRelation,
   389  	}
   390  
   391  	return crr.redispatchOrReportOverDatabaseQuery(
   392  		ctx,
   393  		redispatchOverDatabaseConfig{
   394  			ci:                 ci,
   395  			reader:             reader,
   396  			subjectsFilter:     subjectsFilter,
   397  			sourceResourceType: tuplesetRelationReference,
   398  			foundResourceType:  containingRelation,
   399  			entrypoint:         entrypoint,
   400  			rg:                 rg,
   401  			parentStream:       stream,
   402  			parentRequest:      req,
   403  			dispatched:         dispatched,
   404  		},
   405  	)
   406  }
   407  
   408  var errCanceledBecauseLimitReached = errors.New("canceled because the specified limit was reached")
   409  
   410  // redispatchOrReport checks if further redispatching is necessary for the found resource
   411  // type. If not, and the found resource type+relation matches the target resource type+relation,
   412  // the resource is reported to the parent stream.
   413  func (crr *CursoredReachableResources) redispatchOrReport(
   414  	ctx context.Context,
   415  	ci cursorInformation,
   416  	foundResourceType *core.RelationReference,
   417  	foundResources dispatchableResourcesSubjectMap,
   418  	rg *typesystem.ReachabilityGraph,
   419  	entrypoint typesystem.ReachabilityEntrypoint,
   420  	parentStream dispatch.ReachableResourcesStream,
   421  	parentRequest ValidatedReachableResourcesRequest,
   422  	dispatched *syncONRSet,
   423  ) error {
   424  	if foundResources.isEmpty() {
   425  		// Nothing more to do.
   426  		return nil
   427  	}
   428  
   429  	// Check for entrypoints for the new found resource type.
   430  	hasResourceEntrypoints, err := rg.HasOptimizedEntrypointsForSubjectToResource(ctx, foundResourceType, parentRequest.ResourceRelation)
   431  	if err != nil {
   432  		return err
   433  	}
   434  
   435  	return withSubsetInCursor(ci,
   436  		func(currentOffset int, nextCursorWith afterResponseCursor) error {
   437  			if !hasResourceEntrypoints {
   438  				// If the found resource matches the target resource type and relation, yield the resource.
   439  				if foundResourceType.Namespace == parentRequest.ResourceRelation.Namespace && foundResourceType.Relation == parentRequest.ResourceRelation.Relation {
   440  					resources := foundResources.asReachableResources(entrypoint.IsDirectResult())
   441  					if len(resources) == 0 {
   442  						return nil
   443  					}
   444  
   445  					if currentOffset >= len(resources) {
   446  						return nil
   447  					}
   448  
   449  					offsetted := resources[currentOffset:]
   450  					if len(offsetted) == 0 {
   451  						return nil
   452  					}
   453  
   454  					for index, resource := range offsetted {
   455  						if !ci.limits.prepareForPublishing() {
   456  							return nil
   457  						}
   458  
   459  						err := parentStream.Publish(&v1.DispatchReachableResourcesResponse{
   460  							Resource:            resource,
   461  							Metadata:            emptyMetadata,
   462  							AfterResponseCursor: nextCursorWith(currentOffset + index + 1),
   463  						})
   464  						if err != nil {
   465  							return err
   466  						}
   467  					}
   468  					return nil
   469  				}
   470  			}
   471  			return nil
   472  		}, func(ci cursorInformation) error {
   473  			if !hasResourceEntrypoints {
   474  				return nil
   475  			}
   476  
   477  			// Branch the context so that the dispatch can be canceled without canceling the parent
   478  			// call.
   479  			sctx, cancelDispatch := branchContext(ctx)
   480  
   481  			needsCallAddedToMetadata := true
   482  			stream := &dispatch.WrappedDispatchStream[*v1.DispatchReachableResourcesResponse]{
   483  				Stream: parentStream,
   484  				Ctx:    sctx,
   485  				Processor: func(result *v1.DispatchReachableResourcesResponse) (*v1.DispatchReachableResourcesResponse, bool, error) {
   486  					// If the parent context has been closed, nothing more to do.
   487  					select {
   488  					case <-ctx.Done():
   489  						return nil, false, ctx.Err()
   490  
   491  					default:
   492  					}
   493  
   494  					// If we've exhausted the limit of resources to be returned, nothing more to do.
   495  					if ci.limits.hasExhaustedLimit() {
   496  						cancelDispatch(errCanceledBecauseLimitReached)
   497  						return nil, false, nil
   498  					}
   499  
   500  					// Map the found resources via the subject+resources used for dispatching, to determine
   501  					// if any need to be made conditional due to caveats.
   502  					mappedResource, err := foundResources.mapFoundResource(result.Resource, entrypoint.IsDirectResult())
   503  					if err != nil {
   504  						return nil, false, err
   505  					}
   506  
   507  					if !ci.limits.prepareForPublishing() {
   508  						cancelDispatch(errCanceledBecauseLimitReached)
   509  						return nil, false, nil
   510  					}
   511  
   512  					// The cursor for the response is that of the parent response + the cursor from the result itself.
   513  					afterResponseCursor, err := combineCursors(
   514  						ci.responsePartialCursor(),
   515  						result.AfterResponseCursor,
   516  					)
   517  					if err != nil {
   518  						return nil, false, err
   519  					}
   520  
   521  					// Only the first dispatched result gets the call added to it. This is to prevent overcounting
   522  					// of the batched dispatch.
   523  					var metadata *v1.ResponseMeta
   524  					if needsCallAddedToMetadata {
   525  						metadata = addCallToResponseMetadata(result.Metadata)
   526  						needsCallAddedToMetadata = false
   527  					} else {
   528  						metadata = addAdditionalDepthRequired(result.Metadata)
   529  					}
   530  
   531  					resp := &v1.DispatchReachableResourcesResponse{
   532  						Resource:            mappedResource,
   533  						Metadata:            metadata,
   534  						AfterResponseCursor: afterResponseCursor,
   535  					}
   536  					return resp, true, nil
   537  				},
   538  			}
   539  
   540  			// The new subject type for dispatching was the found type of the *resource*.
   541  			newSubjectType := foundResourceType
   542  
   543  			// To avoid duplicate work, remove any subjects already dispatched.
   544  			filteredSubjectIDs := foundResources.filterSubjectIDsToDispatch(dispatched, newSubjectType)
   545  			if len(filteredSubjectIDs) == 0 {
   546  				return nil
   547  			}
   548  
   549  			// Dispatch the found resources as the subjects for the next call, to continue the
   550  			// resolution.
   551  			return crr.d.DispatchReachableResources(&v1.DispatchReachableResourcesRequest{
   552  				ResourceRelation: parentRequest.ResourceRelation,
   553  				SubjectRelation:  newSubjectType,
   554  				SubjectIds:       filteredSubjectIDs,
   555  				Metadata: &v1.ResolverMeta{
   556  					AtRevision:     parentRequest.Revision.String(),
   557  					DepthRemaining: parentRequest.Metadata.DepthRemaining - 1,
   558  				},
   559  				OptionalCursor: ci.currentCursor,
   560  				OptionalLimit:  ci.limits.currentLimit,
   561  			}, stream)
   562  		})
   563  }