github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/dispatch/graph/lookupresources_test.go (about) 1 package graph 2 3 import ( 4 "context" 5 "fmt" 6 "slices" 7 "strings" 8 "testing" 9 "time" 10 11 "github.com/stretchr/testify/require" 12 "go.uber.org/goleak" 13 14 "github.com/authzed/spicedb/internal/datastore/memdb" 15 "github.com/authzed/spicedb/internal/dispatch" 16 datastoremw "github.com/authzed/spicedb/internal/middleware/datastore" 17 "github.com/authzed/spicedb/internal/testfixtures" 18 "github.com/authzed/spicedb/internal/testutil" 19 "github.com/authzed/spicedb/pkg/genutil/mapz" 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 veryLargeLimit = 1000000000 26 27 func RR(namespaceName string, relationName string) *core.RelationReference { 28 return &core.RelationReference{ 29 Namespace: namespaceName, 30 Relation: relationName, 31 } 32 } 33 34 func resolvedRes(resourceID string) *v1.ResolvedResource { 35 return &v1.ResolvedResource{ 36 ResourceId: resourceID, 37 Permissionship: v1.ResolvedResource_HAS_PERMISSION, 38 } 39 } 40 41 func TestSimpleLookupResources(t *testing.T) { 42 defer goleak.VerifyNone(t, goleakIgnores...) 43 44 testCases := []struct { 45 start *core.RelationReference 46 target *core.ObjectAndRelation 47 expectedResources []*v1.ResolvedResource 48 expectedDispatchCount uint32 49 expectedDepthRequired uint32 50 }{ 51 { 52 RR("document", "view"), 53 ONR("user", "unknown", "..."), 54 []*v1.ResolvedResource{}, 55 0, 56 0, 57 }, 58 { 59 RR("document", "view"), 60 ONR("user", "eng_lead", "..."), 61 []*v1.ResolvedResource{ 62 resolvedRes("masterplan"), 63 }, 64 2, 65 1, 66 }, 67 { 68 RR("document", "owner"), 69 ONR("user", "product_manager", "..."), 70 []*v1.ResolvedResource{ 71 resolvedRes("masterplan"), 72 }, 73 2, 74 0, 75 }, 76 { 77 RR("document", "view"), 78 ONR("user", "legal", "..."), 79 []*v1.ResolvedResource{ 80 resolvedRes("companyplan"), 81 resolvedRes("masterplan"), 82 }, 83 6, 84 3, 85 }, 86 { 87 RR("document", "view_and_edit"), 88 ONR("user", "multiroleguy", "..."), 89 []*v1.ResolvedResource{ 90 resolvedRes("specialplan"), 91 }, 92 7, 93 3, 94 }, 95 { 96 RR("folder", "view"), 97 ONR("user", "owner", "..."), 98 []*v1.ResolvedResource{ 99 resolvedRes("strategy"), 100 resolvedRes("company"), 101 }, 102 8, 103 4, 104 }, 105 } 106 107 for _, tc := range testCases { 108 name := fmt.Sprintf( 109 "%s#%s->%s", 110 tc.start.Namespace, 111 tc.start.Relation, 112 tuple.StringONR(tc.target), 113 ) 114 115 tc := tc 116 t.Run(name, func(t *testing.T) { 117 defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) 118 119 require := require.New(t) 120 ctx, dispatcher, revision := newLocalDispatcher(t) 121 defer dispatcher.Close() 122 123 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 124 err := dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 125 ObjectRelation: tc.start, 126 Subject: tc.target, 127 Metadata: &v1.ResolverMeta{ 128 AtRevision: revision.String(), 129 DepthRemaining: 50, 130 }, 131 OptionalLimit: veryLargeLimit, 132 }, stream) 133 134 require.NoError(err) 135 136 foundResources, maxDepthRequired, maxDispatchCount, maxCachedDispatchCount := processResults(stream) 137 require.ElementsMatch(tc.expectedResources, foundResources, "Found: %v, Expected: %v", foundResources, tc.expectedResources) 138 require.Equal(tc.expectedDepthRequired, maxDepthRequired, "Depth required mismatch") 139 require.LessOrEqual(maxDispatchCount, tc.expectedDispatchCount, "Found dispatch count greater than expected") 140 require.Equal(uint32(0), maxCachedDispatchCount) 141 142 // We have to sleep a while to let the cache converge: 143 // https://github.com/outcaste-io/ristretto/blob/01b9f37dd0fd453225e042d6f3a27cd14f252cd0/cache_test.go#L17 144 time.Sleep(10 * time.Millisecond) 145 146 // Run again with the cache available. 147 stream = dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 148 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 149 ObjectRelation: tc.start, 150 Subject: tc.target, 151 Metadata: &v1.ResolverMeta{ 152 AtRevision: revision.String(), 153 DepthRemaining: 50, 154 }, 155 OptionalLimit: veryLargeLimit, 156 }, stream) 157 dispatcher.Close() 158 159 require.NoError(err) 160 161 foundResources, maxDepthRequired, maxDispatchCount, maxCachedDispatchCount = processResults(stream) 162 require.ElementsMatch(tc.expectedResources, foundResources, "Found: %v, Expected: %v", foundResources, tc.expectedResources) 163 require.Equal(tc.expectedDepthRequired, maxDepthRequired, "Depth required mismatch") 164 require.LessOrEqual(maxCachedDispatchCount, tc.expectedDispatchCount, "Found dispatch count greater than expected") 165 require.Equal(uint32(0), maxDispatchCount) 166 }) 167 } 168 } 169 170 func TestSimpleLookupResourcesWithCursor(t *testing.T) { 171 defer goleak.VerifyNone(t, goleakIgnores...) 172 173 for _, tc := range []struct { 174 subject string 175 expectedFirst []string 176 expectedSecond []string 177 }{ 178 { 179 subject: "owner", 180 expectedFirst: []string{"ownerplan"}, 181 expectedSecond: []string{"companyplan", "masterplan", "ownerplan"}, 182 }, 183 { 184 subject: "chief_financial_officer", 185 expectedFirst: []string{"healthplan"}, 186 expectedSecond: []string{"healthplan", "masterplan"}, 187 }, 188 { 189 subject: "auditor", 190 expectedFirst: []string{"companyplan"}, 191 expectedSecond: []string{"companyplan", "masterplan"}, 192 }, 193 } { 194 tc := tc 195 t.Run(tc.subject, func(t *testing.T) { 196 require := require.New(t) 197 ctx, dispatcher, revision := newLocalDispatcher(t) 198 defer dispatcher.Close() 199 200 found := mapz.NewSet[string]() 201 202 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 203 err := dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 204 ObjectRelation: RR("document", "view"), 205 Subject: ONR("user", tc.subject, "..."), 206 Metadata: &v1.ResolverMeta{ 207 AtRevision: revision.String(), 208 DepthRemaining: 50, 209 }, 210 OptionalLimit: 1, 211 }, stream) 212 213 require.NoError(err) 214 215 require.Equal(1, len(stream.Results())) 216 217 found.Insert(stream.Results()[0].ResolvedResource.ResourceId) 218 require.Equal(tc.expectedFirst, found.AsSlice()) 219 220 cursor := stream.Results()[0].AfterResponseCursor 221 require.NotNil(cursor) 222 223 stream = dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 224 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 225 ObjectRelation: RR("document", "view"), 226 Subject: ONR("user", tc.subject, "..."), 227 Metadata: &v1.ResolverMeta{ 228 AtRevision: revision.String(), 229 DepthRemaining: 50, 230 }, 231 OptionalCursor: cursor, 232 OptionalLimit: 2, 233 }, stream) 234 235 require.NoError(err) 236 237 for _, result := range stream.Results() { 238 found.Insert(result.ResolvedResource.ResourceId) 239 } 240 241 foundResults := found.AsSlice() 242 slices.Sort(foundResults) 243 244 require.Equal(tc.expectedSecond, foundResults) 245 }) 246 } 247 } 248 249 func TestLookupResourcesCursorStability(t *testing.T) { 250 defer goleak.VerifyNone(t, goleakIgnores...) 251 252 require := require.New(t) 253 ctx, dispatcher, revision := newLocalDispatcher(t) 254 defer dispatcher.Close() 255 256 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 257 258 // Make the first first request. 259 err := dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 260 ObjectRelation: RR("document", "view"), 261 Subject: ONR("user", "owner", "..."), 262 Metadata: &v1.ResolverMeta{ 263 AtRevision: revision.String(), 264 DepthRemaining: 50, 265 }, 266 OptionalLimit: 2, 267 }, stream) 268 269 require.NoError(err) 270 require.Equal(2, len(stream.Results())) 271 272 cursor := stream.Results()[1].AfterResponseCursor 273 require.NotNil(cursor) 274 275 // Make the same request and ensure the cursor has not changed. 276 stream = dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 277 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 278 ObjectRelation: RR("document", "view"), 279 Subject: ONR("user", "owner", "..."), 280 Metadata: &v1.ResolverMeta{ 281 AtRevision: revision.String(), 282 DepthRemaining: 50, 283 }, 284 OptionalLimit: 2, 285 }, stream) 286 287 require.NoError(err) 288 289 require.NoError(err) 290 require.Equal(2, len(stream.Results())) 291 292 cursorAgain := stream.Results()[1].AfterResponseCursor 293 require.NotNil(cursor) 294 require.Equal(cursor, cursorAgain) 295 } 296 297 func processResults(stream *dispatch.CollectingDispatchStream[*v1.DispatchLookupResourcesResponse]) ([]*v1.ResolvedResource, uint32, uint32, uint32) { 298 foundResources := []*v1.ResolvedResource{} 299 var maxDepthRequired uint32 300 var maxDispatchCount uint32 301 var maxCachedDispatchCount uint32 302 for _, result := range stream.Results() { 303 foundResources = append(foundResources, result.ResolvedResource) 304 maxDepthRequired = max(maxDepthRequired, result.Metadata.DepthRequired) 305 maxDispatchCount = max(maxDispatchCount, result.Metadata.DispatchCount) 306 maxCachedDispatchCount = max(maxCachedDispatchCount, result.Metadata.CachedDispatchCount) 307 } 308 return foundResources, maxDepthRequired, maxDispatchCount, maxCachedDispatchCount 309 } 310 311 func TestMaxDepthLookup(t *testing.T) { 312 require := require.New(t) 313 314 rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC) 315 require.NoError(err) 316 317 ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require) 318 319 dispatcher := NewLocalOnlyDispatcher(10) 320 defer dispatcher.Close() 321 322 ctx := datastoremw.ContextWithHandle(context.Background()) 323 require.NoError(datastoremw.SetInContext(ctx, ds)) 324 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 325 326 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 327 ObjectRelation: RR("document", "view"), 328 Subject: ONR("user", "legal", "..."), 329 Metadata: &v1.ResolverMeta{ 330 AtRevision: revision.String(), 331 DepthRemaining: 0, 332 }, 333 }, stream) 334 335 require.Error(err) 336 } 337 338 type OrderedResolved []*v1.ResolvedResource 339 340 func (a OrderedResolved) Len() int { return len(a) } 341 342 func (a OrderedResolved) Less(i, j int) bool { 343 return strings.Compare(a[i].ResourceId, a[j].ResourceId) < 0 344 } 345 346 func (a OrderedResolved) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 347 348 func TestLookupResourcesOverSchemaWithCursors(t *testing.T) { 349 testCases := []struct { 350 name string 351 schema string 352 relationships []*core.RelationTuple 353 permission *core.RelationReference 354 subject *core.ObjectAndRelation 355 expectedResourceIDs []string 356 }{ 357 { 358 "basic union", 359 `definition user {} 360 361 definition document { 362 relation editor: user 363 relation viewer: user 364 permission view = viewer + editor 365 }`, 366 testutil.JoinTuples( 367 testutil.GenTuples("document", "viewer", "user", "tom", 1510), 368 testutil.GenTuples("document", "editor", "user", "tom", 1510), 369 ), 370 RR("document", "view"), 371 ONR("user", "tom", "..."), 372 testutil.GenResourceIds("document", 1510), 373 }, 374 { 375 "basic exclusion", 376 `definition user {} 377 378 definition document { 379 relation banned: user 380 relation viewer: user 381 permission view = viewer - banned 382 }`, 383 testutil.GenTuples("document", "viewer", "user", "tom", 1010), 384 RR("document", "view"), 385 ONR("user", "tom", "..."), 386 testutil.GenResourceIds("document", 1010), 387 }, 388 { 389 "basic intersection", 390 `definition user {} 391 392 definition document { 393 relation editor: user 394 relation viewer: user 395 permission view = viewer & editor 396 }`, 397 testutil.JoinTuples( 398 testutil.GenTuples("document", "viewer", "user", "tom", 510), 399 testutil.GenTuples("document", "editor", "user", "tom", 510), 400 ), 401 RR("document", "view"), 402 ONR("user", "tom", "..."), 403 testutil.GenResourceIds("document", 510), 404 }, 405 { 406 "union and exclused union", 407 `definition user {} 408 409 definition document { 410 relation editor: user 411 relation viewer: user 412 relation banned: user 413 permission can_view = viewer - banned 414 permission view = can_view + editor 415 }`, 416 testutil.JoinTuples( 417 testutil.GenTuples("document", "viewer", "user", "tom", 1310), 418 testutil.GenTuplesWithOffset("document", "editor", "user", "tom", 1250, 1200), 419 ), 420 RR("document", "view"), 421 ONR("user", "tom", "..."), 422 testutil.GenResourceIds("document", 2450), 423 }, 424 { 425 "basic caveats", 426 `definition user {} 427 428 caveat somecaveat(somecondition int) { 429 somecondition == 42 430 } 431 432 definition document { 433 relation viewer: user with somecaveat 434 permission view = viewer 435 }`, 436 testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{"somecondition": 42}, 0, 2450), 437 RR("document", "view"), 438 ONR("user", "tom", "..."), 439 testutil.GenResourceIds("document", 2450), 440 }, 441 { 442 "excluded items", 443 `definition user {} 444 445 definition document { 446 relation banned: user 447 relation viewer: user 448 permission view = viewer - banned 449 }`, 450 testutil.JoinTuples( 451 testutil.GenTuples("document", "viewer", "user", "tom", 1310), 452 testutil.GenTuplesWithOffset("document", "banned", "user", "tom", 1210, 100), 453 ), 454 RR("document", "view"), 455 ONR("user", "tom", "..."), 456 testutil.GenResourceIds("document", 1210), 457 }, 458 { 459 "basic caveats with missing field", 460 `definition user {} 461 462 caveat somecaveat(somecondition int) { 463 somecondition == 42 464 } 465 466 definition document { 467 relation viewer: user with somecaveat 468 permission view = viewer 469 }`, 470 testutil.GenTuplesWithCaveat("document", "viewer", "user", "tom", "somecaveat", map[string]any{}, 0, 2450), 471 RR("document", "view"), 472 ONR("user", "tom", "..."), 473 testutil.GenResourceIds("document", 2450), 474 }, 475 { 476 "larger arrow dispatch", 477 `definition user {} 478 479 definition folder { 480 relation viewer: user 481 } 482 483 definition document { 484 relation folder: folder 485 permission view = folder->viewer 486 }`, 487 testutil.JoinTuples( 488 testutil.GenTuples("folder", "viewer", "user", "tom", 150), 489 testutil.GenSubjectTuples("document", "folder", "folder", "...", 150), 490 ), 491 RR("document", "view"), 492 ONR("user", "tom", "..."), 493 testutil.GenResourceIds("document", 150), 494 }, 495 { 496 "big", 497 `definition user {} 498 499 definition document { 500 relation editor: user 501 relation viewer: user 502 permission view = viewer + editor 503 }`, 504 testutil.JoinTuples( 505 testutil.GenTuples("document", "viewer", "user", "tom", 15100), 506 testutil.GenTuples("document", "editor", "user", "tom", 15100), 507 ), 508 RR("document", "view"), 509 ONR("user", "tom", "..."), 510 testutil.GenResourceIds("document", 15100), 511 }, 512 } 513 514 for _, tc := range testCases { 515 tc := tc 516 t.Run(tc.name, func(t *testing.T) { 517 for _, pageSize := range []int{0, 104, 1023} { 518 pageSize := pageSize 519 t.Run(fmt.Sprintf("ps-%d_", pageSize), func(t *testing.T) { 520 require := require.New(t) 521 522 dispatcher := NewLocalOnlyDispatcher(10) 523 524 ds, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC) 525 require.NoError(err) 526 527 ds, revision := testfixtures.DatastoreFromSchemaAndTestRelationships(ds, tc.schema, tc.relationships, require) 528 529 ctx := datastoremw.ContextWithHandle(context.Background()) 530 require.NoError(datastoremw.SetInContext(ctx, ds)) 531 532 var currentCursor *v1.Cursor 533 foundResourceIDs := mapz.NewSet[string]() 534 for { 535 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](ctx) 536 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 537 ObjectRelation: tc.permission, 538 Subject: tc.subject, 539 Metadata: &v1.ResolverMeta{ 540 AtRevision: revision.String(), 541 DepthRemaining: 50, 542 }, 543 OptionalLimit: uint32(pageSize), 544 OptionalCursor: currentCursor, 545 }, stream) 546 require.NoError(err) 547 548 if pageSize > 0 { 549 require.LessOrEqual(len(stream.Results()), pageSize) 550 } 551 552 for _, result := range stream.Results() { 553 foundResourceIDs.Insert(result.ResolvedResource.ResourceId) 554 currentCursor = result.AfterResponseCursor 555 } 556 557 if pageSize == 0 || len(stream.Results()) < pageSize { 558 break 559 } 560 } 561 562 foundResourceIDsSlice := foundResourceIDs.AsSlice() 563 slices.Sort(foundResourceIDsSlice) 564 slices.Sort(tc.expectedResourceIDs) 565 566 require.Equal(tc.expectedResourceIDs, foundResourceIDsSlice) 567 }) 568 } 569 }) 570 } 571 } 572 573 func TestLookupResourcesImmediateTimeout(t *testing.T) { 574 defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) 575 576 require := require.New(t) 577 578 rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC) 579 require.NoError(err) 580 581 ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require) 582 583 dispatcher := NewLocalOnlyDispatcher(10) 584 defer dispatcher.Close() 585 586 ctx := datastoremw.ContextWithHandle(context.Background()) 587 cctx, cancel := context.WithTimeout(ctx, 1*time.Nanosecond) 588 defer cancel() 589 590 require.NoError(datastoremw.SetInContext(cctx, ds)) 591 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](cctx) 592 593 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 594 ObjectRelation: RR("document", "view"), 595 Subject: ONR("user", "legal", "..."), 596 Metadata: &v1.ResolverMeta{ 597 AtRevision: revision.String(), 598 DepthRemaining: 10, 599 }, 600 }, stream) 601 602 require.ErrorIs(err, context.DeadlineExceeded) 603 require.ErrorContains(err, "context deadline exceeded") 604 } 605 606 func TestLookupResourcesWithError(t *testing.T) { 607 defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) 608 609 require := require.New(t) 610 611 rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC) 612 require.NoError(err) 613 614 ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require) 615 616 dispatcher := NewLocalOnlyDispatcher(10) 617 defer dispatcher.Close() 618 619 ctx := datastoremw.ContextWithHandle(context.Background()) 620 cctx, cancel := context.WithTimeout(ctx, 1*time.Nanosecond) 621 defer cancel() 622 623 require.NoError(datastoremw.SetInContext(cctx, ds)) 624 stream := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupResourcesResponse](cctx) 625 626 err = dispatcher.DispatchLookupResources(&v1.DispatchLookupResourcesRequest{ 627 ObjectRelation: RR("document", "view"), 628 Subject: ONR("user", "legal", "..."), 629 Metadata: &v1.ResolverMeta{ 630 AtRevision: revision.String(), 631 DepthRemaining: 1, // Set depth 1 to cause an error within reachable resources 632 }, 633 }, stream) 634 635 require.Error(err) 636 }