Skip to content

Commit

Permalink
feat: add export store command (#306)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevehobbsdev committed Apr 26, 2024
2 parents f8c8118 + 31cb129 commit 41075bc
Show file tree
Hide file tree
Showing 11 changed files with 656 additions and 85 deletions.
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,72 @@ fga store **import**
{}
```

##### Export Store

###### Command
fga store **export**

###### Parameters
* `--store-id`: Specifies the store to export
* `--output-file`: The file to output the store to (optional, writes to the terminal if omitted)
* `--model-id`: Specifies the model to export (optional, exports the latest model if omitted)
* `--max-tuples`: Specifies the max number of tuples to include in the output (option, defaults to 100)

###### Example
`fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4`

###### Response
```yaml
name: Test
model: |+
model
schema 1.1
type user
type group
relations
define member: [user]
define moderator: [user]
tuples:
- user: user:1
relation: member
object: group:admins
- user: user:1
relation: member
object: group:employees
- user: user:2
relation: member
object: group:employees
- user: user:1
relation: moderator
object: group:employees
tests:
- name: Tests
check:
- user: user:1
object: group:admins
assertions:
member: true
- user: user:2
object: group:admins
assertions:
member: false
- user: user:1
object: group:employees
assertions:
member: true
moderator: true
- user: user:2
object: group:employees
assertions:
member: true
moderator: false
```

If using `output-file`, the response will be written to the specified file on disk. If the desired file already exists, you will be prompted to overwrite the file.

##### List Stores

###### Command
Expand Down
44 changes: 2 additions & 42 deletions cmd/model/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,56 +17,16 @@ limitations under the License.
package model

import (
"context"
"fmt"
"os"

openfga "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/client"
"github.com/spf13/cobra"

"github.com/openfga/cli/internal/authorizationmodel"
"github.com/openfga/cli/internal/clierrors"
"github.com/openfga/cli/internal/cmdutils"
"github.com/openfga/cli/internal/fga"
"github.com/openfga/cli/internal/output"
)

func getModel(clientConfig fga.ClientConfig, fgaClient client.SdkClient) (*openfga.ReadAuthorizationModelResponse,
error,
) {
authorizationModelID := clientConfig.AuthorizationModelID

var err error

var model *openfga.ReadAuthorizationModelResponse

if authorizationModelID != "" {
options := client.ClientReadAuthorizationModelOptions{
AuthorizationModelId: openfga.PtrString(authorizationModelID),
}
model, err = fgaClient.ReadAuthorizationModel(context.Background()).Options(options).Execute()
} else {
options := client.ClientReadLatestAuthorizationModelOptions{}
model, err = fgaClient.ReadLatestAuthorizationModel(context.Background()).Options(options).Execute()
}

if err != nil {
return nil, fmt.Errorf("failed to get model %v due to %w", clientConfig.AuthorizationModelID, err)
}

if model.AuthorizationModel == nil {
// If there is no model, try to get the store
if _, err := fgaClient.GetStore(context.Background()).Execute(); err != nil {
return nil, fmt.Errorf("failed to get model %v due to %w", clientConfig.AuthorizationModelID, err)
}

return nil, fmt.Errorf("%w", clierrors.ErrAuthorizationModelNotFound)
}

return model, nil
}

// getCmd represents the get command.
var getCmd = &cobra.Command{
Use: "get",
Expand All @@ -81,9 +41,9 @@ var getCmd = &cobra.Command{
return fmt.Errorf("failed to initialize FGA Client due to %w", err)
}

response, err := getModel(clientConfig, fgaClient)
response, err := authorizationmodel.ReadFromStore(clientConfig, fgaClient)
if err != nil {
return err
return err //nolint:wrapcheck
}

authModel := authorizationmodel.AuthzModel{}
Expand Down
7 changes: 4 additions & 3 deletions cmd/model/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
openfga "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/client"

"github.com/openfga/cli/internal/authorizationmodel"
"github.com/openfga/cli/internal/fga"
mock_client "github.com/openfga/cli/internal/mocks"
)
Expand Down Expand Up @@ -42,7 +43,7 @@ func TestGetModelNoAuthModelID(t *testing.T) {

var clientConfig fga.ClientConfig

output, err := getModel(clientConfig, mockFgaClient)
output, err := authorizationmodel.ReadFromStore(clientConfig, mockFgaClient)
if err != nil {
t.Fatalf("%v", err)
} else if *output != expectedResponse {
Expand Down Expand Up @@ -80,7 +81,7 @@ func TestGetModelAuthModelID(t *testing.T) {
AuthorizationModelID: "01GXSA8YR785C4FYS3C0RTG7B1",
}

output, err := getModel(clientConfig, mockFgaClient)
output, err := authorizationmodel.ReadFromStore(clientConfig, mockFgaClient)
if err != nil {
t.Fatalf("%v", err)
} else if *output != expectedResponse {
Expand Down Expand Up @@ -109,7 +110,7 @@ func TestGetModelNoAuthModelIDError(t *testing.T) {

var clientConfig fga.ClientConfig

_, err := getModel(clientConfig, mockFgaClient)
_, err := authorizationmodel.ReadFromStore(clientConfig, mockFgaClient)
if err == nil {
t.Fatalf("Expect error but there is none")
}
Expand Down
201 changes: 201 additions & 0 deletions cmd/store/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*
Copyright © 2023 OpenFGA
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package store

import (
"context"
"fmt"
"math"
"os"

"github.com/openfga/go-sdk/client"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/openfga/cli/internal/authorizationmodel"
"github.com/openfga/cli/internal/cmdutils"
"github.com/openfga/cli/internal/confirmation"
"github.com/openfga/cli/internal/fga"
"github.com/openfga/cli/internal/output"
"github.com/openfga/cli/internal/storetest"
"github.com/openfga/cli/internal/tuple"
)

// defaultTuplePageSize defines the default number of pages to return when calling Read.
const (
defaultMaxTupleCount = 100
)

// buildStoreData compiles all the data necessary to output to file or stdout,
// or returns an error if this was not successful.
func buildStoreData(config fga.ClientConfig, fgaClient client.SdkClient, maxTupleCount uint) (*storetest.StoreData, error) { //nolint:lll
// get the store
store, _ := fgaClient.GetStore(context.Background()).Execute()

var storeName string
if store != nil {
storeName = store.Name
}

// get the model
model, err := authorizationmodel.ReadFromStore(config, fgaClient)
if err != nil {
return nil, err //nolint:wrapcheck
}

authModel := authorizationmodel.AuthzModel{}
authModel.Set(*model.AuthorizationModel)

dsl, err := authModel.DisplayAsDSL([]string{"model"})
if err != nil {
return nil, fmt.Errorf("unable to get model dsl: %w", err)
}

// get the tuples
maxPages := int(math.Ceil(float64(maxTupleCount) / float64(tuple.DefaultReadPageSize)))

rawTuples, err := tuple.Read(fgaClient, &client.ClientReadRequest{}, maxPages)
if err != nil {
return nil, fmt.Errorf("unable to read tuples: %w", err)
}

tuples := rawTuples.GetTuples()
maxTuplesInOutput := int(math.Min(float64(len(tuples)), float64(maxTupleCount)))
outputTuples := make([]client.ClientContextualTupleKey, 0, maxTuplesInOutput)

for _, t := range tuples[:maxTuplesInOutput] {
outputTuples = append(outputTuples, t.GetKey())
}

// get the assertions
assertionOptions := client.ClientReadAssertionsOptions{
AuthorizationModelId: authModel.ID,
}

assertionResponse, err := fgaClient.ReadAssertions(context.Background()).Options(assertionOptions).Execute()
if err != nil {
return nil, fmt.Errorf("unable to read assertions: %w", err)
}

assertions := assertionResponse.GetAssertions()
modelChecks := map[string]storetest.ModelTestCheck{}

for _, assertion := range assertions {
key := fmt.Sprintf("%s|%s", assertion.TupleKey.User, assertion.TupleKey.Object)
_, exists := modelChecks[key]

if !exists {
modelChecks[key] = storetest.ModelTestCheck{
User: assertion.TupleKey.User,
Object: assertion.TupleKey.Object,
Assertions: map[string]bool{},
}
}

modelChecks[key].Assertions[assertion.GetTupleKey().Relation] = assertion.Expectation
}

checks := []storetest.ModelTestCheck{}
for _, value := range modelChecks {
checks = append(checks, value)
}

storeData := &storetest.StoreData{
Name: storeName,
Model: *dsl,
Tuples: outputTuples,
Tests: []storetest.ModelTest{
{
Name: "Tests",
Check: checks,
},
},
}

return storeData, nil
}

// exportCmd represents the export store command.
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export store data",
Long: `Export a store to YAML`,
Example: "fga store export",
RunE: func(cmd *cobra.Command, _ []string) error {
clientConfig := cmdutils.GetClientConfig(cmd)

fgaClient, err := clientConfig.GetFgaClient()
if err != nil {
return fmt.Errorf("failed to initialize FGA Client due to %w", err)
}

maxTupleCount, _ := cmd.Flags().GetUint("max-tuples")
storeData, err := buildStoreData(clientConfig, fgaClient, maxTupleCount)
if err != nil {
return fmt.Errorf("failed to export store: %w", err)
}

if storeData != nil {
storeYaml, err := yaml.Marshal(storeData)
if err != nil {
return fmt.Errorf("unable to marshal storedata yaml: %w", err)
}

fileName, _ := cmd.Flags().GetString("output-file")
if fileName == "" {
fmt.Println(string(storeYaml))

return nil
}

if _, err := os.Stat(fileName); err == nil {
confirm, err := confirmation.AskForConfirmation("File exists, overwrite?")
if err != nil {
return fmt.Errorf("prompt failed due to %w", err)
}

if !confirm {
fmt.Println("cancelled")

return output.Display(output.EmptyStruct{})
}
}

err = os.WriteFile(fileName, storeYaml, 0o600) //nolint:gomnd
if err != nil {
return err //nolint:wrapcheck
}

fmt.Printf("model written to %s\n", fileName)
}

return output.Display(output.EmptyStruct{})
},
}

func init() {
exportCmd.Flags().String("output-file", "", "name of the file to export the store to")
exportCmd.Flags().String("store-id", "", "store ID")
exportCmd.Flags().String("model-id", "", "Authorization Model ID")
exportCmd.Flags().Uint("max-tuples", defaultMaxTupleCount, "max number of tuples to return in the output")

err := exportCmd.MarkFlagRequired("store-id")
if err != nil {
fmt.Print(err)
os.Exit(1)
}
}
Loading

0 comments on commit 41075bc

Please sign in to comment.