From 9e12f5a502ece1f7d56eb8ef205763ba2cecee88 Mon Sep 17 00:00:00 2001 From: mdp28 <212812676+mdp28@users.noreply.github.com> Date: Fri, 15 May 2026 16:37:29 +1000 Subject: [PATCH] Store Permify CLI credentials by profile --- cmd/permctl/permctl.go | 11 +- core/cli/configure.go | 43 ++++++-- core/client/grpc.go | 125 ++++++++++++++++++++- core/client/grpc_test.go | 189 ++++++++++++++++++++++++++++++++ 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 | 47 +++++--- core/config/credentials.go | 178 ++++++++++++++++++++++++++++++ core/config/credentials_test.go | 182 ++++++++++++++++++++++++++++++ 11 files changed, 760 insertions(+), 43 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/cmd/permctl/permctl.go b/cmd/permctl/permctl.go index 568462b..478b716 100644 --- a/cmd/permctl/permctl.go +++ b/cmd/permctl/permctl.go @@ -4,15 +4,20 @@ package main import ( "fmt" "os" + "path/filepath" "github.com/Permify/permify-cli/core/cli" ) func main() { - home := os.Getenv("HOME") - defaultConfig := fmt.Sprintf("%s/.permctl", home) + home, err := os.UserHomeDir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + defaultConfig := filepath.Join(home, ".permctl") shortDescription := "permctl is a cli for managing and communicating with permify" - permctl := cli.New("permctl", shortDescription, defaultConfig) + permctl := cli.New("permctl", shortDescription, defaultConfig) cli.AddComponents(permctl.Cmd) permctl.Execute() } diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..021e2c3 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 + } + + certKey, err := tui.StringPrompt("enter client certificate key path (optional)", "", config.CliConfig.CertKey) + if err != nil { + return err + } + + resp, err := client.New(client.Params{ + Endpoint: url, + Token: token, + CertPath: certPath, + CertKey: certKey, + }) + 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.CertKey = certKey + if err = config.WriteStoredCredentials(profile, config.CliConfig); err != nil { + return err + } + if err = config.Write(); 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..137ce8d 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,136 @@ 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" ) +// Params contains the connection material needed to initialize a Permify client. +type Params struct { + Endpoint string + Token string + CertPath string + CertKey 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) >= len("bearer ") && strings.EqualFold(trimmed[:len("bearer ")], "bearer ") { + return strings.TrimSpace(trimmed[len("bearer "):]) + } + return trimmed +} + +func serverName(target string) string { + host, _, err := net.SplitHostPort(target) + if err != nil { + return strings.Trim(target, "[]") + } + return strings.Trim(host, "[]") +} + +func dialOptions(params Params) ([]grpc.DialOption, error) { + parsed := parseEndpoint(params.Endpoint) + params.Token = strings.TrimSpace(params.Token) + params.CertPath = strings.TrimSpace(params.CertPath) + params.CertKey = strings.TrimSpace(params.CertKey) + useTLS := parsed.UseTLS + + if (params.CertPath == "") != (params.CertKey == "") { + return nil, fmt.Errorf("both cert_path and cert_key must be set together") + } + + var options []grpc.DialOption + if params.CertPath != "" && params.CertKey != "" { + cert, err := tls.LoadX509KeyPair(params.CertPath, params.CertKey) + 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())) + } + + if token := trimBearerPrefix(params.Token); token != "" { + authorization := map[string]string{"authorization": "Bearer " + token} + if useTLS { + options = append(options, grpc.WithPerRPCCredentials(secureTokenCredentials(authorization))) + } else { + options = append(options, grpc.WithPerRPCCredentials(nonSecureTokenCredentials(authorization))) + } + } + + 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 } + +// NewFromCLIConfig initializes a Permify client from the active profile. +func NewFromCLIConfig() (*permify.Client, error) { + return New(Params{ + Endpoint: config.CliConfig.PermifyURL, + Token: config.CliConfig.Token, + CertPath: config.CliConfig.CertPath, + CertKey: config.CliConfig.CertKey, + }) +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..e6c632c --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,189 @@ +package client + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +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: "grpcs scheme", endpoint: "grpcs://api.example.com:3478", wantTarget: "api.example.com:3478", wantTLS: true}, + {name: "http scheme", endpoint: "http://127.0.0.1:3478", wantTarget: "127.0.0.1:3478"}, + {name: "grpc scheme", endpoint: "grpc://127.0.0.1:3478", wantTarget: "127.0.0.1:3478"}, + {name: "trim whitespace", endpoint: " localhost:3478 ", wantTarget: "localhost:3478"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := parseEndpoint(test.endpoint) + if got.Target != test.wantTarget || got.UseTLS != test.wantTLS { + t.Fatalf("parseEndpoint(%q) = %#v, want target=%q tls=%t", test.endpoint, got, 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 TestTokenCredentialsMetadata(t *testing.T) { + secure := secureTokenCredentials{"authorization": "Bearer secure-token"} + if !secure.RequireTransportSecurity() { + t.Fatalf("secureTokenCredentials should require transport security") + } + metadata, err := secure.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("secure metadata error = %v", err) + } + if metadata["authorization"] != "Bearer secure-token" { + t.Fatalf("secure authorization metadata = %q", metadata["authorization"]) + } + + nonSecure := nonSecureTokenCredentials{"authorization": "Bearer local-token"} + if nonSecure.RequireTransportSecurity() { + t.Fatalf("nonSecureTokenCredentials should allow insecure local transport") + } + metadata, err = nonSecure.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("non-secure metadata error = %v", err) + } + if metadata["authorization"] != "Bearer local-token" { + t.Fatalf("non-secure authorization metadata = %q", metadata["authorization"]) + } +} + +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") { + t.Fatalf("dialOptions() error = %v, want missing cert key error", err) + } + + _, err = dialOptions(Params{ + Endpoint: "localhost:3478", + CertKey: "/tmp/client.key", + }) + if err == nil || !strings.Contains(err.Error(), "both cert_path and cert_key") { + t.Fatalf("dialOptions() error = %v, want missing cert path error", err) + } +} + +func TestDialOptionsAllowBearerTokenOnTLSAndInsecureEndpoints(t *testing.T) { + options, err := dialOptions(Params{ + Endpoint: "https://permify.example:3478", + Token: "Bearer secret", + }) + if err != nil { + t.Fatalf("dialOptions(tls token) error = %v", err) + } + if len(options) < 2 { + t.Fatalf("dialOptions(tls token) returned %d options, want at least 2", len(options)) + } + + options, err = dialOptions(Params{ + Endpoint: "localhost:3478", + Token: "secret", + }) + if err != nil { + t.Fatalf("dialOptions(insecure token) error = %v", err) + } + if len(options) < 2 { + t.Fatalf("dialOptions(insecure token) returned %d options, want at least 2", len(options)) + } +} + +func TestDialOptionsAllowMutualTLSCertPair(t *testing.T) { + certPath, keyPath := writeTestCertificate(t) + + options, err := dialOptions(Params{ + Endpoint: "https://permify.example:3478", + Token: "secret", + CertPath: certPath, + CertKey: keyPath, + }) + if err != nil { + t.Fatalf("dialOptions(mtls) error = %v", err) + } + if len(options) < 2 { + t.Fatalf("dialOptions(mtls) returned %d options, want at least 2", len(options)) + } +} + +func TestNewRejectsEmptyEndpoint(t *testing.T) { + _, err := New(Params{}) + if err == nil || !strings.Contains(err.Error(), "endpoint is empty") { + t.Fatalf("New(empty) error = %v, want endpoint error", err) + } +} + +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: "permify-cli-test", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + 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.crt") + keyPath := filepath.Join(dir, "client.key") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatalf("WriteFile(cert) error = %v", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatalf("WriteFile(key) error = %v", err) + } + return certPath, keyPath +} 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..b62b6fb 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -7,7 +7,6 @@ import ( "os" "strings" - "github.com/Permify/permify-cli/core/logger" "gopkg.in/yaml.v3" ) @@ -25,9 +24,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,omitempty"` + Tenant string `yaml:"tenant"` + Token string `yaml:"-"` + CertPath string `yaml:"-"` + CertKey string `yaml:"-"` + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured @@ -40,14 +42,21 @@ func IsConfigured(file string, profile string) error { if err != nil { return err } - err = yaml.Unmarshal(data, &profileConfigs.Configs) + if err = yaml.Unmarshal(data, &profileConfigs.Configs); err != nil { + return fmt.Errorf("unmarshal config file %s: %w", file, err) + } + if profileConfigs.Configs == nil { + profileConfigs.Configs = make(map[string]CoreConfig) + } + profileConfig := profileConfigs.Configs[profile] + credentials, err := LoadStoredCredentials(profile) if err != nil { - logger.Log.Fatal("Error unmarshaling yaml") + return err } - if profileConfigs.Configs[profile].PermifyURL == "" { + if firstNonEmpty(credentials.EndpointValue(), profileConfig.PermifyURL) == "" { return fmt.Errorf("permify url is empty for profile %s", profile) } - if profileConfigs.Configs[profile].Tenant == "" { + if profileConfig.Tenant == "" { return fmt.Errorf("tenant is empty for profile %s", profile) } return nil @@ -63,14 +72,21 @@ func Load(file string, profile string) error { if err != nil { return err } - err = yaml.Unmarshal(data, &profileConfigs.Configs) - if err != nil { - logger.Log.Fatal("Error unmarshaling yaml") + if err = yaml.Unmarshal(data, &profileConfigs.Configs); err != nil { + return fmt.Errorf("unmarshal config file %s: %w", file, err) + } + if profileConfigs.Configs == nil { + profileConfigs.Configs = make(map[string]CoreConfig) } profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] - CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") + if err = ApplyStoredCredentials(profile); err != nil { + return err + } + CliConfig.SslEnabled = strings.HasPrefix(strings.ToLower(CliConfig.PermifyURL), "https") || + strings.HasPrefix(strings.ToLower(CliConfig.PermifyURL), "grpcs") || + CliConfig.CertPath != "" return err } @@ -95,7 +111,12 @@ func Write() error { return fmt.Errorf("%s config file does not exist", profileConfigs.File) } profile := profileConfigs.Profile - profileConfigs.Configs[profile] = CliConfig + if profileConfigs.Configs == nil { + profileConfigs.Configs = make(map[string]CoreConfig) + } + profileConfig := CliConfig + profileConfig.PermifyURL = "" + profileConfigs.Configs[profile] = profileConfig newConfigDataByte, err := yaml.Marshal(profileConfigs.Configs) if err != nil { return err diff --git a/core/config/credentials.go b/core/config/credentials.go new file mode 100644 index 0000000..fe3f6b9 --- /dev/null +++ b/core/config/credentials.go @@ -0,0 +1,178 @@ +package config + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + + "gopkg.in/yaml.v3" +) + +const credentialsFileMode fs.FileMode = 0o600 + +var credentialsPathOverride string + +// StoredCredentials is the profile-scoped connection material persisted outside +// the normal tenant config file. +type StoredCredentials struct { + Endpoint string `yaml:"endpoint,omitempty"` + LegacyURL string `yaml:"permify_url,omitempty"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` + LegacyCertKey string `yaml:"cert_key_path,omitempty"` +} + +func (c StoredCredentials) EndpointValue() string { + return firstNonEmpty(c.Endpoint, c.LegacyURL) +} + +func (c StoredCredentials) CertKeyValue() string { + return firstNonEmpty(c.CertKey, c.LegacyCertKey) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func credentialsFilePathForHome(home string) string { + return filepath.Join(home, ".permify", "credentials") +} + +func credentialsFilePath() (string, error) { + if credentialsPathOverride != "" { + return credentialsPathOverride, nil + } + + 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(path string) (map[string]StoredCredentials, error) { + 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, fmt.Errorf("unmarshal credentials file %s: %w", path, err) + } + if profiles == nil { + profiles = map[string]StoredCredentials{} + } + return profiles, nil +} + +func writeStoredCredentialsFile(path string, profiles map[string]StoredCredentials) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + for profile, credentials := range profiles { + credentials.Endpoint = credentials.EndpointValue() + credentials.LegacyURL = "" + credentials.CertKey = credentials.CertKeyValue() + credentials.LegacyCertKey = "" + profiles[profile] = credentials + } + + data, err := yaml.Marshal(profiles) + if err != nil { + return err + } + + if runtime.GOOS == "windows" { + return os.WriteFile(path, data, credentialsFileMode) + } + + file, err := os.CreateTemp(filepath.Dir(path), ".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(credentialsFileMode); 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) { + path, err := credentialsFilePath() + if err != nil { + return StoredCredentials{}, err + } + profiles, err := loadStoredCredentialsFile(path) + if err != nil { + return StoredCredentials{}, err + } + return profiles[profile], nil +} + +func ApplyStoredCredentials(profile string) error { + credentials, err := LoadStoredCredentials(profile) + if err != nil { + return err + } + + CliConfig.PermifyURL = firstNonEmpty(credentials.EndpointValue(), CliConfig.PermifyURL) + CliConfig.Token = strings.TrimSpace(credentials.Token) + CliConfig.CertPath = strings.TrimSpace(credentials.CertPath) + CliConfig.CertKey = strings.TrimSpace(credentials.CertKeyValue()) + return nil +} + +func WriteStoredCredentials(profile string, cfg CoreConfig) error { + path, err := credentialsFilePath() + if err != nil { + return err + } + + profiles, err := loadStoredCredentialsFile(path) + if err != nil { + return err + } + profiles[profile] = StoredCredentials{ + Endpoint: strings.TrimSpace(cfg.PermifyURL), + Token: strings.TrimSpace(cfg.Token), + CertPath: strings.TrimSpace(cfg.CertPath), + CertKey: strings.TrimSpace(cfg.CertKey), + } + return writeStoredCredentialsFile(path, profiles) +} diff --git a/core/config/credentials_test.go b/core/config/credentials_test.go new file mode 100644 index 0000000..d648aab --- /dev/null +++ b/core/config/credentials_test.go @@ -0,0 +1,182 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func withCredentialsPath(t *testing.T) string { + t.Helper() + + oldPath := credentialsPathOverride + oldCliConfig := CliConfig + oldProfileConfigs := profileConfigs + path := filepath.Join(t.TempDir(), ".permify", "credentials") + credentialsPathOverride = path + t.Cleanup(func() { + credentialsPathOverride = oldPath + CliConfig = oldCliConfig + profileConfigs = oldProfileConfigs + }) + return path +} + +func TestCredentialsFilePathForHome(t *testing.T) { + home := filepath.Join("tmp", "permify-home") + got := credentialsFilePathForHome(home) + want := filepath.Join(home, ".permify", "credentials") + if got != want { + t.Fatalf("credentialsFilePathForHome() = %q, want %q", got, want) + } +} + +func TestWriteAndLoadStoredCredentialsByProfile(t *testing.T) { + path := withCredentialsPath(t) + + defaultConfig := CoreConfig{ + PermifyURL: "https://permify.example:3478", + Token: "secret-token", + CertPath: "/tmp/client.crt", + CertKey: "/tmp/client.key", + } + stagingConfig := CoreConfig{ + PermifyURL: "grpc://staging.example:3478", + Token: "staging-token", + } + + if err := WriteStoredCredentials("default", defaultConfig); err != nil { + t.Fatalf("WriteStoredCredentials(default) error = %v", err) + } + if err := WriteStoredCredentials("staging", stagingConfig); err != nil { + t.Fatalf("WriteStoredCredentials(staging) error = %v", err) + } + + stored, err := LoadStoredCredentials("default") + if err != nil { + t.Fatalf("LoadStoredCredentials(default) error = %v", err) + } + if stored.EndpointValue() != defaultConfig.PermifyURL { + t.Fatalf("default endpoint = %q, want %q", stored.EndpointValue(), defaultConfig.PermifyURL) + } + if stored.Token != defaultConfig.Token { + t.Fatalf("default token = %q, want %q", stored.Token, defaultConfig.Token) + } + if stored.CertPath != defaultConfig.CertPath { + t.Fatalf("default cert path = %q, want %q", stored.CertPath, defaultConfig.CertPath) + } + if stored.CertKeyValue() != defaultConfig.CertKey { + t.Fatalf("default cert key = %q, want %q", stored.CertKeyValue(), defaultConfig.CertKey) + } + + staging, err := LoadStoredCredentials("staging") + if err != nil { + t.Fatalf("LoadStoredCredentials(staging) error = %v", err) + } + if staging.EndpointValue() != stagingConfig.PermifyURL { + t.Fatalf("staging endpoint = %q, want %q", staging.EndpointValue(), stagingConfig.PermifyURL) + } + if staging.Token != stagingConfig.Token { + t.Fatalf("staging token = %q, want %q", staging.Token, stagingConfig.Token) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(credentials) error = %v", err) + } + contents := string(data) + for _, want := range []string{"endpoint:", "token:", "cert_path:", "cert_key:"} { + if !strings.Contains(contents, want) { + t.Fatalf("credentials file missing %q:\n%s", want, contents) + } + } + if strings.Contains(contents, "permify_url:") || strings.Contains(contents, "cert_key_path:") { + t.Fatalf("credentials file used legacy field names:\n%s", contents) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(credentials) error = %v", err) + } + if mode := info.Mode().Perm(); mode != credentialsFileMode { + t.Fatalf("credentials file mode = %04o, want %04o", mode, credentialsFileMode) + } + } +} + +func TestLoadStoredCredentialsSupportsLegacyNames(t *testing.T) { + path := withCredentialsPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + contents := []byte("default:\n permify_url: https://legacy.example:3478\n token: legacy-token\n cert_path: /tmp/legacy.crt\n cert_key_path: /tmp/legacy.key\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatalf("WriteFile(credentials) error = %v", err) + } + + stored, err := LoadStoredCredentials("default") + if err != nil { + t.Fatalf("LoadStoredCredentials() error = %v", err) + } + if stored.EndpointValue() != "https://legacy.example:3478" { + t.Fatalf("legacy endpoint = %q", stored.EndpointValue()) + } + if stored.CertKeyValue() != "/tmp/legacy.key" { + t.Fatalf("legacy cert key = %q", stored.CertKeyValue()) + } +} + +func TestLoadAppliesStoredCredentialsAndWriteKeepsSecretsOutOfConfig(t *testing.T) { + withCredentialsPath(t) + + dir := t.TempDir() + configPath := filepath.Join(dir, ".permctl") + if err := os.WriteFile(configPath, []byte("default:\n permify_url: localhost:3478\n tenant: t1\n"), 0o644); err != nil { + t.Fatalf("WriteFile(config) error = %v", err) + } + if err := WriteStoredCredentials("default", CoreConfig{ + PermifyURL: "https://secure.example:3478", + Token: "stored-token", + CertPath: "/tmp/client.crt", + CertKey: "/tmp/client.key", + }); err != nil { + t.Fatalf("WriteStoredCredentials() error = %v", err) + } + + if err := IsConfigured(configPath, "default"); err != nil { + t.Fatalf("IsConfigured() error = %v", err) + } + if err := Load(configPath, "default"); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if CliConfig.PermifyURL != "https://secure.example:3478" { + t.Fatalf("CliConfig.PermifyURL = %q", CliConfig.PermifyURL) + } + if CliConfig.Tenant != "t1" { + t.Fatalf("CliConfig.Tenant = %q", CliConfig.Tenant) + } + if CliConfig.Token != "stored-token" { + t.Fatalf("CliConfig.Token = %q", CliConfig.Token) + } + + if err := Write(); err != nil { + t.Fatalf("Write() error = %v", err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(config) error = %v", err) + } + contents := string(data) + for _, forbidden := range []string{"stored-token", "client.crt", "client.key", "https://secure.example"} { + if strings.Contains(contents, forbidden) { + t.Fatalf("normal config leaked %q:\n%s", forbidden, contents) + } + } + if !strings.Contains(contents, "tenant: t1") { + t.Fatalf("normal config lost tenant:\n%s", contents) + } +}