From b89e46a06711e01e5bfa1ea31be631bda103b0c3 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Wed, 10 Jan 2024 17:19:10 +0100 Subject: [PATCH 1/4] feat(tuple write): add support for csv files --- README.md | 4 +- cmd/tuple/testdata/tuples.csv | 4 + cmd/tuple/testdata/tuples.json | 17 ++ cmd/tuple/testdata/tuples.toml | 0 cmd/tuple/testdata/tuples.yaml | 9 + cmd/tuple/testdata/tuples_empty.csv | 0 .../tuples_missing_required_headers.csv | 1 + .../testdata/tuples_with_invalid_rows.csv | 2 + cmd/tuple/testdata/tuples_wrong_headers.csv | 1 + cmd/tuple/write.go | 225 ++++++++++++++---- cmd/tuple/write_test.go | 132 ++++++++++ go.mod | 3 + 12 files changed, 347 insertions(+), 51 deletions(-) create mode 100644 cmd/tuple/testdata/tuples.csv create mode 100644 cmd/tuple/testdata/tuples.json create mode 100644 cmd/tuple/testdata/tuples.toml create mode 100644 cmd/tuple/testdata/tuples.yaml create mode 100644 cmd/tuple/testdata/tuples_empty.csv create mode 100644 cmd/tuple/testdata/tuples_missing_required_headers.csv create mode 100644 cmd/tuple/testdata/tuples_with_invalid_rows.csv create mode 100644 cmd/tuple/testdata/tuples_wrong_headers.csv create mode 100644 cmd/tuple/write_test.go diff --git a/README.md b/README.md index d801bcad..54227748 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) diff --git a/cmd/tuple/testdata/tuples.csv b/cmd/tuple/testdata/tuples.csv new file mode 100644 index 00000000..1b9f26e1 --- /dev/null +++ b/cmd/tuple/testdata/tuples.csv @@ -0,0 +1,4 @@ +user_type,user_id,relation,object_type,object_id +user,anne,owner,folder,product +folder,product,parent,folder,product-2021 +user,beth,viewer,folder,product-2021 diff --git a/cmd/tuple/testdata/tuples.json b/cmd/tuple/testdata/tuples.json new file mode 100644 index 00000000..2f180b7c --- /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 00000000..e69de29b diff --git a/cmd/tuple/testdata/tuples.yaml b/cmd/tuple/testdata/tuples.yaml new file mode 100644 index 00000000..7a5ea840 --- /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 00000000..e69de29b 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 00000000..c0aa787d --- /dev/null +++ b/cmd/tuple/testdata/tuples_missing_required_headers.csv @@ -0,0 +1 @@ +user_type,user_id,relation,object_type,object_identifier 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 00000000..60d14903 --- /dev/null +++ b/cmd/tuple/testdata/tuples_with_invalid_rows.csv @@ -0,0 +1,2 @@ +user_type,user_id,relation,object_type,object_id +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 00000000..defdd681 --- /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 bcbc506b..e9b9f43b 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -17,94 +17,221 @@ limitations under the License. package tuple import ( + "bytes" "context" + "encoding/csv" + "errors" "fmt" + "io" "os" + "path" + "strings" "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.\n" + + "- \"user_id\": The unique identifier of the user.\n" + + "- \"relation\": Defines the nature of the relationship.\n" + + "- \"object_type\": Specifies the type of the object in the relationship tuple.\n" + + "- \"object_id\": The unique identifier of the object.\n\n" + + "For example, a valid CSV file might start with a row like:\n" + + "user_type,user_id,relation,object_type,object_id\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 --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(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(args []string, fgaClient *client.OpenFgaClient) error { + body := client.ClientWriteTuplesBody{ + client.ClientTupleKey{ + User: args[0], + Relation: args[1], + Object: args[2], + }, + } + + _, err := fgaClient. + WriteTuples(context.Background()). + Body(body). + Options(client.ClientWriteOptions{}). + Execute() + if err != nil { + return fmt.Errorf("failed to write tuple: %w", err) + } + + return output.Display( //nolint:wrapcheck + map[string]client.ClientWriteTuplesBody{ + "successful": body, + }, + ) +} - data, err := os.ReadFile(fileName) - if err != nil { - return fmt.Errorf("failed to read file %s due to %w", fileName, err) - } +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 +} - err = yaml.Unmarshal(data, &tuples) - if err != nil { - return fmt.Errorf("failed to parse input tuples due to %w", err) - } +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) + } - writeRequest := client.ClientWriteRequest{ - Writes: tuples, - } - response, err := ImportTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) - if err != nil { + 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 i := 0; true; i++ { + if i == 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 + Relation + ObjectType + ObjectID + ) + + tupleKey := client.ClientTupleKey{ + User: tuple[UserType] + ":" + tuple[UserID], + Relation: tuple[Relation], + Object: tuple[ObjectType] + ":" + tuple[ObjectID], } - 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) + + *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", "relation", "object_type", "object_id"} + + 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 output.Display(output.EmptyStruct{}) //nolint:wrapcheck - }, + return nil } func init() { diff --git a/cmd/tuple/write_test.go b/cmd/tuple/write_test.go new file mode 100644 index 00000000..e787abec --- /dev/null +++ b/cmd/tuple/write_test.go @@ -0,0 +1,132 @@ +package tuple + +import ( + "testing" + + "github.com/openfga/go-sdk/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTuplesFileData(t *testing.T) { + 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", + }, + { + User: "folder:product", + Relation: "parent", + Object: "folder:product-2021", + }, + { + User: "user:beth", + 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", //nolint:lll + }, + { + 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,relation,object_type,object_id\"", //nolint:lll + }, + { + 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", //nolint:lll + }, + } + + 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 126054d2..67f8760d 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 From fc362abb073838b4ff4ba941e091a74c533be9b4 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Wed, 10 Jan 2024 18:16:49 +0100 Subject: [PATCH 2/4] feat(tuple write): add support for user_relation --- cmd/tuple/testdata/tuples.csv | 8 +++---- .../tuples_missing_required_headers.csv | 2 +- .../testdata/tuples_with_invalid_rows.csv | 4 ++-- cmd/tuple/write.go | 23 ++++++++++++------- cmd/tuple/write_test.go | 4 ++-- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/cmd/tuple/testdata/tuples.csv b/cmd/tuple/testdata/tuples.csv index 1b9f26e1..baf1f716 100644 --- a/cmd/tuple/testdata/tuples.csv +++ b/cmd/tuple/testdata/tuples.csv @@ -1,4 +1,4 @@ -user_type,user_id,relation,object_type,object_id -user,anne,owner,folder,product -folder,product,parent,folder,product-2021 -user,beth,viewer,folder,product-2021 +user_type,user_id,user_relation,relation,object_type,object_id +user,anne,,owner,folder,product +folder,product,,parent,folder,product-2021 +team,fga,member,viewer,folder,product-2021 diff --git a/cmd/tuple/testdata/tuples_missing_required_headers.csv b/cmd/tuple/testdata/tuples_missing_required_headers.csv index c0aa787d..f6eac090 100644 --- a/cmd/tuple/testdata/tuples_missing_required_headers.csv +++ b/cmd/tuple/testdata/tuples_missing_required_headers.csv @@ -1 +1 @@ -user_type,user_id,relation,object_type,object_identifier +user_type,user_id,user_relation,relation,object_type,object_identifier diff --git a/cmd/tuple/testdata/tuples_with_invalid_rows.csv b/cmd/tuple/testdata/tuples_with_invalid_rows.csv index 60d14903..7540ceeb 100644 --- a/cmd/tuple/testdata/tuples_with_invalid_rows.csv +++ b/cmd/tuple/testdata/tuples_with_invalid_rows.csv @@ -1,2 +1,2 @@ -user_type,user_id,relation,object_type,object_id -user,beth,viewer,folder,product-2021,a,b,d +user_type,user_id,user_relation,relation,object_type,object_id +user,beth,,viewer,folder,product-2021,a,b,d diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index e9b9f43b..eb1e2b9e 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -48,13 +48,14 @@ var writeCmd = &cobra.Command{ "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.\n" + - "- \"user_id\": The unique identifier of the user.\n" + - "- \"relation\": Defines the nature of the relationship.\n" + - "- \"object_type\": Specifies the type of the object in the relationship tuple.\n" + - "- \"object_id\": The unique identifier of the object.\n\n" + + "- \"user_type\": Specifies the type of the user in the relationship tuple.\n" + + "- \"user_id\": The unique identifier of the user.\n" + + "- \"user_relation\": Defines the user relation forming a userset.\n" + + "- \"relation\": Defines the tuple relation.\n" + + "- \"object_type\": Specifies the type of the object in the relationship tuple.\n" + + "- \"object_id\": The unique identifier of the object.\n\n" + "For example, a valid CSV file might start with a row like:\n" + - "user_type,user_id,relation,object_type,object_id\n\n" + + "user_type,user_id,user_relation,relation,object_type,object_id\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"), @@ -188,13 +189,19 @@ func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error { const ( UserType = iota UserID + UserRelation Relation ObjectType ObjectID ) + tupleUserKey := tuple[UserType] + ":" + tuple[UserID] + if tuple[UserRelation] != "" { + tupleUserKey += "#" + tuple[UserRelation] + } + tupleKey := client.ClientTupleKey{ - User: tuple[UserType] + ":" + tuple[UserID], + User: tupleUserKey, Relation: tuple[Relation], Object: tuple[ObjectType] + ":" + tuple[ObjectID], } @@ -216,7 +223,7 @@ func guardAgainstInvalidHeaderWithinCSV(reader *csv.Reader) error { headerMap[strings.TrimSpace(header)] = true } - requiredHeaders := []string{"user_type", "user_id", "relation", "object_type", "object_id"} + requiredHeaders := []string{"user_type", "user_id", "user_relation", "relation", "object_type", "object_id"} if len(headerMap) != len(requiredHeaders) { return fmt.Errorf( //nolint:goerr113 diff --git a/cmd/tuple/write_test.go b/cmd/tuple/write_test.go index e787abec..92b00c65 100644 --- a/cmd/tuple/write_test.go +++ b/cmd/tuple/write_test.go @@ -32,7 +32,7 @@ func TestParseTuplesFileData(t *testing.T) { Object: "folder:product-2021", }, { - User: "user:beth", + User: "team:fga#member", Relation: "viewer", Object: "folder:product-2021", }, @@ -93,7 +93,7 @@ func TestParseTuplesFileData(t *testing.T) { { 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,relation,object_type,object_id\"", //nolint:lll + 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\"", //nolint:lll }, { name: "it fails to parse a csv file with missing required headers", From b43d1121adfac5e78abc685bc2c35f0d12f0b41c Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Wed, 10 Jan 2024 18:46:14 +0100 Subject: [PATCH 3/4] feat(tuple write): add conditions support for csv files --- README.md | 51 +++++++++++++- cmd/tuple/testdata/tuples.csv | 8 +-- .../tuples_missing_required_headers.csv | 2 +- .../testdata/tuples_with_invalid_rows.csv | 4 +- cmd/tuple/write.go | 70 ++++++++++++++----- cmd/tuple/write_test.go | 13 +++- 6 files changed, 122 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 54227748..ed19e5c0 100644 --- a/README.md +++ b/README.md @@ -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 index baf1f716..b2f78101 100644 --- a/cmd/tuple/testdata/tuples.csv +++ b/cmd/tuple/testdata/tuples.csv @@ -1,4 +1,4 @@ -user_type,user_id,user_relation,relation,object_type,object_id -user,anne,,owner,folder,product -folder,product,,parent,folder,product-2021 -team,fga,member,viewer,folder,product-2021 +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_missing_required_headers.csv b/cmd/tuple/testdata/tuples_missing_required_headers.csv index f6eac090..e9d29eb5 100644 --- a/cmd/tuple/testdata/tuples_missing_required_headers.csv +++ b/cmd/tuple/testdata/tuples_missing_required_headers.csv @@ -1 +1 @@ -user_type,user_id,user_relation,relation,object_type,object_identifier +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 index 7540ceeb..bcf8cc0a 100644 --- a/cmd/tuple/testdata/tuples_with_invalid_rows.csv +++ b/cmd/tuple/testdata/tuples_with_invalid_rows.csv @@ -1,2 +1,2 @@ -user_type,user_id,user_relation,relation,object_type,object_id -user,beth,,viewer,folder,product-2021,a,b,d +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/write.go b/cmd/tuple/write.go index eb1e2b9e..fc627d79 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -27,6 +27,7 @@ import ( "path" "strings" + openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" flag "github.com/spf13/pflag" @@ -48,18 +49,22 @@ var writeCmd = &cobra.Command{ "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.\n" + - "- \"user_id\": The unique identifier of the user.\n" + - "- \"user_relation\": Defines the user relation forming a userset.\n" + - "- \"relation\": Defines the tuple relation.\n" + - "- \"object_type\": Specifies the type of the object in the relationship tuple.\n" + - "- \"object_id\": The unique identifier of the object.\n\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\n\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`, @@ -72,23 +77,29 @@ var writeCmd = &cobra.Command{ } if len(args) == writeCommandArgumentsCount { - return writeTuplesFromArgs(args, fgaClient) + return writeTuplesFromArgs(cmd, args, fgaClient) } return writeTuplesFromFile(cmd.Flags(), fgaClient) }, } -func writeTuplesFromArgs(args []string, fgaClient *client.OpenFgaClient) error { +func writeTuplesFromArgs(cmd *cobra.Command, args []string, fgaClient *client.OpenFgaClient) error { + condition, err := cmdutils.ParseTupleCondition(cmd) + if err != nil { + return err //nolint:wrapcheck + } + body := client.ClientWriteTuplesBody{ client.ClientTupleKey{ - User: args[0], - Relation: args[1], - Object: args[2], + User: args[0], + Relation: args[1], + Object: args[2], + Condition: condition, }, } - _, err := fgaClient. + _, err = fgaClient. WriteTuples(context.Background()). Body(body). Options(client.ClientWriteOptions{}). @@ -193,6 +204,8 @@ func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error { Relation ObjectType ObjectID + ConditionName + ConditionContext ) tupleUserKey := tuple[UserType] + ":" + tuple[UserID] @@ -200,10 +213,24 @@ func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error { tupleUserKey += "#" + tuple[UserRelation] } + var condition *openfga.RelationshipCondition = nil + if tuple[ConditionName] != "" { + conditionContext, err := cmdutils.ParseQueryContextInner(tuple[ConditionContext]) + if err != nil { + return fmt.Errorf("failed to read condition context on line %d: %w", i, err) + } + + condition = &openfga.RelationshipCondition{ + Name: tuple[ConditionName], + Context: conditionContext, + } + } + tupleKey := client.ClientTupleKey{ - User: tupleUserKey, - Relation: tuple[Relation], - Object: tuple[ObjectType] + ":" + tuple[ObjectID], + User: tupleUserKey, + Relation: tuple[Relation], + Object: tuple[ObjectType] + ":" + tuple[ObjectID], + Condition: condition, } *tuples = append(*tuples, tupleKey) @@ -223,7 +250,16 @@ func guardAgainstInvalidHeaderWithinCSV(reader *csv.Reader) error { headerMap[strings.TrimSpace(header)] = true } - requiredHeaders := []string{"user_type", "user_id", "user_relation", "relation", "object_type", "object_id"} + 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 diff --git a/cmd/tuple/write_test.go b/cmd/tuple/write_test.go index 92b00c65..01fec91d 100644 --- a/cmd/tuple/write_test.go +++ b/cmd/tuple/write_test.go @@ -3,6 +3,7 @@ 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" @@ -25,11 +26,21 @@ func TestParseTuplesFileData(t *testing.T) { 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", @@ -93,7 +104,7 @@ func TestParseTuplesFileData(t *testing.T) { { 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\"", //nolint:lll + 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\"", //nolint:lll }, { name: "it fails to parse a csv file with missing required headers", From e48222d82b6dae8f71a6dc1dee67ea37a03945be Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea <28300158+sergiught@users.noreply.github.com> Date: Wed, 10 Jan 2024 19:00:13 +0100 Subject: [PATCH 4/4] chore: fix linter issues --- .golangci.yaml | 7 +++++++ cmd/tuple/write.go | 11 ++++++----- cmd/tuple/write_test.go | 8 ++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 5df5ce12..b660e891 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/cmd/tuple/write.go b/cmd/tuple/write.go index fc627d79..234600e8 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -179,8 +179,8 @@ func parseTuplesFileData(fileName string) ([]client.ClientTupleKey, error) { func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error { reader := csv.NewReader(bytes.NewReader(data)) - for i := 0; true; i++ { - if i == 0 { + for index := 0; true; index++ { + if index == 0 { if err := guardAgainstInvalidHeaderWithinCSV(reader); err != nil { return err } @@ -213,11 +213,12 @@ func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error { tupleUserKey += "#" + tuple[UserRelation] } - var condition *openfga.RelationshipCondition = nil + 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", i, err) + return fmt.Errorf("failed to read condition context on line %d: %w", index, err) } condition = &openfga.RelationshipCondition{ @@ -283,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 index 01fec91d..25951807 100644 --- a/cmd/tuple/write_test.go +++ b/cmd/tuple/write_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseTuplesFileData(t *testing.T) { +func TestParseTuplesFileData(t *testing.T) { //nolint:funlen t.Parallel() tests := []struct { @@ -94,7 +94,7 @@ func TestParseTuplesFileData(t *testing.T) { { 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", //nolint:lll + 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", @@ -104,7 +104,7 @@ func TestParseTuplesFileData(t *testing.T) { { 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\"", //nolint:lll + 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", @@ -119,7 +119,7 @@ func TestParseTuplesFileData(t *testing.T) { { 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", //nolint:lll + expectedError: "failed to parse input tuples: failed to read tuple from csv file: record on line 2: wrong number of fields", }, }