github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/dispatch/graph/expand_test.go (about) 1 package graph 2 3 import ( 4 "context" 5 "fmt" 6 "go/ast" 7 "go/printer" 8 "go/token" 9 "os" 10 "testing" 11 12 "github.com/google/go-cmp/cmp" 13 "github.com/stretchr/testify/require" 14 "go.uber.org/goleak" 15 "google.golang.org/protobuf/encoding/prototext" 16 "google.golang.org/protobuf/testing/protocmp" 17 18 "github.com/authzed/spicedb/internal/datastore/common" 19 "github.com/authzed/spicedb/internal/datastore/memdb" 20 expand "github.com/authzed/spicedb/internal/graph" 21 datastoremw "github.com/authzed/spicedb/internal/middleware/datastore" 22 "github.com/authzed/spicedb/internal/testfixtures" 23 "github.com/authzed/spicedb/pkg/graph" 24 core "github.com/authzed/spicedb/pkg/proto/core/v1" 25 v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" 26 "github.com/authzed/spicedb/pkg/testutil" 27 "github.com/authzed/spicedb/pkg/tuple" 28 ) 29 30 func DS(objectType string, objectID string, objectRelation string) *core.DirectSubject { 31 return &core.DirectSubject{ 32 Subject: ONR(objectType, objectID, objectRelation), 33 } 34 } 35 36 var ( 37 companyOwner = graph.Leaf(ONR("folder", "company", "owner"), 38 (DS("user", "owner", expand.Ellipsis)), 39 ) 40 companyEditor = graph.Leaf(ONR("folder", "company", "editor")) 41 42 companyEdit = graph.Union(ONR("folder", "company", "edit"), 43 companyEditor, 44 companyOwner, 45 ) 46 47 companyViewer = graph.Leaf(ONR("folder", "company", "viewer"), 48 (DS("user", "legal", "...")), 49 (DS("folder", "auditors", "viewer")), 50 ) 51 52 companyView = graph.Union(ONR("folder", "company", "view"), 53 companyViewer, 54 companyEdit, 55 graph.Union(ONR("folder", "company", "view")), 56 ) 57 58 auditorsOwner = graph.Leaf(ONR("folder", "auditors", "owner")) 59 60 auditorsEditor = graph.Leaf(ONR("folder", "auditors", "editor")) 61 62 auditorsEdit = graph.Union(ONR("folder", "auditors", "edit"), 63 auditorsEditor, 64 auditorsOwner, 65 ) 66 67 auditorsViewer = graph.Leaf(ONR("folder", "auditors", "viewer"), 68 (DS("user", "auditor", "...")), 69 ) 70 71 auditorsViewRecursive = graph.Union(ONR("folder", "auditors", "view"), 72 auditorsViewer, 73 auditorsEdit, 74 graph.Union(ONR("folder", "auditors", "view")), 75 ) 76 77 companyViewRecursive = graph.Union(ONR("folder", "company", "view"), 78 graph.Union(ONR("folder", "company", "viewer"), 79 graph.Leaf(ONR("folder", "auditors", "viewer"), 80 (DS("user", "auditor", "..."))), 81 graph.Leaf(ONR("folder", "company", "viewer"), 82 (DS("user", "legal", "...")), 83 (DS("folder", "auditors", "viewer")))), 84 graph.Union(ONR("folder", "company", "edit"), 85 graph.Leaf(ONR("folder", "company", "editor")), 86 graph.Leaf(ONR("folder", "company", "owner"), 87 (DS("user", "owner", "...")))), 88 graph.Union(ONR("folder", "company", "view"))) 89 90 docOwner = graph.Leaf(ONR("document", "masterplan", "owner"), 91 (DS("user", "product_manager", "...")), 92 ) 93 docEditor = graph.Leaf(ONR("document", "masterplan", "editor")) 94 95 docEdit = graph.Union(ONR("document", "masterplan", "edit"), 96 docOwner, 97 docEditor, 98 ) 99 100 docViewer = graph.Leaf(ONR("document", "masterplan", "viewer"), 101 (DS("user", "eng_lead", "...")), 102 ) 103 104 docView = graph.Union(ONR("document", "masterplan", "view"), 105 docViewer, 106 docEdit, 107 graph.Union(ONR("document", "masterplan", "view"), 108 graph.Union(ONR("folder", "plans", "view"), 109 graph.Leaf(ONR("folder", "plans", "viewer"), 110 (DS("user", "chief_financial_officer", "...")), 111 ), 112 graph.Union(ONR("folder", "plans", "edit"), 113 graph.Leaf(ONR("folder", "plans", "editor")), 114 graph.Leaf(ONR("folder", "plans", "owner"))), 115 graph.Union(ONR("folder", "plans", "view"))), 116 graph.Union(ONR("folder", "strategy", "view"), 117 graph.Leaf(ONR("folder", "strategy", "viewer")), 118 graph.Union(ONR("folder", "strategy", "edit"), 119 graph.Leaf(ONR("folder", "strategy", "editor")), 120 graph.Leaf(ONR("folder", "strategy", "owner"), 121 (DS("user", "vp_product", "...")))), 122 graph.Union(ONR("folder", "strategy", "view"), 123 graph.Union(ONR("folder", "company", "view"), 124 graph.Leaf(ONR("folder", "company", "viewer"), 125 (DS("user", "legal", "...")), 126 (DS("folder", "auditors", "viewer"))), 127 graph.Union(ONR("folder", "company", "edit"), 128 graph.Leaf(ONR("folder", "company", "editor")), 129 graph.Leaf(ONR("folder", "company", "owner"), 130 (DS("user", "owner", "...")))), 131 graph.Union(ONR("folder", "company", "view")), 132 ), 133 ), 134 ), 135 ), 136 ) 137 ) 138 139 func TestExpand(t *testing.T) { 140 defer goleak.VerifyNone(t, goleakIgnores...) 141 142 testCases := []struct { 143 start *core.ObjectAndRelation 144 expansionMode v1.DispatchExpandRequest_ExpansionMode 145 expected *core.RelationTupleTreeNode 146 expectedDispatchCount int 147 expectedDepthRequired int 148 }{ 149 {start: ONR("folder", "company", "owner"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: companyOwner, expectedDispatchCount: 1, expectedDepthRequired: 1}, 150 {start: ONR("folder", "company", "edit"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: companyEdit, expectedDispatchCount: 3, expectedDepthRequired: 2}, 151 {start: ONR("folder", "company", "view"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: companyView, expectedDispatchCount: 5, expectedDepthRequired: 3}, 152 {start: ONR("document", "masterplan", "owner"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: docOwner, expectedDispatchCount: 1, expectedDepthRequired: 1}, 153 {start: ONR("document", "masterplan", "edit"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: docEdit, expectedDispatchCount: 3, expectedDepthRequired: 2}, 154 {start: ONR("document", "masterplan", "view"), expansionMode: v1.DispatchExpandRequest_SHALLOW, expected: docView, expectedDispatchCount: 20, expectedDepthRequired: 5}, 155 156 {start: ONR("folder", "auditors", "owner"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: auditorsOwner, expectedDispatchCount: 1, expectedDepthRequired: 1}, 157 {start: ONR("folder", "auditors", "edit"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: auditorsEdit, expectedDispatchCount: 3, expectedDepthRequired: 2}, 158 {start: ONR("folder", "auditors", "view"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: auditorsViewRecursive, expectedDispatchCount: 5, expectedDepthRequired: 3}, 159 160 {start: ONR("folder", "company", "owner"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: companyOwner, expectedDispatchCount: 1, expectedDepthRequired: 1}, 161 {start: ONR("folder", "company", "edit"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: companyEdit, expectedDispatchCount: 3, expectedDepthRequired: 2}, 162 {start: ONR("folder", "company", "view"), expansionMode: v1.DispatchExpandRequest_RECURSIVE, expected: companyViewRecursive, expectedDispatchCount: 6, expectedDepthRequired: 3}, 163 } 164 165 for _, tc := range testCases { 166 tc := tc 167 t.Run(fmt.Sprintf("%s-%s", tuple.StringONR(tc.start), tc.expansionMode), func(t *testing.T) { 168 require := require.New(t) 169 170 ctx, dispatch, revision := newLocalDispatcher(t) 171 172 expandResult, err := dispatch.DispatchExpand(ctx, &v1.DispatchExpandRequest{ 173 ResourceAndRelation: tc.start, 174 Metadata: &v1.ResolverMeta{ 175 AtRevision: revision.String(), 176 DepthRemaining: 50, 177 }, 178 ExpansionMode: tc.expansionMode, 179 }) 180 181 require.NoError(err) 182 require.NotNil(expandResult.TreeNode) 183 require.GreaterOrEqual(expandResult.Metadata.DepthRequired, uint32(1)) 184 require.Equal(tc.expectedDispatchCount, int(expandResult.Metadata.DispatchCount), "mismatch in dispatch count") 185 require.Equal(tc.expectedDepthRequired, int(expandResult.Metadata.DepthRequired), "mismatch in depth required") 186 187 if diff := cmp.Diff(tc.expected, expandResult.TreeNode, protocmp.Transform()); diff != "" { 188 fset := token.NewFileSet() 189 err := printer.Fprint(os.Stdout, fset, serializeToFile(expandResult.TreeNode)) 190 require.NoError(err) 191 t.Errorf("unexpected difference:\n%v", diff) 192 } 193 }) 194 } 195 } 196 197 func serializeToFile(node *core.RelationTupleTreeNode) *ast.File { 198 return &ast.File{ 199 Package: 1, 200 Name: &ast.Ident{ 201 Name: "main", 202 }, 203 Decls: []ast.Decl{ 204 &ast.FuncDecl{ 205 Name: &ast.Ident{ 206 Name: "main", 207 }, 208 Type: &ast.FuncType{ 209 Params: &ast.FieldList{}, 210 }, 211 Body: &ast.BlockStmt{ 212 List: []ast.Stmt{ 213 &ast.ExprStmt{ 214 X: serialize(node), 215 }, 216 }, 217 }, 218 }, 219 }, 220 } 221 } 222 223 func serialize(node *core.RelationTupleTreeNode) *ast.CallExpr { 224 var expanded ast.Expr = ast.NewIdent("_this") 225 if node.Expanded != nil { 226 expanded = onrExpr(node.Expanded) 227 } 228 229 children := []ast.Expr{expanded} 230 231 var fName string 232 switch node.NodeType.(type) { 233 case *core.RelationTupleTreeNode_IntermediateNode: 234 switch node.GetIntermediateNode().Operation { 235 case core.SetOperationUserset_EXCLUSION: 236 fName = "graph.Exclusion" 237 case core.SetOperationUserset_INTERSECTION: 238 fName = "graph.Intersection" 239 case core.SetOperationUserset_UNION: 240 fName = "graph.Union" 241 default: 242 panic("Unknown set operation") 243 } 244 245 for _, child := range node.GetIntermediateNode().ChildNodes { 246 children = append(children, serialize(child)) 247 } 248 249 case *core.RelationTupleTreeNode_LeafNode: 250 fName = "graph.Leaf" 251 for _, subject := range node.GetLeafNode().Subjects { 252 onrExpr := onrExpr(subject.Subject) 253 children = append(children, &ast.CallExpr{ 254 Fun: ast.NewIdent(""), 255 Args: []ast.Expr{onrExpr}, 256 }) 257 } 258 } 259 260 return &ast.CallExpr{ 261 Fun: ast.NewIdent(fName), 262 Args: children, 263 } 264 } 265 266 func onrExpr(onr *core.ObjectAndRelation) ast.Expr { 267 return &ast.CallExpr{ 268 Fun: ast.NewIdent("ONR"), 269 Args: []ast.Expr{ 270 ast.NewIdent("\"" + onr.Namespace + "\""), 271 ast.NewIdent("\"" + onr.ObjectId + "\""), 272 ast.NewIdent("\"" + onr.Relation + "\""), 273 }, 274 } 275 } 276 277 func TestMaxDepthExpand(t *testing.T) { 278 defer goleak.VerifyNone(t, goleakIgnores...) 279 280 require := require.New(t) 281 282 rawDS, err := memdb.NewMemdbDatastore(0, 0, memdb.DisableGC) 283 require.NoError(err) 284 285 ds, _ := testfixtures.StandardDatastoreWithSchema(rawDS, require) 286 287 tpl := tuple.Parse("folder:oops#parent@folder:oops") 288 ctx := datastoremw.ContextWithHandle(context.Background()) 289 290 revision, err := common.WriteTuples(ctx, ds, core.RelationTupleUpdate_CREATE, tpl) 291 require.NoError(err) 292 require.NoError(datastoremw.SetInContext(ctx, ds)) 293 294 dispatch := NewLocalOnlyDispatcher(10) 295 296 _, err = dispatch.DispatchExpand(ctx, &v1.DispatchExpandRequest{ 297 ResourceAndRelation: ONR("folder", "oops", "view"), 298 Metadata: &v1.ResolverMeta{ 299 AtRevision: revision.String(), 300 DepthRemaining: 50, 301 }, 302 ExpansionMode: v1.DispatchExpandRequest_SHALLOW, 303 }) 304 305 require.Error(err) 306 } 307 308 func TestCaveatedExpand(t *testing.T) { 309 defer goleak.VerifyNone(t, goleakIgnores...) 310 311 testCases := []struct { 312 name string 313 schema string 314 relationships []*core.RelationTuple 315 316 start *core.ObjectAndRelation 317 318 expansionMode v1.DispatchExpandRequest_ExpansionMode 319 expectedTreeText string 320 }{ 321 { 322 "basic caveated subject", 323 ` 324 definition user {} 325 326 caveat somecaveat(somecondition int) { 327 somecondition == 42 328 } 329 330 definition document { 331 relation viewer: user with somecaveat | user 332 } 333 `, 334 []*core.RelationTuple{ 335 tuple.MustParse("document:testdoc#viewer@user:sarah[somecaveat]"), 336 tuple.MustParse("document:testdoc#viewer@user:mary"), 337 }, 338 tuple.ParseONR("document:testdoc#viewer"), 339 v1.DispatchExpandRequest_SHALLOW, 340 ` 341 leaf_node: { 342 subjects: { 343 subject: { 344 namespace: "user" 345 object_id: "mary" 346 relation: "..." 347 } 348 } 349 subjects: { 350 subject: { 351 namespace: "user" 352 object_id: "sarah" 353 relation: "..." 354 } 355 caveat_expression: { 356 caveat: { 357 caveat_name: "somecaveat" 358 context: {} 359 } 360 } 361 } 362 } 363 expanded: { 364 namespace: "document" 365 object_id: "testdoc" 366 relation: "viewer" 367 } 368 `, 369 }, 370 { 371 "caveated shallow indirect", 372 ` 373 definition user {} 374 375 caveat somecaveat(somecondition int) { 376 somecondition == 42 377 } 378 379 definition group { 380 relation member: user 381 } 382 383 definition document { 384 relation viewer: group#member with somecaveat 385 } 386 `, 387 []*core.RelationTuple{ 388 tuple.MustParse("document:testdoc#viewer@group:test#member[somecaveat]"), 389 tuple.MustParse("group:test#member@user:mary"), 390 }, 391 tuple.ParseONR("document:testdoc#viewer"), 392 v1.DispatchExpandRequest_SHALLOW, 393 ` 394 leaf_node: { 395 subjects: { 396 subject: { 397 namespace: "group" 398 object_id: "test" 399 relation: "member" 400 } 401 caveat_expression: { 402 caveat: { 403 caveat_name: "somecaveat" 404 context: {} 405 } 406 } 407 } 408 } 409 expanded: { 410 namespace: "document" 411 object_id: "testdoc" 412 relation: "viewer" 413 } 414 `, 415 }, 416 { 417 "caveated recursive indirect", 418 ` 419 definition user {} 420 421 caveat somecaveat(somecondition int) { 422 somecondition == 42 423 } 424 425 caveat anothercaveat(somecondition int) { 426 somecondition != 42 427 } 428 429 definition group { 430 relation member: user 431 } 432 433 definition document { 434 relation viewer: group#member with somecaveat 435 } 436 `, 437 []*core.RelationTuple{ 438 tuple.MustParse("document:testdoc#viewer@group:test#member[somecaveat]"), 439 tuple.MustParse("group:test#member@user:mary"), 440 tuple.MustParse("group:test#member@user:sarah[anothercaveat]"), 441 }, 442 tuple.ParseONR("document:testdoc#viewer"), 443 v1.DispatchExpandRequest_RECURSIVE, 444 ` 445 intermediate_node: { 446 operation: UNION 447 child_nodes: { 448 leaf_node: { 449 subjects: { 450 subject: { 451 namespace: "user" 452 object_id: "mary" 453 relation: "..." 454 } 455 } 456 subjects: { 457 subject: { 458 namespace: "user" 459 object_id: "sarah" 460 relation: "..." 461 } 462 caveat_expression: { 463 caveat: { 464 caveat_name: "anothercaveat" 465 context: {} 466 } 467 } 468 } 469 } 470 expanded: { 471 namespace: "group" 472 object_id: "test" 473 relation: "member" 474 } 475 caveat_expression: { 476 caveat: { 477 caveat_name: "somecaveat" 478 context: {} 479 } 480 } 481 } 482 child_nodes: { 483 leaf_node: { 484 subjects: { 485 subject: { 486 namespace: "group" 487 object_id: "test" 488 relation: "member" 489 } 490 caveat_expression: { 491 caveat: { 492 caveat_name: "somecaveat" 493 context: {} 494 } 495 } 496 } 497 } 498 expanded: { 499 namespace: "document" 500 object_id: "testdoc" 501 relation: "viewer" 502 } 503 } 504 } 505 expanded: { 506 namespace: "document" 507 object_id: "testdoc" 508 relation: "viewer" 509 } 510 `, 511 }, 512 { 513 "shallow caveated arrow", 514 ` 515 definition user {} 516 517 caveat somecaveat(somecondition int) { 518 somecondition == 42 519 } 520 521 caveat anothercaveat(somecondition int) { 522 somecondition != 42 523 } 524 525 caveat orgcaveat(somecondition int) { 526 somecondition < 42 527 } 528 529 definition organization { 530 relation admin: user | user with orgcaveat 531 } 532 533 definition document { 534 relation org: organization with somecaveat 535 relation viewer: user 536 permission view = viewer + org->admin 537 } 538 `, 539 []*core.RelationTuple{ 540 tuple.MustParse("document:testdoc#viewer@user:tom"), 541 tuple.MustParse("document:testdoc#viewer@user:fred[anothercaveat]"), 542 tuple.MustParse("document:testdoc#org@organization:someorg[somecaveat]"), 543 tuple.MustParse("organization:someorg#admin@user:sarah"), 544 tuple.MustParse("organization:someorg#admin@user:mary[orgcaveat]"), 545 }, 546 tuple.ParseONR("document:testdoc#view"), 547 v1.DispatchExpandRequest_SHALLOW, 548 ` 549 intermediate_node: { 550 operation: UNION 551 child_nodes: { 552 leaf_node: { 553 subjects: { 554 subject: { 555 namespace: "user" 556 object_id: "fred" 557 relation: "..." 558 } 559 caveat_expression: { 560 caveat: { 561 caveat_name: "anothercaveat" 562 context: {} 563 } 564 } 565 } 566 subjects: { 567 subject: { 568 namespace: "user" 569 object_id: "tom" 570 relation: "..." 571 } 572 } 573 } 574 expanded: { 575 namespace: "document" 576 object_id: "testdoc" 577 relation: "viewer" 578 } 579 } 580 child_nodes: { 581 intermediate_node: { 582 operation: UNION 583 child_nodes: { 584 leaf_node: { 585 subjects: { 586 subject: { 587 namespace: "user" 588 object_id: "mary" 589 relation: "..." 590 } 591 caveat_expression: { 592 caveat: { 593 caveat_name: "orgcaveat" 594 context: {} 595 } 596 } 597 } 598 subjects: { 599 subject: { 600 namespace: "user" 601 object_id: "sarah" 602 relation: "..." 603 } 604 } 605 } 606 expanded: { 607 namespace: "organization" 608 object_id: "someorg" 609 relation: "admin" 610 } 611 caveat_expression: { 612 caveat: { 613 caveat_name: "somecaveat" 614 context: {} 615 } 616 } 617 } 618 } 619 expanded: { 620 namespace: "document" 621 object_id: "testdoc" 622 relation: "view" 623 } 624 } 625 } 626 expanded: { 627 namespace: "document" 628 object_id: "testdoc" 629 relation: "view" 630 } 631 `, 632 }, 633 { 634 "recursive caveated indirect arrow", 635 `definition user {} 636 637 caveat somecaveat(somecondition int) { 638 somecondition == 42 639 } 640 641 definition folder { 642 relation container: folder with somecaveat 643 relation member: user 644 permission view = container->member 645 } 646 647 definition resource { 648 relation folder: folder 649 permission view = folder->view 650 }`, 651 []*core.RelationTuple{ 652 tuple.MustParse("resource:someresource#folder@folder:first"), 653 tuple.MustParse("folder:first#container@folder:second[somecaveat]"), 654 tuple.MustParse("folder:first#member@user:notreachable"), 655 tuple.MustParse("folder:second#member@user:tom"), 656 }, 657 tuple.ParseONR("resource:someresource#view"), 658 v1.DispatchExpandRequest_RECURSIVE, 659 ` 660 intermediate_node: { 661 operation: UNION 662 child_nodes: { 663 intermediate_node: { 664 operation: UNION 665 child_nodes: { 666 intermediate_node: { 667 operation: UNION 668 child_nodes: { 669 intermediate_node: { 670 operation: UNION 671 child_nodes: { 672 leaf_node: { 673 subjects: { 674 subject: { 675 namespace: "user" 676 object_id: "tom" 677 relation: "..." 678 } 679 } 680 } 681 expanded: { 682 namespace: "folder" 683 object_id: "second" 684 relation: "member" 685 } 686 caveat_expression: { 687 caveat: { 688 caveat_name: "somecaveat" 689 context: {} 690 } 691 } 692 } 693 } 694 expanded: { 695 namespace: "folder" 696 object_id: "first" 697 relation: "view" 698 } 699 } 700 } 701 expanded: { 702 namespace: "folder" 703 object_id: "first" 704 relation: "view" 705 } 706 } 707 } 708 expanded: { 709 namespace: "resource" 710 object_id: "someresource" 711 relation: "view" 712 } 713 } 714 } 715 expanded: { 716 namespace: "resource" 717 object_id: "someresource" 718 relation: "view" 719 } 720 `, 721 }, 722 } 723 724 for _, tc := range testCases { 725 tc := tc 726 t.Run(tc.name, func(t *testing.T) { 727 require := require.New(t) 728 729 ctx, dispatch, revision := newLocalDispatcherWithSchemaAndRels(t, tc.schema, tc.relationships) 730 731 expandResult, err := dispatch.DispatchExpand(ctx, &v1.DispatchExpandRequest{ 732 ResourceAndRelation: tc.start, 733 Metadata: &v1.ResolverMeta{ 734 AtRevision: revision.String(), 735 DepthRemaining: 50, 736 }, 737 ExpansionMode: tc.expansionMode, 738 }) 739 require.NoError(err) 740 741 expectedTree := &core.RelationTupleTreeNode{} 742 err = prototext.Unmarshal([]byte(tc.expectedTreeText), expectedTree) 743 require.NoError(err) 744 745 require.NoError(err) 746 require.NotNil(expandResult.TreeNode) 747 testutil.RequireProtoEqual(t, expectedTree, expandResult.TreeNode, "Got different expansion trees") 748 }) 749 } 750 }