Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ linters:
- github.com/spf13/viper
- golang.org/x/time/rate
- google.golang.org/protobuf/encoding/protojson
- google.golang.org/protobuf/proto
- google.golang.org/protobuf/types/known/structpb
- gopkg.in/yaml.v3
test:
Expand All @@ -59,6 +60,7 @@ linters:
- go.uber.org/mock/gomock
- github.com/spf13/cobra
- github.com/spf13/viper
- google.golang.org/protobuf/proto
funlen:
lines: 120
statements: 80
Expand Down
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,13 +491,16 @@ fga model **get**
* `--store-id`: Specifies the store id
* `--model-id`: Specifies the model id
* `--format`: Authorization model output format. Can be "fga" or "json" (default fga).
* `--field`: Fields to display, choices are: `id`, `created_at` and `model`. Default is `model`.
* `--field`: Fields to display, choices are: `id`, `created_at`, `size` and `model`. Default is `model`.

###### Example
`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1`
`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at`

###### Response
```python
# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1
# Created At: 2023-04-11 23:26:34.759 +0000 UTC
# Size: 20.05 KB
model
schema 1.1

Expand All @@ -508,6 +511,20 @@ type document
define can_view: [user]
```

In `json` format, the fields appear intermixed with the model.

`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at --format=json`

```json
{
"id":"01GXSA8YR785C4FYS3C0RTG7B1",
"created_at":"2023-04-11T23:26:34.759Z",
"size_kb":20.05,
"schema_version":"1.1",
"type_definitions": [...]
}
```

##### Read the Latest Authorization Model

If `model-id` is not specified when using the `get` command, the latest authorization model will be returned.
Expand Down Expand Up @@ -545,22 +562,24 @@ fga model **validate**
###### Example
`fga model validate --file model.json`

When the input parses successfully, the JSON response includes `size_kb`, the protobuf-serialized size of the model in KB. If the input cannot be parsed, the command exits with an error and does not emit a JSON response.

###### JSON Response
* Valid model with an ID
```json5
{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true}
{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05}
```
* Valid model without an ID
```json5
{"is_valid":true}
{"is_valid":true,"size_kb":0.05}
```
* Invalid model with an ID
```json5
{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version"}
{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05}
```
* Invalid model without an ID
```json5
{"is_valid":false,"error":"the relation type 'employee' on 'member' in object type 'group' is not valid"}
{"is_valid":false,"error":"the relation type 'employee' on 'member' in object type 'group' is not valid","size_kb":0.05}
```

