From b64068880330edd0b17147856f69bbbb67ed1bfb Mon Sep 17 00:00:00 2001 From: minhmci101001 Date: Thu, 28 May 2026 15:34:42 +0700 Subject: [PATCH] feat: store token and TLS certs for permctl --- README.md | 25 +++++++++- core/cli/configure.go | 23 ++++++++- core/client/grpc.go | 88 +++++++++++++++++++++++++++++++++-- core/cmd/data/client.go | 4 +- core/cmd/permission/client.go | 4 +- core/cmd/schema/client.go | 4 +- core/cmd/tenancy/client.go | 4 +- core/config/config.go | 43 +++++++++++++++-- core/config/config_test.go | 60 ++++++++++++++++++++++++ tui/prompt.go | 21 +++++++++ 10 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 core/config/config_test.go diff --git a/README.md b/README.md index 67356b9..4f23933 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,27 @@ If you like Permify, please consider giving us a :star: permify | Linkedin -

\ No newline at end of file +

+ +## Configuration + +Run `permctl configure` to create/update your config file and select a tenant. + +Example config file: + +```yaml +default: + permify_url: "localhost:3478" + tenant: "t1" + token: "YOUR_TOKEN" # optional + cert_path: "/path/to/client.crt" # optional (mTLS) + cert_key_path: "/path/to/client.key" # optional (mTLS) +``` + +Environment variable overrides (not written back to file): + +- `PERMCTL_PERMIFY_URL` +- `PERMCTL_TENANT` +- `PERMCTL_TOKEN` +- `PERMCTL_CERT_PATH` +- `PERMCTL_CERT_KEY_PATH` diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..56714f2 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -102,7 +102,25 @@ func runE(cmd *cobra.Command, _ []string) error { return err } - resp, err := client.New(url) + token, err := tui.SecretPrompt("enter permify 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 + } + + resp, err := client.New(url, token, certPath, certKeyPath) + if err != nil { + return err + } // Todo: Implement pagination tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{}) @@ -123,6 +141,9 @@ func runE(cmd *cobra.Command, _ []string) error { logger.Log.Error(err) } config.CliConfig.PermifyURL = url + config.CliConfig.Token = token + config.CliConfig.CertPath = certPath + config.CliConfig.CertKeyPath = certKeyPath 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..2b98a9a 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,101 @@ package client import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "strings" + permify "github.com/Permify/permify-go/v1" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" ) // New initializes a new permify client -func New(endpoint string) (*permify.Client, error) { +func New(endpoint string, token string, certPath string, certKeyPath string) (*permify.Client, error) { + dialOptions := []grpc.DialOption{ + grpc.WithUnaryInterceptor(authUnaryInterceptor(token)), + grpc.WithStreamInterceptor(authStreamInterceptor(token)), + } + + transportCredentials, err := transportCredentials(endpoint, certPath, certKeyPath) + if err != nil { + return nil, err + } + dialOptions = append(dialOptions, grpc.WithTransportCredentials(transportCredentials)) + client, err := permify.NewClient( permify.Config{ Endpoint: endpoint, }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + dialOptions..., ) return client, err } + +func transportCredentials(endpoint string, certPath string, certKeyPath string) (credentials.TransportCredentials, error) { + if certPath != "" || certKeyPath != "" { + if certPath == "" || certKeyPath == "" { + return nil, fmt.Errorf("both cert_path and cert_key_path must be set") + } + cert, err := tls.LoadX509KeyPair(certPath, certKeyPath) + if err != nil { + return nil, err + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + return credentials.NewTLS(&tls.Config{ + RootCAs: pool, + Certificates: []tls.Certificate{cert}, + }), nil + } + + // Keep backwards-compatible default. Many local/dev Permify instances use plaintext gRPC. + // If the endpoint includes an https scheme, prefer TLS (grpc itself typically uses host:port). + if strings.HasPrefix(strings.ToLower(endpoint), "https://") { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + return credentials.NewTLS(&tls.Config{RootCAs: pool}), nil + } + + return insecure.NewCredentials(), nil +} + +func authUnaryInterceptor(token string) grpc.UnaryClientInterceptor { + value := bearerValue(token) + return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if value != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", value) + } + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +func authStreamInterceptor(token string) grpc.StreamClientInterceptor { + value := bearerValue(token) + return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + if value != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", value) + } + return streamer(ctx, desc, cc, method, opts...) + } +} + +func bearerValue(token string) string { + t := strings.TrimSpace(token) + if t == "" { + return "" + } + // If user already included "Bearer", don't double-prefix. + if strings.HasPrefix(strings.ToLower(t), "bearer ") { + return t + } + return "Bearer " + t +} diff --git a/core/cmd/data/client.go b/core/cmd/data/client.go index a567b61..0beec74 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath) if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") 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..8ad46c5 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath) if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") 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..d2538fd 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath) if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") 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..35e4bdb 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath) if err != nil { log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`") 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..2be0f02 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -25,11 +25,23 @@ 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"` + CertKeyPath string `yaml:"cert_key_path,omitempty"` + SslEnabled bool `yaml:"-"` } +const ( + // Env overrides (do not get written back to config automatically) + PermifyURLEnv = "PERMCTL_PERMIFY_URL" + PermifyTenantEnv = "PERMCTL_TENANT" + PermifyTokenEnv = "PERMCTL_TOKEN" + PermifyCertEnv = "PERMCTL_CERT_PATH" + PermifyCertKeyEnv = "PERMCTL_CERT_KEY_PATH" +) + // IsConfigured checks if permctl cli has been configured func IsConfigured(file string, profile string) error { _, err := os.Stat(file) @@ -70,10 +82,29 @@ func Load(file string, profile string) error { profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] + applyEnvOverrides(&CliConfig) CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") return err } +func applyEnvOverrides(cfg *CoreConfig) { + if v := strings.TrimSpace(os.Getenv(PermifyURLEnv)); v != "" { + cfg.PermifyURL = v + } + if v := strings.TrimSpace(os.Getenv(PermifyTenantEnv)); v != "" { + cfg.Tenant = v + } + if v := strings.TrimSpace(os.Getenv(PermifyTokenEnv)); v != "" { + cfg.Token = v + } + if v := strings.TrimSpace(os.Getenv(PermifyCertEnv)); v != "" { + cfg.CertPath = v + } + if v := strings.TrimSpace(os.Getenv(PermifyCertKeyEnv)); v != "" { + cfg.CertKeyPath = v + } +} + // New initializes a new config file for permctl with the mentioned profile func New(file string, profile string) error { profileConfigs.Profile = profile @@ -84,7 +115,8 @@ func New(file string, profile string) error { if err != nil { return err } - err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0644)) + // config may contain secrets (token), so default to owner-read/write. + err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0600)) return err } @@ -100,6 +132,7 @@ func Write() error { if err != nil { return err } - err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) + // config may contain secrets (token), so default to owner-read/write. + err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0600)) return err } diff --git a/core/config/config_test.go b/core/config/config_test.go new file mode 100644 index 0000000..9848a01 --- /dev/null +++ b/core/config/config_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_AppliesEnvOverrides(t *testing.T) { + t.Setenv(PermifyURLEnv, "") + t.Setenv(PermifyTenantEnv, "") + t.Setenv(PermifyTokenEnv, "") + t.Setenv(PermifyCertEnv, "") + t.Setenv(PermifyCertKeyEnv, "") + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "permctl.yaml") + err := os.WriteFile(cfgPath, []byte(` +default: + permify_url: "localhost:3478" + tenant: "t1" + token: "fromfile" + cert_path: "/tmp/client.crt" + cert_key_path: "/tmp/client.key" +`), 0600) + if err != nil { + t.Fatal(err) + } + + // reset globals between loads + CliConfig = CoreConfig{} + profileConfigs = ProfileConfigs{} + + if err := Load(cfgPath, "default"); err != nil { + t.Fatal(err) + } + if CliConfig.Token != "fromfile" { + t.Fatalf("expected token from file, got %q", CliConfig.Token) + } + + t.Setenv(PermifyTokenEnv, "fromenv") + t.Setenv(PermifyCertEnv, "/env/client.crt") + t.Setenv(PermifyCertKeyEnv, "/env/client.key") + + CliConfig = CoreConfig{} + profileConfigs = ProfileConfigs{} + if err := Load(cfgPath, "default"); err != nil { + t.Fatal(err) + } + if CliConfig.Token != "fromenv" { + t.Fatalf("expected token from env, got %q", CliConfig.Token) + } + if CliConfig.CertPath != "/env/client.crt" { + t.Fatalf("expected cert_path from env, got %q", CliConfig.CertPath) + } + if CliConfig.CertKeyPath != "/env/client.key" { + t.Fatalf("expected cert_key_path from env, got %q", CliConfig.CertKeyPath) + } +} + diff --git a/tui/prompt.go b/tui/prompt.go index a8a23aa..1a62a1e 100644 --- a/tui/prompt.go +++ b/tui/prompt.go @@ -27,6 +27,27 @@ func StringPrompt(msg string, Placeholder, defaultVal string) (string, error) { return "", errors.New("prompt cancelled") } +func SecretPrompt(msg string, placeholder, defaultVal string) (string, error) { + t := &Tui{} + prompt := textinput.New() + prompt.Prompt = Pink(fmt.Sprintf("%s: ", msg)) + prompt.Placeholder = placeholder + prompt.EchoMode = textinput.EchoPassword + prompt.EchoCharacter = '*' + if defaultVal != "" { + prompt.SetValue(strings.Trim(defaultVal, "\"")) + } + 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 BoolPrompt(msg string, defaultVal string) (bool, error) { t := &Tui{} prompt := textinput.New()