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 }