Skip to content
Open
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 .mise.toml
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

???

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what @rhamzeh asked to do (this was a part of go toolchain bump).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rhamzeh should we keep it?

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
go = "1.26.2"
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ A cross-platform CLI to interact with an OpenFGA server
- [Building from Source](#building-from-source)
- [Usage](#usage)
- [Configuration](#configuration)
- [Custom Headers](#custom-headers)
- [Commands](#commands)
- [Stores](#stores)
- [List All Stores](#list-stores)
Expand Down Expand Up @@ -151,6 +152,7 @@ For any command that interacts with an OpenFGA server, these configuration value
| Token Audience | `--api-audience` | `FGA_API_AUDIENCE` | `api-audience` |
| Store ID | `--store-id` | `FGA_STORE_ID` | `store-id` |
| Authorization Model ID | `--model-id` | `FGA_MODEL_ID` | `model-id` |
| Custom Headers | `--custom-headers` | `FGA_CUSTOM_HEADERS` | `custom-headers` |
Comment thread
alexanderzobnin marked this conversation as resolved.

If you are authenticating with a shared secret, you should specify the API Token value. If you are authenticating using OAuth, you should specify the Client ID, Client Secret, API Audience and Token Issuer. For example:

Expand All @@ -164,6 +166,37 @@ api-token-issuer: auth.fga.dev
store-id: 01H0H015178Y2V4CX10C2KGHF4
```

#### Custom Headers

You can add custom HTTP headers to all requests sent to the API using the `--custom-headers` flag. Headers are specified in `<name>: <value>` format, and the flag can be repeated to add multiple headers.

##### Flag
```shell
--custom-headers "Header-Name: header-value"
```

##### Example
```shell
fga store list --custom-headers "X-Custom-Header: value1" --custom-headers "X-Request-ID: abc123"
```

##### Configuration

Custom headers can also be configured via the CLI environment variable or the configuration file:

| Name | Flag | CLI | ~/.fga.yaml |
|----------------|----------------------|------------------------|---------------------|
| Custom Headers | `--custom-headers` | `FGA_CUSTOM_HEADERS` | `custom-headers` |

Example `~/.fga.yaml`:
```yaml
api-url: https://api.fga.example
store-id: 01H0H015178Y2V4CX10C2KGHF4
custom-headers:
- "X-Custom-Header: value1"
- "X-Request-ID: abc123"
```
Comment thread
alexanderzobnin marked this conversation as resolved.

### Commands

#### Stores
Expand Down
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ func init() {
rootCmd.PersistentFlags().String("api-token", "", "API Token. Will be sent in as a Bearer in the Authorization header")
rootCmd.PersistentFlags().String("api-token-issuer", "", "API Token Issuer. API responsible for issuing the API Token. Used in the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().String("api-audience", "", "API Audience. Used when performing the Client Credentials flow")
rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll
rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP headers in 'Header: value' format (repeat option for multiple values)") //nolint:lll
rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode - can print more detailed information for debugging")

_ = rootCmd.Flags().MarkHidden("debug")
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module github.com/openfga/cli

go 1.25.0

toolchain go1.26.1
toolchain go1.26.2

require (
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
Expand Down
2 changes: 0 additions & 2 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ limitations under the License.

// Package build provides build information that is linked into the application. Other
// packages within this project can use this information in logs etc..

//nolint:revive // package name conflicts with stdlib is acceptable here
package build

var (
Expand Down
23 changes: 21 additions & 2 deletions internal/cmdutils/bind-viper-to-flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package cmdutils

import (
"fmt"
"reflect"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand All @@ -31,12 +32,30 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) {

if !flag.Changed && viperInstance.IsSet(configName) {
value := viperInstance.Get(configName)
err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", value))
cobra.CheckErr(err)
for _, strVal := range viperValueToStrings(value) {
cobra.CheckErr(cmd.Flags().Set(flag.Name, strVal))
}
}
})

for _, subcmd := range cmd.Commands() {
BindViperToFlags(subcmd, viperInstance)
}
}

// viperValueToStrings converts a Viper config value to a slice of strings
// suitable for pflag.Set calls. Slice values (from YAML lists) produce one
// string per element; scalar values produce a single-element slice.
func viperValueToStrings(value any) []string {
rv := reflect.ValueOf(value)
if rv.Kind() != reflect.Slice {
return []string{fmt.Sprintf("%v", value)}
}

result := make([]string, 0, rv.Len())
for i := range rv.Len() {
result = append(result, fmt.Sprintf("%v", rv.Index(i).Interface()))
}

return result
}
86 changes: 86 additions & 0 deletions internal/cmdutils/bind-viper-to-flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
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 cmdutils

import (
"testing"

"github.com/stretchr/testify/assert"
)

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

testcases := []struct {
name string
value any
expected []string
}{
{
name: "slice value produces one string per element",
value: []any{
"X-Custom-Header: value1",
"X-Request-ID: abc123",
},
expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
},
{
name: "single element slice",
value: []any{"X-Custom-Header: value1"},
expected: []string{"X-Custom-Header: value1"},
},
{
name: "empty slice",
value: []any{},
expected: []string{},
},
{
name: "typed string slice produces one string per element",
value: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
},
{
name: "typed int slice produces one string per element",
value: []int{1, 2, 3},
expected: []string{"1", "2", "3"},
},
{
name: "scalar string produces single-element slice",
value: "https://api.fga.example",
expected: []string{"https://api.fga.example"},
},
{
name: "boolean value is stringified",
value: true,
expected: []string{"true"},
},
{
name: "integer value is stringified",
value: 42,
expected: []string{"42"},
},
}

for _, test := range testcases {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

result := viperValueToStrings(test.value)
assert.Equal(t, test.expected, result)
})
}
}
2 changes: 2 additions & 0 deletions internal/cmdutils/get-client-config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig {
clientCredentialsClientID, _ := cmd.Flags().GetString("client-id")
clientCredentialsClientSecret, _ := cmd.Flags().GetString("client-secret")
clientCredentialsScopes, _ := cmd.Flags().GetStringArray("api-scopes")
customHeaders, _ := cmd.Flags().GetStringArray("custom-headers")
debug, _ := cmd.Flags().GetBool("debug")

return fga.ClientConfig{
Expand All @@ -56,6 +57,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig {
ClientID: clientCredentialsClientID,
ClientSecret: clientCredentialsClientSecret,
APIScopes: clientCredentialsScopes,
CustomHeaders: customHeaders,
Debug: debug,
}
}
52 changes: 46 additions & 6 deletions internal/fga/fga.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ limitations under the License.
package fga

import (
"errors"
"fmt"
"strings"

openfga "github.com/openfga/go-sdk"
Expand All @@ -32,23 +34,34 @@ const (
MinSdkWaitInMs = 500
)

var userAgent = "openfga-cli/" + build.Version
var (
userAgent = "openfga-cli/" + build.Version

ErrInvalidHeaderFormat = errors.New("expected format \"Header-Name: value\"")
ErrEmptyHeaderName = errors.New("header name must not be empty")
)

type ClientConfig struct {
ApiUrl string `json:"api_url,omitempty"` //nolint:revive,stylecheck
StoreID string `json:"store_id,omitempty"`
AuthorizationModelID string `json:"authorization_model_id,omitempty"`
APIToken string `json:"api_token,omitempty"` //nolint:gosec
APIToken string `json:"api_token,omitempty"`
APITokenIssuer string `json:"api_token_issuer,omitempty"`
APIAudience string `json:"api_audience,omitempty"`
APIScopes []string `json:"api_scopes,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` //nolint:gosec
ClientSecret string `json:"client_secret,omitempty"`
CustomHeaders []string `json:"custom_headers,omitempty"`
Debug bool `json:"debug,omitempty"`
}

func (c ClientConfig) GetFgaClient() (*client.OpenFgaClient, error) {
fgaClient, err := client.NewSdkClient(c.getClientConfig())
clientConfig, err := c.getClientConfig()
if err != nil {
return nil, err
}

fgaClient, err := client.NewSdkClient(clientConfig)
if err != nil {
return nil, err //nolint:wrapcheck
}
Expand Down Expand Up @@ -84,7 +97,12 @@ func (c ClientConfig) getCredentials() *credentials.Credentials {
}
}

func (c ClientConfig) getClientConfig() *client.ClientConfiguration {
func (c ClientConfig) getClientConfig() (*client.ClientConfiguration, error) {
customHeaders, err := c.getCustomHeaders()
if err != nil {
return nil, fmt.Errorf("invalid custom headers configuration: %w", err)
}

return &client.ClientConfiguration{
ApiUrl: c.ApiUrl,
StoreId: c.StoreID,
Expand All @@ -95,6 +113,28 @@ func (c ClientConfig) getClientConfig() *client.ClientConfiguration {
MaxRetry: MaxSdkRetry,
MinWaitInMs: MinSdkWaitInMs,
},
Debug: c.Debug,
Debug: c.Debug,
DefaultHeaders: customHeaders,
}, nil
}

func (c ClientConfig) getCustomHeaders() (map[string]string, error) {
headers := make(map[string]string, len(c.CustomHeaders))

for _, header := range c.CustomHeaders {
name, value, found := strings.Cut(header, ":")
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
if !found {
return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat)
}
Comment thread
alexanderzobnin marked this conversation as resolved.
if name == "" && value == "" {
return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat)
}
if name == "" {
return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrEmptyHeaderName)
}
headers[name] = value
}

return headers, nil
}
Loading