diff --git a/.golangci.yaml b/.golangci.yaml index 8fee21c..6d7fabb 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -33,16 +33,17 @@ linters-settings: allow: - $gostd - github.com/mattn/go-isatty + - github.com/muesli/mango-cobra + - github.com/muesli/roff - github.com/nwidger/jsoncolor - github.com/oklog/ulid/v2 - github.com/openfga/cli - github.com/openfga/go-sdk + - github.com/openfga/language - github.com/openfga/openfga - github.com/spf13/cobra - github.com/spf13/pflag - github.com/spf13/viper - - github.com/muesli/mango-cobra - - github.com/muesli/roff - go.buf.build/openfga/go/openfga/api - google.golang.org/protobuf/encoding/protojson - gopkg.in/yaml.v3 @@ -52,9 +53,10 @@ linters-settings: allow: - $gostd - github.com/golang/mock/gomock - - github.com/stretchr - github.com/openfga/cli - github.com/openfga/go-sdk + - github.com/openfga/openfga + - github.com/stretchr tagliatelle: case: diff --git a/cmd/model/get.go b/cmd/model/get.go index 0407136..1752b70 100644 --- a/cmd/model/get.go +++ b/cmd/model/get.go @@ -21,6 +21,8 @@ import ( "fmt" "os" + "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" @@ -52,6 +54,15 @@ func getModel(clientConfig fga.ClientConfig, fgaClient client.SdkClient) (*openf 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 } @@ -74,13 +85,36 @@ var getCmd = &cobra.Command{ return err } - return output.Display(*response) //nolint:wrapcheck + authModel := authorizationmodel.AuthzModel{} + authModel.Set(*response.AuthorizationModel) + + fields, err := cmd.Flags().GetStringArray("field") + if err != nil { + return fmt.Errorf("failed to parse field array flag due to %w", err) + } + + if getOutputFormat == authorizationmodel.ModelFormatJSON { + return output.Display(authModel.DisplayAsJSON(fields)) //nolint:wrapcheck + } + + dslModel, err := authModel.DisplayAsDSL(fields) + if err != nil { + return fmt.Errorf("failed to display model due to %w", err) + } + + fmt.Printf("%v", *dslModel) + + return nil }, } +var getOutputFormat = authorizationmodel.ModelFormatFGA + func init() { getCmd.Flags().String("model-id", "", "Authorization Model ID") getCmd.Flags().String("store-id", "", "Store ID") + getCmd.Flags().StringArray("field", []string{"model"}, "Fields to display, choices are: id, created_at and model") //nolint:lll + getCmd.Flags().Var(&getOutputFormat, "format", `Authorization model output format. Can be "fga" or "json"`) if err := getCmd.MarkFlagRequired("store-id"); err != nil { fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/get", err) diff --git a/cmd/model/list.go b/cmd/model/list.go index 6045932..23ec1c9 100644 --- a/cmd/model/list.go +++ b/cmd/model/list.go @@ -21,6 +21,7 @@ import ( "fmt" "os" + "github.com/openfga/cli/internal/authorizationmodel" "github.com/openfga/cli/internal/cmdutils" "github.com/openfga/cli/internal/output" openfga "github.com/openfga/go-sdk" @@ -86,13 +87,27 @@ var listCmd = &cobra.Command{ return err } - return output.Display(*response) //nolint:wrapcheck + fields, err := cmd.Flags().GetStringArray("field") + if err != nil { + return fmt.Errorf("failed to parse field array flag due to %w", err) + } + + models := authorizationmodel.AuthzModelList{} + authzModels := *response.AuthorizationModels + for index := 0; index < len(authzModels); index++ { + authModel := authorizationmodel.AuthzModel{} + authModel.Set(authzModels[index]) + models.AuthorizationModels = append(models.AuthorizationModels, authModel.DisplayAsJSON(fields)) + } + + return output.Display(models) //nolint:wrapcheck }, } func init() { listCmd.Flags().Int("max-pages", MaxModelsPagesLength, "Max number of pages to get.") listCmd.Flags().String("store-id", "", "Store ID") + listCmd.Flags().StringArray("field", []string{"id", "created_at"}, "Fields to display, choices are: id, created_at and model") //nolint:lll if err := listCmd.MarkFlagRequired("store-id"); err != nil { fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/list", err) diff --git a/cmd/model/model.go b/cmd/model/model.go index a35bc32..30759d8 100644 --- a/cmd/model/model.go +++ b/cmd/model/model.go @@ -33,5 +33,6 @@ func init() { ModelCmd.AddCommand(listCmd) ModelCmd.AddCommand(getCmd) ModelCmd.AddCommand(validateCmd) + ModelCmd.AddCommand(transformCmd) ModelCmd.PersistentFlags().String("store-id", "", "Store ID") } diff --git a/cmd/model/transform.go b/cmd/model/transform.go new file mode 100644 index 0000000..177f8bd --- /dev/null +++ b/cmd/model/transform.go @@ -0,0 +1,83 @@ +/* +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 model + +import ( + "fmt" + + "github.com/openfga/cli/internal/authorizationmodel" + "github.com/openfga/cli/internal/output" + openfga "github.com/openfga/go-sdk" + "github.com/spf13/cobra" +) + +// transformCmd represents the transform command. +var transformCmd = &cobra.Command{ + Use: "transform", + Short: "Transforms an authorization model", + Example: `fga model transform --file=model.json --from-json +fga model transform --file=model.fga +fga model transform '{"schema_version":"1.1,"type_definitions":[{"type":"user"}]} --from-json'`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var inputModel string + if err := authorizationmodel.ReadFromInputFileOrArg( + cmd, + args, + "file", + false, + &inputModel, + openfga.PtrString(""), + &writeInputFormat); err != nil { + return err //nolint:wrapcheck + } + + fields, err := cmd.Flags().GetStringArray("field") + if err != nil { + return fmt.Errorf("failed to parse field array flag due to %w", err) + } + + authModel := authorizationmodel.AuthzModel{} + if transformInputFormat == authorizationmodel.ModelFormatJSON { + if err := authModel.ReadFromJSONString(inputModel); err != nil { + return err //nolint:wrapcheck + } + + dslModel, err := authModel.DisplayAsDSL(fields) + if err != nil { + return fmt.Errorf("failed to transform model due to %w", err) + } + fmt.Printf("%v", *dslModel) + + return nil + } + + if err := authModel.ReadFromDSLString(inputModel); err != nil { + return err //nolint:wrapcheck + } + + return output.Display(authModel.DisplayAsJSON(fields)) //nolint:wrapcheck + }, +} + +var transformInputFormat = authorizationmodel.ModelFormatFGA + +func init() { + transformCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON or DSL format") + transformCmd.Flags().Var(&transformInputFormat, "input-format", `Authorization model input format. Can be "fga" or "json"`) //nolint:lll + transformCmd.Flags().StringArray("field", []string{"id", "created_at", "model"}, "Fields to display, choices are: id, created_at and model") //nolint:lll +} diff --git a/cmd/model/validate.go b/cmd/model/validate.go index 3baf7d5..8f7fd85 100644 --- a/cmd/model/validate.go +++ b/cmd/model/validate.go @@ -21,7 +21,9 @@ import ( "time" "github.com/oklog/ulid/v2" + "github.com/openfga/cli/internal/authorizationmodel" "github.com/openfga/cli/internal/output" + openfga "github.com/openfga/go-sdk" "github.com/openfga/openfga/pkg/typesystem" "github.com/spf13/cobra" pb "go.buf.build/openfga/go/openfga/api/openfga/v1" @@ -35,13 +37,22 @@ type validationResult struct { Error *string `json:"error,omitempty"` } -func validate(inputModel string) validationResult { +func validate(inputModel authorizationmodel.AuthzModel) validationResult { model := &pb.AuthorizationModel{} output := validationResult{ IsValid: true, } - err := protojson.Unmarshal([]byte(inputModel), model) + modelJSONString, err := inputModel.GetAsJSONString() + if err != nil { + output.IsValid = false + errorString := "unable to parse json input" + output.Error = &errorString + + return output + } + + err = protojson.Unmarshal([]byte(*modelJSONString), model) if err != nil { output.IsValid = false errorString := "unable to parse json input" @@ -81,13 +92,42 @@ var validateCmd = &cobra.Command{ Short: "Validate Authorization Model", Long: "Validates that an authorization model is valid.", Example: `fga model validate '{"schema_version":"1.1,"type_definitions":[{"type":"user"}]}'`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - response := validate(args[0]) + var inputModel string + if err := authorizationmodel.ReadFromInputFileOrArg( + cmd, + args, + "file", + false, + &inputModel, + openfga.PtrString(""), + &validateInputFormat); err != nil { + return err //nolint:wrapcheck + } + + authModel := authorizationmodel.AuthzModel{} + var err error + + if validateInputFormat == authorizationmodel.ModelFormatJSON { + err = authModel.ReadFromJSONString(inputModel) + } else { + err = authModel.ReadFromDSLString(inputModel) + } + + if err != nil { + return err //nolint:wrapcheck + } + + response := validate(authModel) return output.Display(response) //nolint:wrapcheck }, } +var validateInputFormat = authorizationmodel.ModelFormatDefault + func init() { + validateCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON or DSL format") + validateCmd.Flags().Var(&validateInputFormat, "format", `Authorization model input format. Can be "fga" or "json"`) } diff --git a/cmd/model/validate_test.go b/cmd/model/validate_test.go index 8782d60..242d6f7 100644 --- a/cmd/model/validate_test.go +++ b/cmd/model/validate_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + "github.com/openfga/cli/internal/authorizationmodel" openfga "github.com/openfga/go-sdk" ) @@ -80,7 +81,12 @@ func TestValidate(t *testing.T) { t.Run(test.Name, func(t *testing.T) { t.Parallel() - output := validate(test.Input) + model := authorizationmodel.AuthzModel{} + err := model.ReadFromJSONString(test.Input) + if err != nil { + return + } + output := validate(model) if !reflect.DeepEqual(output, test.ExpectedOutput) { t.Fatalf("Expect output %v actual %v", test.ExpectedOutput, output) diff --git a/cmd/model/write.go b/cmd/model/write.go index 02ce047..4ca4b36 100644 --- a/cmd/model/write.go +++ b/cmd/model/write.go @@ -18,22 +18,24 @@ package model import ( "context" - "encoding/json" "fmt" "os" + "github.com/openfga/cli/internal/authorizationmodel" "github.com/openfga/cli/internal/cmdutils" "github.com/openfga/cli/internal/output" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" ) -func write(fgaClient client.SdkClient, text string) (*client.ClientWriteAuthorizationModelResponse, error) { - body := &client.ClientWriteAuthorizationModelRequest{} - - err := json.Unmarshal([]byte(text), &body) - if err != nil { - return nil, fmt.Errorf("failed to parse model due to %w", err) +func Write( + fgaClient client.SdkClient, + inputModel authorizationmodel.AuthzModel, +) (*client.ClientWriteAuthorizationModelResponse, error) { + body := &client.ClientWriteAuthorizationModelRequest{ + SchemaVersion: inputModel.GetSchemaVersion(), + TypeDefinitions: inputModel.GetTypeDefinitions(), } model, err := fgaClient.WriteAuthorizationModel(context.Background()).Body(*body).Execute() @@ -60,26 +62,31 @@ fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions":[{"ty return fmt.Errorf("failed to initialize FGA Client due to %w", err) } - fileName, err := cmd.Flags().GetString("file") - if err != nil { - return fmt.Errorf("failed to parse file name due to %w", err) + var inputModel string + if err := authorizationmodel.ReadFromInputFileOrArg( + cmd, + args, + "file", + false, + &inputModel, + openfga.PtrString(""), + &writeInputFormat); err != nil { + return err //nolint:wrapcheck } - var inputModel string - if fileName != "" { - file, err := os.ReadFile(fileName) - if err != nil { - return fmt.Errorf("failed to read file %s due to %w", fileName, err) - } - inputModel = string(file) + authModel := authorizationmodel.AuthzModel{} + + if writeInputFormat == authorizationmodel.ModelFormatJSON { + err = authModel.ReadFromJSONString(inputModel) } else { - if len(args) == 0 || args[0] == "-" { - return cmd.Help() //nolint:wrapcheck - } - inputModel = args[0] + err = authModel.ReadFromDSLString(inputModel) } - response, err := write(fgaClient, inputModel) + if err != nil { + return err //nolint:wrapcheck + } + + response, err := Write(fgaClient, authModel) if err != nil { return err } @@ -88,9 +95,12 @@ fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions":[{"ty }, } +var writeInputFormat = authorizationmodel.ModelFormatDefault + func init() { writeCmd.Flags().String("store-id", "", "Store ID") - writeCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON format") + writeCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON or DSL format") + writeCmd.Flags().Var(&writeInputFormat, "format", `Authorization model input format. Can be "fga" or "json"`) if err := writeCmd.MarkFlagRequired("store-id"); err != nil { fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/write", err) diff --git a/cmd/model/write_test.go b/cmd/model/write_test.go index 747edf0..d564881 100644 --- a/cmd/model/write_test.go +++ b/cmd/model/write_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/openfga/cli/internal/authorizationmodel" mockclient "github.com/openfga/cli/internal/mocks" openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" @@ -14,20 +15,6 @@ import ( var errMockWrite = errors.New("mock error") -func TestWriteInvalidModel(t *testing.T) { - t.Parallel() - - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - mockFgaClient := mockclient.NewMockSdkClient(mockCtrl) - modelString := "{bad_json" - - _, err := write(mockFgaClient, modelString) - if err == nil { - t.Fatalf("Expect error but there is none") - } -} - func TestWriteModelFail(t *testing.T) { t.Parallel() @@ -51,7 +38,14 @@ func TestWriteModelFail(t *testing.T) { mockFgaClient.EXPECT().WriteAuthorizationModel(context.Background()).Return(mockRequest) - _, err = write(mockFgaClient, modelJSONTxt) + model := authorizationmodel.AuthzModel{} + err = model.ReadFromJSONString(modelJSONTxt) + + if err != nil { + return + } + + _, err = Write(mockFgaClient, model) if err == nil { t.Fatalf("Expect error but there is none") } @@ -86,7 +80,14 @@ func TestWriteModel(t *testing.T) { mockFgaClient.EXPECT().WriteAuthorizationModel(context.Background()).Return(mockRequest) - output, err := write(mockFgaClient, modelJSONTxt) + model := authorizationmodel.AuthzModel{} + + err = model.ReadFromJSONString(modelJSONTxt) + if err != nil { + return + } + + output, err := Write(mockFgaClient, model) if err != nil { t.Fatal(err) } diff --git a/cmd/store/create.go b/cmd/store/create.go index 8aa97dd..2140473 100644 --- a/cmd/store/create.go +++ b/cmd/store/create.go @@ -19,14 +19,21 @@ package store import ( "context" "fmt" - "os" + "github.com/openfga/cli/cmd/model" + "github.com/openfga/cli/internal/authorizationmodel" "github.com/openfga/cli/internal/cmdutils" + "github.com/openfga/cli/internal/fga" "github.com/openfga/cli/internal/output" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" ) +type CreateStoreAndModelResponse struct { + Store client.ClientCreateStoreResponse `json:"store"` + Model *client.ClientWriteAuthorizationModelResponse `json:"model,omitempty"` +} + func create(fgaClient client.SdkClient, storeName string) (*client.ClientCreateStoreResponse, error) { body := client.ClientCreateStoreRequest{Name: storeName} @@ -38,6 +45,54 @@ func create(fgaClient client.SdkClient, storeName string) (*client.ClientCreateS return store, nil } +func createStoreWithModel( + clientConfig fga.ClientConfig, + storeName string, + inputModel string, + inputFormat authorizationmodel.ModelFormat, +) (*CreateStoreAndModelResponse, error) { + fgaClient, err := clientConfig.GetFgaClient() + if err != nil { + return nil, fmt.Errorf("failed to initialize FGA Client due to %w", err) + } + + response := CreateStoreAndModelResponse{} + + if storeName == "" { + return nil, fmt.Errorf(`required flag(s) "name" not set`) //nolint:goerr113 + } + + createStoreResponse, err := create(fgaClient, storeName) + if err != nil { + return nil, err + } + + response.Store = *createStoreResponse + fgaClient.SetStoreId(*response.Store.Id) + + if inputModel != "" { + authModel := authorizationmodel.AuthzModel{} + if inputFormat == authorizationmodel.ModelFormatJSON { + err = authModel.ReadFromJSONString(inputModel) + } else { + err = authModel.ReadFromDSLString(inputModel) + } + + if err != nil { + return nil, err //nolint:wrapcheck + } + + createAuthZModelResponse, err := model.Write(fgaClient, authModel) + if err != nil { + return nil, err //nolint:wrapcheck + } + + response.Model = createAuthZModelResponse + } + + return &response, nil +} + // createCmd represents the store create command. var createCmd = &cobra.Command{ Use: "create", @@ -46,26 +101,33 @@ var createCmd = &cobra.Command{ Example: `fga store create --name "FGA Demo Store"`, RunE: func(cmd *cobra.Command, args []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) - } storeName, _ := cmd.Flags().GetString("name") - response, err := create(fgaClient, storeName) + + var inputModel string + if err := authorizationmodel.ReadFromInputFileOrArg( + cmd, + args, + "model", + true, + &inputModel, + &storeName, + &createModelInputFormat); err != nil { + return err //nolint:wrapcheck + } + + response, err := createStoreWithModel(clientConfig, storeName, inputModel, createModelInputFormat) if err != nil { return err } - return output.Display(*response) //nolint:wrapcheck + return output.Display(response) //nolint:wrapcheck }, } +var createModelInputFormat = authorizationmodel.ModelFormatDefault + func init() { createCmd.Flags().String("name", "", "Store Name") - - err := createCmd.MarkFlagRequired("name") - if err != nil { - fmt.Print(err) - os.Exit(1) - } + createCmd.Flags().String("model", "", "Authorization Model File Name") + createCmd.Flags().Var(&createModelInputFormat, "format", `Authorization model input format. Can be "fga" or "json"`) } diff --git a/go.mod b/go.mod index 71bac3c..502670a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/nwidger/jsoncolor v0.3.2 github.com/oklog/ulid/v2 v2.1.0 github.com/openfga/go-sdk v0.2.3-0.20230710203920-f6922b2d8c6d + github.com/openfga/language/pkg/go v0.0.0-20230720235004-169b7fcddf7e github.com/openfga/openfga v1.2.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 @@ -20,6 +21,7 @@ require ( ) require ( + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect @@ -44,7 +46,7 @@ require ( go.opentelemetry.io/otel v1.16.0 // indirect go.opentelemetry.io/otel/metric v1.16.0 // indirect go.opentelemetry.io/otel/trace v1.16.0 // indirect - golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.12.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.10.0 // indirect diff --git a/go.sum b/go.sum index 1b67edc..423cab3 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230321174746-8dcc6526cfb1 h1:X8MJ0fnN5FPdcGF5Ij2/OW+HgiJrRg3AfHAx1PJtIzM= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -243,6 +245,8 @@ github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/openfga/go-sdk v0.2.3-0.20230710203920-f6922b2d8c6d h1:xK4EfSnsB+U8zLyZ05h8V9omL6LF6Xh763bQphghuLk= github.com/openfga/go-sdk v0.2.3-0.20230710203920-f6922b2d8c6d/go.mod h1:ZB13O8GilPc0ITWssOszgxmz6CnIe8PQLZqbqAnx2IY= +github.com/openfga/language/pkg/go v0.0.0-20230720235004-169b7fcddf7e h1:P1k6pqw8wkmlOy7z5PhwFXMuX50jgDZulR2f23cDhhY= +github.com/openfga/language/pkg/go v0.0.0-20230720235004-169b7fcddf7e/go.mod h1:ePKm+lwzqeWndHqec2g+7gJkf3a3Ko7YiAaSk8yizyY= github.com/openfga/openfga v1.2.0 h1:7UAcw6OF69j/L5kmeTyqGRXXPwbycDHn4iyHIuxke1Y= github.com/openfga/openfga v1.2.0/go.mod h1:Nv/8zVfVCJCSpJhM8cf3RzZn88WXX0SaAxCMSIY5C1g= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= @@ -255,7 +259,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= @@ -280,8 +284,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -328,8 +332,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb h1:xIApU0ow1zwMa2uL1VDNeQlNVFTWMQxZUZCMDy0Q4Us= -golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/internal/authorizationmodel/authorizationmodel.go b/internal/authorizationmodel/authorizationmodel.go new file mode 100644 index 0000000..3465661 --- /dev/null +++ b/internal/authorizationmodel/authorizationmodel.go @@ -0,0 +1,18 @@ +/* +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 authorizationmodel contains cli specific auth model interfaces and functionality +package authorizationmodel diff --git a/internal/authorizationmodel/format.go b/internal/authorizationmodel/format.go new file mode 100644 index 0000000..986e170 --- /dev/null +++ b/internal/authorizationmodel/format.go @@ -0,0 +1,50 @@ +/* +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 authorizationmodel + +import ( + "fmt" + + "github.com/openfga/cli/internal/clierrors" +) + +type ModelFormat string + +const ( + ModelFormatDefault ModelFormat = "default" + ModelFormatJSON ModelFormat = "json" + ModelFormatFGA ModelFormat = "fga" +) + +func (format *ModelFormat) String() string { + return string(*format) +} + +func (format *ModelFormat) Set(v string) error { + switch v { + case "json", "fga": + *format = ModelFormat(v) + + return nil + default: + return fmt.Errorf(`%w: must be one of "%v" or "%v"`, clierrors.ErrInvalidFormat, ModelFormatJSON, ModelFormatFGA) + } +} + +func (format *ModelFormat) Type() string { + return "ModelFormat" +} diff --git a/internal/authorizationmodel/model.go b/internal/authorizationmodel/model.go new file mode 100644 index 0000000..5bbec8a --- /dev/null +++ b/internal/authorizationmodel/model.go @@ -0,0 +1,229 @@ +/* +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 authorizationmodel + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/oklog/ulid/v2" + "github.com/openfga/cli/internal/slices" + openfga "github.com/openfga/go-sdk" + language "github.com/openfga/language/pkg/go/transformer" + pb "go.buf.build/openfga/go/openfga/api/openfga/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +func getCreatedAtFromModelID(id string) (*time.Time, error) { + modelID, err := ulid.Parse(id) + if err != nil { + return nil, fmt.Errorf("error parsing model id %w", err) + } + + createdAt := time.Unix(int64(modelID.Time()/1_000), 0).UTC() //nolint:gomnd + + return &createdAt, nil +} + +type AuthzModelList struct { + AuthorizationModels []AuthzModel `json:"authorization_models"` +} + +type AuthzModel struct { + ID *string `json:"id,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + SchemaVersion *string `json:"schema_version,omitempty"` + TypeDefinitions *[]openfga.TypeDefinition `json:"type_definitions,omitempty"` +} + +func (model *AuthzModel) GetID() string { + if model == nil || model.ID == nil { + var ret string + + return ret + } + + return *model.ID +} + +func (model *AuthzModel) GetSchemaVersion() string { + if model == nil || model.SchemaVersion == nil { + var ret string + + return ret + } + + return *model.SchemaVersion +} + +func (model *AuthzModel) GetTypeDefinitions() []openfga.TypeDefinition { + if model == nil || model.TypeDefinitions == nil { + var ret []openfga.TypeDefinition + + return ret + } + + return *model.TypeDefinitions +} + +func (model *AuthzModel) GetCreatedAt() *time.Time { + if model == nil { + return nil + } + + if model.CreatedAt != nil { + return model.CreatedAt + } + + if model.ID != nil { + createdAt, _ := getCreatedAtFromModelID(model.GetID()) + + return createdAt + } + + return nil +} + +func (model *AuthzModel) Set(authzModel openfga.AuthorizationModel) { + model.ID = authzModel.Id + model.SchemaVersion = &authzModel.SchemaVersion + model.TypeDefinitions = authzModel.TypeDefinitions + + if model.ID != nil { + model.setCreatedAt() + } +} + +func (model *AuthzModel) ReadFromJSONString(jsonString string) error { + jsonAuthModel := &openfga.AuthorizationModel{} + + err := json.Unmarshal([]byte(jsonString), jsonAuthModel) + if err != nil { + return fmt.Errorf("failed to parse input as json due to %w", err) + } + + model.Set(*jsonAuthModel) + + return nil +} + +func (model *AuthzModel) ReadFromDSLString(dslString string) error { + parsedAuthModel := language.TransformDslToJSON(dslString) + + bytes, err := protojson.Marshal(parsedAuthModel) + if err != nil { + return fmt.Errorf("failed to transform due to %w", err) + } + + jsonAuthModel := &openfga.AuthorizationModel{} + + err = json.Unmarshal(bytes, jsonAuthModel) + if err != nil { + return fmt.Errorf("failed to transform due to %w", err) + } + + model.Set(*jsonAuthModel) + + return nil +} + +func (model *AuthzModel) setCreatedAt() { + if *model.ID != "" { + modelID, err := ulid.Parse(*model.ID) + if err == nil { + createdAt := time.Unix(int64(modelID.Time()/1_000), 0).UTC() //nolint:gomnd + model.CreatedAt = &createdAt + } + } +} + +func (model *AuthzModel) GetAsJSONString() (*string, error) { + bytes, err := json.Marshal(model) + if err != nil { + return nil, fmt.Errorf("failed to marshal due to %w", err) + } + + jsonString := string(bytes) + + return &jsonString, nil +} + +func (model *AuthzModel) DisplayAsJSON(fields []string) AuthzModel { + newModel := AuthzModel{} + + if len(fields) < 1 { + fields = append(fields, "model") + } + + if slices.Contains(fields, "id") { + newModel.ID = model.ID + } + + if slices.Contains(fields, "created_at") { + newModel.CreatedAt = model.CreatedAt + } + + if slices.Contains(fields, "model") { + newModel.SchemaVersion = model.SchemaVersion + newModel.TypeDefinitions = model.TypeDefinitions + } + + return newModel +} + +func (model *AuthzModel) DisplayAsDSL(fields []string) (*string, error) { + modelPb := pb.AuthorizationModel{} + + if len(fields) < 1 { + fields = append(fields, "model") + } + + dslModel := "" + + if slices.Contains(fields, "id") { + if model.ID != nil { + dslModel += fmt.Sprintf("# Model ID: %v\n", *model.ID) + } else { + dslModel += fmt.Sprintf("# Model ID: %v\n", "N/A") + } + } + + if slices.Contains(fields, "created_at") { + if model.CreatedAt != nil { + dslModel += fmt.Sprintf("# Created At: %v\n", *model.CreatedAt) + } else { + dslModel += fmt.Sprintf("# Created At: %v\n", "N/A") + } + } + + if slices.Contains(fields, "model") { + modelJSON, err := model.GetAsJSONString() + if err != nil { + return nil, err + } + + err = protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(*modelJSON), &modelPb) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal model json string due to: %w", err) + } + + dslModel += fmt.Sprintf("%v\n", language.TransformJSONToDSL(&modelPb)) + } + + return &dslModel, nil +} diff --git a/internal/authorizationmodel/model_test.go b/internal/authorizationmodel/model_test.go new file mode 100644 index 0000000..5c88ef6 --- /dev/null +++ b/internal/authorizationmodel/model_test.go @@ -0,0 +1,125 @@ +package authorizationmodel_test + +import ( + "testing" + + "github.com/openfga/cli/internal/authorizationmodel" + openfga "github.com/openfga/go-sdk" + "github.com/openfga/openfga/pkg/typesystem" +) + +const ( + modelID = "01GVKXGDCV2SMG6TRE9NMBQ2VG" + typeName = "user" + modelCreatedAt = "2023-03-16 00:35:51 +0000 UTC" +) + +func TestReadingInvalidModelFromInvalidJSON(t *testing.T) { + t.Parallel() + + modelString := "{bad_json" + + model := authorizationmodel.AuthzModel{} + + err := model.ReadFromJSONString(modelString) + if err == nil { + t.Errorf("Expected error, got none") + } +} + +func TestReadingValidModelFromJSON(t *testing.T) { + t.Parallel() + + modelString := `{"id":"01GVKXGDCV2SMG6TRE9NMBQ2VG","schema_version":"1.1","type_definitions":[{"type":"user"}]}` + + model := authorizationmodel.AuthzModel{} + + err := model.ReadFromJSONString(modelString) + if err != nil { + t.Errorf("Got error when reading a valid model %v", err) + } + + if model.GetSchemaVersion() != typesystem.SchemaVersion1_1 { + t.Errorf("Expected %v to equal %v", model.GetSchemaVersion(), typesystem.SchemaVersion1_1) + } + + if model.GetID() != modelID { + t.Errorf("Expected %v to equal %v", model.GetID(), modelID) + } + + if model.CreatedAt.String() != modelCreatedAt { + t.Errorf("Expected %v to equal %v", model.CreatedAt.String(), modelCreatedAt) + } + + if model.GetTypeDefinitions()[0].GetType() != typeName { + t.Errorf("Expected %v to equal %v", model.GetTypeDefinitions()[0].GetType(), typeName) + } +} + +func TestReadingValidModelFromDSL(t *testing.T) { + t.Parallel() + + model := authorizationmodel.AuthzModel{} + if err := model.ReadFromDSLString(`model + schema 1.1 + +type user +`); err != nil { + t.Errorf("Got error when parsing a valid model %v", err) + } + + if model.GetSchemaVersion() != typesystem.SchemaVersion1_1 { + t.Errorf("Expected %v to equal %v", model.GetSchemaVersion(), typesystem.SchemaVersion1_1) + } + + if model.GetTypeDefinitions()[0].GetType() != typeName { + t.Errorf("Expected %v to equal %v", model.GetTypeDefinitions()[0].GetType(), typeName) + } +} + +func TestDisplayAsJsonWithFields(t *testing.T) { + t.Parallel() + + typeDefs := []openfga.TypeDefinition{{ + Type: typeName, + }} + model := authorizationmodel.AuthzModel{ + SchemaVersion: openfga.PtrString(typesystem.SchemaVersion1_1), + ID: openfga.PtrString(modelID), + TypeDefinitions: &typeDefs, + } + + jsonModel1 := model.DisplayAsJSON([]string{"model", "id", "created_at"}) + if jsonModel1.GetSchemaVersion() != typesystem.SchemaVersion1_1 { + t.Errorf("Expected %v to equal %v", jsonModel1.GetSchemaVersion(), typesystem.SchemaVersion1_1) + } + + if jsonModel1.GetID() != modelID { + t.Errorf("Expected %v to equal %v", jsonModel1.GetID(), modelID) + } + + if jsonModel1.GetCreatedAt().String() != modelCreatedAt { + t.Errorf("Expected %v to equal %v", jsonModel1.CreatedAt.String(), modelCreatedAt) + } + + if jsonModel1.GetTypeDefinitions()[0].GetType() != typeName { + t.Errorf("Expected %v to equal %v", jsonModel1.GetTypeDefinitions()[0].GetType(), typeName) + } + + jsonModel2 := model.DisplayAsJSON([]string{"id", "created_at"}) + if jsonModel2.GetSchemaVersion() != "" { + t.Errorf("Expected %v to be empty", jsonModel2.GetSchemaVersion()) + } + + if jsonModel2.GetID() != modelID { + t.Errorf("Expected %v to equal %v", jsonModel2.GetID(), modelID) + } + + if jsonModel1.GetCreatedAt().String() != modelCreatedAt { + t.Errorf("Expected %v to equal %v", jsonModel2.CreatedAt.String(), modelCreatedAt) + } + + if jsonModel2.GetTypeDefinitions() != nil { + t.Errorf("Expected %v to equal nil", jsonModel2.GetTypeDefinitions()) + } +} diff --git a/internal/authorizationmodel/read-input-or-arg.go b/internal/authorizationmodel/read-input-or-arg.go new file mode 100644 index 0000000..e65272e --- /dev/null +++ b/internal/authorizationmodel/read-input-or-arg.go @@ -0,0 +1,75 @@ +/* +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 authorizationmodel + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +func ReadFromInputFileOrArg( //nolint:cyclop + cmd *cobra.Command, + args []string, + fileNameArg string, + isOptional bool, + input *string, + storeName *string, + format *ModelFormat, +) error { + fileName, err := cmd.Flags().GetString(fileNameArg) + if err != nil { + return fmt.Errorf("failed to parse file name due to %w", err) + } + + switch { + case fileName != "": + file, err := os.ReadFile(fileName) + if err != nil { + return fmt.Errorf("failed to read file %s due to %w", fileName, err) + } + + *input = string(file) + + // if the input format is set as the default, set it from the file extension (and default to fga) + if *format == ModelFormatDefault { + if strings.HasSuffix(fileName, "json") { + *format = ModelFormatJSON + } else { + *format = ModelFormatFGA + } + } + + if *storeName == "" { + *storeName = strings.TrimSuffix(path.Base(fileName), filepath.Ext(fileName)) + } + case len(args) > 0 && args[0] != "-": + *input = args[0] + // if the input format is set as the default, set it from the file extension (and default to fga) + if *format == ModelFormatDefault { + *format = ModelFormatFGA + } + case !isOptional: + return cmd.Help() //nolint:wrapcheck + } + + return nil +} diff --git a/internal/clierrors/clierrors.go b/internal/clierrors/clierrors.go index 0cf0707..aea40fe 100644 --- a/internal/clierrors/clierrors.go +++ b/internal/clierrors/clierrors.go @@ -22,7 +22,12 @@ import ( "fmt" ) -var ErrValidation = errors.New("validation error") +var ( + ErrValidation = errors.New("validation error") + ErrInvalidFormat = errors.New("invalid format") + ErrStoreNotFound = errors.New("store not found") + ErrAuthorizationModelNotFound = errors.New("authorization model not found") +) func ValidationError(op string, details string) error { return fmt.Errorf("%w - %s: %s", ErrValidation, op, details) diff --git a/internal/slices/contains.go b/internal/slices/contains.go new file mode 100644 index 0000000..50a76ec --- /dev/null +++ b/internal/slices/contains.go @@ -0,0 +1,30 @@ +/* +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 slices + +// Contains returns whether a string is in a list of strings +// Similar to slices.Contains from golang.org/x/exp/slices +// https://cs.opensource.google/go/x/exp/+/515e97eb:slices/slices.go;l=116 +func Contains(itemArray []string, lookingFor string) bool { + for _, item := range itemArray { + if item == lookingFor { + return true + } + } + + return false +} diff --git a/internal/slices/slices.go b/internal/slices/slices.go new file mode 100644 index 0000000..e726e13 --- /dev/null +++ b/internal/slices/slices.go @@ -0,0 +1,18 @@ +/* +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 slices contains some functionality to make working with slices easier +package slices