From 0eb22c00b0701d61bf264cf6a160b2063f012469 Mon Sep 17 00:00:00 2001 From: MavenTheAI Date: Fri, 22 May 2026 02:42:12 -0700 Subject: [PATCH 1/2] Add config fields for token and mTLS --- SUBMISSION_PACKET_2.md | 34 ++++++++++++++++++++++++++++++++ core/cli/configure.go | 25 +++++++++++++++++++++++- core/client/grpc.go | 44 ++++++++++++++++++++++++++++++++++++++++-- core/config/config.go | 14 +++++++++++--- 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 SUBMISSION_PACKET_2.md diff --git a/SUBMISSION_PACKET_2.md b/SUBMISSION_PACKET_2.md new file mode 100644 index 0000000..71f6ae7 --- /dev/null +++ b/SUBMISSION_PACKET_2.md @@ -0,0 +1,34 @@ +# Submission Packet — Permify/permify-cli Issue #2 + +Issue: https://github.com/Permify/permify-cli/issues/2 +Algora org page: https://algora.io/Permify + +## Goal +Implement persistent storage (via the existing CLI config YAML) for: +- endpoint +- bearer token (optional) +- TLS client cert path (optional) +- TLS client cert key path (optional) + +Then ensure the permify client uses the stored values during client creation. + +## Implementation summary +- Extend `core/config.CoreConfig` to include `token`, `cert_path`, `cert_key` fields in YAML. +- Update `permctl configure` flow to prompt for token/cert paths before listing tenants, store these values to config, then persist to YAML. +- Update `core/client.New` to: + - use TLS when endpoint is `https://` + - optionally attach `authorization: Bearer ` via `grpc.WithPerRPCCredentials` (TLS-only) + - optionally present a client cert (mTLS) when both `cert_path` and `cert_key` are provided + +## Patch +Local patch file (generated from branch `feat/bounty-2-credential-storage`): +- `Permify-permify-cli-2-credential-storage.patch` + +## How to submit (manual) +1) In `/Users/mavenai/Desktop/Singaw Sity Labs/Singaw Sity Codex/bounty-work/permify-cli`, push branch `feat/bounty-2-credential-storage` to your fork. +2) Open a PR to `Permify/permify-cli` targeting `main`. +3) Post a concise public comment on the issue linking the PR (and `/attempt` if Algora requires it). + +## Notes / safety +- Token is only sent over TLS; if endpoint is not `https://`, client creation errors. +- No credentials are embedded in code; this only changes config schema + client wiring. diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..6b4a5f7 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.StringPrompt("enter auth token (optional, bearer token)", "", config.CliConfig.Token) + if err != nil { + return err + } + + certPath, err := tui.StringPrompt("enter tls client cert path (optional)", "", config.CliConfig.CertPath) + if err != nil { + return err + } + + certKey, err := tui.StringPrompt("enter tls client cert key path (optional)", "", config.CliConfig.CertKey) + if err != nil { + return err + } + + // Use the provided values to validate connectivity and list tenants. + config.CliConfig.PermifyURL = url + config.CliConfig.Token = token + config.CliConfig.CertPath = certPath + config.CliConfig.CertKey = certKey + resp, err := client.New(url) + if err != nil { + return err + } // Todo: Implement pagination tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{}) @@ -122,7 +146,6 @@ func runE(cmd *cobra.Command, _ []string) error { 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..95b478b 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -2,19 +2,59 @@ package client import ( + "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 func New(endpoint string) (*permify.Client, error) { + if endpoint == "" { + endpoint = config.CliConfig.PermifyURL + } + + dialOptions := []grpc.DialOption{} + isTLS := strings.HasPrefix(endpoint, "https://") + + if isTLS { + tlsConfig := &tls.Config{} + + if config.CliConfig.CertPath != "" || config.CliConfig.CertKey != "" { + if config.CliConfig.CertPath == "" || config.CliConfig.CertKey == "" { + return nil, fmt.Errorf("both cert_path and cert_key must be set for mTLS") + } + certPair, err := tls.LoadX509KeyPair(config.CliConfig.CertPath, config.CliConfig.CertKey) + if err != nil { + return nil, fmt.Errorf("load mTLS cert/key: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{certPair} + } + + dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + } else { + dialOptions = append(dialOptions, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + if config.CliConfig.Token != "" { + if !isTLS { + return nil, fmt.Errorf("refusing to send token over insecure transport (set an https:// endpoint)") + } + dialOptions = append(dialOptions, grpc.WithPerRPCCredentials(secureTokenCredentials{ + "authorization": "Bearer " + config.CliConfig.Token, + })) + } + client, err := permify.NewClient( permify.Config{ Endpoint: endpoint, }, - // Todo: Implement secure call with tls certificate - grpc.WithTransportCredentials(insecure.NewCredentials()), + dialOptions..., ) return client, err } diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..a3c8486 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -25,9 +25,17 @@ 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 is an optional bearer token used for API authentication. + Token string `yaml:"token,omitempty"` + + // CertPath/CertKey are optional paths to a TLS client certificate/key (mTLS). + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` + + SslEnabled bool `yaml:"-"` } // IsConfigured checks if permctl cli has been configured From 75db70194fea3c4e141af779b886be5f6545e6da Mon Sep 17 00:00:00 2001 From: MavenTheAI Date: Sat, 23 May 2026 22:26:04 -0700 Subject: [PATCH 2/2] Store CLI credentials in ~/.permify/credentials --- core/cli/configure.go | 19 +++++++++- core/client/grpc.go | 32 +++++++++++++---- core/config/credentials.go | 71 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 core/config/credentials.go diff --git a/core/cli/configure.go b/core/cli/configure.go index 6b4a5f7..e62fe63 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -147,10 +147,27 @@ func runE(cmd *cobra.Command, _ []string) error { logger.Log.Error(err) } config.CliConfig.Tenant = tenantIds[tenant] - err = config.Write() + + // Store connection credentials separately to avoid repeatedly prompting users. + credentialsFile, err := config.WriteCredentials(config.Credentials{ + Endpoint: url, + Token: token, + CertPath: certPath, + CertKey: certKey, + }) if err != nil { + return err + } + + // Avoid persisting tokens/cert paths in the main config YAML. + config.CliConfig.Token = "" + config.CliConfig.CertPath = "" + config.CliConfig.CertKey = "" + + if err := config.Write(); err != nil { logger.Log.Error(err) } logger.Log.Info("successfully configured ", "config file", configFile) + logger.Log.Info("saved connection credentials", "credentials file", credentialsFile) return nil } diff --git a/core/client/grpc.go b/core/client/grpc.go index 95b478b..5f874f5 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -15,8 +15,17 @@ import ( // New initializes a new permify client func New(endpoint string) (*permify.Client, error) { + creds, err := config.LoadCredentials() + if err != nil { + return nil, err + } + if endpoint == "" { - endpoint = config.CliConfig.PermifyURL + if creds.Endpoint != "" { + endpoint = creds.Endpoint + } else { + endpoint = config.CliConfig.PermifyURL + } } dialOptions := []grpc.DialOption{} @@ -25,11 +34,18 @@ func New(endpoint string) (*permify.Client, error) { if isTLS { tlsConfig := &tls.Config{} - if config.CliConfig.CertPath != "" || config.CliConfig.CertKey != "" { - if config.CliConfig.CertPath == "" || config.CliConfig.CertKey == "" { + certPath := creds.CertPath + certKey := creds.CertKey + if certPath == "" && certKey == "" { + certPath = config.CliConfig.CertPath + certKey = config.CliConfig.CertKey + } + + if certPath != "" || certKey != "" { + if certPath == "" || certKey == "" { return nil, fmt.Errorf("both cert_path and cert_key must be set for mTLS") } - certPair, err := tls.LoadX509KeyPair(config.CliConfig.CertPath, config.CliConfig.CertKey) + certPair, err := tls.LoadX509KeyPair(certPath, certKey) if err != nil { return nil, fmt.Errorf("load mTLS cert/key: %w", err) } @@ -41,12 +57,16 @@ func New(endpoint string) (*permify.Client, error) { dialOptions = append(dialOptions, grpc.WithTransportCredentials(insecure.NewCredentials())) } - if config.CliConfig.Token != "" { + token := creds.Token + if token == "" { + token = config.CliConfig.Token + } + if token != "" { if !isTLS { return nil, fmt.Errorf("refusing to send token over insecure transport (set an https:// endpoint)") } dialOptions = append(dialOptions, grpc.WithPerRPCCredentials(secureTokenCredentials{ - "authorization": "Bearer " + config.CliConfig.Token, + "authorization": "Bearer " + token, })) } diff --git a/core/config/credentials.go b/core/config/credentials.go new file mode 100644 index 0000000..768eee7 --- /dev/null +++ b/core/config/credentials.go @@ -0,0 +1,71 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + + "gopkg.in/yaml.v3" +) + +// Credentials are stored separately from the main profile YAML config to avoid +// repeatedly prompting users for connection details. +type Credentials struct { + Endpoint string `yaml:"endpoint,omitempty"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKey string `yaml:"cert_key,omitempty"` +} + +func credentialsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if runtime.GOOS == "windows" { + return filepath.Join(home, ".permify", "credentials"), nil + } + return filepath.Join(home, ".permify", "credentials"), nil +} + +func LoadCredentials() (Credentials, error) { + path, err := credentialsPath() + if err != nil { + return Credentials{}, err + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Credentials{}, nil + } + return Credentials{}, err + } + var creds Credentials + if err := yaml.Unmarshal(data, &creds); err != nil { + return Credentials{}, fmt.Errorf("unmarshal credentials: %w", err) + } + return creds, nil +} + +func WriteCredentials(creds Credentials) (string, error) { + path, err := credentialsPath() + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + data, err := yaml.Marshal(creds) + if err != nil { + return "", err + } + // Credentials may include tokens; restrict permissions. + if err := os.WriteFile(path, data, fs.FileMode(0o600)); err != nil { + return "", err + } + return path, nil +} +