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

     1  package graph
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  
     8  	"github.com/rs/zerolog"
     9  	"go.opentelemetry.io/otel"
    10  	"go.opentelemetry.io/otel/attribute"
    11  	"go.opentelemetry.io/otel/trace"
    12  	"google.golang.org/grpc/codes"
    13  	"google.golang.org/grpc/status"
    14  
    15  	"github.com/authzed/spicedb/internal/dispatch"
    16  	"github.com/authzed/spicedb/internal/graph"
    17  	log "github.com/authzed/spicedb/internal/logging"
    18  	datastoremw "github.com/authzed/spicedb/internal/middleware/datastore"
    19  	"github.com/authzed/spicedb/pkg/datastore"
    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/tuple"
    23  )
    24  
    25  const errDispatch = "error dispatching request: %w"
    26  
    27  var tracer = otel.Tracer("spicedb/internal/dispatch/local")
    28  
    29  // ConcurrencyLimits defines per-dispatch-type concurrency limits.
    30  //
    31  //go:generate go run github.com/ecordell/optgen -output zz_generated.options.go . ConcurrencyLimits
    32  type ConcurrencyLimits struct {
    33  	Check              uint16 `debugmap:"visible"`
    34  	ReachableResources uint16 `debugmap:"visible"`
    35  	LookupResources    uint16 `debugmap:"visible"`
    36  	LookupSubjects     uint16 `debugmap:"visible"`
    37  }
    38  
    39  const defaultConcurrencyLimit = 50
    40  
    41  // WithOverallDefaultLimit sets the overall default limit for any unspecified limits
    42  // and returns a new struct.
    43  func (cl ConcurrencyLimits) WithOverallDefaultLimit(overallDefaultLimit uint16) ConcurrencyLimits {
    44  	return limitsOrDefaults(cl, overallDefaultLimit)
    45  }
    46  
    47  func (cl ConcurrencyLimits) MarshalZerologObject(e *zerolog.Event) {
    48  	e.Uint16("concurrency-limit-check-permission", cl.Check)
    49  	e.Uint16("concurrency-limit-lookup-resources", cl.LookupResources)
    50  	e.Uint16("concurrency-limit-lookup-subjects", cl.LookupSubjects)
    51  	e.Uint16("concurrency-limit-reachable-resources", cl.ReachableResources)
    52  }
    53  
    54  func limitsOrDefaults(limits ConcurrencyLimits, overallDefaultLimit uint16) ConcurrencyLimits {
    55  	limits.Check = limitOrDefault(limits.Check, overallDefaultLimit)
    56  	limits.LookupResources = limitOrDefault(limits.LookupResources, overallDefaultLimit)
    57  	limits.LookupSubjects = limitOrDefault(limits.LookupSubjects, overallDefaultLimit)
    58  	limits.ReachableResources = limitOrDefault(limits.ReachableResources, overallDefaultLimit)
    59  	return limits
    60  }
    61  
    62  func limitOrDefault(limit uint16, defaultLimit uint16) uint16 {
    63  	if limit <= 0 {
    64  		return defaultLimit
    65  	}
    66  	return limit
    67  }
    68  
    69  // SharedConcurrencyLimits returns a ConcurrencyLimits struct with the limit
    70  // set to that provided for each operation.
    71  func SharedConcurrencyLimits(concurrencyLimit uint16) ConcurrencyLimits {
    72  	return ConcurrencyLimits{
    73  		Check:              concurrencyLimit,
    74  		ReachableResources: concurrencyLimit,
    75  		LookupResources:    concurrencyLimit,
    76  		LookupSubjects:     concurrencyLimit,
    77  	}
    78  }
    79  
    80  // NewLocalOnlyDispatcher creates a dispatcher that consults with the graph to formulate a response.
    81  func NewLocalOnlyDispatcher(concurrencyLimit uint16) dispatch.Dispatcher {
    82  	return NewLocalOnlyDispatcherWithLimits(SharedConcurrencyLimits(concurrencyLimit))
    83  }
    84  
    85  // NewLocalOnlyDispatcherWithLimits creates a dispatcher thatg consults with the graph to formulate a response
    86  // and has the defined concurrency limits per dispatch type.
    87  func NewLocalOnlyDispatcherWithLimits(concurrencyLimits ConcurrencyLimits) dispatch.Dispatcher {
    88  	d := &localDispatcher{}
    89  
    90  	concurrencyLimits = limitsOrDefaults(concurrencyLimits, defaultConcurrencyLimit)
    91  
    92  	d.checker = graph.NewConcurrentChecker(d, concurrencyLimits.Check)
    93  	d.expander = graph.NewConcurrentExpander(d)
    94  	d.reachableResourcesHandler = graph.NewCursoredReachableResources(d, concurrencyLimits.ReachableResources)
    95  	d.lookupResourcesHandler = graph.NewCursoredLookupResources(d, d, concurrencyLimits.LookupResources)
    96  	d.lookupSubjectsHandler = graph.NewConcurrentLookupSubjects(d, concurrencyLimits.LookupSubjects)
    97  
    98  	return d
    99  }
   100  
   101  // NewDispatcher creates a dispatcher that consults with the graph and redispatches subproblems to
   102  // the provided redispatcher.
   103  func NewDispatcher(redispatcher dispatch.Dispatcher, concurrencyLimits ConcurrencyLimits) dispatch.Dispatcher {
   104  	concurrencyLimits = limitsOrDefaults(concurrencyLimits, defaultConcurrencyLimit)
   105  
   106  	checker := graph.NewConcurrentChecker(redispatcher, concurrencyLimits.Check)
   107  	expander := graph.NewConcurrentExpander(redispatcher)
   108  	reachableResourcesHandler := graph.NewCursoredReachableResources(redispatcher, concurrencyLimits.ReachableResources)
   109  	lookupResourcesHandler := graph.NewCursoredLookupResources(redispatcher, redispatcher, concurrencyLimits.LookupResources)
   110  	lookupSubjectsHandler := graph.NewConcurrentLookupSubjects(redispatcher, concurrencyLimits.LookupSubjects)
   111  
   112  	return &localDispatcher{
   113  		checker:                   checker,
   114  		expander:                  expander,
   115  		reachableResourcesHandler: reachableResourcesHandler,
   116  		lookupResourcesHandler:    lookupResourcesHandler,
   117  		lookupSubjectsHandler:     lookupSubjectsHandler,
   118  	}
   119  }
   120  
   121  type localDispatcher struct {
   122  	checker                   *graph.ConcurrentChecker
   123  	expander                  *graph.ConcurrentExpander
   124  	reachableResourcesHandler *graph.CursoredReachableResources
   125  	lookupResourcesHandler    *graph.CursoredLookupResources
   126  	lookupSubjectsHandler     *graph.ConcurrentLookupSubjects
   127  }
   128  
   129  func (ld *localDispatcher) loadNamespace(ctx context.Context, nsName string, revision datastore.Revision) (*core.NamespaceDefinition, error) {
   130  	ds := datastoremw.MustFromContext(ctx).SnapshotReader(revision)
   131  
   132  	// Load namespace and relation from the datastore
   133  	ns, _, err := ds.ReadNamespaceByName(ctx, nsName)
   134  	if err != nil {
   135  		return nil, rewriteNamespaceError(err)
   136  	}
   137  
   138  	return ns, err
   139  }
   140  
   141  func (ld *localDispatcher) parseRevision(ctx context.Context, s string) (datastore.Revision, error) {
   142  	ds := datastoremw.MustFromContext(ctx)
   143  	return ds.RevisionFromString(s)
   144  }
   145  
   146  func (ld *localDispatcher) lookupRelation(_ context.Context, ns *core.NamespaceDefinition, relationName string) (*core.Relation, error) {
   147  	var relation *core.Relation
   148  	for _, candidate := range ns.Relation {
   149  		if candidate.Name == relationName {
   150  			relation = candidate
   151  			break
   152  		}
   153  	}
   154  
   155  	if relation == nil {
   156  		return nil, NewRelationNotFoundErr(ns.Name, relationName)
   157  	}
   158  
   159  	return relation, nil
   160  }
   161  
   162  // DispatchCheck implements dispatch.Check interface
   163  func (ld *localDispatcher) DispatchCheck(ctx context.Context, req *v1.DispatchCheckRequest) (*v1.DispatchCheckResponse, error) {
   164  	resourceType := tuple.StringRR(req.ResourceRelation)
   165  	spanName := "DispatchCheck → " + resourceType + "@" + req.Subject.Namespace + "#" + req.Subject.Relation
   166  	ctx, span := tracer.Start(ctx, spanName, trace.WithAttributes(
   167  		attribute.String("resource-type", resourceType),
   168  		attribute.StringSlice("resource-ids", req.ResourceIds),
   169  		attribute.String("subject", tuple.StringONR(req.Subject)),
   170  	))
   171  	defer span.End()
   172  
   173  	if err := dispatch.CheckDepth(ctx, req); err != nil {
   174  		if req.Debug != v1.DispatchCheckRequest_ENABLE_BASIC_DEBUGGING {
   175  			return &v1.DispatchCheckResponse{
   176  				Metadata: &v1.ResponseMeta{
   177  					DispatchCount: 0,
   178  				},
   179  			}, rewriteError(ctx, err)
   180  		}
   181  
   182  		// NOTE: we return debug information here to ensure tooling can see the cycle.
   183  		return &v1.DispatchCheckResponse{
   184  			Metadata: &v1.ResponseMeta{
   185  				DispatchCount: 0,
   186  				DebugInfo: &v1.DebugInformation{
   187  					Check: &v1.CheckDebugTrace{
   188  						Request: req,
   189  					},
   190  				},
   191  			},
   192  		}, rewriteError(ctx, err)
   193  	}
   194  
   195  	revision, err := ld.parseRevision(ctx, req.Metadata.AtRevision)
   196  	if err != nil {
   197  		return &v1.DispatchCheckResponse{Metadata: emptyMetadata}, rewriteError(ctx, err)
   198  	}
   199  
   200  	ns, err := ld.loadNamespace(ctx, req.ResourceRelation.Namespace, revision)
   201  	if err != nil {
   202  		return &v1.DispatchCheckResponse{Metadata: emptyMetadata}, rewriteError(ctx, err)
   203  	}
   204  
   205  	relation, err := ld.lookupRelation(ctx, ns, req.ResourceRelation.Relation)
   206  	if err != nil {
   207  		return &v1.DispatchCheckResponse{Metadata: emptyMetadata}, rewriteError(ctx, err)
   208  	}
   209  
   210  	// If the relation is aliasing another one and the subject does not have the same type as
   211  	// resource, load the aliased relation and dispatch to it. We cannot use the alias if the
   212  	// resource and subject types are the same because a check on the *exact same* resource and
   213  	// subject must pass, and we don't know how many intermediate steps may hit that case.
   214  	if relation.AliasingRelation != "" && req.ResourceRelation.Namespace != req.Subject.Namespace {
   215  		relation, err := ld.lookupRelation(ctx, ns, relation.AliasingRelation)
   216  		if err != nil {
   217  			return &v1.DispatchCheckResponse{Metadata: emptyMetadata}, rewriteError(ctx, err)
   218  		}
   219  
   220  		// Rewrite the request over the aliased relation.
   221  		validatedReq := graph.ValidatedCheckRequest{
   222  			DispatchCheckRequest: &v1.DispatchCheckRequest{
   223  				ResourceRelation: &core.RelationReference{
   224  					Namespace: req.ResourceRelation.Namespace,
   225  					Relation:  relation.Name,
   226  				},
   227  				ResourceIds: req.ResourceIds,
   228  				Subject:     req.Subject,
   229  				Metadata:    req.Metadata,
   230  				Debug:       req.Debug,
   231  			},
   232  			Revision: revision,
   233  		}
   234  
   235  		resp, err := ld.checker.Check(ctx, validatedReq, relation)
   236  		return resp, rewriteError(ctx, err)
   237  	}
   238  
   239  	resp, err := ld.checker.Check(ctx, graph.ValidatedCheckRequest{
   240  		DispatchCheckRequest: req,
   241  		Revision:             revision,
   242  	}, relation)
   243  	return resp, rewriteError(ctx, err)
   244  }
   245  
   246  // DispatchExpand implements dispatch.Expand interface
   247  func (ld *localDispatcher) DispatchExpand(ctx context.Context, req *v1.DispatchExpandRequest) (*v1.DispatchExpandResponse, error) {
   248  	ctx, span := tracer.Start(ctx, "DispatchExpand", trace.WithAttributes(
   249  		attribute.String("start", tuple.StringONR(req.ResourceAndRelation)),
   250  	))
   251  	defer span.End()
   252  
   253  	if err := dispatch.CheckDepth(ctx, req); err != nil {
   254  		return &v1.DispatchExpandResponse{Metadata: emptyMetadata}, err
   255  	}
   256  
   257  	revision, err := ld.parseRevision(ctx, req.Metadata.AtRevision)
   258  	if err != nil {
   259  		return &v1.DispatchExpandResponse{Metadata: emptyMetadata}, err
   260  	}
   261  
   262  	ns, err := ld.loadNamespace(ctx, req.ResourceAndRelation.Namespace, revision)
   263  	if err != nil {
   264  		return &v1.DispatchExpandResponse{Metadata: emptyMetadata}, err
   265  	}
   266  
   267  	relation, err := ld.lookupRelation(ctx, ns, req.ResourceAndRelation.Relation)
   268  	if err != nil {
   269  		return &v1.DispatchExpandResponse{Metadata: emptyMetadata}, err
   270  	}
   271  
   272  	return ld.expander.Expand(ctx, graph.ValidatedExpandRequest{
   273  		DispatchExpandRequest: req,
   274  		Revision:              revision,
   275  	}, relation)
   276  }
   277  
   278  // DispatchReachableResources implements dispatch.ReachableResources interface
   279  func (ld *localDispatcher) DispatchReachableResources(
   280  	req *v1.DispatchReachableResourcesRequest,
   281  	stream dispatch.ReachableResourcesStream,
   282  ) error {
   283  	resourceType := tuple.StringRR(req.ResourceRelation)
   284  	subjectRelation := tuple.StringRR(req.SubjectRelation)
   285  	spanName := "DispatchReachableResources → " + resourceType + "@" + subjectRelation
   286  	ctx, span := tracer.Start(stream.Context(), spanName, trace.WithAttributes(
   287  		attribute.String("resource-type", resourceType),
   288  		attribute.String("subject-type", subjectRelation),
   289  		attribute.StringSlice("subject-ids", req.SubjectIds),
   290  	))
   291  	defer span.End()
   292  
   293  	if err := dispatch.CheckDepth(ctx, req); err != nil {
   294  		return err
   295  	}
   296  
   297  	revision, err := ld.parseRevision(ctx, req.Metadata.AtRevision)
   298  	if err != nil {
   299  		return err
   300  	}
   301  
   302  	return ld.reachableResourcesHandler.ReachableResources(
   303  		graph.ValidatedReachableResourcesRequest{
   304  			DispatchReachableResourcesRequest: req,
   305  			Revision:                          revision,
   306  		},
   307  		dispatch.StreamWithContext(ctx, stream),
   308  	)
   309  }
   310  
   311  // DispatchLookupResources implements dispatch.LookupResources interface
   312  func (ld *localDispatcher) DispatchLookupResources(
   313  	req *v1.DispatchLookupResourcesRequest,
   314  	stream dispatch.LookupResourcesStream,
   315  ) error {
   316  	ctx, span := tracer.Start(stream.Context(), "DispatchLookupResources", trace.WithAttributes(
   317  		attribute.String("resource-type", tuple.StringRR(req.ObjectRelation)),
   318  		attribute.String("subject", tuple.StringONR(req.Subject)),
   319  	))
   320  	defer span.End()
   321  
   322  	if err := dispatch.CheckDepth(ctx, req); err != nil {
   323  		return err
   324  	}
   325  
   326  	revision, err := ld.parseRevision(ctx, req.Metadata.AtRevision)
   327  	if err != nil {
   328  		return err
   329  	}
   330  
   331  	return ld.lookupResourcesHandler.LookupResources(
   332  		graph.ValidatedLookupResourcesRequest{
   333  			DispatchLookupResourcesRequest: req,
   334  			Revision:                       revision,
   335  		},
   336  		dispatch.StreamWithContext(ctx, stream),
   337  	)
   338  }
   339  
   340  // DispatchLookupSubjects implements dispatch.LookupSubjects interface
   341  func (ld *localDispatcher) DispatchLookupSubjects(
   342  	req *v1.DispatchLookupSubjectsRequest,
   343  	stream dispatch.LookupSubjectsStream,
   344  ) error {
   345  	resourceType := tuple.StringRR(req.ResourceRelation)
   346  	subjectRelation := tuple.StringRR(req.SubjectRelation)
   347  	spanName := "DispatchLookupSubjects → " + resourceType + "@" + subjectRelation
   348  
   349  	ctx, span := tracer.Start(stream.Context(), spanName, trace.WithAttributes(
   350  		attribute.String("resource-type", resourceType),
   351  		attribute.String("subject-type", subjectRelation),
   352  		attribute.StringSlice("resource-ids", req.ResourceIds),
   353  	))
   354  	defer span.End()
   355  
   356  	if err := dispatch.CheckDepth(ctx, req); err != nil {
   357  		return err
   358  	}
   359  
   360  	revision, err := ld.parseRevision(ctx, req.Metadata.AtRevision)
   361  	if err != nil {
   362  		return err
   363  	}
   364  
   365  	return ld.lookupSubjectsHandler.LookupSubjects(
   366  		graph.ValidatedLookupSubjectsRequest{
   367  			DispatchLookupSubjectsRequest: req,
   368  			Revision:                      revision,
   369  		},
   370  		dispatch.StreamWithContext(ctx, stream),
   371  	)
   372  }
   373  
   374  func (ld *localDispatcher) Close() error {
   375  	return nil
   376  }
   377  
   378  func (ld *localDispatcher) ReadyState() dispatch.ReadyState {
   379  	return dispatch.ReadyState{IsReady: true}
   380  }
   381  
   382  func rewriteNamespaceError(original error) error {
   383  	nsNotFound := datastore.ErrNamespaceNotFound{}
   384  
   385  	switch {
   386  	case errors.As(original, &nsNotFound):
   387  		return NewNamespaceNotFoundErr(nsNotFound.NotFoundNamespaceName())
   388  	case errors.As(original, &ErrNamespaceNotFound{}):
   389  		fallthrough
   390  	case errors.As(original, &ErrRelationNotFound{}):
   391  		return original
   392  	default:
   393  		return fmt.Errorf(errDispatch, original)
   394  	}
   395  }
   396  
   397  // rewriteError transforms graph errors into a gRPC Status
   398  func rewriteError(ctx context.Context, err error) error {
   399  	if err == nil {
   400  		return nil
   401  	}
   402  
   403  	// Check if the error can be directly used.
   404  	if st, ok := status.FromError(err); ok {
   405  		return st.Err()
   406  	}
   407  
   408  	switch {
   409  	case errors.Is(err, context.DeadlineExceeded):
   410  		return status.Errorf(codes.DeadlineExceeded, "%s", err)
   411  	case errors.Is(err, context.Canceled):
   412  		err := context.Cause(ctx)
   413  		if err != nil {
   414  			if _, ok := status.FromError(err); ok {
   415  				return err
   416  			}
   417  		}
   418  
   419  		return status.Errorf(codes.Canceled, "%s", err)
   420  	default:
   421  		log.Ctx(ctx).Err(err).Msg("received unexpected graph error")
   422  		return err
   423  	}
   424  }
   425  
   426  var emptyMetadata = &v1.ResponseMeta{
   427  	DispatchCount: 0,
   428  }