diff --git a/.golangci.yaml b/.golangci.yaml index 5df5ce1..b660e89 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -72,3 +72,10 @@ linters-settings: goimports: local-prefixes: "github.com/openfga/cli" + +issues: + exclude-use-default: true + exclude-rules: + - path: "cmd/tuple/write(.*).go" + linters: + - lll diff --git a/README.md b/README.md index d801bca..ed19e5c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ A cross-platform CLI to interact with an OpenFGA server - [Relationship Tuples](#relationship-tuples) - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) - [Read Relationship Tuples](#read-relationship-tuples) - - [Create Relationship Tuples](#create-relationship-tuples) + - [Write Relationship Tuples](#write-relationship-tuples) - [Delete Relationship Tuples](#delete-relationship-tuples) - [Relationship Queries](#relationship-queries) - [Check](#check) @@ -590,7 +590,7 @@ fga tuple **write** --store-id= * `--condition-context`: Condition context (optional) * `--store-id`: Specifies the store id * `--model-id`: Specifies the model id to target (optional) -* `--file`: Specifies the file name, `yaml` and `json` files are supported +* `--file`: Specifies the file name, `json`, `yaml` and `csv` files are supported * `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=1) * `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) @@ -600,12 +600,61 @@ fga tuple **write** --store-id= ###### Response ```json5 -{} +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], +} ``` ###### Example (with file) `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` +If using a `csv` file, the format should be: + +```csv +user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context +folder,product,,parent,folder,product-2021,inOfficeIP,"{""ip_addr"":""10.0.0.1""}" +``` + + +If using a `yaml` file, the format should be: + +```yaml +- user: folder:5 + relation: parent + object: folder:product-2021 +- user: folder:product-2021 + relation: parent + object: folder:product-2021Q1 +``` + +If using a `json` file, the format should be: + +```json +[ + { + "user": "user:anne", + "relation": "owner", + "object": "folder:product" + }, + { + "user": "folder:product", + "relation": "parent", + "object": "folder:product-2021" + }, + { + "user": "user:beth", + "relation": "viewer", + "object": "folder:product-2021" + } +] +``` + ###### Response ```json5 { diff --git a/cmd/tuple/testdata/tuples.csv b/cmd/tuple/testdata/tuples.csv new file mode 100644 index 0000000..b2f7810 --- /dev/null +++ b/cmd/tuple/testdata/tuples.csv @@ -0,0 +1,4 @@ +user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context +user,anne,,owner,folder,product,inOfficeIP, +folder,product,,parent,folder,product-2021,inOfficeIP,"{""ip_addr"":""10.0.0.1""}" +team,fga,member,viewer,folder,product-2021,, diff --git a/cmd/tuple/testdata/tuples.json b/cmd/tuple/testdata/tuples.json new file mode 100644 index 0000000..2f180b7 --- /dev/null +++ b/cmd/tuple/testdata/tuples.json @@ -0,0 +1,17 @@ +[ + { + "user": "user:anne", + "relation": "owner", + "object": "folder:product" + }, + { + "user": "folder:product", + "relation": "parent", + "object": "folder:product-2021" + }, + { + "user": "user:beth", + "relation": "viewer", + "object": "folder:product-2021" + } +] diff --git a/cmd/tuple/testdata/tuples.toml b/cmd/tuple/testdata/tuples.toml new file mode 100644 index 0000000..e69de29 diff --git a/cmd/tuple/testdata/tuples.yaml b/cmd/tuple/testdata/tuples.yaml new file mode 100644 index 0000000..7a5ea84 --- /dev/null +++ b/cmd/tuple/testdata/tuples.yaml @@ -0,0 +1,9 @@ +- user: user:anne + relation: owner + object: folder:product +- user: folder:product + relation: parent + object: folder:product-2021 +- user: user:beth + relation: viewer + object: folder:product-2021 diff --git a/cmd/tuple/testdata/tuples_empty.csv b/cmd/tuple/testdata/tuples_empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/cmd/tuple/testdata/tuples_missing_required_headers.csv b/cmd/tuple/testdata/tuples_missing_required_headers.csv new file mode 100644 index 0000000..e9d29eb --- /dev/null +++ b/cmd/tuple/testdata/tuples_missing_required_headers.csv @@ -0,0 +1 @@ +user_type,user_id,user_relation,relation,object_type,object_identifier,condition_name,condition_context diff --git a/cmd/tuple/testdata/tuples_with_invalid_rows.csv b/cmd/tuple/testdata/tuples_with_invalid_rows.csv new file mode 100644 index 0000000..bcf8cc0 --- /dev/null +++ b/cmd/tuple/testdata/tuples_with_invalid_rows.csv @@ -0,0 +1,2 @@ +user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context +user,beth,,viewer,folder,product-2021,a,b,d,, diff --git a/cmd/tuple/testdata/tuples_wrong_headers.csv b/cmd/tuple/testdata/tuples_wrong_headers.csv new file mode 100644 index 0000000..defdd68 --- /dev/null +++ b/cmd/tuple/testdata/tuples_wrong_headers.csv @@ -0,0 +1 @@ +a,b,c,d diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index bcbc506..234600e 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -17,94 +17,265 @@ limitations under the License. package tuple import ( + "bytes" "context" + "encoding/csv" + "errors" "fmt" + "io" "os" + "path" + "strings" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" + flag "github.com/spf13/pflag" "gopkg.in/yaml.v3" "github.com/openfga/cli/internal/cmdutils" "github.com/openfga/cli/internal/output" ) +const writeCommandArgumentsCount = 3 + // writeCmd represents the write command. var writeCmd = &cobra.Command{ Use: "write", Short: "Create Relationship Tuples", - Long: "Add relationship tuples to the store.", - Args: ExactArgsOrFlag(3, "file"), //nolint:gomnd - Example: `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap -fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json -fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.yaml -`, + Long: "Add relationship tuples to the store. This command allows for the creation of " + + "relationship tuples either through direct command line arguments or by specifying a " + + "file. The file can be in JSON, YAML, or CSV format.\n\n" + + "When using a CSV file, the file must adhere to a specific header structure for the " + + "command to correctly interpret the data. The required CSV header structure is as " + + "follows:\n" + + "- \"user_type\": Specifies the type of the user in the relationship tuple. (e.g. \"team\")\n" + + "- \"user_id\": The unique identifier of the user. (e.g. \"marketing\")\n" + + "- \"user_relation\": Defines the user relation forming a userset. (optional) (e.g. \"member\")\n" + + "- \"relation\": Defines the tuple relation. (e.g. \"viewer\")\n" + + "- \"object_type\": Specifies the type of the object in the relationship tuple. (e.g. \"document\")\n" + + "- \"object_id\": The unique identifier of the object. (e.g. \"roadmap\")\n" + + "- \"condition_name\": The name of the condition. (optional) (e.g. \"inOfficeIP\")\n" + + "- \"condition_context\": The context of the condition as a json object. " + + "(optional) (e.g. \"{\"\"ip_addr\"\":\"\"10.0.0.1\"\"}\")\n\n" + + "For example, a valid CSV file might start with a row like:\n" + + "user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context\n\n" + + "This command is flexible in accepting data inputs, making it easier to add multiple " + + "relationship tuples in various convenient formats.", + Args: ExactArgsOrFlag(writeCommandArgumentsCount, "file"), + Example: ` fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --condition-name inOffice --condition-context '{"office_ip":"10.0.1.10"}' + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.yaml + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv`, 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) + return fmt.Errorf("failed to initialize fga client: %w", err) } - fileName, err := cmd.Flags().GetString("file") - if err != nil { - return fmt.Errorf("failed to parse file name due to %w", err) + if len(args) == writeCommandArgumentsCount { + return writeTuplesFromArgs(cmd, args, fgaClient) } - if fileName != "" { - maxTuplesPerWrite, err := cmd.Flags().GetInt("max-tuples-per-write") - if err != nil { - return fmt.Errorf("failed to parse max tuples per write due to %w", err) - } - maxParallelRequests, err := cmd.Flags().GetInt("max-parallel-requests") - if err != nil { - return fmt.Errorf("failed to parse parallel requests due to %w", err) - } + return writeTuplesFromFile(cmd.Flags(), fgaClient) + }, +} - var tuples []client.ClientTupleKey +func writeTuplesFromArgs(cmd *cobra.Command, args []string, fgaClient *client.OpenFgaClient) error { + condition, err := cmdutils.ParseTupleCondition(cmd) + if err != nil { + return err //nolint:wrapcheck + } - data, err := os.ReadFile(fileName) - if err != nil { - return fmt.Errorf("failed to read file %s due to %w", fileName, err) - } + body := client.ClientWriteTuplesBody{ + client.ClientTupleKey{ + User: args[0], + Relation: args[1], + Object: args[2], + Condition: condition, + }, + } - err = yaml.Unmarshal(data, &tuples) - if err != nil { - return fmt.Errorf("failed to parse input tuples due to %w", err) - } + _, err = fgaClient. + WriteTuples(context.Background()). + Body(body). + Options(client.ClientWriteOptions{}). + Execute() + if err != nil { + return fmt.Errorf("failed to write tuple: %w", err) + } - writeRequest := client.ClientWriteRequest{ - Writes: tuples, - } - response, err := ImportTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) - if err != nil { + return output.Display( //nolint:wrapcheck + map[string]client.ClientWriteTuplesBody{ + "successful": body, + }, + ) +} + +func writeTuplesFromFile(flags *flag.FlagSet, fgaClient *client.OpenFgaClient) error { + fileName, err := flags.GetString("file") + if err != nil { + return fmt.Errorf("failed to parse file name: %w", err) + } + + if fileName == "" { + return fmt.Errorf("file name cannot be empty") //nolint:goerr113 + } + + maxTuplesPerWrite, err := flags.GetInt("max-tuples-per-write") + if err != nil { + return fmt.Errorf("failed to parse max tuples per write: %w", err) + } + + maxParallelRequests, err := flags.GetInt("max-parallel-requests") + if err != nil { + return fmt.Errorf("failed to parse parallel requests: %w", err) + } + + tuples, err := parseTuplesFileData(fileName) + if err != nil { + return err + } + + writeRequest := client.ClientWriteRequest{ + Writes: tuples, + } + + response, err := ImportTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) + if err != nil { + return err + } + + return output.Display(response) //nolint:wrapcheck +} + +func parseTuplesFileData(fileName string) ([]client.ClientTupleKey, error) { + data, err := os.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("failed to read file %q: %w", fileName, err) + } + + var tuples []client.ClientTupleKey + + switch path.Ext(fileName) { + case ".json", ".yaml", ".yml": + err = yaml.Unmarshal(data, &tuples) + case ".csv": + err = parseTuplesFromCSV(data, &tuples) + default: + err = fmt.Errorf("unsupported file format %q", path.Ext(fileName)) //nolint:goerr113 + } + + if err != nil { + return nil, fmt.Errorf("failed to parse input tuples: %w", err) + } + + return tuples, nil +} + +func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error { + reader := csv.NewReader(bytes.NewReader(data)) + + for index := 0; true; index++ { + if index == 0 { + if err := guardAgainstInvalidHeaderWithinCSV(reader); err != nil { return err } - return output.Display(*response) //nolint:wrapcheck + continue } - condition, err := cmdutils.ParseTupleCondition(cmd) + tuple, err := reader.Read() if err != nil { - return err //nolint:wrapcheck + if errors.Is(err, io.EOF) { + break + } + + return fmt.Errorf("failed to read tuple from csv file: %w", err) } - body := &client.ClientWriteTuplesBody{ - client.ClientTupleKey{ - User: args[0], - Relation: args[1], - Object: args[2], - Condition: condition, - }, + const ( + UserType = iota + UserID + UserRelation + Relation + ObjectType + ObjectID + ConditionName + ConditionContext + ) + + tupleUserKey := tuple[UserType] + ":" + tuple[UserID] + if tuple[UserRelation] != "" { + tupleUserKey += "#" + tuple[UserRelation] } - options := &client.ClientWriteOptions{} - _, err = fgaClient.WriteTuples(context.Background()).Body(*body).Options(*options).Execute() - if err != nil { - return fmt.Errorf("failed to write tuples due to %w", err) + + var condition *openfga.RelationshipCondition + + if tuple[ConditionName] != "" { + conditionContext, err := cmdutils.ParseQueryContextInner(tuple[ConditionContext]) + if err != nil { + return fmt.Errorf("failed to read condition context on line %d: %w", index, err) + } + + condition = &openfga.RelationshipCondition{ + Name: tuple[ConditionName], + Context: conditionContext, + } } - return output.Display(output.EmptyStruct{}) //nolint:wrapcheck - }, + tupleKey := client.ClientTupleKey{ + User: tupleUserKey, + Relation: tuple[Relation], + Object: tuple[ObjectType] + ":" + tuple[ObjectID], + Condition: condition, + } + + *tuples = append(*tuples, tupleKey) + } + + return nil +} + +func guardAgainstInvalidHeaderWithinCSV(reader *csv.Reader) error { + headers, err := reader.Read() + if err != nil { + return fmt.Errorf("failed to read csv headers: %w", err) + } + + headerMap := make(map[string]bool) + for _, header := range headers { + headerMap[strings.TrimSpace(header)] = true + } + + requiredHeaders := []string{ + "user_type", + "user_id", + "user_relation", + "relation", + "object_type", + "object_id", + "condition_name", + "condition_context", + } + + if len(headerMap) != len(requiredHeaders) { + return fmt.Errorf( //nolint:goerr113 + "csv file must have exactly these headers in order: %q", + strings.Join(requiredHeaders, ","), + ) + } + + for _, header := range requiredHeaders { + if _, ok := headerMap[header]; !ok { + return fmt.Errorf("required csv header %q not found", header) //nolint:goerr113 + } + } + + return nil } func init() { @@ -113,5 +284,5 @@ func init() { writeCmd.Flags().String("condition-name", "", "Condition Name") writeCmd.Flags().String("condition-context", "", "Condition Context (as a JSON string)") writeCmd.Flags().Int("max-tuples-per-write", MaxTuplesPerWrite, "Max tuples per write chunk.") - writeCmd.Flags().Int("max-parallel-requests", MaxParallelRequests, "Max number of requests to issue to the server in parallel.") //nolint:lll + writeCmd.Flags().Int("max-parallel-requests", MaxParallelRequests, "Max number of requests to issue to the server in parallel.") } diff --git a/cmd/tuple/write_test.go b/cmd/tuple/write_test.go new file mode 100644 index 0000000..2595180 --- /dev/null +++ b/cmd/tuple/write_test.go @@ -0,0 +1,143 @@ +package tuple + +import ( + "testing" + + openfga "github.com/openfga/go-sdk" + "github.com/openfga/go-sdk/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTuplesFileData(t *testing.T) { //nolint:funlen + t.Parallel() + + tests := []struct { + name string + file string + expectedTuples []client.ClientTupleKey + expectedError string + }{ + { + name: "it can correctly parse a csv file", + file: "testdata/tuples.csv", + expectedTuples: []client.ClientTupleKey{ + { + User: "user:anne", + Relation: "owner", + Object: "folder:product", + Condition: &openfga.RelationshipCondition{ + Name: "inOfficeIP", + Context: &map[string]interface{}{}, + }, + }, + { + User: "folder:product", + Relation: "parent", + Object: "folder:product-2021", + Condition: &openfga.RelationshipCondition{ + Name: "inOfficeIP", + Context: &map[string]interface{}{ + "ip_addr": "10.0.0.1", + }, + }, + }, + { + User: "team:fga#member", + Relation: "viewer", + Object: "folder:product-2021", + }, + }, + }, + { + name: "it can correctly parse a json file", + file: "testdata/tuples.json", + expectedTuples: []client.ClientTupleKey{ + { + User: "user:anne", + Relation: "owner", + Object: "folder:product", + }, + { + User: "folder:product", + Relation: "parent", + Object: "folder:product-2021", + }, + { + User: "user:beth", + Relation: "viewer", + Object: "folder:product-2021", + }, + }, + }, + { + name: "it can correctly parse a yaml file", + file: "testdata/tuples.yaml", + expectedTuples: []client.ClientTupleKey{ + { + User: "user:anne", + Relation: "owner", + Object: "folder:product", + }, + { + User: "folder:product", + Relation: "parent", + Object: "folder:product-2021", + }, + { + User: "user:beth", + Relation: "viewer", + Object: "folder:product-2021", + }, + }, + }, + { + name: "it fails to parse a non-existent file", + file: "testdata/tuples.bad", + expectedError: "failed to read file \"testdata/tuples.bad\": open testdata/tuples.bad: no such file or directory", + }, + { + name: "it fails to parse a non-supported file format", + file: "testdata/tuples.toml", + expectedError: "failed to parse input tuples: unsupported file format \".toml\"", + }, + { + name: "it fails to parse a csv file with wrong headers", + file: "testdata/tuples_wrong_headers.csv", + expectedError: "failed to parse input tuples: csv file must have exactly these headers in order: \"user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context\"", + }, + { + name: "it fails to parse a csv file with missing required headers", + file: "testdata/tuples_missing_required_headers.csv", + expectedError: "failed to parse input tuples: required csv header \"object_id\" not found", + }, + { + name: "it fails to parse an empty csv file", + file: "testdata/tuples_empty.csv", + expectedError: "failed to parse input tuples: failed to read csv headers: EOF", + }, + { + name: "it fails to parse a csv file with invalid rows", + file: "testdata/tuples_with_invalid_rows.csv", + expectedError: "failed to parse input tuples: failed to read tuple from csv file: record on line 2: wrong number of fields", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actualTuples, err := parseTuplesFileData(test.file) + + if test.expectedError != "" { + require.EqualError(t, err, test.expectedError) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.expectedTuples, actualTuples) + }) + } +} diff --git a/go.mod b/go.mod index 126054d..67f8760 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.8.4 google.golang.org/protobuf v1.32.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -25,6 +26,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/envoyproxy/protoc-gen-validate v1.0.2 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -48,6 +50,7 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/natefinch/wrap v0.2.0 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.18.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect