From 31cb1294cc2ad1321d4f2b7806bd0c53dbc25a2b Mon Sep 17 00:00:00 2001 From: Steve Hobbs Date: Thu, 29 Feb 2024 15:48:53 +0000 Subject: [PATCH] feat: add export store command --- README.md | 66 +++++ cmd/model/get.go | 44 +-- cmd/model/get_test.go | 7 +- cmd/store/export.go | 201 +++++++++++++ cmd/store/export_test.go | 273 ++++++++++++++++++ cmd/store/store.go | 1 + cmd/tuple/read.go | 37 +-- cmd/tuple/read_test.go | 7 + .../authorizationmodel/read-from-store.go | 48 +++ internal/storetest/storedata.go | 14 +- internal/tuple/read.go | 43 +++ 11 files changed, 656 insertions(+), 85 deletions(-) create mode 100644 cmd/store/export.go create mode 100644 cmd/store/export_test.go create mode 100644 internal/authorizationmodel/read-from-store.go create mode 100644 internal/tuple/read.go diff --git a/README.md b/README.md index 0b836db..1c321ba 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,72 @@ fga store **import** {} ``` +##### Export Store + +###### Command +fga store **export** + +###### Parameters +* `--store-id`: Specifies the store to export +* `--output-file`: The file to output the store to (optional, writes to the terminal if omitted) +* `--model-id`: Specifies the model to export (optional, exports the latest model if omitted) +* `--max-tuples`: Specifies the max number of tuples to include in the output (option, defaults to 100) + +###### Example +`fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4` + +###### Response +```yaml +name: Test +model: |+ + model + schema 1.1 + + type user + + type group + relations + define member: [user] + define moderator: [user] + +tuples: + - user: user:1 + relation: member + object: group:admins + - user: user:1 + relation: member + object: group:employees + - user: user:2 + relation: member + object: group:employees + - user: user:1 + relation: moderator + object: group:employees +tests: + - name: Tests + check: + - user: user:1 + object: group:admins + assertions: + member: true + - user: user:2 + object: group:admins + assertions: + member: false + - user: user:1 + object: group:employees + assertions: + member: true + moderator: true + - user: user:2 + object: group:employees + assertions: + member: true + moderator: false +``` + +If using `output-file`, the response will be written to the specified file on disk. If the desired file already exists, you will be prompted to overwrite the file. + ##### List Stores ###### Command diff --git a/cmd/model/get.go b/cmd/model/get.go index dfb6459..0ad9e8a 100644 --- a/cmd/model/get.go +++ b/cmd/model/get.go @@ -17,56 +17,16 @@ limitations under the License. package model import ( - "context" "fmt" "os" - openfga "github.com/openfga/go-sdk" - "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" "github.com/openfga/cli/internal/authorizationmodel" - "github.com/openfga/cli/internal/clierrors" "github.com/openfga/cli/internal/cmdutils" - "github.com/openfga/cli/internal/fga" "github.com/openfga/cli/internal/output" ) -func getModel(clientConfig fga.ClientConfig, fgaClient client.SdkClient) (*openfga.ReadAuthorizationModelResponse, - error, -) { - authorizationModelID := clientConfig.AuthorizationModelID - - var err error - - var model *openfga.ReadAuthorizationModelResponse - - if authorizationModelID != "" { - options := client.ClientReadAuthorizationModelOptions{ - AuthorizationModelId: openfga.PtrString(authorizationModelID), - } - model, err = fgaClient.ReadAuthorizationModel(context.Background()).Options(options).Execute() - } else { - options := client.ClientReadLatestAuthorizationModelOptions{} - model, err = fgaClient.ReadLatestAuthorizationModel(context.Background()).Options(options).Execute() - } - - if err != nil { - return nil, fmt.Errorf("failed to get model %v due to %w", clientConfig.AuthorizationModelID, err) - } - - if model.AuthorizationModel == nil { - // If there is no model, try to get the store - if _, err := fgaClient.GetStore(context.Background()).Execute(); err != nil { - return nil, fmt.Errorf("failed to get model %v due to %w", clientConfig.AuthorizationModelID, err) - } - - return nil, fmt.Errorf("%w", clierrors.ErrAuthorizationModelNotFound) - } - - return model, nil -} - // getCmd represents the get command. var getCmd = &cobra.Command{ Use: "get", @@ -81,9 +41,9 @@ var getCmd = &cobra.Command{ return fmt.Errorf("failed to initialize FGA Client due to %w", err) } - response, err := getModel(clientConfig, fgaClient) + response, err := authorizationmodel.ReadFromStore(clientConfig, fgaClient) if err != nil { - return err + return err //nolint:wrapcheck } authModel := authorizationmodel.AuthzModel{} diff --git a/cmd/model/get_test.go b/cmd/model/get_test.go index 27f8f09..66b4cf0 100644 --- a/cmd/model/get_test.go +++ b/cmd/model/get_test.go @@ -10,6 +10,7 @@ import ( openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" + "github.com/openfga/cli/internal/authorizationmodel" "github.com/openfga/cli/internal/fga" mock_client "github.com/openfga/cli/internal/mocks" ) @@ -42,7 +43,7 @@ func TestGetModelNoAuthModelID(t *testing.T) { var clientConfig fga.ClientConfig - output, err := getModel(clientConfig, mockFgaClient) + output, err := authorizationmodel.ReadFromStore(clientConfig, mockFgaClient) if err != nil { t.Fatalf("%v", err) } else if *output != expectedResponse { @@ -80,7 +81,7 @@ func TestGetModelAuthModelID(t *testing.T) { AuthorizationModelID: "01GXSA8YR785C4FYS3C0RTG7B1", } - output, err := getModel(clientConfig, mockFgaClient) + output, err := authorizationmodel.ReadFromStore(clientConfig, mockFgaClient) if err != nil { t.Fatalf("%v", err) } else if *output != expectedResponse { @@ -109,7 +110,7 @@ func TestGetModelNoAuthModelIDError(t *testing.T) { var clientConfig fga.ClientConfig - _, err := getModel(clientConfig, mockFgaClient) + _, err := authorizationmodel.ReadFromStore(clientConfig, mockFgaClient) if err == nil { t.Fatalf("Expect error but there is none") } diff --git a/cmd/store/export.go b/cmd/store/export.go new file mode 100644 index 0000000..73b09e2 --- /dev/null +++ b/cmd/store/export.go @@ -0,0 +1,201 @@ +/* +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 store + +import ( + "context" + "fmt" + "math" + "os" + + "github.com/openfga/go-sdk/client" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/openfga/cli/internal/authorizationmodel" + "github.com/openfga/cli/internal/cmdutils" + "github.com/openfga/cli/internal/confirmation" + "github.com/openfga/cli/internal/fga" + "github.com/openfga/cli/internal/output" + "github.com/openfga/cli/internal/storetest" + "github.com/openfga/cli/internal/tuple" +) + +// defaultTuplePageSize defines the default number of pages to return when calling Read. +const ( + defaultMaxTupleCount = 100 +) + +// buildStoreData compiles all the data necessary to output to file or stdout, +// or returns an error if this was not successful. +func buildStoreData(config fga.ClientConfig, fgaClient client.SdkClient, maxTupleCount uint) (*storetest.StoreData, error) { //nolint:lll + // get the store + store, _ := fgaClient.GetStore(context.Background()).Execute() + + var storeName string + if store != nil { + storeName = store.Name + } + + // get the model + model, err := authorizationmodel.ReadFromStore(config, fgaClient) + if err != nil { + return nil, err //nolint:wrapcheck + } + + authModel := authorizationmodel.AuthzModel{} + authModel.Set(*model.AuthorizationModel) + + dsl, err := authModel.DisplayAsDSL([]string{"model"}) + if err != nil { + return nil, fmt.Errorf("unable to get model dsl: %w", err) + } + + // get the tuples + maxPages := int(math.Ceil(float64(maxTupleCount) / float64(tuple.DefaultReadPageSize))) + + rawTuples, err := tuple.Read(fgaClient, &client.ClientReadRequest{}, maxPages) + if err != nil { + return nil, fmt.Errorf("unable to read tuples: %w", err) + } + + tuples := rawTuples.GetTuples() + maxTuplesInOutput := int(math.Min(float64(len(tuples)), float64(maxTupleCount))) + outputTuples := make([]client.ClientContextualTupleKey, 0, maxTuplesInOutput) + + for _, t := range tuples[:maxTuplesInOutput] { + outputTuples = append(outputTuples, t.GetKey()) + } + + // get the assertions + assertionOptions := client.ClientReadAssertionsOptions{ + AuthorizationModelId: authModel.ID, + } + + assertionResponse, err := fgaClient.ReadAssertions(context.Background()).Options(assertionOptions).Execute() + if err != nil { + return nil, fmt.Errorf("unable to read assertions: %w", err) + } + + assertions := assertionResponse.GetAssertions() + modelChecks := map[string]storetest.ModelTestCheck{} + + for _, assertion := range assertions { + key := fmt.Sprintf("%s|%s", assertion.TupleKey.User, assertion.TupleKey.Object) + _, exists := modelChecks[key] + + if !exists { + modelChecks[key] = storetest.ModelTestCheck{ + User: assertion.TupleKey.User, + Object: assertion.TupleKey.Object, + Assertions: map[string]bool{}, + } + } + + modelChecks[key].Assertions[assertion.GetTupleKey().Relation] = assertion.Expectation + } + + checks := []storetest.ModelTestCheck{} + for _, value := range modelChecks { + checks = append(checks, value) + } + + storeData := &storetest.StoreData{ + Name: storeName, + Model: *dsl, + Tuples: outputTuples, + Tests: []storetest.ModelTest{ + { + Name: "Tests", + Check: checks, + }, + }, + } + + return storeData, nil +} + +// exportCmd represents the export store command. +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export store data", + Long: `Export a store to YAML`, + Example: "fga store export", + RunE: func(cmd *cobra.Command, _ []string) error { + clientConfig := cmdutils.GetClientConfig(cmd) + + fgaClient, err := clientConfig.GetFgaClient() + if err != nil { + return fmt.Errorf("failed to initialize FGA Client due to %w", err) + } + + maxTupleCount, _ := cmd.Flags().GetUint("max-tuples") + storeData, err := buildStoreData(clientConfig, fgaClient, maxTupleCount) + if err != nil { + return fmt.Errorf("failed to export store: %w", err) + } + + if storeData != nil { + storeYaml, err := yaml.Marshal(storeData) + if err != nil { + return fmt.Errorf("unable to marshal storedata yaml: %w", err) + } + + fileName, _ := cmd.Flags().GetString("output-file") + if fileName == "" { + fmt.Println(string(storeYaml)) + + return nil + } + + if _, err := os.Stat(fileName); err == nil { + confirm, err := confirmation.AskForConfirmation("File exists, overwrite?") + if err != nil { + return fmt.Errorf("prompt failed due to %w", err) + } + + if !confirm { + fmt.Println("cancelled") + + return output.Display(output.EmptyStruct{}) + } + } + + err = os.WriteFile(fileName, storeYaml, 0o600) //nolint:gomnd + if err != nil { + return err //nolint:wrapcheck + } + + fmt.Printf("model written to %s\n", fileName) + } + + return output.Display(output.EmptyStruct{}) + }, +} + +func init() { + exportCmd.Flags().String("output-file", "", "name of the file to export the store to") + exportCmd.Flags().String("store-id", "", "store ID") + exportCmd.Flags().String("model-id", "", "Authorization Model ID") + exportCmd.Flags().Uint("max-tuples", defaultMaxTupleCount, "max number of tuples to return in the output") + + err := exportCmd.MarkFlagRequired("store-id") + if err != nil { + fmt.Print(err) + os.Exit(1) + } +} diff --git a/cmd/store/export_test.go b/cmd/store/export_test.go new file mode 100644 index 0000000..a81a811 --- /dev/null +++ b/cmd/store/export_test.go @@ -0,0 +1,273 @@ +package store + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + openfga "github.com/openfga/go-sdk" + + "github.com/openfga/cli/internal/fga" + "github.com/openfga/cli/internal/storetest" + "github.com/openfga/cli/internal/tuple" + + "github.com/golang/mock/gomock" + "github.com/openfga/go-sdk/client" + + mock_client "github.com/openfga/cli/internal/mocks" +) + +//nolint:funlen,cyclop +func TestExportSuccess(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockFgaClient := mock_client.NewMockSdkClient(mockCtrl) + + clientConfig := fga.ClientConfig{ + StoreID: "12345", + AuthorizationModelID: "01GXSA8YR785C4FYS3C0RTG7B1", + } + + // Mocking Store GET... + expectedTime := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + + storeResponse := client.ClientGetStoreResponse{ + Id: "12345", + Name: "Test store", + CreatedAt: expectedTime, + UpdatedAt: expectedTime, + } + + mockExecute := mock_client.NewMockSdkClientGetStoreRequestInterface(mockCtrl) + mockExecute.EXPECT().Execute().Return(&storeResponse, nil) + mockFgaClient.EXPECT().GetStore(context.Background()).Return(mockExecute) + + // Mocking Authorization model GET... + var modelResponse client.ClientReadAuthorizationModelResponse + + mockGetModelRequest := mock_client.NewMockSdkClientReadAuthorizationModelRequestInterface(mockCtrl) + modelJSON := `{ + "authorization_model": { + "id": "01GXSA8YR785C4FYS3C0RTG7B1", + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "type": "github-repo", + "relations": { + "viewer": { + "this": {} + }, + "admin": { + "this": {} + } + }, + "metadata": { + "relations": { + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "admin": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + } + } + ] + } + }` + + if err := json.Unmarshal([]byte(modelJSON), &modelResponse); err != nil { + t.Fatalf("%v", err) + } + + getModelOptions := client.ClientReadAuthorizationModelOptions{ + AuthorizationModelId: openfga.PtrString("01GXSA8YR785C4FYS3C0RTG7B1"), + } + + mockGetModelRequest.EXPECT().Options(getModelOptions).Return(mockGetModelRequest) + mockGetModelRequest.EXPECT().Execute().Return(&modelResponse, nil) + mockFgaClient.EXPECT().ReadAuthorizationModel(context.Background()).Return(mockGetModelRequest) + + // Mocking Tuples GET... + readResponse := client.ClientReadResponse{ + Tuples: []openfga.Tuple{ + { + Key: openfga.TupleKey{ + User: "user:user-1", + Relation: "viewer", + Object: "github-repo:demo", + }, + }, + { + Key: openfga.TupleKey{ + User: "user:user-2", + Relation: "viewer", + Object: "github-repo:demo", + }, + }, + { + Key: openfga.TupleKey{ + User: "user:user-2", + Relation: "admin", + Object: "github-repo:demo", + }, + }, + }, + } + + readRequest := client.ClientReadRequest{} + readOptions := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), + ContinuationToken: openfga.PtrString(""), + } + + mockReadRequest := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) + mockReadRequest.EXPECT().Body(readRequest).Return(mockReadRequest) + mockReadRequest.EXPECT().Options(readOptions).Return(mockReadRequest) + mockReadRequest.EXPECT().Execute().Return(&readResponse, nil) + mockFgaClient.EXPECT().Read(context.Background()).Return(mockReadRequest) + + // Mocking assertions GET... + assertionsResponse := client.ClientReadAssertionsResponse{ + Assertions: &[]openfga.Assertion{ + { + TupleKey: openfga.AssertionTupleKey{User: "user:user-1", Relation: "viewer", Object: "github-repo:demo"}, + Expectation: true, + }, + { + TupleKey: openfga.AssertionTupleKey{User: "user:user-2", Relation: "viewer", Object: "github-repo:demo"}, + Expectation: true, + }, + { + TupleKey: openfga.AssertionTupleKey{User: "user:user-2", Relation: "admin", Object: "github-repo:demo"}, + Expectation: true, + }, + { + TupleKey: openfga.AssertionTupleKey{User: "user:user-1", Relation: "admin", Object: "github-repo:demo"}, + Expectation: false, + }, + }, + } + + readAssertionsOptions := client.ClientReadAssertionsOptions{ + AuthorizationModelId: openfga.PtrString("01GXSA8YR785C4FYS3C0RTG7B1"), + } + + mockAssertionsRequest := mock_client.NewMockSdkClientReadAssertionsRequestInterface(mockCtrl) + mockAssertionsRequest.EXPECT().Options(readAssertionsOptions).Return(mockAssertionsRequest) + mockAssertionsRequest.EXPECT().Execute().Return(&assertionsResponse, nil) + mockFgaClient.EXPECT().ReadAssertions(context.Background()).Return(mockAssertionsRequest) + + // Execute + output, err := buildStoreData(clientConfig, mockFgaClient, 50) + // Expect + if err != nil { + t.Error(err) + } + + expectedResponse := storetest.StoreData{ + Name: "Test store", + Model: `model + schema 1.1 + +type user + +type github-repo + relations + define admin: [user] + define viewer: [user] + +`, + Tuples: []openfga.TupleKey{ + { + User: "user:user-1", + Relation: "viewer", + Object: "github-repo:demo", + }, + { + User: "user:user-2", + Relation: "viewer", + Object: "github-repo:demo", + }, + { + User: "user:user-2", + Relation: "admin", + Object: "github-repo:demo", + }, + }, + Tests: []storetest.ModelTest{ + { + Name: "Tests", + Check: []storetest.ModelTestCheck{ + { + User: "user:user-1", + Object: "github-repo:demo", + Assertions: map[string]bool{ + "admin": false, + "viewer": true, + }, + }, + { + User: "user:user-2", + Object: "github-repo:demo", + Assertions: map[string]bool{ + "admin": true, + "viewer": true, + }, + }, + }, + }, + }, + } + + if output.Name != expectedResponse.Name { + t.Errorf("Expected name %s got %s", expectedResponse.Name, output.Name) + } + + if strings.TrimSpace(output.Model) != strings.TrimSpace(expectedResponse.Model) { + t.Errorf("Expected model %s\n\ngot\n\n%s", expectedResponse.Model, output.Model) + } + + if !reflect.DeepEqual(output.Tuples, expectedResponse.Tuples) { + t.Errorf("Expected tuples %v\n\ngot\n\n%v", expectedResponse.Tuples, output.Tuples) + } + + if len(output.Tests) != 1 { + t.Errorf("Expected 1 output test, got %d", len(output.Tests)) + } + + for _, tst := range expectedResponse.Tests { + for _, expectedCheck := range tst.Check { + found := false + + for _, outputCheck := range output.Tests[0].Check { + if reflect.DeepEqual(expectedCheck, outputCheck) { + found = true + + break + } + } + + if !found { + t.Errorf("Expected check %v not found in output", expectedCheck) + } + } + } +} diff --git a/cmd/store/store.go b/cmd/store/store.go index 71e889d..8829441 100644 --- a/cmd/store/store.go +++ b/cmd/store/store.go @@ -34,4 +34,5 @@ func init() { StoreCmd.AddCommand(getCmd) StoreCmd.AddCommand(deleteCmd) StoreCmd.AddCommand(importCmd) + StoreCmd.AddCommand(exportCmd) } diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index db5b855..998e044 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -17,7 +17,6 @@ limitations under the License. package tuple import ( - "context" "encoding/json" "fmt" "strings" @@ -28,6 +27,7 @@ import ( "github.com/openfga/cli/internal/cmdutils" "github.com/openfga/cli/internal/output" + "github.com/openfga/cli/internal/tuple" ) // MaxReadPagesLength Limit the tuples so that we are not paginating indefinitely. @@ -37,6 +37,7 @@ type readResponse struct { complete *openfga.ReadResponse simple []openfga.TupleKey } + type readResponseCSVDTO struct { UserType string `csv:"user_type"` UserID string `csv:"user_id"` @@ -87,36 +88,6 @@ func (r readResponse) toCsvDTO() ([]readResponseCSVDTO, error) { return readResponseDTO, nil } -func baseRead(fgaClient client.SdkClient, body *client.ClientReadRequest, maxPages int) ( - *openfga.ReadResponse, error, -) { - tuples := make([]openfga.Tuple, 0) - continuationToken := "" - pageIndex := 0 - options := client.ClientReadOptions{} - - for { - options.ContinuationToken = &continuationToken - - response, err := fgaClient.Read(context.Background()).Body(*body).Options(options).Execute() - if err != nil { - return nil, fmt.Errorf("failed to read tuples due to %w", err) - } - - tuples = append(tuples, response.Tuples...) - pageIndex++ - - if response.ContinuationToken == "" || - (maxPages != 0 && pageIndex >= maxPages) { - break - } - - continuationToken = response.ContinuationToken - } - - return &openfga.ReadResponse{Tuples: tuples}, nil -} - func read(fgaClient client.SdkClient, user string, relation string, object string, maxPages int) ( *readResponse, error, ) { @@ -133,9 +104,9 @@ func read(fgaClient client.SdkClient, user string, relation string, object strin body.Object = &object } - response, err := baseRead(fgaClient, body, maxPages) + response, err := tuple.Read(fgaClient, body, maxPages) if err != nil { - return nil, err + return nil, err //nolint:wrapcheck } justKeys := make([]openfga.TupleKey, 0) diff --git a/cmd/tuple/read_test.go b/cmd/tuple/read_test.go index 4c82373..8aa46e2 100644 --- a/cmd/tuple/read_test.go +++ b/cmd/tuple/read_test.go @@ -14,6 +14,7 @@ import ( "github.com/openfga/go-sdk/client" mock_client "github.com/openfga/cli/internal/mocks" + "github.com/openfga/cli/internal/tuple" ) var errMockRead = errors.New("mock error") @@ -33,6 +34,7 @@ func TestReadError(t *testing.T) { mockRequest := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) options := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), ContinuationToken: openfga.PtrString(""), } mockRequest.EXPECT().Options(options).Return(mockExecute) @@ -73,6 +75,7 @@ func TestReadEmpty(t *testing.T) { mockRequest := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) options := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), ContinuationToken: openfga.PtrString(""), } mockRequest.EXPECT().Options(options).Return(mockExecute) @@ -147,6 +150,7 @@ func TestReadSinglePage(t *testing.T) { mockRequest := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) options := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), ContinuationToken: openfga.PtrString(""), } mockRequest.EXPECT().Options(options).Return(mockExecute) @@ -246,10 +250,12 @@ func TestReadMultiPages(t *testing.T) { mockRequest1 := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) options1 := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), ContinuationToken: openfga.PtrString(""), } mockRequest2 := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) options2 := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), ContinuationToken: openfga.PtrString(continuationToken), } gomock.InOrder( @@ -334,6 +340,7 @@ func TestReadMultiPagesMaxLimit(t *testing.T) { mockRequest := mock_client.NewMockSdkClientReadRequestInterface(mockCtrl) options := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(tuple.DefaultReadPageSize), ContinuationToken: openfga.PtrString(""), } mockRequest.EXPECT().Options(options).Return(mockExecute) diff --git a/internal/authorizationmodel/read-from-store.go b/internal/authorizationmodel/read-from-store.go new file mode 100644 index 0000000..dee6529 --- /dev/null +++ b/internal/authorizationmodel/read-from-store.go @@ -0,0 +1,48 @@ +package authorizationmodel + +import ( + "context" + "fmt" + + openfga "github.com/openfga/go-sdk" + "github.com/openfga/go-sdk/client" + + "github.com/openfga/cli/internal/clierrors" + "github.com/openfga/cli/internal/fga" +) + +// ReadFromStore reads the model from the store with the given the AuthorizationModelID, +// or the latest model if no ID was provided. +func ReadFromStore(clientConfig fga.ClientConfig, fgaClient client.SdkClient) (*openfga.ReadAuthorizationModelResponse, error) { //nolint:lll + authorizationModelID := clientConfig.AuthorizationModelID + + var ( + err error + model *openfga.ReadAuthorizationModelResponse + ) + + if authorizationModelID != "" { + options := client.ClientReadAuthorizationModelOptions{ + AuthorizationModelId: openfga.PtrString(authorizationModelID), + } + model, err = fgaClient.ReadAuthorizationModel(context.Background()).Options(options).Execute() + } else { + options := client.ClientReadLatestAuthorizationModelOptions{} + model, err = fgaClient.ReadLatestAuthorizationModel(context.Background()).Options(options).Execute() + } + + if err != nil { + return nil, fmt.Errorf("failed to get model %v due to %w", clientConfig.AuthorizationModelID, err) + } + + if model.AuthorizationModel == nil { + // If there is no model, try to get the store + if _, err := fgaClient.GetStore(context.Background()).Execute(); err != nil { + return nil, fmt.Errorf("failed to get model %v due to %w", clientConfig.AuthorizationModelID, err) + } + + return nil, fmt.Errorf("%w", clierrors.ErrAuthorizationModelNotFound) + } + + return model, nil +} diff --git a/internal/storetest/storedata.go b/internal/storetest/storedata.go index b2e950e..86fdf45 100644 --- a/internal/storetest/storedata.go +++ b/internal/storetest/storedata.go @@ -31,7 +31,7 @@ import ( type ModelTestCheck struct { User string `json:"user" yaml:"user"` Object string `json:"object" yaml:"object"` - Context *map[string]interface{} `json:"context" yaml:"context"` + Context *map[string]interface{} `json:"context" yaml:"context,omitempty"` Assertions map[string]bool `json:"assertions" yaml:"assertions"` } @@ -44,19 +44,19 @@ type ModelTestListObjects struct { type ModelTest struct { Name string `json:"name" yaml:"name"` - Description string `json:"description" yaml:"description"` - Tuples []client.ClientContextualTupleKey `json:"tuples" yaml:"tuples"` - TupleFile string `json:"tuple_file" yaml:"tuple_file"` //nolint:tagliatelle + Description string `json:"description" yaml:"description,omitempty"` + Tuples []client.ClientContextualTupleKey `json:"tuples" yaml:"tuples,omitempty"` + TupleFile string `json:"tuple_file" yaml:"tuple_file,omitempty"` //nolint:tagliatelle Check []ModelTestCheck `json:"check" yaml:"check"` - ListObjects []ModelTestListObjects `json:"list_objects" yaml:"list_objects"` //nolint:tagliatelle + ListObjects []ModelTestListObjects `json:"list_objects" yaml:"list_objects,omitempty"` //nolint:tagliatelle } type StoreData struct { Name string `json:"name" yaml:"name"` Model string `json:"model" yaml:"model"` - ModelFile string `json:"model_file" yaml:"model_file"` //nolint:tagliatelle + ModelFile string `json:"model_file" yaml:"model_file,omitempty"` //nolint:tagliatelle Tuples []client.ClientContextualTupleKey `json:"tuples" yaml:"tuples"` - TupleFile string `json:"tuple_file" yaml:"tuple_file"` //nolint:tagliatelle + TupleFile string `json:"tuple_file" yaml:"tuple_file,omitempty"` //nolint:tagliatelle Tests []ModelTest `json:"tests" yaml:"tests"` } diff --git a/internal/tuple/read.go b/internal/tuple/read.go new file mode 100644 index 0000000..918f4c3 --- /dev/null +++ b/internal/tuple/read.go @@ -0,0 +1,43 @@ +package tuple + +import ( + "context" + "fmt" + + openfga "github.com/openfga/go-sdk" + "github.com/openfga/go-sdk/client" +) + +const DefaultReadPageSize int32 = 50 + +func Read(fgaClient client.SdkClient, body *client.ClientReadRequest, maxPages int) ( + *openfga.ReadResponse, error, +) { + tuples := make([]openfga.Tuple, 0) + continuationToken := "" + pageIndex := 0 + options := client.ClientReadOptions{ + PageSize: openfga.PtrInt32(DefaultReadPageSize), + } + + for { + options.ContinuationToken = &continuationToken + + response, err := fgaClient.Read(context.Background()).Body(*body).Options(options).Execute() + if err != nil { + return nil, fmt.Errorf("failed to read tuples due to %w", err) + } + + tuples = append(tuples, response.Tuples...) + pageIndex++ + + if response.ContinuationToken == "" || + (maxPages != 0 && pageIndex >= maxPages) { + break + } + + continuationToken = response.ContinuationToken + } + + return &openfga.ReadResponse{Tuples: tuples}, nil +}