diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..e39cfcf 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,12 +103,37 @@ func runE(cmd *cobra.Command, _ []string) error { return err } + token, err := tui.StringPrompt("enter permify token", "", config.CliCredentials.Token) + if err != nil { + return err + } + + certPath, err := tui.StringPrompt("enter cert path", "", config.CliCredentials.CertPath) + if err != nil { + return err + } + + certKey, err := tui.StringPrompt("enter cert key", "", config.CliCredentials.CertKey) + if err != nil { + return err + } + + config.CliConfig.PermifyURL = url + config.CliConfig.SslEnabled = strings.HasPrefix(url, "https") + config.CliCredentials.Endpoint = url + config.CliCredentials.Token = token + config.CliCredentials.CertPath = certPath + config.CliCredentials.CertKey = certKey + resp, err := client.New(url) + if err != nil { + return err + } // Todo: Implement pagination tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{}) if err != nil { - logger.Log.Fatal(err) + return err } tenantNames := []string{} @@ -117,16 +143,19 @@ 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) + return err } - config.CliConfig.PermifyURL = url config.CliConfig.Tenant = tenantIds[tenant] err = config.Write() if err != nil { - logger.Log.Error(err) + return err + } + err = config.WriteCredentials() + if err != nil { + return err } logger.Log.Info("successfully configured ", "config file", configFile) return nil diff --git a/core/client/grpc.go b/core/client/grpc.go index 11835df..6d26e48 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,115 @@ package client import ( + "crypto/tls" + "errors" + "fmt" + "net/url" + "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 +// New initializes a new permify client. func New(endpoint string) (*permify.Client, error) { + connectionEndpoint := endpoint + if config.CliCredentials.Endpoint != "" { + connectionEndpoint = config.CliCredentials.Endpoint + } + secureTransport := config.CliConfig.SslEnabled || + strings.HasPrefix(config.CliCredentials.Endpoint, "https://") || + strings.HasPrefix(connectionEndpoint, "https://") || + strings.TrimSpace(config.CliCredentials.CertPath) != "" || + strings.TrimSpace(config.CliCredentials.CertKey) != "" + connectionEndpoint = sanitizeEndpoint(connectionEndpoint) + if connectionEndpoint == "" { + return nil, errors.New("permify endpoint is empty") + } + + transportCredentials, err := transportCredentials(secureTransport) + if err != nil { + return nil, err + } + + dialOptions := []grpc.DialOption{ + grpc.WithTransportCredentials(transportCredentials), + } + + token := strings.TrimSpace(config.CliCredentials.Token) + if token != "" { + dialOptions = append(dialOptions, grpc.WithPerRPCCredentials(tokenCredentials(token, secureTransport))) + } + client, err := permify.NewClient( permify.Config{ - Endpoint: endpoint, + Endpoint: connectionEndpoint, }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + dialOptions..., ) return client, err } + +func sanitizeEndpoint(endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "" + } + + if !strings.Contains(endpoint, "://") { + return endpoint + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" { + return endpoint + } + if parsed.Host != "" { + return parsed.Host + } + if parsed.Path != "" { + return strings.TrimLeft(parsed.Path, "/") + } + return strings.TrimPrefix(endpoint, parsed.Scheme+"://") +} + +func transportCredentials(secureTransport bool) (credentials.TransportCredentials, error) { + if secureTransport { + certPath := strings.TrimSpace(config.CliCredentials.CertPath) + certKey := strings.TrimSpace(config.CliCredentials.CertKey) + if certPath == "" && certKey == "" { + return credentials.NewTLS(&tls.Config{}), nil + } + if certPath == "" || certKey == "" { + return nil, fmt.Errorf("both cert path and cert key must be provided") + } + if _, err := os.Stat(certPath); err != nil { + return nil, fmt.Errorf("failed to read cert path: %w", err) + } + if _, err := os.Stat(certKey); err != nil { + return nil, fmt.Errorf("failed to read cert key: %w", err) + } + cert, err := tls.LoadX509KeyPair(certPath, certKey) + if err != nil { + return nil, err + } + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + }), nil + } + + return insecure.NewCredentials(), nil +} + +func tokenCredentials(token string, secureTransport bool) credentials.PerRPCCredentials { + metadata := map[string]string{ + "authorization": "Bearer " + strings.TrimSpace(token), + } + if secureTransport { + return secureTokenCredentials(metadata) + } + return nonSecureTokenCredentials(metadata) +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..15124d5 --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,139 @@ +package client + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Permify/permify-cli/core/config" +) + +func TestTokenCredentials(t *testing.T) { + secureCreds := tokenCredentials("secret", true) + if !secureCreds.RequireTransportSecurity() { + t.Fatalf("expected secure credentials to require transport security") + } + secureMetadata, err := secureCreds.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("GetRequestMetadata() error = %v", err) + } + if got := secureMetadata["authorization"]; got != "Bearer secret" { + t.Fatalf("unexpected secure metadata authorization: got %q", got) + } + + insecureCreds := tokenCredentials("secret", false) + if insecureCreds.RequireTransportSecurity() { + t.Fatalf("expected insecure credentials to not require transport security") + } + insecureMetadata, err := insecureCreds.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("GetRequestMetadata() error = %v", err) + } + if got := insecureMetadata["authorization"]; got != "Bearer secret" { + t.Fatalf("unexpected insecure metadata authorization: got %q", got) + } +} + +func TestTransportCredentials(t *testing.T) { + originalCredentials := config.CliCredentials + originalConfig := config.CliConfig + t.Cleanup(func() { + config.CliCredentials = originalCredentials + config.CliConfig = originalConfig + }) + + t.Run("insecure", func(t *testing.T) { + config.CliConfig.SslEnabled = false + creds, err := transportCredentials(false) + if err != nil { + t.Fatalf("transportCredentials(false) error = %v", err) + } + if creds == nil { + t.Fatalf("expected credentials, got nil") + } + }) + + t.Run("tls pair", func(t *testing.T) { + certPath, certKey := generateTestCertificatePair(t) + config.CliConfig.SslEnabled = true + config.CliCredentials.CertPath = certPath + config.CliCredentials.CertKey = certKey + + creds, err := transportCredentials(true) + if err != nil { + t.Fatalf("transportCredentials(true) error = %v", err) + } + if creds == nil { + t.Fatalf("expected credentials, got nil") + } + }) + + t.Run("missing cert key", func(t *testing.T) { + config.CliConfig.SslEnabled = true + config.CliCredentials.CertPath = "/tmp/client.pem" + config.CliCredentials.CertKey = "" + + _, err := transportCredentials(true) + if err == nil { + t.Fatalf("expected error for missing cert key") + } + }) +} + +func generateTestCertificatePair(t *testing.T) (string, string) { + t.Helper() + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "permify-cli-test", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("CreateCertificate() error = %v", err) + } + + tempDir := t.TempDir() + certPath := filepath.Join(tempDir, "client.crt") + certKey := filepath.Join(tempDir, "client.key") + + certFile, err := os.Create(certPath) + if err != nil { + t.Fatalf("os.Create(cert) error = %v", err) + } + defer certFile.Close() + if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + t.Fatalf("pem.Encode(cert) error = %v", err) + } + + keyFile, err := os.Create(certKey) + if err != nil { + t.Fatalf("os.Create(key) error = %v", err) + } + defer keyFile.Close() + if err := pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { + t.Fatalf("pem.Encode(key) error = %v", err) + } + + return certPath, certKey +} diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..3187fc4 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -5,32 +5,95 @@ import ( "fmt" "io/fs" "os" + "path/filepath" "strings" "github.com/Permify/permify-cli/core/logger" "gopkg.in/yaml.v3" ) -// CliConfig is the global config variable +// CliConfig is the global config variable. var CliConfig = CoreConfig{} +// CliCredentials stores the connection secrets for the active profile. +var CliCredentials = ConnectionCredentials{} + var profileConfigs = ProfileConfigs{} +var credentialsConfigs = CredentialProfiles{} -// ProfileConfigs stores configs for all profiles +// ProfileConfigs stores configs for all profiles. type ProfileConfigs struct { Configs map[string]CoreConfig File string Profile string } -// CoreConfig is the config struct +// CredentialProfiles stores credentials for all profiles. +type CredentialProfiles struct { + Configs map[string]ConnectionCredentials + File string + Profile string +} + +// 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"` + SslEnabled bool `yaml:"-"` +} + +// ConnectionCredentials keeps endpoint and connection secrets outside the main config file. +type ConnectionCredentials struct { + Endpoint string `yaml:"endpoint"` + Token string `yaml:"token"` + CertPath string `yaml:"cert_path"` + CertKey string `yaml:"cert_key"` +} + +func defaultCredentialsPath() string { + homeDir, err := os.UserHomeDir() + if err != nil || homeDir == "" { + return filepath.Join(".", ".permify", "credentials") + } + return filepath.Join(homeDir, ".permify", "credentials") } -// IsConfigured checks if permctl cli has been configured +func ensureCredentialState(profile string) { + if credentialsConfigs.Configs == nil { + credentialsConfigs.Configs = make(map[string]ConnectionCredentials) + } + credentialsConfigs.Profile = profile +} + +func loadCredentials(file string, profile string) error { + _, err := os.Stat(file) + if err != nil { + return err + } + data, err := os.ReadFile(file) + if err != nil { + return err + } + ensureCredentialState(profile) + credentialsConfigs.File = file + err = yaml.Unmarshal(data, &credentialsConfigs.Configs) + if err != nil { + logger.Log.Fatal("Error unmarshaling yaml") + } + CliCredentials = credentialsConfigs.Configs[profile] + if CliCredentials.Endpoint != "" { + CliConfig.PermifyURL = CliCredentials.Endpoint + CliConfig.SslEnabled = strings.HasPrefix(CliCredentials.Endpoint, "https") + } + return nil +} + +// LoadCredentials loads the credentials file into the global variable. +func LoadCredentials(file string, profile string) error { + return loadCredentials(file, profile) +} + +// IsConfigured checks if permctl cli has been configured. func IsConfigured(file string, profile string) error { _, err := os.Stat(file) if err != nil { @@ -53,7 +116,7 @@ func IsConfigured(file string, profile string) error { return nil } -// Load the permctl configuration specified by the user into the global variable +// Load the permctl configuration specified by the user into the global variable. func Load(file string, profile string) error { _, err := os.Stat(file) if err != nil { @@ -71,15 +134,23 @@ func Load(file string, profile string) error { profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") + CliCredentials = ConnectionCredentials{} + _ = loadCredentials(defaultCredentialsPath(), profile) + if CliCredentials.Endpoint != "" { + CliConfig.PermifyURL = CliCredentials.Endpoint + CliConfig.SslEnabled = strings.HasPrefix(CliCredentials.Endpoint, "https") + } return err } -// New initializes a new config file for permctl with the mentioned profile +// New initializes a new config file for permctl with the mentioned profile. func New(file string, profile string) error { profileConfigs.Profile = profile profileConfigs.File = file profileConfigs.Configs = make(map[string]CoreConfig) profileConfigs.Configs[profile] = CliConfig + ensureCredentialState(profile) + credentialsConfigs.File = defaultCredentialsPath() newConfigDataByte, err := yaml.Marshal(profileConfigs.Configs) if err != nil { return err @@ -88,7 +159,7 @@ func New(file string, profile string) error { return err } -// Write the config file +// Write the config file. func Write() error { _, err := os.Stat(profileConfigs.File) if err != nil { @@ -103,3 +174,20 @@ func Write() error { err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) return err } + +// WriteCredentials writes the credentials file using the active profile. +func WriteCredentials() error { + if credentialsConfigs.File == "" { + credentialsConfigs.File = defaultCredentialsPath() + } + ensureCredentialState(credentialsConfigs.Profile) + if err := os.MkdirAll(filepath.Dir(credentialsConfigs.File), 0700); err != nil { + return err + } + credentialsConfigs.Configs[credentialsConfigs.Profile] = CliCredentials + newCredentialsDataByte, err := yaml.Marshal(credentialsConfigs.Configs) + if err != nil { + return err + } + return os.WriteFile(credentialsConfigs.File, newCredentialsDataByte, fs.FileMode(0600)) +} diff --git a/core/config/config_test.go b/core/config/config_test.go new file mode 100644 index 0000000..905b67a --- /dev/null +++ b/core/config/config_test.go @@ -0,0 +1,68 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestWriteAndLoadCredentials(t *testing.T) { + tmpDir := t.TempDir() + credentialFile := filepath.Join(tmpDir, "credentials") + + originalCredentials := CliCredentials + originalCredentialProfiles := credentialsConfigs + t.Cleanup(func() { + CliCredentials = originalCredentials + credentialsConfigs = originalCredentialProfiles + }) + + credentialsConfigs = CredentialProfiles{ + Configs: map[string]ConnectionCredentials{}, + File: credentialFile, + Profile: "default", + } + CliCredentials = ConnectionCredentials{ + Endpoint: "localhost:3478", + Token: "secret-token", + CertPath: "/tmp/client.pem", + CertKey: "/tmp/client-key.pem", + } + + if err := WriteCredentials(); err != nil { + t.Fatalf("WriteCredentials() error = %v", err) + } + + info, err := os.Stat(credentialFile) + if err != nil { + t.Fatalf("os.Stat() error = %v", err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("unexpected file mode: got %v want 0600", info.Mode().Perm()) + } + + CliCredentials = ConnectionCredentials{} + credentialsConfigs = CredentialProfiles{ + Configs: map[string]ConnectionCredentials{}, + File: credentialFile, + Profile: "default", + } + + if err := LoadCredentials(credentialFile, "default"); err != nil { + t.Fatalf("LoadCredentials() error = %v", err) + } + + if CliCredentials.Endpoint != "localhost:3478" { + t.Fatalf("unexpected endpoint: got %q", CliCredentials.Endpoint) + } + if CliCredentials.Token != "secret-token" { + t.Fatalf("unexpected token: got %q", CliCredentials.Token) + } + if CliCredentials.CertPath != "/tmp/client.pem" { + t.Fatalf("unexpected cert path: got %q", CliCredentials.CertPath) + } + if CliCredentials.CertKey != "/tmp/client-key.pem" { + t.Fatalf("unexpected cert key: got %q", CliCredentials.CertKey) + } +}