Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions core/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/Permify/permify-cli/core/client"
"github.com/Permify/permify-cli/core/config"
Expand Down Expand Up @@ -97,12 +98,36 @@ func validateFlags(cmd *cobra.Command, args []string) error {
func runE(cmd *cobra.Command, _ []string) error {
configFile, _ := cmd.Flags().GetString("config")

url, err := tui.StringPrompt("enter permify url", "", config.CliConfig.PermifyURL)
endpoint, err := tui.StringPrompt("enter permify endpoint (host:port)", "", config.CliConfig.PermifyURL)
if err != nil {
return err
}

resp, err := client.New(url)
token, err := tui.StringPrompt("enter auth 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.PermifyURL = endpoint
config.CliConfig.Token = token
config.CliConfig.CertPath = certPath
config.CliConfig.CertKey = certKey
config.CliConfig.SslEnabled = strings.HasPrefix(endpoint, "https") || certPath != "" || certKey != ""

resp, err := client.New(endpoint)
if err != nil {
return err
}

// Todo: Implement pagination
tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{})
Expand All @@ -117,12 +142,11 @@ 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]
err = config.Write()
if err != nil {
Expand Down
81 changes: 79 additions & 2 deletions core/client/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,96 @@
package client

import (
"crypto/tls"
"crypto/x509"
"os"
"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
}

var dialOpts []grpc.DialOption

if config.CliConfig.SslEnabled {
tlsConfig, err := buildTLSConfig(config.CliConfig.CertPath, config.CliConfig.CertKey)
if err != nil {
return nil, err
}
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}

if token := strings.TrimSpace(config.CliConfig.Token); token != "" {
md := map[string]string{
"authorization": formatBearerToken(token),
}
if config.CliConfig.SslEnabled {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(secureTokenCredentials(md)))
} else {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(nonSecureTokenCredentials(md)))
}
}

client, err := permify.NewClient(
permify.Config{
Endpoint: endpoint,
},
// Todo: Implement secure call with tls certificate
grpc.WithTransportCredentials(insecure.NewCredentials()),
dialOpts...,
)
return client, err
}

func formatBearerToken(token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(token), "bearer ") {
return token
}
return "Bearer " + token
}

func buildTLSConfig(certPath, certKey string) (*tls.Config, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}

certPath = strings.TrimSpace(certPath)
certKey = strings.TrimSpace(certKey)

// If both cert+key are provided, use them as a client certificate (mTLS).
if certPath != "" && certKey != "" {
cert, err := tls.LoadX509KeyPair(certPath, certKey)
if err != nil {
return nil, err
}
tlsConfig.Certificates = []tls.Certificate{cert}
return tlsConfig, nil
}

// If only a cert path is provided, treat it as the CA bundle for verifying the server.
if certPath != "" {
caPem, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
rootCAs := x509.NewCertPool()
if ok := rootCAs.AppendCertsFromPEM(caPem); ok {
tlsConfig.RootCAs = rootCAs
}
}

return tlsConfig, nil
}
25 changes: 25 additions & 0 deletions core/client/grpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package client

import "testing"

func TestFormatBearerToken(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: ""},
{name: "raw", in: "abc", want: "Bearer abc"},
{name: "raw trimmed", in: " abc ", want: "Bearer abc"},
{name: "already bearer", in: "Bearer abc", want: "Bearer abc"},
{name: "already bearer lower", in: "bearer abc", want: "bearer abc"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatBearerToken(tt.in); got != tt.want {
t.Fatalf("formatBearerToken(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
23 changes: 17 additions & 6 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ 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"`
Token string `yaml:"token,omitempty"`
CertPath string `yaml:"cert_path,omitempty"`
CertKey string `yaml:"cert_key,omitempty"`
Tenant string `yaml:"tenant"`

// SslEnabled is computed at load-time and intentionally not persisted.
// It indicates whether the client should use TLS when dialing gRPC.
SslEnabled bool `yaml:"-"`
}

// IsConfigured checks if permctl cli has been configured
Expand Down Expand Up @@ -70,7 +76,10 @@ func Load(file string, profile string) error {
profileConfigs.File = file
profileConfigs.Profile = profile
CliConfig = profileConfigs.Configs[profile]
CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https")
// Permify gRPC endpoints are typically host:port (no scheme). For backwards
// compatibility, keep the "https" heuristic, but also enable TLS whenever
// cert material is configured.
CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") || CliConfig.CertPath != "" || CliConfig.CertKey != ""
return err
}

Expand All @@ -84,7 +93,8 @@ func New(file string, profile string) error {
if err != nil {
return err
}
err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0644))
// Config may include secrets (e.g. token). Restrict to owner-only.
err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0600))
return err
}

Expand All @@ -100,6 +110,7 @@ func Write() error {
if err != nil {
return err
}
err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644))
// Config may include secrets (e.g. token). Restrict to owner-only.
err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0600))
return err
}
62 changes: 62 additions & 0 deletions core/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package config

import (
"os"
"path/filepath"
"testing"
)

func TestConfigNewWriteLoad_PreservesFieldsAndRestrictsPermissions(t *testing.T) {
oldCliConfig := CliConfig
oldProfileConfigs := profileConfigs
t.Cleanup(func() {
CliConfig = oldCliConfig
profileConfigs = oldProfileConfigs
})

tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "permctl.yaml")

CliConfig = CoreConfig{
PermifyURL: "localhost:3478",
Token: "my-token",
CertPath: "/tmp/ca.pem",
CertKey: "/tmp/client.key",
Tenant: "tenant-id",
}

if err := New(configFile, "default"); err != nil {
t.Fatalf("New() error: %v", err)
}

info, err := os.Stat(configFile)
if err != nil {
t.Fatalf("stat error: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("expected config perms 0600, got %v", got)
}

if err := Load(configFile, "default"); err != nil {
t.Fatalf("Load() error: %v", err)
}

if CliConfig.PermifyURL != "localhost:3478" {
t.Fatalf("PermifyURL mismatch: %q", CliConfig.PermifyURL)
}
if CliConfig.Token != "my-token" {
t.Fatalf("Token mismatch: %q", CliConfig.Token)
}
if CliConfig.CertPath != "/tmp/ca.pem" {
t.Fatalf("CertPath mismatch: %q", CliConfig.CertPath)
}
if CliConfig.CertKey != "/tmp/client.key" {
t.Fatalf("CertKey mismatch: %q", CliConfig.CertKey)
}
if CliConfig.Tenant != "tenant-id" {
t.Fatalf("Tenant mismatch: %q", CliConfig.Tenant)
}
if !CliConfig.SslEnabled {
t.Fatalf("expected SslEnabled to be true when cert material is configured")
}
}