diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..0b94f25 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" @@ -97,12 +98,36 @@ func validateFlags(cmd *cobra.Command, args []string) error { func runE(cmd *cobra.Command, _ []string) error { configFile, _ := cmd.Flags().GetString("config") - url, err := tui.StringPrompt("enter permify url", "", config.CliConfig.PermifyURL) + endpoint, err := tui.StringPrompt("enter permify endpoint (host:port)", "", config.CliConfig.PermifyURL) if err != nil { return err } - resp, err := client.New(url) + token, err := tui.StringPrompt("enter auth token (optional)", "", config.CliConfig.Token) + if err != nil { + return err + } + + certPath, err := tui.StringPrompt("enter TLS cert path (optional)", "", config.CliConfig.CertPath) + if err != nil { + return err + } + + certKey, err := tui.StringPrompt("enter TLS cert key path (optional)", "", config.CliConfig.CertKey) + if err != nil { + return err + } + + config.CliConfig.PermifyURL = endpoint + config.CliConfig.Token = token + config.CliConfig.CertPath = certPath + config.CliConfig.CertKey = certKey + config.CliConfig.SslEnabled = strings.HasPrefix(endpoint, "https") || certPath != "" || certKey != "" + + resp, err := client.New(endpoint) + if err != nil { + return err + } // Todo: Implement pagination tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{}) @@ -117,12 +142,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..663c8c6 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,96 @@ package client import ( + "crypto/tls" + "crypto/x509" + "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) { + if endpoint == "" { + endpoint = config.CliConfig.PermifyURL + } + + var dialOpts []grpc.DialOption + + if config.CliConfig.SslEnabled { + tlsConfig, err := buildTLSConfig(config.CliConfig.CertPath, config.CliConfig.CertKey) + if err != nil { + return nil, err + } + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + if token := strings.TrimSpace(config.CliConfig.Token); token != "" { + md := map[string]string{ + "authorization": formatBearerToken(token), + } + if config.CliConfig.SslEnabled { + dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(secureTokenCredentials(md))) + } else { + dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(nonSecureTokenCredentials(md))) + } + } + client, err := permify.NewClient( permify.Config{ Endpoint: endpoint, }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + dialOpts..., ) return client, err } + +func formatBearerToken(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + return token + } + return "Bearer " + token +} + +func buildTLSConfig(certPath, certKey string) (*tls.Config, error) { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + certPath = strings.TrimSpace(certPath) + certKey = strings.TrimSpace(certKey) + + // If both cert+key are provided, use them as a client certificate (mTLS). + if certPath != "" && certKey != "" { + cert, err := tls.LoadX509KeyPair(certPath, certKey) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{cert} + return tlsConfig, nil + } + + // If only a cert path is provided, treat it as the CA bundle for verifying the server. + if certPath != "" { + caPem, err := os.ReadFile(certPath) + if err != nil { + return nil, err + } + rootCAs := x509.NewCertPool() + if ok := rootCAs.AppendCertsFromPEM(caPem); ok { + tlsConfig.RootCAs = rootCAs + } + } + + return tlsConfig, nil +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..7f7eafc --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,25 @@ +package client + +import "testing" + +func TestFormatBearerToken(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "empty", in: "", want: ""}, + {name: "raw", in: "abc", want: "Bearer abc"}, + {name: "raw trimmed", in: " abc ", want: "Bearer abc"}, + {name: "already bearer", in: "Bearer abc", want: "Bearer abc"}, + {name: "already bearer lower", in: "bearer abc", want: "bearer abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatBearerToken(tt.in); got != tt.want { + t.Fatalf("formatBearerToken(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..f3d627a 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -25,9 +25,15 @@ 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"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` + Tenant string `yaml:"tenant"` + + // SslEnabled is computed at load-time and intentionally not persisted. + // It indicates whether the client should use TLS when dialing gRPC. + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured @@ -70,7 +76,10 @@ func Load(file string, profile string) error { profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] - CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") + // Permify gRPC endpoints are typically host:port (no scheme). For backwards + // compatibility, keep the "https" heuristic, but also enable TLS whenever + // cert material is configured. + CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") || CliConfig.CertPath != "" || CliConfig.CertKey != "" return err } @@ -84,7 +93,8 @@ func New(file string, profile string) error { if err != nil { return err } - err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0644)) + // Config may include secrets (e.g. token). Restrict to owner-only. + err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0600)) return err } @@ -100,6 +110,7 @@ func Write() error { if err != nil { return err } - err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) + // Config may include secrets (e.g. token). Restrict to owner-only. + err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0600)) return err } diff --git a/core/config/config_test.go b/core/config/config_test.go new file mode 100644 index 0000000..38370cf --- /dev/null +++ b/core/config/config_test.go @@ -0,0 +1,62 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigNewWriteLoad_PreservesFieldsAndRestrictsPermissions(t *testing.T) { + oldCliConfig := CliConfig + oldProfileConfigs := profileConfigs + t.Cleanup(func() { + CliConfig = oldCliConfig + profileConfigs = oldProfileConfigs + }) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "permctl.yaml") + + CliConfig = CoreConfig{ + PermifyURL: "localhost:3478", + Token: "my-token", + CertPath: "/tmp/ca.pem", + CertKey: "/tmp/client.key", + Tenant: "tenant-id", + } + + if err := New(configFile, "default"); err != nil { + t.Fatalf("New() error: %v", err) + } + + info, err := os.Stat(configFile) + if err != nil { + t.Fatalf("stat error: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected config perms 0600, got %v", got) + } + + if err := Load(configFile, "default"); err != nil { + t.Fatalf("Load() error: %v", err) + } + + if CliConfig.PermifyURL != "localhost:3478" { + t.Fatalf("PermifyURL mismatch: %q", CliConfig.PermifyURL) + } + if CliConfig.Token != "my-token" { + t.Fatalf("Token mismatch: %q", CliConfig.Token) + } + if CliConfig.CertPath != "/tmp/ca.pem" { + t.Fatalf("CertPath mismatch: %q", CliConfig.CertPath) + } + if CliConfig.CertKey != "/tmp/client.key" { + t.Fatalf("CertKey mismatch: %q", CliConfig.CertKey) + } + if CliConfig.Tenant != "tenant-id" { + t.Fatalf("Tenant mismatch: %q", CliConfig.Tenant) + } + if !CliConfig.SslEnabled { + t.Fatalf("expected SslEnabled to be true when cert material is configured") + } +}