##### Run Tests on an Authorization Model
Expand Down
2 changes: 1 addition & 1 deletion cmd/model/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ 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().StringArray("field", []string{"model"}, "Fields to display, choices are: id, created_at, size 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 {
Expand Down
6 changes: 5 additions & 1 deletion cmd/model/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type validationResult struct {
CreatedAt *time.Time `json:"created_at,omitempty"`
IsValid bool `json:"is_valid"`
Error *string `json:"error,omitempty"`
SizeKB *float64 `json:"size_kb,omitempty"`
}

func validate(inputModel authorizationmodel.AuthzModel) validationResult {
Expand All @@ -54,7 +55,7 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult {
return output
}

err = protojson.Unmarshal([]byte(*modelJSONString), model)
err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(*modelJSONString), model)
if err != nil {
output.IsValid = false
errorString := "unable to parse json input"
Expand All @@ -63,6 +64,9 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult {
return output
}

sizeKB := authorizationmodel.ProtoModelSizeInKB(model)
output.SizeKB = &sizeKB

if model.GetId() != "" {
output.ID = model.GetId()

Expand Down
92 changes: 66 additions & 26 deletions cmd/model/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,54 +22,46 @@ func TestValidate(t *testing.T) {
t.Parallel()

type validationTest struct {
Name string
Input string
IsValid bool
ExpectedOutput validationResult
Name string
Input string
ExpectParseError bool
ExpectedOutput validationResult
}

tests := []validationTest{
{
Name: "missing schema version",
Input: "{",
IsValid: false,
ExpectedOutput: validationResult{
IsValid: false,
Error: openfga.PtrString("unable to parse json input"),
},
Name: "invalid json",
Input: "{",
ExpectParseError: true,
},
{
Name: "missing schema version",
Input: `{"id":"abcde","schema_version":"1.1"}`,
IsValid: false,
Name: "invalid model id",
Input: `{"id":"abcde","schema_version":"1.1"}`,
ExpectedOutput: validationResult{
ID: "abcde",
IsValid: false,
Error: openfga.PtrString("unable to parse id: invalid ulid format"),
},
},
{
Name: "missing schema version",
Input: "{}",
IsValid: false,
Name: "missing schema version",
Input: "{}",
ExpectedOutput: validationResult{
IsValid: false,
Error: openfga.PtrString("invalid schema version"),
},
},
{
Name: "invalid schema version",
Input: `{"schema_version":"1.0"}`,
IsValid: false,
Name: "invalid schema version",
Input: `{"schema_version":"1.0"}`,
ExpectedOutput: validationResult{
IsValid: false,
Error: openfga.PtrString("invalid schema version"),
},
},
{
Name: "only schema",
Input: `{"schema_version":"1.1"}`,
IsValid: true,
Name: "only schema",
Input: `{"schema_version":"1.1"}`,
ExpectedOutput: validationResult{
IsValid: true,
},
Expand All @@ -83,15 +75,63 @@ func TestValidate(t *testing.T) {
model := authorizationmodel.AuthzModel{}

err := model.ReadFromJSONString(test.Input)
if err != nil {
if test.ExpectParseError {
if err == nil {
t.Fatalf("Expected parse error for input %q, got none", test.Input)
}

return
}

if err != nil {
t.Fatalf("Unexpected parse error for input %q: %v", test.Input, err)
}

expected := test.ExpectedOutput
size := model.GetSizeInKB()
expected.SizeKB = &size

output := validate(model)

if !reflect.DeepEqual(output, test.ExpectedOutput) {
t.Fatalf("Expect output %v actual %v", test.ExpectedOutput, output)
if !reflect.DeepEqual(output, expected) {
t.Fatalf("Expect output %v actual %v", expected, output)
}
})
}
}

func TestValidateReportsSize(t *testing.T) {
t.Parallel()

model := authorizationmodel.AuthzModel{}
if err := model.ReadFromJSONString(`{"schema_version":"1.1"}`); err != nil {
t.Fatalf("unexpected parse error: %v", err)
}

result := validate(model)
if result.SizeKB == nil {
t.Fatalf("expected SizeKB to be set")
}

if *result.SizeKB != model.GetSizeInKB() {
t.Errorf("expected %v to equal %v", *result.SizeKB, model.GetSizeInKB())
}
}

func TestValidateWithValidIDReportsSize(t *testing.T) {
t.Parallel()

model := authorizationmodel.AuthzModel{}
if err := model.ReadFromJSONString(`{"id":"01GVKXGDCV2SMG6TRE9NMBQ2VG","schema_version":"1.1"}`); err != nil {
t.Fatalf("unexpected parse error: %v", err)
}

result := validate(model)
if !result.IsValid {
t.Fatalf("expected valid model, got error: %v", *result.Error)
}

if result.SizeKB == nil {
t.Fatalf("expected SizeKB to be set")
}
}
70 changes: 52 additions & 18 deletions internal/authorizationmodel/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path"
"time"
Expand All @@ -29,6 +30,7 @@ import (
openfga "github.com/openfga/go-sdk"
language "github.com/openfga/language/pkg/go/transformer"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"

"github.com/openfga/cli/internal/slices"
)
Expand All @@ -51,6 +53,7 @@ type AuthzModelList struct {
type AuthzModel struct {
ID *string `json:"id,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
SizeKB *float64 `json:"size_kb,omitempty"`
SchemaVersion *string `json:"schema_version,omitempty"`
TypeDefinitions *[]openfga.TypeDefinition `json:"type_definitions,omitempty"`
Conditions map[string]*openfga.Condition `json:"conditions,omitempty"`
Expand Down Expand Up @@ -112,13 +115,29 @@ func (model *AuthzModel) GetProtoModel() *pb.AuthorizationModel {
return nil
}

if err = protojson.Unmarshal([]byte(*jsonModel), &pbModel); err != nil {
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(*jsonModel), &pbModel); err != nil {
return nil
}

return &pbModel
}

func (model *AuthzModel) GetSizeInKB() float64 {
return ProtoModelSizeInKB(model.GetProtoModel())
}

// ProtoModelSizeInKB returns the protobuf-serialized size of the model in KB,
// rounded to two decimal places. Returns 0 for a nil model.
func ProtoModelSizeInKB(pbModel *pb.AuthorizationModel) float64 {
if pbModel == nil {
return 0
}

sizeKB := float64(proto.Size(pbModel)) / 1024.0 //nolint:mnd

return math.Round(sizeKB*100) / 100 //nolint:mnd
}

func (model *AuthzModel) GetCreatedAt() *time.Time {
if model == nil {
return nil
Expand Down Expand Up @@ -318,6 +337,11 @@ func (model *AuthzModel) DisplayAsJSON(fields []string) AuthzModel {
newModel.Conditions = model.Conditions
}

if slices.Contains(fields, "size") {
size := model.GetSizeInKB()
newModel.SizeKB = &size
}

return newModel
}

Expand All @@ -328,23 +352,7 @@ func (model *AuthzModel) DisplayAsDSL(fields []string) (*string, error) {
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")
}
}
dslModel := model.buildDSLMetadata(fields)

if slices.Contains(fields, "model") {
modelJSON, err := model.GetAsJSONString()
Expand All @@ -368,6 +376,32 @@ func (model *AuthzModel) DisplayAsDSL(fields []string) (*string, error) {
return &dslModel, nil
}

func (model *AuthzModel) buildDSLMetadata(fields []string) string {
metadata := ""

if slices.Contains(fields, "id") {
if model.ID != nil {
metadata += fmt.Sprintf("# Model ID: %v\n", *model.ID)
} else {
metadata += fmt.Sprintf("# Model ID: %v\n", "N/A")
}
}

if slices.Contains(fields, "created_at") {
if model.CreatedAt != nil {
metadata += fmt.Sprintf("# Created At: %v\n", *model.CreatedAt)
} else {
metadata += fmt.Sprintf("# Created At: %v\n", "N/A")
}
}

if slices.Contains(fields, "size") {
metadata += fmt.Sprintf("# Size: %.2f KB\n", model.GetSizeInKB())
}

return metadata
}

func (model *AuthzModel) setCreatedAt() {
if *model.ID != "" {
modelID, err := ulid.Parse(*model.ID)
Expand Down
Loading
Loading