diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..ad6d353 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/Permify/permify-cli/core/client" "github.com/Permify/permify-cli/core/config" @@ -102,7 +103,34 @@ func runE(cmd *cobra.Command, _ []string) error { return err } + token, err := tui.StringPrompt("enter permify token (optional)", "", config.CliConfig.Token) + if err != nil { + return err + } + + certPath, err := tui.StringPrompt("enter client cert path (optional)", "", config.CliConfig.CertPath) + if err != nil { + return err + } + + certKey, err := tui.StringPrompt("enter client cert key path (optional)", "", config.CliConfig.CertKey) + if err != nil { + return err + } + + if (strings.TrimSpace(certPath) == "") != (strings.TrimSpace(certKey) == "") { + return fmt.Errorf("client cert path and key path must be configured together") + } + + config.CliConfig.PermifyURL = url + config.CliConfig.Token = strings.TrimSpace(token) + config.CliConfig.CertPath = strings.TrimSpace(certPath) + config.CliConfig.CertKey = strings.TrimSpace(certKey) + resp, err := client.New(url) + if err != nil { + return err + } // Todo: Implement pagination tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{}) @@ -117,12 +145,11 @@ func runE(cmd *cobra.Command, _ []string) error { tenantNames = append(tenantNames, nameID) tenantIds[nameID] = tenant.Id } - + tenant, err := tui.Choice("Select a tenant: ", tenantNames) if err != nil { logger.Log.Error(err) } - config.CliConfig.PermifyURL = url config.CliConfig.Tenant = tenantIds[tenant] err = config.Write() if err != nil { diff --git a/core/client/grpc.go b/core/client/grpc.go index 11835df..a063d78 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,110 @@ package client import ( + "crypto/tls" + "errors" + "fmt" + "os" + "strings" + + "github.com/Permify/permify-cli/core/config" permify "github.com/Permify/permify-go/v1" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) // New initializes a new permify client func New(endpoint string) (*permify.Client, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + endpoint = strings.TrimSpace(config.CliConfig.PermifyURL) + } + if endpoint == "" { + return nil, errors.New("permify endpoint missing: provide an endpoint or configure stored credentials") + } + + opts, err := dialOptions(endpoint, config.CliConfig) + if err != nil { + return nil, err + } + client, err := permify.NewClient( permify.Config{ Endpoint: endpoint, }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + opts..., ) return client, err } + +func dialOptions(endpoint string, cfg config.CoreConfig) ([]grpc.DialOption, error) { + transport, err := transportCredentials(endpoint, cfg) + if err != nil { + return nil, err + } + + opts := []grpc.DialOption{grpc.WithTransportCredentials(transport)} + + token := strings.TrimSpace(cfg.Token) + if token != "" { + auth := map[string]string{"authorization": fmt.Sprintf("Bearer %s", token)} + if isSecureEndpoint(endpoint) || cfg.CertPath != "" { + opts = append(opts, grpc.WithPerRPCCredentials(secureTokenCredentials(auth))) + } else { + opts = append(opts, grpc.WithPerRPCCredentials(nonSecureTokenCredentials(auth))) + } + } + + return opts, nil +} + +func transportCredentials(endpoint string, cfg config.CoreConfig) (credentials.TransportCredentials, error) { + certPath := strings.TrimSpace(cfg.CertPath) + certKey := strings.TrimSpace(cfg.CertKey) + + if (certPath == "") != (certKey == "") { + return nil, errors.New("both cert_path and cert_key must be configured together") + } + + if certPath != "" { + if err := fileIsReadable(certPath); err != nil { + return nil, fmt.Errorf("cert_path: %w", err) + } + if err := fileIsReadable(certKey); err != nil { + return nil, fmt.Errorf("cert_key: %w", err) + } + certificate, err := tls.LoadX509KeyPair(certPath, certKey) + if err != nil { + return nil, fmt.Errorf("load tls key pair: %w", err) + } + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + }), nil + } + + if isSecureEndpoint(endpoint) { + return credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + }), nil + } + + return insecure.NewCredentials(), nil +} + +func fileIsReadable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("%s is a directory", path) + } + return nil +} + +func isSecureEndpoint(endpoint string) bool { + endpoint = strings.ToLower(strings.TrimSpace(endpoint)) + return strings.HasPrefix(endpoint, "https://") || strings.HasPrefix(endpoint, "grpcs://") +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..b97aa95 --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,43 @@ +package client + +import ( + "testing" + + "github.com/Permify/permify-cli/core/config" +) + +func TestIsSecureEndpoint(t *testing.T) { + tests := map[string]bool{ + "https://permify.example.com": true, + "grpcs://permify.example.com": true, + "http://permify.example.com": false, + "localhost:3478": false, + } + + for endpoint, want := range tests { + if got := isSecureEndpoint(endpoint); got != want { + t.Fatalf("isSecureEndpoint(%q) = %v, want %v", endpoint, got, want) + } + } +} + +func TestTransportCredentialsRequiresCertPair(t *testing.T) { + _, err := transportCredentials("localhost:3478", config.CoreConfig{ + CertPath: "client.crt", + }) + if err == nil { + t.Fatal("expected error when only cert path is configured") + } +} + +func TestDialOptionsAllowsStoredToken(t *testing.T) { + opts, err := dialOptions("localhost:3478", config.CoreConfig{ + Token: "test-token", + }) + if err != nil { + t.Fatalf("dialOptions returned error: %v", err) + } + if len(opts) != 2 { + t.Fatalf("dialOptions returned %d options, want 2", len(opts)) + } +} diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..6e22050 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -25,9 +25,12 @@ type ProfileConfigs struct { // CoreConfig is the config struct type CoreConfig struct { - PermifyURL string `yaml:"permify_url"` - Tenant string `yaml:"tenant"` - SslEnabled bool `yaml:"-"` + PermifyURL string `yaml:"permify_url"` + Tenant string `yaml:"tenant"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured @@ -70,7 +73,7 @@ func Load(file string, profile string) error { profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] - CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") + CliConfig.SslEnabled = usesTLS(CliConfig) return err } @@ -103,3 +106,10 @@ func Write() error { err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) return err } + +func usesTLS(cfg CoreConfig) bool { + endpoint := strings.ToLower(cfg.PermifyURL) + return strings.HasPrefix(endpoint, "https://") || + strings.HasPrefix(endpoint, "grpcs://") || + (cfg.CertPath != "" && cfg.CertKey != "") +} diff --git a/core/config/config_test.go b/core/config/config_test.go new file mode 100644 index 0000000..1e1b3ea --- /dev/null +++ b/core/config/config_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "testing" +) + +func TestUsesTLS(t *testing.T) { + tests := []struct { + name string + cfg CoreConfig + want bool + }{ + { + name: "https endpoint", + cfg: CoreConfig{PermifyURL: "https://permify.example.com"}, + want: true, + }, + { + name: "grpcs endpoint", + cfg: CoreConfig{PermifyURL: "grpcs://permify.example.com"}, + want: true, + }, + { + name: "client certificate pair", + cfg: CoreConfig{PermifyURL: "localhost:3478", CertPath: "client.crt", CertKey: "client.key"}, + want: true, + }, + { + name: "plain localhost", + cfg: CoreConfig{PermifyURL: "localhost:3478"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := usesTLS(tt.cfg); got != tt.want { + t.Fatalf("usesTLS() = %v, want %v", got, tt.want) + } + }) + } +}