diff --git a/README.md b/README.md index 1459f91..cc450a3 100644 --- a/README.md +++ b/README.md @@ -981,10 +981,11 @@ fga query **check** [--condition] [--contextual-tuple * `--model-id`: Specifies the model id to target (optional) * `--contextual-tuple`: Contextual tuples (optional) * `--context`: Condition context (optional) +* `--consistency`: Consistency preference (optional) ###### Example - `fga query check --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap"` -- `fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}'` +- `fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency="HIGHER_CONSISTENCY"` ###### Response @@ -1004,10 +1005,11 @@ fga query **list-objects** [--contextual-tuple " * `--model-id`: Specifies the model id to target (optional) * `--contextual-tuple`: Contextual tuples (optional) (can be multiple) * `--context`: Condition context (optional) +* `--consistency`: Consistency preference (optional) ###### Example - `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap"` -- `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --context '{"ip_address":"127.0.0.1"}` +- `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --context '{"ip_address":"127.0.0.1"} --consistency="HIGHER_CONSISTENCY"` ###### Response ```json5 @@ -1029,11 +1031,12 @@ fga query **list-relations** [--relation ]* [--context * `--model-id`: Specifies the model id to target (optional) * `--contextual-tuple`: Contextual tuples (optional) (can be multiple) * `--context`: Condition context (optional) +* `--consistency`: Consistency preference (optional) ###### Example - `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view` - `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --contextual-tuple "user:anne can_view folder:product"` -- `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --context '{"ip_address":"127.0.0.1"}` +- `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --context '{"ip_address":"127.0.0.1"} --consistency="HIGHER_CONSISTENCY"` ###### Response ```json5 @@ -1052,6 +1055,7 @@ fga query **expand** --store-id= [--model-id= --relation --user-filter < * `--model-id`: Specifies the model id to target (optional) * `--contextual-tuple`: Contextual tuples (optional) (can be multiple) * `--context`: Condition context (optional) +* `--consistency`: Consistency preference (optional) ###### Example - `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user` - `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user --contextual-tuple "user:anne can_view folder:product"` -- `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter group#member --context '{"ip_address":"127.0.0.1"}` +- `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter group#member --context '{"ip_address":"127.0.0.1"} --consistency="HIGHER_CONSISTENCY"` ###### Response ```json5 diff --git a/cmd/query/check.go b/cmd/query/check.go index 65cd5c8..78f3a9e 100644 --- a/cmd/query/check.go +++ b/cmd/query/check.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" @@ -34,6 +35,7 @@ func check( object string, contextualTuples []client.ClientContextualTupleKey, queryContext *map[string]interface{}, + consistency *openfga.ConsistencyPreference, ) (*client.ClientCheckResponse, error) { body := &client.ClientCheckRequest{ User: user, @@ -44,6 +46,11 @@ func check( } options := &client.ClientCheckOptions{} + // Don't set if UNSPECIFIED has been provided, it's the default anyway + if *consistency != openfga.CONSISTENCYPREFERENCE_UNSPECIFIED { + options.Consistency = consistency + } + response, err := fgaClient.Check(context.Background()).Body(*body).Options(*options).Execute() if err != nil { return nil, fmt.Errorf("failed to check due to %w", err) @@ -76,7 +83,12 @@ var checkCmd = &cobra.Command{ return fmt.Errorf("error parsing query context for check: %w", err) } - response, err := check(fgaClient, args[0], args[1], args[2], contextualTuples, queryContext) + consistency, err := cmdutils.ParseConsistencyFromCmd(cmd) + if err != nil { + return fmt.Errorf("error parsing consistency for check: %w", err) + } + + response, err := check(fgaClient, args[0], args[1], args[2], contextualTuples, queryContext, consistency) if err != nil { return fmt.Errorf("failed to check due to %w", err) } diff --git a/cmd/query/check_test.go b/cmd/query/check_test.go index 76ee480..f4c8bbf 100644 --- a/cmd/query/check_test.go +++ b/cmd/query/check_test.go @@ -47,7 +47,15 @@ func TestCheckWithError(t *testing.T) { mockFgaClient.EXPECT().Check(context.Background()).Return(mockBody) - _, err := check(mockFgaClient, "user:foo", "writer", "doc:doc1", contextualTuples, queryContext) + _, err := check( + mockFgaClient, + "user:foo", + "writer", + "doc:doc1", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err == nil { t.Error("Expect error but there is none") } @@ -91,7 +99,73 @@ func TestCheckWithNoError(t *testing.T) { mockFgaClient.EXPECT().Check(context.Background()).Return(mockBody) - output, err := check(mockFgaClient, "user:foo", "writer", "doc:doc1", contextualTuples, queryContext) + output, err := check( + mockFgaClient, + "user:foo", + "writer", + "doc:doc1", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) + if err != nil { + t.Error(err) + } + + if *output != expectedResponse { + t.Errorf("Expected output %v actual %v", expectedResponse, *output) + } +} + +func TestCheckWithConsistency(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFgaClient := mock_client.NewMockSdkClient(mockCtrl) + + mockExecute := mock_client.NewMockSdkClientCheckRequestInterface(mockCtrl) + + expectedResponse := client.ClientCheckResponse{ + CheckResponse: openfga.CheckResponse{ + Allowed: openfga.PtrBool(true), + }, + HttpResponse: nil, + } + + mockExecute.EXPECT().Execute().Return(&expectedResponse, nil) + + mockRequest := mock_client.NewMockSdkClientCheckRequestInterface(mockCtrl) + options := client.ClientCheckOptions{ + Consistency: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + } + mockRequest.EXPECT().Options(options).Return(mockExecute) + + mockBody := mock_client.NewMockSdkClientCheckRequestInterface(mockCtrl) + + contextualTuples := []client.ClientContextualTupleKey{ + {User: "user:foo", Relation: "admin", Object: "doc:doc1"}, + } + body := client.ClientCheckRequest{ + User: "user:foo", + Relation: "writer", + Object: "doc:doc1", + ContextualTuples: contextualTuples, + Context: queryContext, + } + mockBody.EXPECT().Body(body).Return(mockRequest) + + mockFgaClient.EXPECT().Check(context.Background()).Return(mockBody) + + output, err := check( + mockFgaClient, + "user:foo", + "writer", + "doc:doc1", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + ) if err != nil { t.Error(err) } diff --git a/cmd/query/expand.go b/cmd/query/expand.go index d76b955..feed6ad 100644 --- a/cmd/query/expand.go +++ b/cmd/query/expand.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" @@ -27,13 +28,24 @@ import ( "github.com/openfga/cli/internal/output" ) -func expand(fgaClient client.SdkClient, relation string, object string) (*client.ClientExpandResponse, error) { +func expand( + fgaClient client.SdkClient, + relation string, + object string, + consistency *openfga.ConsistencyPreference, +) (*client.ClientExpandResponse, error) { body := &client.ClientExpandRequest{ Relation: relation, Object: object, } - tuples, err := fgaClient.Expand(context.Background()).Body(*body).Execute() + options := &client.ClientExpandOptions{} + + if *consistency != openfga.CONSISTENCYPREFERENCE_UNSPECIFIED { + options.Consistency = consistency + } + + tuples, err := fgaClient.Expand(context.Background()).Body(*body).Options(*options).Execute() if err != nil { return nil, fmt.Errorf("failed to expand tuples due to %w", err) } @@ -56,7 +68,12 @@ var expandCmd = &cobra.Command{ return fmt.Errorf("failed to initialize FGA Client due to %w", err) } - response, err := expand(fgaClient, args[0], args[1]) + consistency, err := cmdutils.ParseConsistencyFromCmd(cmd) + if err != nil { + return fmt.Errorf("error parsing consistency for check: %w", err) + } + + response, err := expand(fgaClient, args[0], args[1], consistency) if err != nil { return err } diff --git a/cmd/query/expand_test.go b/cmd/query/expand_test.go index 0c40f35..a374004 100644 --- a/cmd/query/expand_test.go +++ b/cmd/query/expand_test.go @@ -7,6 +7,7 @@ import ( "reflect" "testing" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "go.uber.org/mock/gomock" @@ -28,17 +29,21 @@ func TestExpandWithError(t *testing.T) { mockExecute.EXPECT().Execute().Return(&expectedResponse, errMockExpand) + mockRequest := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) + options := client.ClientExpandOptions{} + mockRequest.EXPECT().Options(options).Return(mockExecute) + mockBody := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) body := client.ClientExpandRequest{ Relation: "writer", Object: "doc:doc1", } - mockBody.EXPECT().Body(body).Return(mockExecute) + mockBody.EXPECT().Body(body).Return(mockRequest) mockFgaClient.EXPECT().Expand(context.Background()).Return(mockBody) - _, err := expand(mockFgaClient, "writer", "doc:doc1") + _, err := expand(mockFgaClient, "writer", "doc:doc1", openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr()) if err == nil { t.Error("Expect error but there is none") } @@ -62,17 +67,65 @@ func TestExpandWithNoError(t *testing.T) { mockExecute.EXPECT().Execute().Return(&expectedResponse, nil) + mockRequest := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) + options := client.ClientExpandOptions{} + mockRequest.EXPECT().Options(options).Return(mockExecute) + + mockBody := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) + + body := client.ClientExpandRequest{ + Relation: "writer", + Object: "doc:doc1", + } + mockBody.EXPECT().Body(body).Return(mockRequest) + + mockFgaClient.EXPECT().Expand(context.Background()).Return(mockBody) + + output, err := expand(mockFgaClient, "writer", "doc:doc1", openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr()) + if err != nil { + t.Error(err) + } + + if !(reflect.DeepEqual(*output, expectedResponse)) { + t.Errorf("Expect output response %v actual response %v", expandResponseTxt, *output) + } +} + +func TestExpandWithConsistency(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFgaClient := mock_client.NewMockSdkClient(mockCtrl) + + mockExecute := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) + + expandResponseTxt := `{"tree":{"root":{"name":"document:roadmap#viewer","union":{"nodes":[{"name": "document:roadmap#viewer","leaf":{"users":{"users":["user:81684243-9356-4421-8fbf-a4f8d36aa31b"]}}}]}}}}` //nolint:all + + expectedResponse := client.ClientExpandResponse{} + if err := json.Unmarshal([]byte(expandResponseTxt), &expectedResponse); err != nil { + t.Fatalf("%v", err) + } + + mockExecute.EXPECT().Execute().Return(&expectedResponse, nil) + + mockRequest := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) + options := client.ClientExpandOptions{ + Consistency: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + } + mockRequest.EXPECT().Options(options).Return(mockExecute) + mockBody := mock_client.NewMockSdkClientExpandRequestInterface(mockCtrl) body := client.ClientExpandRequest{ Relation: "writer", Object: "doc:doc1", } - mockBody.EXPECT().Body(body).Return(mockExecute) + mockBody.EXPECT().Body(body).Return(mockRequest) mockFgaClient.EXPECT().Expand(context.Background()).Return(mockBody) - output, err := expand(mockFgaClient, "writer", "doc:doc1") + output, err := expand(mockFgaClient, "writer", "doc:doc1", openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr()) if err != nil { t.Error(err) } diff --git a/cmd/query/list-objects.go b/cmd/query/list-objects.go index 3e62473..c5194e0 100644 --- a/cmd/query/list-objects.go +++ b/cmd/query/list-objects.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" @@ -35,6 +36,7 @@ func listObjects( objectType string, contextualTuples []client.ClientContextualTupleKey, queryContext *map[string]interface{}, + consistency *openfga.ConsistencyPreference, ) (*client.ClientListObjectsResponse, error) { body := &client.ClientListObjectsRequest{ User: user, @@ -45,6 +47,10 @@ func listObjects( } options := &client.ClientListObjectsOptions{} + if *consistency != openfga.CONSISTENCYPREFERENCE_UNSPECIFIED { + options.Consistency = consistency + } + response, err := fgaClient.ListObjects(context.Background()).Body(*body).Options(*options).Execute() if err != nil { return nil, fmt.Errorf("failed to list objects due to %w", err) @@ -75,10 +81,15 @@ var listObjectsCmd = &cobra.Command{ queryContext, err := cmdutils.ParseQueryContext(cmd, "context") if err != nil { - return fmt.Errorf("error parsing query context for check: %w", err) + return fmt.Errorf("error parsing query context for listObjects: %w", err) + } + + consistency, err := cmdutils.ParseConsistencyFromCmd(cmd) + if err != nil { + return fmt.Errorf("error parsing consistency for listObjects: %w", err) } - response, err := listObjects(fgaClient, args[0], args[1], args[2], contextualTuples, queryContext) + response, err := listObjects(fgaClient, args[0], args[1], args[2], contextualTuples, queryContext, consistency) if err != nil { return fmt.Errorf("failed to list objects due to %w", err) } diff --git a/cmd/query/list-objects_test.go b/cmd/query/list-objects_test.go index 4f63365..186da13 100644 --- a/cmd/query/list-objects_test.go +++ b/cmd/query/list-objects_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "go.uber.org/mock/gomock" @@ -46,7 +47,15 @@ func TestListObjectsWithError(t *testing.T) { mockFgaClient.EXPECT().ListObjects(context.Background()).Return(mockBody) - _, err := listObjects(mockFgaClient, "user:foo", "writer", "doc", contextualTuples, queryContext) + _, err := listObjects( + mockFgaClient, + "user:foo", + "writer", + "doc", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err == nil { t.Error("Expect error but there is none") } @@ -86,7 +95,69 @@ func TestListObjectsWithNoError(t *testing.T) { mockFgaClient.EXPECT().ListObjects(context.Background()).Return(mockBody) - output, err := listObjects(mockFgaClient, "user:foo", "writer", "doc", contextualTuples, nil) + output, err := listObjects( + mockFgaClient, + "user:foo", + "writer", + "doc", + contextualTuples, + nil, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) + if err != nil { + t.Error(err) + } + + if output != &expectedResponse { + t.Errorf("Expect %v but actual %v", expectedResponse, *output) + } +} + +func TestListObjectsWithConsistency(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFgaClient := mock_client.NewMockSdkClient(mockCtrl) + + mockExecute := mock_client.NewMockSdkClientListObjectsRequestInterface(mockCtrl) + + expectedResponse := client.ClientListObjectsResponse{ + Objects: []string{"doc:doc1", "doc:doc2"}, + } + + mockExecute.EXPECT().Execute().Return(&expectedResponse, nil) + + mockRequest := mock_client.NewMockSdkClientListObjectsRequestInterface(mockCtrl) + options := client.ClientListObjectsOptions{ + Consistency: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + } + mockRequest.EXPECT().Options(options).Return(mockExecute) + + mockBody := mock_client.NewMockSdkClientListObjectsRequestInterface(mockCtrl) + + contextualTuples := []client.ClientContextualTupleKey{ + {User: "user:foo", Relation: "admin", Object: "doc:doc1"}, + } + body := client.ClientListObjectsRequest{ + User: "user:foo", + Relation: "writer", + Type: "doc", + ContextualTuples: contextualTuples, + } + mockBody.EXPECT().Body(body).Return(mockRequest) + + mockFgaClient.EXPECT().ListObjects(context.Background()).Return(mockBody) + + output, err := listObjects( + mockFgaClient, + "user:foo", + "writer", + "doc", + contextualTuples, + nil, + openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + ) if err != nil { t.Error(err) } diff --git a/cmd/query/list-relations.go b/cmd/query/list-relations.go index 4c471af..a351cd3 100644 --- a/cmd/query/list-relations.go +++ b/cmd/query/list-relations.go @@ -78,6 +78,7 @@ func listRelations(clientConfig fga.ClientConfig, relations []string, contextualTuples []client.ClientContextualTupleKey, queryContext *map[string]interface{}, + consistency *openfga.ConsistencyPreference, ) (*client.ClientListRelationsResponse, error) { if len(relations) < 1 { relationsForType, err := getRelationsForType(clientConfig, fgaClient, object) @@ -104,6 +105,11 @@ func listRelations(clientConfig fga.ClientConfig, } options := &client.ClientListRelationsOptions{} + // Don't set if UNSPECIFIED has been provided, it's the default anyway + if *consistency != openfga.CONSISTENCYPREFERENCE_UNSPECIFIED { + options.Consistency = consistency + } + response, err := fgaClient.ListRelations(context.Background()).Body(*body).Options(*options).Execute() if err != nil { return nil, fmt.Errorf("failed to list relations due to %w", err) @@ -140,9 +146,23 @@ var listRelationsCmd = &cobra.Command{ return fmt.Errorf("error parsing query context for check: %w", err) } + consistency, err := cmdutils.ParseConsistencyFromCmd(cmd) + if err != nil { + return fmt.Errorf("error parsing consistency for check: %w", err) + } + relations, _ := cmd.Flags().GetStringArray("relation") - response, err := listRelations(clientConfig, fgaClient, args[0], args[1], relations, contextualTuples, queryContext) + response, err := listRelations( + clientConfig, + fgaClient, + args[0], + args[1], + relations, + contextualTuples, + queryContext, + consistency, + ) if err != nil { return fmt.Errorf("failed to list relations due to %w", err) } diff --git a/cmd/query/list-relations_test.go b/cmd/query/list-relations_test.go index e8afc17..c8ff384 100644 --- a/cmd/query/list-relations_test.go +++ b/cmd/query/list-relations_test.go @@ -7,6 +7,7 @@ import ( "reflect" "testing" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "go.uber.org/mock/gomock" @@ -41,7 +42,16 @@ func TestListRelationsLatestAuthModelError(t *testing.T) { contextualTuples := []client.ClientContextualTupleKey{ {User: "user:foo", Relation: "admin", Object: "doc:doc1"}, } - _, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", relations, contextualTuples, queryContext) + _, err := listRelations( + clientConfig, + mockFgaClient, + "user:foo", + "doc:doc1", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err == nil { t.Error("Expect error but there is none") @@ -70,7 +80,16 @@ func TestListRelationsAuthModelSpecifiedError(t *testing.T) { contextualTuples := []client.ClientContextualTupleKey{ {User: "user:foo", Relation: "admin", Object: "doc:doc1"}, } - _, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", relations, contextualTuples, queryContext) + _, err := listRelations( + clientConfig, + mockFgaClient, + "user:foo", + "doc:doc1", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err == nil { t.Error("Expect error but there is none") @@ -128,7 +147,16 @@ func TestListRelationsLatestAuthModelListError(t *testing.T) { var clientConfig fga.ClientConfig - _, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", relations, contextualTuples, queryContext) + _, err := listRelations( + clientConfig, + mockFgaClient, + "user:foo", + "doc:doc1", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err == nil { t.Error("Expect error but there is none") } @@ -164,8 +192,16 @@ func TestListRelationsLatestAuthModelEmpty(t *testing.T) { Relations: []string{}, } - response, err := listRelations(clientConfig, mockFgaClient, "doc:doc1", "user:foo", relations, - contextualTuples, queryContext) + response, err := listRelations( + clientConfig, + mockFgaClient, + "doc:doc1", + "user:foo", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err != nil { t.Error(err) } @@ -228,8 +264,16 @@ func TestListRelationsLatestAuthModelList(t *testing.T) { var clientConfig fga.ClientConfig - output, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", relations, - contextualTuples, queryContext) + output, err := listRelations( + clientConfig, + mockFgaClient, + "user:foo", + "doc:doc1", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err != nil { t.Error(err) } @@ -280,8 +324,76 @@ func TestListRelationsMultipleRelations(t *testing.T) { var clientConfig fga.ClientConfig - output, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", relations, - contextualTuples, queryContext) + output, err := listRelations( + clientConfig, + mockFgaClient, + "user:foo", + "doc:doc1", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) + if err != nil { + t.Error(err) + } + + if !reflect.DeepEqual(*output, expectedListRelationsResponse) { + t.Errorf("Expect output %v actual %v", expectedListRelationsResponse, *output) + } +} + +func TestListRelationsWithConsistency(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFgaClient := mock_client.NewMockSdkClient(mockCtrl) + + mockListRelationsExecute := mock_client.NewMockSdkClientListRelationsRequestInterface(mockCtrl) + + expectedListRelationsResponse := client.ClientListRelationsResponse{ + Relations: []string{"viewer"}, + } + + mockListRelationsExecute.EXPECT().Execute().Return(&expectedListRelationsResponse, nil) + + mockListRelationsRequest := mock_client.NewMockSdkClientListRelationsRequestInterface(mockCtrl) + listRelationsOptions := client.ClientListRelationsOptions{ + Consistency: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + } + mockListRelationsRequest.EXPECT().Options(listRelationsOptions).Return(mockListRelationsExecute) + + mockBody := mock_client.NewMockSdkClientListRelationsRequestInterface(mockCtrl) + + relations := []string{"viewer", "editor"} + contextualTuples := []client.ClientContextualTupleKey{ + {User: "user:foo", Relation: "admin", Object: "doc:doc1"}, + } + body := client.ClientListRelationsRequest{ + User: "user:foo", + Relations: []string{"viewer", "editor"}, + Object: "doc:doc1", + ContextualTuples: contextualTuples, + Context: queryContext, + } + mockBody.EXPECT().Body(body).Return(mockListRelationsRequest) + gomock.InOrder( + mockFgaClient.EXPECT().ListRelations(context.Background()).Return(mockBody), + ) + + var clientConfig fga.ClientConfig + + output, err := listRelations( + clientConfig, + mockFgaClient, + "user:foo", + "doc:doc1", + relations, + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + ) if err != nil { t.Error(err) } diff --git a/cmd/query/list-users.go b/cmd/query/list-users.go index db6b8de..84d35ac 100644 --- a/cmd/query/list-users.go +++ b/cmd/query/list-users.go @@ -66,6 +66,7 @@ func listUsers( rawUserFilter string, contextualTuples []client.ClientContextualTupleKey, queryContext *map[string]interface{}, + consistency *openfga.ConsistencyPreference, ) (*client.ClientListUsersResponse, error) { body := &client.ClientListUsersRequest{ Object: parseObject(rawObject), @@ -76,6 +77,11 @@ func listUsers( } options := &client.ClientListUsersOptions{} + // Don't set if UNSPECIFIED has been provided, it's the default anyway + if *consistency != openfga.CONSISTENCYPREFERENCE_UNSPECIFIED { + options.Consistency = consistency + } + response, err := fgaClient.ListUsers(context.Background()).Body(*body).Options(*options).Execute() if err != nil { return nil, fmt.Errorf("failed to list users due to %w", err) @@ -106,11 +112,16 @@ var listUsersCmd = &cobra.Command{ return fmt.Errorf("error parsing query context: %w", err) } + consistency, err := cmdutils.ParseConsistencyFromCmd(cmd) + if err != nil { + return fmt.Errorf("error parsing consistency for check: %w", err) + } + userFilter, _ := cmd.Flags().GetString("user-filter") object, _ := cmd.Flags().GetString("object") relation, _ := cmd.Flags().GetString("relation") - response, err := listUsers(fgaClient, object, relation, userFilter, contextualTuples, queryContext) + response, err := listUsers(fgaClient, object, relation, userFilter, contextualTuples, queryContext, consistency) if err != nil { return fmt.Errorf("failed to list users due to %w", err) } diff --git a/cmd/query/list-users_test.go b/cmd/query/list-users_test.go index bba07db..cf1ded0 100644 --- a/cmd/query/list-users_test.go +++ b/cmd/query/list-users_test.go @@ -60,7 +60,15 @@ func TestListUsersSimpleType(t *testing.T) { mockFgaClient.EXPECT().ListUsers(context.Background()).Return(mockBody) - output, err := listUsers(mockFgaClient, "doc:doc1", "admin", "user", contextualTuples, queryContext) + output, err := listUsers( + mockFgaClient, + "doc:doc1", + "admin", + "user", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) if err != nil { t.Error(err) } @@ -120,7 +128,84 @@ func TestListUsersSimpleTypeAndRelation(t *testing.T) { mockFgaClient.EXPECT().ListUsers(context.Background()).Return(mockBody) - output, err := listUsers(mockFgaClient, "doc:doc1", "admin", "group#member", contextualTuples, queryContext) + output, err := listUsers( + mockFgaClient, + "doc:doc1", + "admin", + "group#member", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + ) + if err != nil { + t.Error(err) + } + + if output != &expectedResponse { + t.Errorf("Expect %v but actual %v", expectedResponse, *output) + } +} + +func TestListUsersWithConsistency(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFgaClient := mock_client.NewMockSdkClient(mockCtrl) + + mockExecute := mock_client.NewMockSdkClientListUsersRequestInterface(mockCtrl) + + expectedResponse := client.ClientListUsersResponse{ + Users: []openfga.User{ + { + Object: &openfga.FgaObject{ + Type: "user", + Id: "anne", + }, + }, + }, + } + + mockExecute.EXPECT().Execute().Return(&expectedResponse, nil) + + mockRequest := mock_client.NewMockSdkClientListUsersRequestInterface(mockCtrl) + options := client.ClientListUsersOptions{ + Consistency: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + } + mockRequest.EXPECT().Options(options).Return(mockExecute) + + mockBody := mock_client.NewMockSdkClientListUsersRequestInterface(mockCtrl) + + contextualTuples := []client.ClientContextualTupleKey{} + userFilters := []openfga.UserTypeFilter{ + { + Type: "user", + }, + } + + body := client.ClientListUsersRequest{ + Object: openfga.FgaObject{ + Type: "doc", + Id: "doc1", + }, + Relation: "admin", + UserFilters: userFilters, + ContextualTuples: contextualTuples, + Context: queryContext, + } + mockBody.EXPECT().Body(body).Return(mockRequest) + + mockFgaClient.EXPECT().ListUsers(context.Background()).Return(mockBody) + + output, err := listUsers( + mockFgaClient, + "doc:doc1", + "admin", + "user", + contextualTuples, + queryContext, + openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + ) if err != nil { t.Error(err) } diff --git a/cmd/query/query.go b/cmd/query/query.go index 3d21b45..7df4834 100644 --- a/cmd/query/query.go +++ b/cmd/query/query.go @@ -42,6 +42,11 @@ func init() { QueryCmd.PersistentFlags().String("model-id", "", "Model ID") QueryCmd.PersistentFlags().StringArray("contextual-tuple", []string{}, `Contextual Tuple, output: "user relation object"`) //nolint:lll QueryCmd.PersistentFlags().String("context", "", "Query context (as a JSON string)") + QueryCmd.PersistentFlags().String( + "consistency", + "", + "Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY.", + ) err := QueryCmd.MarkPersistentFlagRequired("store-id") if err != nil { diff --git a/internal/cmdutils/get-consistency.go b/internal/cmdutils/get-consistency.go new file mode 100644 index 0000000..e230b65 --- /dev/null +++ b/internal/cmdutils/get-consistency.go @@ -0,0 +1,55 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmdutils + +import ( + "fmt" + "strings" + + openfga "github.com/openfga/go-sdk" + "github.com/spf13/cobra" +) + +func ParseConsistency(consistency string) (*openfga.ConsistencyPreference, error) { + if consistency == "" { + return openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), nil + } + + val := openfga.ConsistencyPreference(consistency) + if val.IsValid() { + return &val, nil + } + + val = openfga.ConsistencyPreference(strings.ToUpper(consistency)) + if val.IsValid() { + return &val, nil + } + + return nil, fmt.Errorf( //nolint:err113 + "invalid value '%s' for consistency. Valid values are HIGHER_CONSISTENCY and MINIMIZE_LATENCY", + consistency, + ) +} + +func ParseConsistencyFromCmd(cmd *cobra.Command) (*openfga.ConsistencyPreference, error) { + consistency, err := cmd.Flags().GetString("consistency") + if err != nil { + return nil, fmt.Errorf("failed to parse consistency due to %w", err) + } + + return ParseConsistency(consistency) +} diff --git a/internal/cmdutils/get-consistency_test.go b/internal/cmdutils/get-consistency_test.go new file mode 100644 index 0000000..715d061 --- /dev/null +++ b/internal/cmdutils/get-consistency_test.go @@ -0,0 +1,74 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmdutils_test + +import ( + "testing" + + openfga "github.com/openfga/go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openfga/cli/internal/cmdutils" +) + +func TestGetConsistency(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + stringVal string + expected *openfga.ConsistencyPreference + err string + }{ + { + name: "handles parsing correct value", + stringVal: "HIGHER_CONSISTENCY", + expected: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + }, + { + name: "handles parsing value from lowercase", + stringVal: "higher_consistency", + expected: openfga.CONSISTENCYPREFERENCE_HIGHER_CONSISTENCY.Ptr(), + }, + { + name: "handles no value", + stringVal: "", + expected: openfga.CONSISTENCYPREFERENCE_UNSPECIFIED.Ptr(), + }, + { + name: "throws for unknown values", + stringVal: "invalid", + err: "invalid value 'invalid' for consistency", + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + consistency, err := cmdutils.ParseConsistency(test.stringVal) + + if err == nil { + assert.Equal(t, test.expected, consistency) + } else { + require.Error(t, err) + assert.ErrorContains(t, err, test.err) + } + }) + } +}