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 }