From 4faa4608796ae23aed3b3638fd5538fc79113144 Mon Sep 17 00:00:00 2001 From: SaiPrasadK14 Date: Mon, 11 May 2026 11:42:23 -0500 Subject: [PATCH] feat: store CLI credentials separately --- core/cli/configure.go | 43 +++++++-- core/client/grpc.go | 122 ++++++++++++++++++++++++- core/client/grpc_test.go | 79 +++++++++++++++++ core/client/utils.go | 14 --- core/cmd/data/client.go | 7 +- core/cmd/permission/client.go | 7 +- core/cmd/schema/client.go | 7 +- core/cmd/tenancy/client.go | 7 +- core/config/config.go | 20 ++++- core/config/credentials.go | 152 ++++++++++++++++++++++++++++++++ core/config/credentials_test.go | 106 ++++++++++++++++++++++ 11 files changed, 519 insertions(+), 45 deletions(-) create mode 100644 core/client/grpc_test.go create mode 100644 core/config/credentials.go create mode 100644 core/config/credentials_test.go diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..951b0b2 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -96,18 +96,42 @@ func validateFlags(cmd *cobra.Command, args []string) error { func runE(cmd *cobra.Command, _ []string) error { configFile, _ := cmd.Flags().GetString("config") + profile, _ := cmd.Flags().GetString("profile") url, err := tui.StringPrompt("enter permify url", "", config.CliConfig.PermifyURL) if err != nil { return err } - resp, err := client.New(url) + token, err := tui.StringPrompt("enter bearer token (optional)", "", config.CliConfig.Token) + if err != nil { + return err + } + + certPath, err := tui.StringPrompt("enter client certificate path (optional)", "", config.CliConfig.CertPath) + if err != nil { + return err + } + + certKeyPath, err := tui.StringPrompt("enter client certificate key path (optional)", "", config.CliConfig.CertKeyPath) + if err != nil { + return err + } + + resp, err := client.New(client.Params{ + Endpoint: url, + Token: token, + CertPath: certPath, + CertKeyPath: certKeyPath, + }) + 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 +141,21 @@ 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) + config.CliConfig.Token = token + config.CliConfig.CertPath = certPath + config.CliConfig.CertKeyPath = certKeyPath + if err := config.Write(); err != nil { + return err + } + if err := config.WriteStoredCredentials(profile, config.CliConfig); 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..9ea6476 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,133 @@ package client import ( + "crypto/tls" + "fmt" + "net" + "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" ) +type Params struct { + Endpoint string + Token string + CertPath string + CertKeyPath string +} + +type parsedEndpoint struct { + Target string + UseTLS bool +} + +func parseEndpoint(endpoint string) parsedEndpoint { + trimmed := strings.TrimSpace(endpoint) + lower := strings.ToLower(trimmed) + + switch { + case strings.HasPrefix(lower, "https://"): + return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("https://"):]), UseTLS: true} + case strings.HasPrefix(lower, "grpcs://"): + return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("grpcs://"):]), UseTLS: true} + case strings.HasPrefix(lower, "http://"): + return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("http://"):])} + case strings.HasPrefix(lower, "grpc://"): + return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("grpc://"):])} + default: + return parsedEndpoint{Target: trimmed} + } +} + +func trimBearerPrefix(token string) string { + trimmed := strings.TrimSpace(token) + if len(trimmed) >= 7 && strings.EqualFold(trimmed[:7], "bearer ") { + return strings.TrimSpace(trimmed[7:]) + } + return trimmed +} + +func serverName(target string) string { + host, _, err := net.SplitHostPort(target) + if err != nil { + return target + } + return host +} + +func dialOptions(params Params) ([]grpc.DialOption, error) { + parsed := parseEndpoint(params.Endpoint) + useTLS := parsed.UseTLS + + if (params.CertPath == "") != (params.CertKeyPath == "") { + return nil, fmt.Errorf("both cert_path and cert_key_path must be set together") + } + + var options []grpc.DialOption + if params.CertPath != "" && params.CertKeyPath != "" { + cert, err := tls.LoadX509KeyPair(params.CertPath, params.CertKeyPath) + if err != nil { + return nil, fmt.Errorf("load client certificate: %w", err) + } + + options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + ServerName: serverName(parsed.Target), + }))) + useTLS = true + } else if useTLS { + options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: serverName(parsed.Target), + }))) + } else { + options = append(options, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + token := trimBearerPrefix(params.Token) + if token != "" { + if !useTLS { + return nil, fmt.Errorf("bearer token requires TLS; use an https:// or grpcs:// endpoint, or configure mTLS credentials") + } + options = append(options, grpc.WithPerRPCCredentials(secureTokenCredentials{ + "authorization": "Bearer " + token, + })) + } + + return options, nil +} + // New initializes a new permify client -func New(endpoint string) (*permify.Client, error) { +func New(params Params) (*permify.Client, error) { + parsed := parseEndpoint(params.Endpoint) + if parsed.Target == "" { + return nil, fmt.Errorf("endpoint is empty") + } + + options, err := dialOptions(params) + if err != nil { + return nil, err + } + client, err := permify.NewClient( permify.Config{ - Endpoint: endpoint, + Endpoint: parsed.Target, }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + options..., ) return client, err } + +func NewFromCLIConfig() (*permify.Client, error) { + return New(Params{ + Endpoint: config.CliConfig.PermifyURL, + Token: config.CliConfig.Token, + CertPath: config.CliConfig.CertPath, + CertKeyPath: config.CliConfig.CertKeyPath, + }) +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..0635f4e --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,79 @@ +package client + +import ( + "strings" + "testing" +) + +func TestParseEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + wantTarget string + wantTLS bool + }{ + {name: "plain host", endpoint: "localhost:3478", wantTarget: "localhost:3478"}, + {name: "https scheme", endpoint: "https://api.example.com:443", wantTarget: "api.example.com:443", wantTLS: true}, + {name: "grpc scheme", endpoint: "grpc://127.0.0.1:3478", wantTarget: "127.0.0.1:3478"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed := parseEndpoint(test.endpoint) + if parsed.Target != test.wantTarget || parsed.UseTLS != test.wantTLS { + t.Fatalf("parseEndpoint(%q) = %#v, want target=%q tls=%t", test.endpoint, parsed, test.wantTarget, test.wantTLS) + } + }) + } +} + +func TestTrimBearerPrefix(t *testing.T) { + tests := []struct { + token string + want string + }{ + {token: "", want: ""}, + {token: "token", want: "token"}, + {token: "Bearer abc", want: "abc"}, + {token: "bearer abc ", want: "abc"}, + } + + for _, test := range tests { + if got := trimBearerPrefix(test.token); got != test.want { + t.Fatalf("trimBearerPrefix(%q) = %q, want %q", test.token, got, test.want) + } + } +} + +func TestDialOptionsRequireBothCertFiles(t *testing.T) { + _, err := dialOptions(Params{ + Endpoint: "localhost:3478", + CertPath: "/tmp/client.crt", + }) + if err == nil || !strings.Contains(err.Error(), "both cert_path and cert_key_path") { + t.Fatalf("dialOptions() error = %v, want missing key error", err) + } +} + +func TestDialOptionsRequireTLSForToken(t *testing.T) { + _, err := dialOptions(Params{ + Endpoint: "localhost:3478", + Token: "secret", + }) + if err == nil || !strings.Contains(err.Error(), "bearer token requires TLS") { + t.Fatalf("dialOptions() error = %v, want TLS error", err) + } +} + +func TestDialOptionsAllowTokenWithTLSScheme(t *testing.T) { + options, err := dialOptions(Params{ + Endpoint: "https://permify.example:3478", + Token: "secret", + }) + if err != nil { + t.Fatalf("dialOptions() error = %v", err) + } + if len(options) < 2 { + t.Fatalf("dialOptions() returned %d options, want at least 2", len(options)) + } +} diff --git a/core/client/utils.go b/core/client/utils.go index 918fb7a..b35988d 100644 --- a/core/client/utils.go +++ b/core/client/utils.go @@ -17,17 +17,3 @@ func (c secureTokenCredentials) RequireTransportSecurity() bool { func (c secureTokenCredentials) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { return c, nil // Returns the secure tokens as metadata with no error. } - -// nonSecureTokenCredentials represents a map used for storing non-secure tokens. -// These tokens do not require transport security. -type nonSecureTokenCredentials map[string]string - -// RequireTransportSecurity indicates that transport security is not required for these credentials. -func (c nonSecureTokenCredentials) RequireTransportSecurity() bool { - return false // Transport security is not required for non-secure tokens. -} - -// GetRequestMetadata retrieves the current metadata (non-secure tokens) for a request. -func (c nonSecureTokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { - return c, nil // Returns the non-secure tokens as metadata with no error. -} diff --git a/core/cmd/data/client.go b/core/cmd/data/client.go index a567b61..c21b876 100644 --- a/core/cmd/data/client.go +++ b/core/cmd/data/client.go @@ -4,16 +4,15 @@ import ( "os" "github.com/Permify/permify-cli/core/client" - "github.com/Permify/permify-cli/core/config" v1 "github.com/Permify/permify-go/generated/base/v1" "github.com/charmbracelet/log" ) func Client() v1.DataClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromCLIConfig() if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") - os.Exit(-1) + os.Exit(-1) } return c.Data -} \ No newline at end of file +} diff --git a/core/cmd/permission/client.go b/core/cmd/permission/client.go index 092f240..59aec68 100644 --- a/core/cmd/permission/client.go +++ b/core/cmd/permission/client.go @@ -4,16 +4,15 @@ import ( "os" "github.com/Permify/permify-cli/core/client" - "github.com/Permify/permify-cli/core/config" v1 "github.com/Permify/permify-go/generated/base/v1" "github.com/charmbracelet/log" ) func Client() v1.PermissionClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromCLIConfig() if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") - os.Exit(-1) + os.Exit(-1) } return c.Permission -} \ No newline at end of file +} diff --git a/core/cmd/schema/client.go b/core/cmd/schema/client.go index 6d0f3c1..befe134 100644 --- a/core/cmd/schema/client.go +++ b/core/cmd/schema/client.go @@ -4,16 +4,15 @@ import ( "os" "github.com/Permify/permify-cli/core/client" - "github.com/Permify/permify-cli/core/config" v1 "github.com/Permify/permify-go/generated/base/v1" "github.com/charmbracelet/log" ) func Client() v1.SchemaClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromCLIConfig() if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") - os.Exit(-1) + os.Exit(-1) } return c.Schema -} \ No newline at end of file +} diff --git a/core/cmd/tenancy/client.go b/core/cmd/tenancy/client.go index 74c8213..0fbf262 100644 --- a/core/cmd/tenancy/client.go +++ b/core/cmd/tenancy/client.go @@ -4,16 +4,15 @@ import ( "os" "github.com/Permify/permify-cli/core/client" - "github.com/Permify/permify-cli/core/config" v1 "github.com/Permify/permify-go/generated/base/v1" "github.com/charmbracelet/log" ) func Client() v1.TenancyClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromCLIConfig() if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") - os.Exit(-1) + os.Exit(-1) } return c.Tenancy -} \ No newline at end of file +} diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..a5b681c 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:"-"` + CertPath string `yaml:"-"` + CertKeyPath string `yaml:"-"` + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured @@ -44,7 +47,12 @@ func IsConfigured(file string, profile string) error { if err != nil { logger.Log.Fatal("Error unmarshaling yaml") } - if profileConfigs.Configs[profile].PermifyURL == "" { + storedCredentials, err := LoadStoredCredentials(profile) + if err != nil { + return err + } + permifyURL := firstNonEmpty(storedCredentials.PermifyURL, profileConfigs.Configs[profile].PermifyURL) + if permifyURL == "" { return fmt.Errorf("permify url is empty for profile %s", profile) } if profileConfigs.Configs[profile].Tenant == "" { @@ -70,6 +78,10 @@ func Load(file string, profile string) error { profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] + err = ApplyStoredCredentials(profile) + if err != nil { + return err + } CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") return err } diff --git a/core/config/credentials.go b/core/config/credentials.go new file mode 100644 index 0000000..a5ffa0b --- /dev/null +++ b/core/config/credentials.go @@ -0,0 +1,152 @@ +package config + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + + "gopkg.in/yaml.v3" +) + +type StoredCredentials 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"` +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func credentialsFilePathForHome(home string) string { + return filepath.Join(home, ".permify", "credentials") +} + +func credentialsFilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve credentials path: %w", err) + } + home = strings.TrimSpace(home) + if home == "" { + return "", fmt.Errorf("resolve credentials path: empty home directory") + } + return credentialsFilePathForHome(home), nil +} + +func loadStoredCredentialsFile() (map[string]StoredCredentials, error) { + path, err := credentialsFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]StoredCredentials{}, nil + } + return nil, err + } + var profiles map[string]StoredCredentials + if err := yaml.Unmarshal(data, &profiles); err != nil { + return nil, err + } + if profiles == nil { + profiles = map[string]StoredCredentials{} + } + return profiles, nil +} + +func writeStoredCredentialsFile(path string, data []byte) error { + if runtime.GOOS == "windows" { + return os.WriteFile(path, data, fs.FileMode(0o600)) + } + + file, err := os.CreateTemp(filepath.Dir(path), ".permify-credentials-*.tmp") + if err != nil { + return err + } + tempPath := file.Name() + cleanup := true + defer func() { + _ = file.Close() + if cleanup { + _ = os.Remove(tempPath) + } + }() + + if err := file.Chmod(0o600); err != nil { + return err + } + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(tempPath, path); err != nil { + return err + } + cleanup = false + return nil +} + +func LoadStoredCredentials(profile string) (StoredCredentials, error) { + credentials, err := loadStoredCredentialsFile() + if err != nil { + return StoredCredentials{}, err + } + return credentials[profile], nil +} + +func ApplyStoredCredentials(profile string) error { + storedCredentials, err := LoadStoredCredentials(profile) + if err != nil { + return err + } + + CliConfig.PermifyURL = firstNonEmpty(storedCredentials.PermifyURL, CliConfig.PermifyURL) + CliConfig.Token = storedCredentials.Token + CliConfig.CertPath = storedCredentials.CertPath + CliConfig.CertKeyPath = storedCredentials.CertKeyPath + return nil +} + +func WriteStoredCredentials(profile string, cfg CoreConfig) error { + credentials, err := loadStoredCredentialsFile() + if err != nil { + return err + } + if credentials == nil { + credentials = map[string]StoredCredentials{} + } + + path, err := credentialsFilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + credentials[profile] = StoredCredentials{ + PermifyURL: cfg.PermifyURL, + Token: cfg.Token, + CertPath: cfg.CertPath, + CertKeyPath: cfg.CertKeyPath, + } + + data, err := yaml.Marshal(credentials) + if err != nil { + return err + } + return writeStoredCredentialsFile(path, data) +} diff --git a/core/config/credentials_test.go b/core/config/credentials_test.go new file mode 100644 index 0000000..63b08b5 --- /dev/null +++ b/core/config/credentials_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCredentialsFilePathForHome(t *testing.T) { + got := credentialsFilePathForHome("/tmp/permify-home") + want := filepath.Join("/tmp/permify-home", ".permify", "credentials") + if got != want { + t.Fatalf("credentialsFilePathForHome() = %q, want %q", got, want) + } +} + +func TestWriteAndLoadStoredCredentials(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cfg := CoreConfig{ + PermifyURL: "https://permify.example:3478", + Token: "secret-token", + CertPath: "/tmp/client.crt", + CertKeyPath: "/tmp/client.key", + } + + if err := WriteStoredCredentials("default", cfg); err != nil { + t.Fatalf("WriteStoredCredentials() error = %v", err) + } + + path, err := credentialsFilePath() + if err != nil { + t.Fatalf("credentialsFilePath() error = %v", err) + } + + stored, err := LoadStoredCredentials("default") + if err != nil { + t.Fatalf("LoadStoredCredentials() error = %v", err) + } + + if stored.PermifyURL != cfg.PermifyURL { + t.Fatalf("stored endpoint = %q, want %q", stored.PermifyURL, cfg.PermifyURL) + } + if stored.Token != cfg.Token { + t.Fatalf("stored token = %q, want %q", stored.Token, cfg.Token) + } + if stored.CertPath != cfg.CertPath { + t.Fatalf("stored cert path = %q, want %q", stored.CertPath, cfg.CertPath) + } + if stored.CertKeyPath != cfg.CertKeyPath { + t.Fatalf("stored cert key path = %q, want %q", stored.CertKeyPath, cfg.CertKeyPath) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("os.Stat() error = %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("credentials file mode = %04o, want 0600", mode) + } + } +} + +func TestLoadPrefersStoredCredentialEndpoint(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + oldCliConfig := CliConfig + oldProfileConfigs := profileConfigs + defer func() { + CliConfig = oldCliConfig + profileConfigs = oldProfileConfigs + }() + + configPath := filepath.Join(home, "permctl.yaml") + configContents := []byte("default:\n permify_url: localhost:3478\n tenant: tenant-1\n") + if err := os.WriteFile(configPath, configContents, 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + if err := WriteStoredCredentials("default", CoreConfig{ + PermifyURL: "https://secure.permify.example:3478", + Token: "stored-token", + CertPath: "/tmp/client.crt", + CertKeyPath: "/tmp/client.key", + }); err != nil { + t.Fatalf("WriteStoredCredentials() error = %v", err) + } + + if err := Load(configPath, "default"); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if CliConfig.PermifyURL != "https://secure.permify.example:3478" { + t.Fatalf("CliConfig.PermifyURL = %q, want stored endpoint", CliConfig.PermifyURL) + } + if CliConfig.Tenant != "tenant-1" { + t.Fatalf("CliConfig.Tenant = %q, want tenant-1", CliConfig.Tenant) + } + if CliConfig.Token != "stored-token" { + t.Fatalf("CliConfig.Token = %q, want stored-token", CliConfig.Token) + } +}