From c288632c5df9a4633860101975df9ae3ae500fbf Mon Sep 17 00:00:00 2001 From: Magic-Jia <130212383+Magic-Jia@users.noreply.github.com> Date: Mon, 25 May 2026 13:42:37 +0200 Subject: [PATCH] feat: persist CLI credentials for client creation Store API token and TLS certificate paths in the CLI config, use the persisted configuration when creating Permify clients, and keep config files owner-only because they may now contain tokens. --- core/cli/configure.go | 27 ++++++++-- core/cli/configure_test.go | 29 +++++++++++ core/client/grpc.go | 90 ++++++++++++++++++++++++++++---- core/client/grpc_test.go | 58 +++++++++++++++++++++ core/cmd/data/client.go | 6 +-- core/cmd/permission/client.go | 6 +-- core/cmd/schema/client.go | 6 +-- core/cmd/tenancy/client.go | 6 +-- core/config/config.go | 20 +++++-- core/config/config_test.go | 98 +++++++++++++++++++++++++++++++++++ 10 files changed, 317 insertions(+), 29 deletions(-) create mode 100644 core/cli/configure_test.go create mode 100644 core/client/grpc_test.go create mode 100644 core/config/config_test.go diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..0df17d5 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -94,6 +94,16 @@ func validateFlags(cmd *cobra.Command, args []string) error { return err } +func buildConfig(url, tenant, token, certPath, certKey string) config.CoreConfig { + return config.CoreConfig{ + PermifyURL: url, + Tenant: tenant, + Token: token, + CertPath: certPath, + CertKey: certKey, + } +} + func runE(cmd *cobra.Command, _ []string) error { configFile, _ := cmd.Flags().GetString("config") @@ -117,13 +127,24 @@ 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] + token, err := tui.StringPrompt("enter API 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 = buildConfig(url, tenantIds[tenant], token, certPath, certKey) err = config.Write() if err != nil { logger.Log.Error(err) diff --git a/core/cli/configure_test.go b/core/cli/configure_test.go new file mode 100644 index 0000000..508a182 --- /dev/null +++ b/core/cli/configure_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "testing" + + "github.com/Permify/permify-cli/core/config" +) + +func TestBuildConfigPreservesCredentialFields(t *testing.T) { + cfg := buildConfig("https://permify.example.com", "tenant-1", "token-1", "/tmp/client.crt", "/tmp/client.key") + + if cfg.PermifyURL != "https://permify.example.com" { + t.Fatalf("expected permify url to be stored, got %q", cfg.PermifyURL) + } + if cfg.Tenant != "tenant-1" { + t.Fatalf("expected tenant to be stored, got %q", cfg.Tenant) + } + if cfg.Token != "token-1" { + t.Fatalf("expected token to be stored, got %q", cfg.Token) + } + if cfg.CertPath != "/tmp/client.crt" { + t.Fatalf("expected cert path to be stored, got %q", cfg.CertPath) + } + if cfg.CertKey != "/tmp/client.key" { + t.Fatalf("expected cert key to be stored, got %q", cfg.CertKey) + } +} + +var _ config.CoreConfig = buildConfig("", "", "", "", "") diff --git a/core/client/grpc.go b/core/client/grpc.go index 11835df..193335c 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,91 @@ package client import ( + "context" + "crypto/tls" + "fmt" + "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 +type permifyConfig = permify.Config +type permifyClient = *permify.Client + +type clientFactory func(permifyConfig, ...grpc.DialOption) (permifyClient, error) + +type bearerTokenCredentials struct { + token string + requireTransportSecurity bool +} + +func (c bearerTokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer " + c.token}, nil +} + +func (c bearerTokenCredentials) RequireTransportSecurity() bool { + return c.requireTransportSecurity +} + +// DialOptions builds gRPC dial options from the persisted CLI configuration. +func DialOptions(cfg config.CoreConfig) ([]grpc.DialOption, error) { + requireTransportSecurity := strings.HasPrefix(cfg.PermifyURL, "https") || cfg.SslEnabled + + opts := []grpc.DialOption{} + transportCredentials, err := transportCredentials(cfg, requireTransportSecurity) + if err != nil { + return nil, err + } + opts = append(opts, grpc.WithTransportCredentials(transportCredentials)) + + if cfg.Token != "" { + opts = append(opts, grpc.WithPerRPCCredentials(bearerTokenCredentials{ + token: cfg.Token, + requireTransportSecurity: requireTransportSecurity, + })) + } + + return opts, nil +} + +func transportCredentials(cfg config.CoreConfig, secure bool) (credentials.TransportCredentials, error) { + if !secure { + return insecure.NewCredentials(), nil + } + + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if cfg.CertPath != "" || cfg.CertKey != "" { + if cfg.CertPath == "" || cfg.CertKey == "" { + return nil, fmt.Errorf("both cert path and cert key must be configured for TLS client certificates") + } + cert, err := tls.LoadX509KeyPair(cfg.CertPath, cfg.CertKey) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + return credentials.NewTLS(tlsConfig), nil +} + +func newFromConfig(cfg config.CoreConfig, factory clientFactory) (*permify.Client, error) { + opts, err := DialOptions(cfg) + if err != nil { + return nil, err + } + return factory(permify.Config{Endpoint: cfg.PermifyURL}, opts...) +} + +// New initializes a new permify client with an explicit endpoint. func New(endpoint string) (*permify.Client, error) { - client, err := permify.NewClient( - permify.Config{ - Endpoint: endpoint, - }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - return client, err + return NewFromConfig(config.CoreConfig{PermifyURL: endpoint}) +} + +// NewFromConfig initializes a new permify client from persisted CLI configuration. +func NewFromConfig(cfg config.CoreConfig) (*permify.Client, error) { + return newFromConfig(cfg, permify.NewClient) } diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..23ccc46 --- /dev/null +++ b/core/client/grpc_test.go @@ -0,0 +1,58 @@ +package client + +import ( + "context" + "testing" + + "github.com/Permify/permify-cli/core/config" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestDialOptionsUseInsecureCredentialsForHTTP(t *testing.T) { + opts, err := DialOptions(config.CoreConfig{PermifyURL: "http://localhost:3478"}) + if err != nil { + t.Fatalf("DialOptions returned error: %v", err) + } + if len(opts) != 1 { + t.Fatalf("expected only transport credentials option, got %d", len(opts)) + } +} + +func TestDialOptionsAddBearerTokenPerRPCCredentials(t *testing.T) { + opts, err := DialOptions(config.CoreConfig{PermifyURL: "http://localhost:3478", Token: "secret-token"}) + if err != nil { + t.Fatalf("DialOptions returned error: %v", err) + } + if len(opts) != 2 { + t.Fatalf("expected transport credentials and token credentials options, got %d", len(opts)) + } + + creds := bearerTokenCredentials{token: "secret-token", requireTransportSecurity: false} + metadata, err := creds.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatalf("GetRequestMetadata returned error: %v", err) + } + if metadata["authorization"] != "Bearer secret-token" { + t.Fatalf("expected bearer token authorization metadata, got %q", metadata["authorization"]) + } +} + +func TestNewFromConfigUsesConfiguredEndpoint(t *testing.T) { + factory := func(c permifyConfig, opts ...grpc.DialOption) (permifyClient, error) { + if c.Endpoint != "localhost:3478" { + t.Fatalf("expected configured endpoint, got %q", c.Endpoint) + } + if len(opts) != 1 { + t.Fatalf("expected generated dial options to be passed to client factory") + } + return nil, nil + } + + _, err := newFromConfig(config.CoreConfig{PermifyURL: "localhost:3478"}, factory) + if err != nil { + t.Fatalf("newFromConfig returned error: %v", err) + } +} + +var _ grpc.DialOption = grpc.WithTransportCredentials(insecure.NewCredentials()) diff --git a/core/cmd/data/client.go b/core/cmd/data/client.go index a567b61..9abe712 100644 --- a/core/cmd/data/client.go +++ b/core/cmd/data/client.go @@ -10,10 +10,10 @@ import ( ) func Client() v1.DataClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromConfig(config.CliConfig) 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..dad38ba 100644 --- a/core/cmd/permission/client.go +++ b/core/cmd/permission/client.go @@ -10,10 +10,10 @@ import ( ) func Client() v1.PermissionClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromConfig(config.CliConfig) 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..a99401a 100644 --- a/core/cmd/schema/client.go +++ b/core/cmd/schema/client.go @@ -10,10 +10,10 @@ import ( ) func Client() v1.SchemaClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromConfig(config.CliConfig) 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..92c9b55 100644 --- a/core/cmd/tenancy/client.go +++ b/core/cmd/tenancy/client.go @@ -10,10 +10,10 @@ import ( ) func Client() v1.TenancyClient { - c, err := client.New(config.CliConfig.PermifyURL) + c, err := client.NewFromConfig(config.CliConfig) 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..daf07cc 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:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured @@ -84,10 +87,17 @@ func New(file string, profile string) error { if err != nil { return err } - err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0644)) + err = writeConfigFile(file, newConfigDataByte) return err } +func writeConfigFile(file string, data []byte) error { + if err := os.WriteFile(file, data, fs.FileMode(0600)); err != nil { + return err + } + return os.Chmod(file, fs.FileMode(0600)) +} + // Write the config file func Write() error { _, err := os.Stat(profileConfigs.File) @@ -100,6 +110,6 @@ func Write() error { if err != nil { return err } - err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) + err = writeConfigFile(profileConfigs.File, newConfigDataByte) return err } diff --git a/core/config/config_test.go b/core/config/config_test.go new file mode 100644 index 0000000..5f1b826 --- /dev/null +++ b/core/config/config_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCoreConfigStoresCredentialFields(t *testing.T) { + cfg := CoreConfig{ + PermifyURL: "https://permify.example.com", + Tenant: "tenant-1", + Token: "secret-token", + CertPath: "/tmp/client.crt", + CertKey: "/tmp/client.key", + } + + profileConfigs = ProfileConfigs{ + Configs: map[string]CoreConfig{"default": cfg}, + Profile: "default", + } + + CliConfig = profileConfigs.Configs["default"] + + if CliConfig.Token != "secret-token" { + t.Fatalf("expected token to be stored on CoreConfig") + } + if CliConfig.CertPath != "/tmp/client.crt" { + t.Fatalf("expected cert path to be stored on CoreConfig") + } + if CliConfig.CertKey != "/tmp/client.key" { + t.Fatalf("expected cert key to be stored on CoreConfig") + } +} + +func TestConfigFilesAreWrittenOwnerOnlyBecauseTheyMayContainTokens(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + CliConfig = CoreConfig{ + PermifyURL: "https://permify.example.com", + Tenant: "tenant-1", + Token: "secret-token", + CertPath: "/tmp/client.crt", + CertKey: "/tmp/client.key", + } + + if err := New(path, "default"); err != nil { + t.Fatalf("New returned error: %v", err) + } + assertOwnerOnly(t, path) + + CliConfig.Token = "new-token" + if err := Write(); err != nil { + t.Fatalf("Write returned error: %v", err) + } + assertOwnerOnly(t, path) + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + for _, want := range []string{"token: new-token", "cert_path: /tmp/client.crt", "cert_key: /tmp/client.key"} { + if !strings.Contains(string(contents), want) { + t.Fatalf("expected config file to contain %q, got:\n%s", want, contents) + } + } +} + +func TestWriteTightensExistingConfigPermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("default:\n permify_url: http://localhost\n tenant: t1\n"), 0644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + profileConfigs = ProfileConfigs{ + Configs: map[string]CoreConfig{"default": {PermifyURL: "http://localhost", Tenant: "t1", Token: "secret-token"}}, + File: path, + Profile: "default", + } + CliConfig = profileConfigs.Configs["default"] + + if err := Write(); err != nil { + t.Fatalf("Write returned error: %v", err) + } + + assertOwnerOnly(t, path) +} + +func assertOwnerOnly(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat returned error: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("expected config file permissions 0600, got %o", got) + } +}