diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..acf3a32 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -102,7 +102,31 @@ func runE(cmd *cobra.Command, _ []string) error { return err } + token, err := tui.SensitiveStringPrompt("enter access token (optional)", "", config.CliConfig.Token) + if err != nil { + return err + } + + certPath, err := tui.StringPrompt("enter cert path (optional)", "", config.CliConfig.CertPath) + if err != nil { + return err + } + + certKeyPath, err := tui.StringPrompt("enter cert key path (optional)", "", config.CliConfig.CertKeyPath) + if err != nil { + return err + } + + config.CliConfig.PermifyURL = url + config.CliConfig.Token = token + config.CliConfig.CertPath = certPath + config.CliConfig.CertKeyPath = certKeyPath + config.CliConfig.SslEnabled = certPath != "" + resp, err := client.New(url) + if err != nil { + return err + } // Todo: Implement pagination tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{}) @@ -117,12 +141,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..f8f7331 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,96 @@ package client import ( + "crypto/tls" + "crypto/x509" + "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) { + transportCredentials, err := transportCredentials(config.CliConfig) + if err != nil { + return nil, err + } + + opts := []grpc.DialOption{grpc.WithTransportCredentials(transportCredentials)} + if tokenCredentials := tokenCredentials(config.CliConfig); tokenCredentials != nil { + opts = append(opts, grpc.WithPerRPCCredentials(tokenCredentials)) + } + client, err := permify.NewClient( permify.Config{ - Endpoint: endpoint, + Endpoint: normalizeEndpoint(endpoint), }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + opts..., ) return client, err } + +func transportCredentials(cfg config.CoreConfig) (credentials.TransportCredentials, error) { + if cfg.CertPath == "" { + if strings.TrimSpace(cfg.CertKeyPath) != "" { + return nil, fmt.Errorf("cert key path requires cert path") + } + return insecure.NewCredentials(), nil + } + + if cfg.CertKeyPath == "" { + return credentials.NewClientTLSFromFile(cfg.CertPath, "") + } + + certPool := x509.NewCertPool() + certPEM, err := os.ReadFile(cfg.CertPath) + if err != nil { + return nil, err + } + if !certPool.AppendCertsFromPEM(certPEM) { + return nil, fmt.Errorf("failed to parse certificate at %s", cfg.CertPath) + } + + clientCertificate, err := tls.LoadX509KeyPair(cfg.CertPath, cfg.CertKeyPath) + if err != nil { + return nil, err + } + + return credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: certPool, + Certificates: []tls.Certificate{clientCertificate}, + }), nil +} + +func tokenCredentials(cfg config.CoreConfig) credentials.PerRPCCredentials { + token := strings.TrimSpace(cfg.Token) + if token == "" { + return nil + } + + metadata := map[string]string{ + "authorization": bearerToken(token), + } + if cfg.SslEnabled || cfg.CertPath != "" { + return secureTokenCredentials(metadata) + } + return nonSecureTokenCredentials(metadata) +} + +func bearerToken(token string) string { + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + return token + } + return fmt.Sprintf("Bearer %s", token) +} + +func normalizeEndpoint(endpoint string) string { + withoutHTTPS := strings.TrimPrefix(endpoint, "https://") + return strings.TrimPrefix(withoutHTTPS, "http://") +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..854aa8d --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,156 @@ +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 TestTokenCredentialsAddsBearerPrefix(t *testing.T) { + creds := tokenCredentials(config.CoreConfig{ + Token: "secret-token", + CertPath: "certs/client.pem", + }) + + if creds == nil { + t.Fatal("tokenCredentials() = nil, want credentials") + } + + metadata, err := creds.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("GetRequestMetadata() error = %v", err) + } + + if got, want := metadata["authorization"], "Bearer secret-token"; got != want { + t.Fatalf("authorization metadata = %q, want %q", got, want) + } + if !creds.RequireTransportSecurity() { + t.Fatal("RequireTransportSecurity() = false, want true for TLS connections") + } +} + +func TestTokenCredentialsKeepsExistingBearerPrefix(t *testing.T) { + creds := tokenCredentials(config.CoreConfig{ + Token: "Bearer already-prefixed", + }) + + if creds == nil { + t.Fatal("tokenCredentials() = nil, want credentials") + } + + metadata, err := creds.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("GetRequestMetadata() error = %v", err) + } + + if got, want := metadata["authorization"], "Bearer already-prefixed"; got != want { + t.Fatalf("authorization metadata = %q, want %q", got, want) + } + if creds.RequireTransportSecurity() { + t.Fatal("RequireTransportSecurity() = true, want false for insecure connections") + } +} + +func TestTransportCredentialsDefaultsToInsecure(t *testing.T) { + creds, err := transportCredentials(config.CoreConfig{}) + if err != nil { + t.Fatalf("transportCredentials() error = %v", err) + } + + if got, want := creds.Info().SecurityProtocol, "insecure"; got != want { + t.Fatalf("SecurityProtocol = %q, want %q", got, want) + } +} + +func TestTransportCredentialsUsesTLSCertificate(t *testing.T) { + certPath, keyPath := writeTestCertificate(t) + + creds, err := transportCredentials(config.CoreConfig{ + CertPath: certPath, + CertKeyPath: keyPath, + }) + if err != nil { + t.Fatalf("transportCredentials() error = %v", err) + } + + if got, want := creds.Info().SecurityProtocol, "tls"; got != want { + t.Fatalf("SecurityProtocol = %q, want %q", got, want) + } +} + +func TestTransportCredentialsRejectsKeyWithoutCertificate(t *testing.T) { + _, err := transportCredentials(config.CoreConfig{ + CertKeyPath: "certs/client.key", + }) + if err == nil { + t.Fatal("transportCredentials() error = nil, want validation error") + } +} + +func TestNormalizeEndpointStripsHTTPAndHTTPSSchemes(t *testing.T) { + testCases := map[string]string{ + "http://localhost:3478": "localhost:3478", + "https://permify.example": "permify.example", + "permify.internal:3478": "permify.internal:3478", + "https://localhost:3478/v1": "localhost:3478/v1", + } + + for input, want := range testCases { + if got := normalizeEndpoint(input); got != want { + t.Fatalf("normalizeEndpoint(%q) = %q, want %q", input, got, want) + } + } +} + +func writeTestCertificate(t *testing.T) (string, string) { + t.Helper() + + privateKey, 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: "localhost", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatalf("CreateCertificate() error = %v", err) + } + + dir := t.TempDir() + certPath := filepath.Join(dir, "client.pem") + keyPath := filepath.Join(dir, "client.key") + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + t.Fatalf("WriteFile(cert) error = %v", err) + } + + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatalf("WriteFile(key) error = %v", err) + } + + return certPath, keyPath +} diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..8861961 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -2,9 +2,11 @@ package config import ( + "errors" "fmt" "io/fs" "os" + "path/filepath" "strings" "github.com/Permify/permify-cli/core/logger" @@ -23,11 +25,22 @@ type ProfileConfigs struct { Profile string } +// ClientCredentials stores endpoint-adjacent client auth material separately from the main profile config. +type ClientCredentials struct { + PermifyURL string `yaml:"permify_url,omitempty"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKeyPath string `yaml:"cert_key_path,omitempty"` +} + // 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"` + CertPath string `yaml:"cert_path"` + CertKeyPath string `yaml:"cert_key_path"` + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured @@ -44,10 +57,16 @@ func IsConfigured(file string, profile string) error { if err != nil { logger.Log.Fatal("Error unmarshaling yaml") } - if profileConfigs.Configs[profile].PermifyURL == "" { + cfg := profileConfigs.Configs[profile] + credentials, err := loadCredentials(profile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + cfg = applyCredentials(cfg, credentials) + if cfg.PermifyURL == "" { return fmt.Errorf("permify url is empty for profile %s", profile) } - if profileConfigs.Configs[profile].Tenant == "" { + if cfg.Tenant == "" { return fmt.Errorf("tenant is empty for profile %s", profile) } return nil @@ -70,7 +89,12 @@ func Load(file string, profile string) error { profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] - CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") + credentials, err := loadCredentials(profile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + CliConfig = applyCredentials(CliConfig, credentials) + CliConfig.SslEnabled = usesTLS(CliConfig) return err } @@ -95,11 +119,112 @@ func Write() error { return fmt.Errorf("%s config file does not exist", profileConfigs.File) } profile := profileConfigs.Profile - profileConfigs.Configs[profile] = CliConfig + profileConfigs.Configs[profile] = sanitizeProfileConfig(CliConfig) newConfigDataByte, err := yaml.Marshal(profileConfigs.Configs) if err != nil { return err } err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) - return err + if err != nil { + return err + } + return writeCredentials(profile, extractCredentials(CliConfig)) +} + +func usesTLS(cfg CoreConfig) bool { + return strings.HasPrefix(strings.ToLower(cfg.PermifyURL), "https://") || cfg.CertPath != "" +} + +func credentialsFilePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".permify", "credentials"), nil +} + +func loadCredentials(profile string) (ClientCredentials, error) { + credentialsFile, err := credentialsFilePath() + if err != nil { + return ClientCredentials{}, err + } + + data, err := os.ReadFile(credentialsFile) + if err != nil { + return ClientCredentials{}, err + } + + configs := map[string]ClientCredentials{} + err = yaml.Unmarshal(data, &configs) + if err != nil { + logger.Log.Fatal("Error unmarshaling yaml") + } + + return configs[profile], nil +} + +func writeCredentials(profile string, credentials ClientCredentials) error { + credentialsFile, err := credentialsFilePath() + if err != nil { + return err + } + + configs := map[string]ClientCredentials{} + data, err := os.ReadFile(credentialsFile) + if err == nil { + err = yaml.Unmarshal(data, &configs) + if err != nil { + logger.Log.Fatal("Error unmarshaling yaml") + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + configs[profile] = credentials + + newCredentialsData, err := yaml.Marshal(configs) + if err != nil { + return err + } + + err = os.MkdirAll(filepath.Dir(credentialsFile), fs.FileMode(0755)) + if err != nil { + return err + } + + return os.WriteFile(credentialsFile, newCredentialsData, fs.FileMode(0600)) +} + +func applyCredentials(cfg CoreConfig, credentials ClientCredentials) CoreConfig { + if credentials.PermifyURL != "" { + cfg.PermifyURL = credentials.PermifyURL + } + if credentials.Token != "" { + cfg.Token = credentials.Token + } + if credentials.CertPath != "" { + cfg.CertPath = credentials.CertPath + } + if credentials.CertKeyPath != "" { + cfg.CertKeyPath = credentials.CertKeyPath + } + return cfg +} + +func extractCredentials(cfg CoreConfig) ClientCredentials { + return ClientCredentials{ + PermifyURL: cfg.PermifyURL, + Token: cfg.Token, + CertPath: cfg.CertPath, + CertKeyPath: cfg.CertKeyPath, + } +} + +func sanitizeProfileConfig(cfg CoreConfig) CoreConfig { + cfg.PermifyURL = "" + cfg.Token = "" + cfg.CertPath = "" + cfg.CertKeyPath = "" + cfg.SslEnabled = false + return cfg } diff --git a/core/config/config_test.go b/core/config/config_test.go new file mode 100644 index 0000000..41e3f90 --- /dev/null +++ b/core/config/config_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteStoresCredentialsInDedicatedFile(t *testing.T) { + t.Cleanup(resetConfigGlobals) + configFile, credentialsFile := configureTestPaths(t) + profile := "work" + + CliConfig = CoreConfig{ + PermifyURL: "permify.example:9443", + Tenant: "tenant-2", + Token: "updated-token", + CertPath: "certs/updated.pem", + CertKeyPath: "certs/updated.key", + } + + if err := New(configFile, profile); err != nil { + t.Fatalf("New() error = %v", err) + } + + if err := Write(); err != nil { + t.Fatalf("Write() error = %v", err) + } + + configData, err := os.ReadFile(configFile) + if err != nil { + t.Fatalf("ReadFile(config) error = %v", err) + } + configText := string(configData) + for _, forbidden := range []string{"permify.example:9443", "updated-token", "certs/updated.pem", "certs/updated.key"} { + if strings.Contains(configText, forbidden) { + t.Fatalf("primary config unexpectedly contains %q", forbidden) + } + } + if !strings.Contains(configText, "tenant: tenant-2") { + t.Fatalf("primary config missing tenant entry: %s", configText) + } + + credentialsData, err := os.ReadFile(credentialsFile) + if err != nil { + t.Fatalf("ReadFile(credentials) error = %v", err) + } + credentialsText := string(credentialsData) + for _, expected := range []string{"permify.example:9443", "updated-token", "certs/updated.pem", "certs/updated.key"} { + if !strings.Contains(credentialsText, expected) { + t.Fatalf("credentials file missing %q", expected) + } + } +} + +func TestLoadMergesPrimaryConfigWithStoredCredentials(t *testing.T) { + t.Cleanup(resetConfigGlobals) + configFile, _ := configureTestPaths(t) + profile := "work" + + CliConfig = CoreConfig{ + PermifyURL: "permify.example:3478", + Tenant: "tenant-1", + Token: "secret-token", + CertPath: "certs/client.pem", + CertKeyPath: "certs/client.key", + } + + if err := New(configFile, profile); err != nil { + t.Fatalf("New() error = %v", err) + } + + if err := Write(); err != nil { + t.Fatalf("Write() error = %v", err) + } + + CliConfig = CoreConfig{} + profileConfigs = ProfileConfigs{} + + if err := Load(configFile, profile); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got, want := CliConfig.PermifyURL, "permify.example:3478"; got != want { + t.Fatalf("PermifyURL = %q, want %q", got, want) + } + if got, want := CliConfig.Tenant, "tenant-1"; got != want { + t.Fatalf("Tenant = %q, want %q", got, want) + } + if got, want := CliConfig.Token, "secret-token"; got != want { + t.Fatalf("Token = %q, want %q", got, want) + } + if got, want := CliConfig.CertPath, "certs/client.pem"; got != want { + t.Fatalf("CertPath = %q, want %q", got, want) + } + if got, want := CliConfig.CertKeyPath, "certs/client.key"; got != want { + t.Fatalf("CertKeyPath = %q, want %q", got, want) + } + if !CliConfig.SslEnabled { + t.Fatal("SslEnabled = false, want true when certificate path is configured") + } +} + +func TestIsConfiguredAcceptsEndpointFromCredentialsStore(t *testing.T) { + t.Cleanup(resetConfigGlobals) + configFile, _ := configureTestPaths(t) + profile := "work" + + CliConfig = CoreConfig{ + PermifyURL: "permify.example:3478", + Tenant: "tenant-1", + } + + if err := New(configFile, profile); err != nil { + t.Fatalf("New() error = %v", err) + } + + if err := Write(); err != nil { + t.Fatalf("Write() error = %v", err) + } + + if err := IsConfigured(configFile, profile); err != nil { + t.Fatalf("IsConfigured() error = %v", err) + } +} + +func configureTestPaths(t *testing.T) (string, string) { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configFile := filepath.Join(home, "permctl.yaml") + credentialsFile := filepath.Join(home, ".permify", "credentials") + return configFile, credentialsFile +} + +func resetConfigGlobals() { + CliConfig = CoreConfig{} + profileConfigs = ProfileConfigs{} +} diff --git a/templates/configure/examples.md b/templates/configure/examples.md index 3f98d65..11854dc 100644 --- a/templates/configure/examples.md +++ b/templates/configure/examples.md @@ -1,2 +1,5 @@ 1. print help `permctl configure -h` + +2. store endpoint, token, and certificates interactively + `permctl configure` diff --git a/templates/configure/long_description.md b/templates/configure/long_description.md index 696575c..2463f80 100644 --- a/templates/configure/long_description.md +++ b/templates/configure/long_description.md @@ -1 +1 @@ -configure permctl +Configure `permctl` by storing the Permify endpoint, tenant, and optional authentication credentials for future commands. diff --git a/tui/prompt.go b/tui/prompt.go index a8a23aa..057f5d9 100644 --- a/tui/prompt.go +++ b/tui/prompt.go @@ -10,12 +10,21 @@ import ( func StringPrompt(msg string, Placeholder, defaultVal string) (string, error) { t := &Tui{} - prompt := textinput.New() - prompt.Prompt = Pink(fmt.Sprintf("%s: ", msg)) - prompt.Placeholder = Placeholder - if defaultVal != "" { - prompt.SetValue(strings.Trim(defaultVal, "\"")) + prompt := buildPrompt(msg, Placeholder, defaultVal, false) + t.Inputs = append(t.Inputs, prompt) + t.Inputs[0].Focus() + t.Execute() + + if t.Done { + return t.Inputs[0].Value(), nil } + + return "", errors.New("prompt cancelled") +} + +func SensitiveStringPrompt(msg string, Placeholder, defaultVal string) (string, error) { + t := &Tui{} + prompt := buildPrompt(msg, Placeholder, defaultVal, true) t.Inputs = append(t.Inputs, prompt) t.Inputs[0].Focus() t.Execute() @@ -29,9 +38,7 @@ func StringPrompt(msg string, Placeholder, defaultVal string) (string, error) { func BoolPrompt(msg string, defaultVal string) (bool, error) { t := &Tui{} - prompt := textinput.New() - prompt.Prompt = Pink(fmt.Sprintf("%s (y/n): ", msg)) - prompt.Placeholder = defaultVal + prompt := buildPrompt(fmt.Sprintf("%s (y/n)", msg), defaultVal, "", false) t.Inputs = append(t.Inputs, prompt) t.Execute() @@ -47,3 +54,16 @@ func BoolPrompt(msg string, defaultVal string) (bool, error) { } return false, errors.New("prompt cancelled") } + +func buildPrompt(msg string, placeholder string, defaultVal string, sensitive bool) textinput.Model { + prompt := textinput.New() + prompt.Prompt = Pink(fmt.Sprintf("%s: ", msg)) + prompt.Placeholder = placeholder + if defaultVal != "" { + prompt.SetValue(strings.Trim(defaultVal, "\"")) + } + if sensitive { + prompt.EchoMode = textinput.EchoPassword + } + return prompt +} diff --git a/tui/prompt_test.go b/tui/prompt_test.go new file mode 100644 index 0000000..e64e831 --- /dev/null +++ b/tui/prompt_test.go @@ -0,0 +1,26 @@ +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" +) + +func TestBuildPromptUsesPasswordEchoModeForSensitiveInputs(t *testing.T) { + prompt := buildPrompt("enter access token", "", "secret-token", true) + + if got, want := prompt.EchoMode, textinput.EchoPassword; got != want { + t.Fatalf("EchoMode = %v, want %v", got, want) + } + if got, want := prompt.Value(), "secret-token"; got != want { + t.Fatalf("Value() = %q, want %q", got, want) + } +} + +func TestBuildPromptUsesNormalEchoModeForStandardInputs(t *testing.T) { + prompt := buildPrompt("enter permify url", "", "http://localhost:3478", false) + + if got, want := prompt.EchoMode, textinput.EchoNormal; got != want { + t.Fatalf("EchoMode = %v, want %v", got, want) + } +}