diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index be6ed11..7e18780 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -21,6 +21,7 @@ jobs: go-version-file: './go.mod' cache-dependency-path: './go.sum' check-latest: true + go-version: '>=1.20.6' - name: golangci-lint uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299 # v3.6.0 with: @@ -37,6 +38,7 @@ jobs: go-version-file: './go.mod' cache-dependency-path: './go.sum' check-latest: true + go-version: '>=1.20.6' - name: Run govulncheck run: make audit @@ -53,6 +55,7 @@ jobs: go-version-file: './go.mod' cache-dependency-path: './go.sum' check-latest: true + go-version: '>=1.20.6' - name: Build run: make build @@ -86,6 +89,7 @@ jobs: go-version-file: './go.mod' cache-dependency-path: './go.sum' check-latest: true + go-version: '>=1.20.6' - name: Login to Docker Hub uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0 diff --git a/.golangci.yaml b/.golangci.yaml index 12cf4bc..9dc5a9b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -32,17 +32,18 @@ linters-settings: - "!$test" allow: - $gostd - - github.com/spf13/cobra - - github.com/spf13/pflag - - github.com/spf13/viper + - github.com/mattn/go-isatty + - github.com/nwidger/jsoncolor + - github.com/oklog/ulid/v2 - github.com/openfga/cli - github.com/openfga/go-sdk - github.com/openfga/openfga - - github.com/mattn/go-isatty - - github.com/nwidger/jsoncolor + - github.com/spf13/cobra + - github.com/spf13/pflag + - github.com/spf13/viper - go.buf.build/openfga/go/openfga/api - - github.com/oklog/ulid/v2 - google.golang.org/protobuf/encoding/protojson + - gopkg.in/yaml.v3 test: files: - "$test" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2a452a7..3e274b9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -12,7 +12,7 @@ builds: - darwin ldflags: - "-s -w" - - "-X github.com/openfga/cli/internal/build.Version=v{{ .Version }}" + - "-X github.com/openfga/cli/internal/build.Version={{ .Version }}" - "-X github.com/openfga/cli/internal/build.Commit={{.Commit}}" - "-X github.com/openfga/cli/internal/build.Date={{.Date}}" @@ -135,11 +135,6 @@ brews: test: | system "#{bin}/fga version" -archives: - - rlcp: true - files: - - assets - checksum: name_template: 'checksums.txt' @@ -152,3 +147,4 @@ changelog: exclude: - '^docs:' - '^test:' + - '^chore' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6bf9dee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## v0.1.0-beta + +### [0.1.0-beta](https://github.com/openfga/go-sdk/releases/tag/v0.1.0-beta) (2023-07-11) + +Initial OpenFGA CLI release +- Support for [OpenFGA](https://github.com/openfga/openfga) API + - Create, read, list and delete stores + - Create, read, list and validate authorization models + - Write, delete, read and import tuples + - Read tuple changes + - Run authorization checks + - List objects a user has access to + - List relations a user has on an object + - Use Expand to understand why access was granted diff --git a/README.md b/README.md index ec7811e..465d452 100644 --- a/README.md +++ b/README.md @@ -200,11 +200,11 @@ fga store **delete** * `model` -| Description | command | parameters | example | -|-------------------------------------------------------------------------|---------|----------------------------|-------------------------------------------------------------------------------------------------------------| -| [Read Authorization Models](#read-authorization-models) | `list` | `--store-id` | `fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Write Authorization Model ](#write-authorization-model) | `write` | `--store-id` | `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"schema_version":"1.1","type_definitions":[...]}'` | -| [Read a Single Authorization Model](#read-a-single-authorization-model) | `get` | `--store-id`, `--model-id` | `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | +| Description | command | parameters | example | +|-------------------------------------------------------------------------|---------|----------------------------|---------------------------------------------------------------------------------------------| +| [Read Authorization Models](#read-authorization-models) | `list` | `--store-id` | `fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4` | +| [Write Authorization Model ](#write-authorization-model) | `write` | `--store-id`, `--file` | `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.json` | +| [Read a Single Authorization Model](#read-a-single-authorization-model) | `get` | `--store-id`, `--model-id` | `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | ##### Read Authorization Models @@ -239,19 +239,16 @@ fga model **write** ###### Parameters * `--store-id`: Specifies the store id +* `--file`: Specifies the file containing the model in JSON format ###### Example -`fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions": [ { "type": "user" }, { "type": "document", "relations": { "can_view": { "this": {} } }, "metadata": { "relations": { "can_view": { "directly_related_user_types": [ { "type": "user" } ] }}}} ], "schema_version": "1.1"}'` +* `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.json` +* `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions": [ { "type": "user" }, { "type": "document", "relations": { "can_view": { "this": {} } }, "metadata": { "relations": { "can_view": { "directly_related_user_types": [ { "type": "user" } ] }}}} ], "schema_version": "1.1"}'` ###### JSON Response ```json5 { - "schema_version": "1.1", - "id": "01GXSA8YR785C4FYS3C0RTG7B1", - "type_definitions": [ - {"type": "user"}, - // { ... } - ], + "authorization_model_id":"01GXSA8YR785C4FYS3C0RTG7B1" } ``` @@ -336,13 +333,13 @@ fga model **validate** * `tuple` -| Description | command | parameters | example | -|-----------------------------------------------------------------------------------|-----------|----------------------------|---------------------------------------------------------------------------------------------------------| -| [Write Relationship Tuples](#write-relationship-tuples) | `write` | `--store-id`, `--model-id` | `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"schema_version":"1.1","type_definitions":[...]}'` | -| [Delete Relationship Tuples](#delete-relationship-tuples) | `delete` | `--store-id`, `--model-id` | `fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Read Relationship Tuples](#read-relationship-tuples) | `read` | `--store-id`, `--model-id` | `fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | -| [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) | `changes` | `--store-id`, `--model-id` | `fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | -| [Import Relationship Tuples](#import-relationship-tuplesl) | `import` | `--store-id`, `--model-id` | `fga tuple import --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | +| Description | command | parameters | example | +|-----------------------------------------------------------------------------------|-----------|--------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| [Write Relationship Tuples](#write-relationship-tuples) | `write` | `--store-id`, `--model-id` | `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"schema_version":"1.1","type_definitions":[...]}'` | +| [Delete Relationship Tuples](#delete-relationship-tuples) | `delete` | `--store-id`, `--model-id` | `fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4` | +| [Read Relationship Tuples](#read-relationship-tuples) | `read` | `--store-id`, `--model-id` | `fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | +| [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) | `changes` | `--store-id`, `--model-id` | `fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | +| [Import Relationship Tuples](#import-relationship-tuplesl) | `import` | `--store-id`, `--model-id`, `--file` | `fga tuple import --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --file=tuples.json` | ##### Write Relationship Tuples @@ -443,6 +440,69 @@ fga tuple **changes** --type --store-id= } ``` +##### Import Relationship Tuples + +###### Command +fga tuple **import** --store-id= [--model-id=] --file= [--max-tuples-per-write=] [--max-parallel-requests=] + +###### Parameters +* `--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 +* `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=20) +* `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) + +File format should be: +In YAML: +```yaml +- user: user:anne + relation: can_view + object: document:roadmap +- user: user:beth + relation: can_view + object: document:roadmap +``` + +In JSON: + +```json +[{ + "user": "user:anne", + "relation": "can_view", + "object": "document:roadmap" +}, { + "user": "user:beth", + "relation": "can_view", + "object": "document:roadmap" +}] +``` + +###### Example +`fga tuple import --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` + +###### JSON Response +```json5 +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [ + { + "tuple_key": { + "object":"document:roadmap", + "relation":"writer", + "user":"carl" + }, + "reason":"Write validation error ..." + } + ] +} +``` + #### Relationship Queries - `query` diff --git a/cmd/model/write.go b/cmd/model/write.go index a2395ce..4887ec8 100644 --- a/cmd/model/write.go +++ b/cmd/model/write.go @@ -47,8 +47,8 @@ func write(fgaClient client.SdkClient, text string) (*client.ClientWriteAuthoriz var writeCmd = &cobra.Command{ Use: "write", Short: "Write Authorization Model", - Args: cobra.ExactArgs(1), - Example: `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions": [ { "type": "user" }, { "type": "document", "relations": { "can_view": { "this": {} } }, "metadata": { "relations": { "can_view": { "directly_related_user_types": [ { "type": "user" } ] }}}} ], "schema_version": "1.1"}'`, //nolint:lll + Args: cobra.MaximumNArgs(1), + Example: `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.json`, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) @@ -57,7 +57,26 @@ var writeCmd = &cobra.Command{ return fmt.Errorf("failed to initialize FGA Client due to %w", err) } - response, err := write(fgaClient, args[0]) + 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 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) + } else { + if len(args) == 0 || args[0] == "-" { + return cmd.Help() //nolint:wrapcheck + } + inputModel = args[0] + } + + response, err := write(fgaClient, inputModel) if err != nil { return err } @@ -68,6 +87,7 @@ var writeCmd = &cobra.Command{ 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") 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/tuple/import.go b/cmd/tuple/import.go new file mode 100644 index 0000000..462e1e4 --- /dev/null +++ b/cmd/tuple/import.go @@ -0,0 +1,145 @@ +/* +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 tuple + +import ( + "context" + "fmt" + "os" + + cmdutils "github.com/openfga/cli/lib/cmd-utils" + "github.com/openfga/cli/lib/output" + "github.com/openfga/go-sdk/client" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// MaxTuplesPerWrite Limit the tuples in a single batch. +var MaxTuplesPerWrite = 20 + +// MaxParallelRequests Limit the parallel writes to the API. +var MaxParallelRequests = 4 + +type failedWriteResponse struct { + TupleKey client.ClientTupleKey `json:"tuple_key"` + Reason string `json:"reason"` +} + +type importResponse struct { + Successful []client.ClientTupleKey `json:"successful"` + Failed []failedWriteResponse `json:"failed"` +} + +func importTuples( + fgaClient client.SdkClient, + tuples []client.ClientTupleKey, + maxTuplesPerWrite int, + maxParallelRequests int, +) (*importResponse, error) { + options := client.ClientWriteOptions{ + Transaction: &client.TransactionOptions{ + Disable: true, + MaxPerChunk: int32(maxTuplesPerWrite), + MaxParallelRequests: int32(maxParallelRequests), + }, + } + + deletes := []client.ClientTupleKey{} + body := &client.ClientWriteRequest{ + Writes: &tuples, + Deletes: &deletes, + } + + response, err := fgaClient.Write(context.Background()).Body(*body).Options(options).Execute() + if err != nil { + return nil, fmt.Errorf("failed to import tuples due to %w", err) + } + + successfulWrites := []client.ClientTupleKey{} + failedWrites := []failedWriteResponse{} + + for index := 0; index < len(response.Writes); index++ { + write := response.Writes[index] + if write.Status == client.SUCCESS { + successfulWrites = append(successfulWrites, write.TupleKey) + } else { + failedWrites = append(failedWrites, failedWriteResponse{ + TupleKey: write.TupleKey, + Reason: write.Error.Error(), + }) + } + } + + result := importResponse{Successful: successfulWrites, Failed: failedWrites} + + return &result, nil +} + +// importCmd represents the import command. +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import Relationship Tuples", + Long: "Imports Relationship Tuples to the store. " + + "This will write the tuples in chunks and at the end will report the tuple chunks that failed", + 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) + } + + fileName, err := cmd.Flags().GetString("file") + if err != nil { + return fmt.Errorf("failed to parse file name due to %w", err) + } + + 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) + } + + tuples := []client.ClientTupleKey{} + + data, err := os.ReadFile(fileName) + if err != nil { + return fmt.Errorf("failed to read file %s due to %w", fileName, err) + } + + err = yaml.Unmarshal(data, &tuples) + if err != nil { + return fmt.Errorf("failed to parse input tuples due to %w", err) + } + + result, err := importTuples(fgaClient, tuples, maxTuplesPerWrite, maxParallelRequests) + if err != nil { + return err + } + + return output.Display(*result) //nolint:wrapcheck + }, +} + +func init() { + importCmd.Flags().String("file", "", "Tuples file") + importCmd.Flags().Int("max-tuples-per-write", MaxTuplesPerWrite, "Max tuples per write chunk.") + importCmd.Flags().Int("max-parallel-requests", MaxParallelRequests, "Max number of requests to issue to the server in parallel.") //nolint:lll +} diff --git a/cmd/tuple/tuple.go b/cmd/tuple/tuple.go index 3b82df1..6dae81c 100644 --- a/cmd/tuple/tuple.go +++ b/cmd/tuple/tuple.go @@ -29,10 +29,11 @@ var TupleCmd = &cobra.Command{ } func init() { + TupleCmd.AddCommand(changesCmd) + TupleCmd.AddCommand(readCmd) TupleCmd.AddCommand(writeCmd) TupleCmd.AddCommand(deleteCmd) - TupleCmd.AddCommand(readCmd) - TupleCmd.AddCommand(changesCmd) + TupleCmd.AddCommand(importCmd) TupleCmd.PersistentFlags().String("store-id", "", "Store ID") err := TupleCmd.MarkPersistentFlagRequired("store-id") diff --git a/cmd/version.go b/cmd/version.go index 28eb535..c21bcb1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -var versionStr = fmt.Sprintf("`%s` (commit: `%s`, date: `%s`)", build.Version, build.Commit, build.Date) +var versionStr = fmt.Sprintf("v`%s` (commit: `%s`, date: `%s`)", build.Version, build.Commit, build.Date) // versionCmd is the entrypoint for the `fga version“ command. var versionCmd *cobra.Command = &cobra.Command{ diff --git a/go.mod b/go.mod index 2b083ef..5e25d6c 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,10 @@ require ( github.com/mattn/go-isatty v0.0.19 github.com/nwidger/jsoncolor v0.3.2 github.com/oklog/ulid/v2 v2.1.0 - github.com/openfga/go-sdk v0.2.3-0.20230706193033-786d614eebbd + github.com/openfga/go-sdk v0.2.3-0.20230710203920-f6922b2d8c6d github.com/openfga/openfga v1.2.0 github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.16.0 go.buf.build/openfga/go/openfga/api v1.2.58 google.golang.org/protobuf v1.31.0 @@ -32,7 +33,6 @@ require ( github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect go.buf.build/openfga/go/envoyproxy/protoc-gen-validate v1.2.8 // indirect go.buf.build/openfga/go/grpc-ecosystem/grpc-gateway v1.2.51 // indirect diff --git a/go.sum b/go.sum index 00cc6ba..7f31b2a 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,8 @@ github.com/nwidger/jsoncolor v0.3.2 h1:rVJJlwAWDJShnbTYOQ5RM7yTA20INyKXlJ/fg4JMh github.com/nwidger/jsoncolor v0.3.2/go.mod h1:Cs34umxLbJvgBMnVNVqhji9BhoT/N/KinHqZptQ7cf4= 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.20230706193033-786d614eebbd h1:T/gza+TXR9Ld9FQIOzZhitmFQ+k7k6F8fUFG8NtrWBc= -github.com/openfga/go-sdk v0.2.3-0.20230706193033-786d614eebbd/go.mod h1:ZB13O8GilPc0ITWssOszgxmz6CnIe8PQLZqbqAnx2IY= +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/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= diff --git a/internal/build/build.go b/internal/build/build.go index 356e754..dc173ed 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -2,9 +2,8 @@ // packages within this project can use this information in logs etc.. package build -var ( - - // Version is the build version of the app (e.g. v0.1.0). +const ( + // Version is the build version of the app (e.g. 0.1.0). Version = "dev" // Commit is the sha of the git commit the app was built against. diff --git a/lib/fga/fga.go b/lib/fga/fga.go index 012061d..0747f8d 100644 --- a/lib/fga/fga.go +++ b/lib/fga/fga.go @@ -3,12 +3,13 @@ package fga import ( "net/url" + "github.com/openfga/cli/internal/build" openfga "github.com/openfga/go-sdk" "github.com/openfga/go-sdk/client" "github.com/openfga/go-sdk/credentials" ) -const userAgent = "openfga-cli/0.0.1" +const userAgent = "openfga-cli/" + build.Version type ClientConfig struct { ServerURL string `json:"server_url,omitempty"`