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 }