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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ fga store **import**
* `--store-id`: Specifies the store id to import into
* `--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)
* `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the store file to resolve outside the store file's directory (optional, default=false). Only enable this for store files you trust.

###### Example
`fga store import --file model.fga.yaml`
Expand Down Expand Up @@ -596,6 +597,7 @@ fga model **test**
* `--tests`: Name of the tests file, or a glob pattern to multiple files (for example `"tests/*.fga.yaml"`, or `"**/*.fga.yaml"`). Each file must be in yaml format. See [Store File Format](docs/STORE_FILE.md) for detailed documentation.
* `--verbose`: Outputs the results in JSON
* `--max-types-per-authorization-model`: Max allowed number of type definitions per authorization model (default: 100). Increase this when testing models with more than 100 type definitions.
* `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the test file to resolve outside the test file's directory (optional, default=false). Only enable this for test files you trust.

If a model is provided, the test will run in a built-in OpenFGA instance (you do not need a separate server). Otherwise, the test will be run against the configured store of your OpenFGA instance. When running against a remote instance, the tuples will be sent as contextual tuples, and will have to abide by the OpenFGA server limits (20 contextual tuples per request).

Expand Down
8 changes: 7 additions & 1 deletion cmd/model/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ var modelTestCmd = &cobra.Command{
return fmt.Errorf("failed to get suppress-summary flag: %w", err)
}

allowExternalFiles, err := cmd.Flags().GetBool("allow-external-files")
if err != nil {
return fmt.Errorf("failed to get allow-external-files flag: %w", err)
}

maxTypes, err := cmd.Flags().GetInt("max-types-per-authorization-model")
if err != nil {
return fmt.Errorf("failed to get max-types-per-authorization-model flag: %w", err)
Expand Down Expand Up @@ -94,7 +99,7 @@ var modelTestCmd = &cobra.Command{
summaries := []string{}

for _, file := range fileNames {
format, storeData, err := storetest.ReadFromFile(file, "")
format, storeData, err := storetest.ReadFromFile(file, "", allowExternalFiles)
if err != nil {
return fmt.Errorf("failed to read test file %s: %w", file, err)
}
Expand Down Expand Up @@ -172,6 +177,7 @@ func init() {
modelTestCmd.Flags().Bool("suppress-summary", false, "Suppress the plain text summary output")
modelTestCmd.Flags().Int("max-types-per-authorization-model", 100, //nolint:mnd
"Max allowed number of type definitions per authorization model")
modelTestCmd.Flags().Bool("allow-external-files", false, "Allow model_file, tuple_file and tuple_files references in the test file to resolve to paths outside the test file's directory. Only enable this for test files you trust.") //nolint:lll

if err := modelTestCmd.MarkFlagRequired("tests"); err != nil {
fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/test", err)
Expand Down
16 changes: 15 additions & 1 deletion cmd/store/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ func CreateStoreWithModel(
storeName string,
inputModel string,
inputFormat authorizationmodel.ModelFormat,
) (*CreateStoreAndModelResponse, error) {
return CreateStoreWithModelContained(ctx, fgaClient, storeName, inputModel, inputFormat, "")
}

// CreateStoreWithModelContained is CreateStoreWithModel, but for a modular
// model it contains every file the fga.mod references to containBase. Pass an
// empty containBase to read without containment.
func CreateStoreWithModelContained(
ctx context.Context,
fgaClient client.SdkClient,
storeName string,
inputModel string,
inputFormat authorizationmodel.ModelFormat,
containBase string,
) (*CreateStoreAndModelResponse, error) {
response := CreateStoreAndModelResponse{}

Expand All @@ -76,7 +90,7 @@ func CreateStoreWithModel(
if inputModel != "" {
authModel := authorizationmodel.AuthzModel{}

err = authModel.ReadModelFromString(inputModel, inputFormat)
err = authModel.ReadModelFromStringContained(inputModel, inputFormat, containBase)
if err != nil {
return nil, err //nolint:wrapcheck
}
Expand Down
18 changes: 14 additions & 4 deletions cmd/store/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ func createStore(
storeDataName = strings.TrimSuffix(path.Base(fileName), ".fga.yaml")
}

createStoreAndModelResponse, err := CreateStoreWithModel(ctx, fgaClient, storeDataName, storeData.Model, format)
// The contain base travels with the store data so that a modular model
// referenced from the store file keeps its module reads contained on the
// create path too, exactly as updateStore does.
createStoreAndModelResponse, err := CreateStoreWithModelContained(
ctx, fgaClient, storeDataName, storeData.Model, format, storeData.ModelContainBase())
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -97,7 +101,7 @@ func updateStore(
authModel := authorizationmodel.AuthzModel{}
clientConfig.StoreID = storeID

if err := authModel.ReadModelFromString(storeData.Model, format); err != nil {
if err := authModel.ReadModelFromStringContained(storeData.Model, format, storeData.ModelContainBase()); err != nil {
Comment thread
SoulPancake marked this conversation as resolved.
return nil, fmt.Errorf("failed to read model: %w", err)
}

Expand Down Expand Up @@ -317,7 +321,12 @@ var importCmd = &cobra.Command{
return fmt.Errorf("failed to get file name: %w", err)
}

format, storeData, err := storetest.ReadFromFile(fileName, "")
allowExternalFiles, err := cmd.Flags().GetBool("allow-external-files")
if err != nil {
return fmt.Errorf("failed to get allow-external-files flag: %w", err)
}

format, storeData, err := storetest.ReadFromFile(fileName, "", allowExternalFiles)
if err != nil {
return fmt.Errorf("failed to read from file: %w", err)
}
Expand Down Expand Up @@ -347,7 +356,8 @@ func init() {
importCmd.Flags().String("file", "", "File Name. The file should have the store")
importCmd.Flags().String("store-id", "", "Store ID")
importCmd.Flags().Int("max-tuples-per-write", tuple.MaxTuplesPerWrite, "Max tuples per write chunk.")
importCmd.Flags().Int("max-parallel-requests", tuple.MaxParallelRequests, "Max number of requests to issue to the server in parallel.") //nolint:lll
importCmd.Flags().Int("max-parallel-requests", tuple.MaxParallelRequests, "Max number of requests to issue to the server in parallel.") //nolint:lll
Comment thread
SoulPancake marked this conversation as resolved.
importCmd.Flags().Bool("allow-external-files", false, "Allow model_file, tuple_file and tuple_files references in the store file to resolve to paths outside the store file's directory. Only enable this for store files you trust.") //nolint:lll

if err := importCmd.MarkFlagRequired("file"); err != nil {
fmt.Printf("error setting flag as required - %v: %v\n", "cmd/models/write", err)
Expand Down
48 changes: 48 additions & 0 deletions cmd/store/import_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package store

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/openfga/go-sdk/client"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/openfga/cli/internal/fga"
Expand Down Expand Up @@ -278,6 +281,51 @@ func TestImportStoreWithTruncatedAssertions(t *testing.T) {
}
}

// TestImportStoreCreatePathContainsModularModel verifies that importing a
// store without --store-id (the create path) contains the module files of a
// modular model to the store file's directory, exactly as the update path
// does: an fga.mod contents entry whose file is a symlink pointing outside the
// tree must be rejected before any model is written.
func TestImportStoreCreatePathContainsModularModel(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()

// The file the module entry escapes to, outside the store directory.
outside := filepath.Join(tmpDir, "outside.fga")
require.NoError(t, os.WriteFile(outside, []byte("module core\n"), 0o600))

storeDir := filepath.Join(tmpDir, "store")
require.NoError(t, os.Mkdir(storeDir, 0o750))

modFile := "schema: '1.2'\ncontents:\n - core.fga\n"
require.NoError(t, os.WriteFile(filepath.Join(storeDir, "model.fga.mod"), []byte(modFile), 0o600))

// The module file named by the fga.mod is a symlink out of the tree.
require.NoError(t, os.Symlink(
filepath.Join("..", "outside.fga"),
filepath.Join(storeDir, "core.fga"),
))

storeFile := filepath.Join(storeDir, "store.fga.yaml")
require.NoError(t, os.WriteFile(storeFile, []byte("name: test-store\nmodel_file: model.fga.mod\n"), 0o600))

format, storeData, err := storetest.ReadFromFile(storeFile, "", false)
require.NoError(t, err)

mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()

mockFgaClient := mockclient.NewMockSdkClient(mockCtrl)

setupCreateStoreMock(mockCtrl, mockFgaClient, testStoreID)
mockFgaClient.EXPECT().WriteAuthorizationModel(gomock.Any()).Times(0)

_, err = importStore(t.Context(), &fga.ClientConfig{}, mockFgaClient, storeData, format, "", 10, 1, storeFile)
require.Error(t, err)
require.ErrorContains(t, err, "core.fga")
}

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

Expand Down
170 changes: 109 additions & 61 deletions internal/authorizationmodel/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import (
"errors"
"fmt"
"math"
"os"
"path"
"path/filepath"
"time"

"github.com/oklog/ulid/v2"
Expand All @@ -32,9 +31,27 @@ import (
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"

"github.com/openfga/cli/internal/safefile"
"github.com/openfga/cli/internal/slices"
)

// readModelFile reads a file belonging to a modular model. When containBase is
// non-empty the read is contained to that directory, so a contents entry cannot
// escape it; otherwise the file is read directly. Either way the target must be
// a regular file.
func readModelFile(filePath string, containBase string) ([]byte, error) {
if containBase == "" {
return safefile.ReadExternal(filePath) //nolint:wrapcheck
}

rel, err := filepath.Rel(containBase, filePath)
if err != nil {
return nil, fmt.Errorf("unable to resolve %q against %q: %w", filePath, containBase, err)
}

return safefile.ReadContained(containBase, rel) //nolint:wrapcheck
}

func getCreatedAtFromModelID(id string) (*time.Time, error) {
modelID, err := ulid.Parse(id)
if err != nil {
Expand Down Expand Up @@ -214,69 +231,34 @@ func (model *AuthzModel) ReadFromDSLString(dslString string) error {
return nil
}

// ReadModelFromModFGA reads a modular model from modFile, resolving its
// contents entries relative to the directory holding modFile with no
// containment. It is used when the user names the fga.mod file directly.
func (model *AuthzModel) ReadModelFromModFGA(modFile string) error {
modFileContents, err := os.ReadFile(modFile)
if err != nil {
return fmt.Errorf("failed to read fga.mod file due to %w", err)
}

parsedModFile, err := language.TransformModFile(string(modFileContents))
if err != nil {
return fmt.Errorf("failed to transform fga.mod file due to %w", err)
}

moduleFiles := []language.ModuleFile{}

var fileReadErrors []error

directory := path.Dir(modFile)

for _, fileName := range parsedModFile.Contents.Value {
filePath := path.Join(directory, fileName.Value)

fileContents, err := os.ReadFile(filePath)
if err != nil {
fileReadErrors = append(
fileReadErrors,
fmt.Errorf("failed to read module file %s due to %w", fileName.Value, err),
)

continue
}

moduleFiles = append(moduleFiles, language.ModuleFile{
Name: fileName.Value,
Contents: string(fileContents),
})
}

if len(fileReadErrors) != 0 {
return errors.Join(fileReadErrors...)
}

parsedAuthModel, err := language.TransformModuleFilesToModel(moduleFiles, parsedModFile.Schema.Value)
if err != nil {
return fmt.Errorf("failed to transform module to model due to %w", err)
}

bytes, err := protojson.Marshal(parsedAuthModel)
if err != nil {
return fmt.Errorf("failed to transform due to %w", err)
}

jsonAuthModel := openfga.AuthorizationModel{}

err = json.Unmarshal(bytes, &jsonAuthModel)
if err != nil {
return fmt.Errorf("failed to transform due to %w", err)
}

model.Set(jsonAuthModel)
return model.readModelFromModFGA(modFile, "")
}

return nil
// ReadModelFromModFGAContained reads a modular model from modFile and contains
// every file it references to containBase. A contents entry that escapes
// containBase (via "..", an absolute path, or a symlink) is rejected, so a
// modular model referenced from a store file cannot read files the store file
// itself could not.
func (model *AuthzModel) ReadModelFromModFGAContained(modFile string, containBase string) error {
return model.readModelFromModFGA(modFile, containBase)
}

func (model *AuthzModel) ReadModelFromString(input string, format ModelFormat) error {
return model.ReadModelFromStringContained(input, format, "")
}

// ReadModelFromStringContained is ReadModelFromString, but for a modular model
// it contains every file the fga.mod references to containBase. Pass an empty
// containBase to read without containment.
func (model *AuthzModel) ReadModelFromStringContained(
input string,
format ModelFormat,
containBase string,
) error {
if input == "" {
return nil
}
Expand All @@ -295,7 +277,7 @@ func (model *AuthzModel) ReadModelFromString(input string, format ModelFormat) e

return nil
case ModelFormatModular:
if err := model.ReadModelFromModFGA(input); err != nil {
if err := model.readModelFromModFGA(input, containBase); err != nil {
return err
}

Expand Down Expand Up @@ -376,6 +358,72 @@ func (model *AuthzModel) DisplayAsDSL(fields []string) (*string, error) {
return &dslModel, nil
}

// readModelFromModFGA reads a modular model. When containBase is non-empty, the
// fga.mod file and each of its contents entries must resolve inside it.
func (model *AuthzModel) readModelFromModFGA(modFile string, containBase string) error {
modFileContents, err := readModelFile(modFile, containBase)
if err != nil {
return fmt.Errorf("failed to read fga.mod file due to %w", err)
}

parsedModFile, err := language.TransformModFile(string(modFileContents))
if err != nil {
return fmt.Errorf("failed to transform fga.mod file due to %w", err)
}

moduleFiles := []language.ModuleFile{}

var fileReadErrors []error

// modFile is an OS filesystem path, while a contents entry inside fga.mod is
// always slash-separated, so each entry is converted before being joined.
directory := filepath.Dir(modFile)

for _, fileName := range parsedModFile.Contents.Value {
filePath := filepath.Join(directory, filepath.FromSlash(fileName.Value))

fileContents, err := readModelFile(filePath, containBase)
if err != nil {
fileReadErrors = append(
fileReadErrors,
fmt.Errorf("failed to read module file %s due to %w", fileName.Value, err),
)

continue
}

moduleFiles = append(moduleFiles, language.ModuleFile{
Name: fileName.Value,
Contents: string(fileContents),
})
}

if len(fileReadErrors) != 0 {
return errors.Join(fileReadErrors...)
}

parsedAuthModel, err := language.TransformModuleFilesToModel(moduleFiles, parsedModFile.Schema.Value)
if err != nil {
return fmt.Errorf("failed to transform module to model due to %w", err)
}

bytes, err := protojson.Marshal(parsedAuthModel)
if err != nil {
return fmt.Errorf("failed to transform due to %w", err)
}

jsonAuthModel := openfga.AuthorizationModel{}

err = json.Unmarshal(bytes, &jsonAuthModel)
if err != nil {
return fmt.Errorf("failed to transform due to %w", err)
}

model.Set(jsonAuthModel)

return nil
}

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

Expand Down
Loading
Loading