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
42 changes: 39 additions & 3 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 @@ -102,13 +103,45 @@ func runE(cmd *cobra.Command, _ []string) error {
return err
}

resp, err := client.New(url)
token, err := tui.SecretPrompt("enter permify token (optional, leave blank to keep current)", "", "")
if err != nil {
return err
}
token = strings.TrimSpace(token)
if token == "" {
token = config.CliConfig.Token
}

certPath, err := tui.StringPrompt("enter tls cert path (optional)", "", config.CliConfig.CertPath)
if err != nil {
return err
}
certKeyPath, err := tui.StringPrompt("enter tls cert key path (optional)", "", config.CliConfig.CertKeyPath)
if err != nil {
return err
}
if (certPath == "") != (certKeyPath == "") {
return fmt.Errorf("both cert path and cert key path must be set")
}

resp, err := client.NewFromConfig(config.CoreConfig{
PermifyURL: url,
Token: token,
CertPath: certPath,
CertKeyPath: certKeyPath,
})
if err != nil {
return err
}

// Todo: Implement pagination
tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{})
if err != nil {
logger.Log.Fatal(err)
}
if len(tenants.Tenants) == 0 {
return fmt.Errorf("no tenants found")
}

tenantNames := []string{}
tenantIds := map[string]string{}
Expand All @@ -117,13 +150,16 @@ 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]
config.CliConfig.Token = token
config.CliConfig.CertPath = certPath
config.CliConfig.CertKeyPath = certKeyPath
err = config.Write()
if err != nil {
logger.Log.Error(err)
Expand Down
86 changes: 84 additions & 2 deletions core/client/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,101 @@
package client

import (
"crypto/tls"
"fmt"
"net/url"
"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) {
cfg := config.CoreConfig{PermifyURL: endpoint}
return NewFromConfig(cfg)
}

// NewFromConfig initializes a new permify client using stored CLI configuration.
// It supports optional token auth and optional TLS (client cert + key pair).
func NewFromConfig(cfg config.CoreConfig) (*permify.Client, error) {
endpoint := normalizeEndpoint(cfg.PermifyURL)
if endpoint == "" {
return nil, fmt.Errorf("permify url is empty")
}

if (cfg.CertPath == "") != (cfg.CertKeyPath == "") {
return nil, fmt.Errorf("both cert_path and cert_key_path must be set")
}

dialOpts := []grpc.DialOption{}

transportCreds, err := transportCredentials(cfg)
if err != nil {
return nil, err
}
dialOpts = append(dialOpts, grpc.WithTransportCredentials(transportCreds))

if strings.TrimSpace(cfg.Token) != "" {
token := strings.TrimSpace(cfg.Token)
headerVal := fmt.Sprintf("Bearer %s", token)
if transportCreds.Info().SecurityProtocol == "insecure" {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(nonSecureTokenCredentials{"authorization": headerVal}))
} else {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(secureTokenCredentials{"authorization": headerVal}))
}
}

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

func transportCredentials(cfg config.CoreConfig) (credentials.TransportCredentials, error) {
// Default (backwards compatible): insecure.
if cfg.CertPath == "" && cfg.CertKeyPath == "" && !tlsEnabled(cfg) {
return insecure.NewCredentials(), nil
}

tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}

if cfg.CertPath != "" && cfg.CertKeyPath != "" {
cert, err := tls.LoadX509KeyPair(cfg.CertPath, cfg.CertKeyPath)
if err != nil {
return nil, err
}
tlsCfg.Certificates = []tls.Certificate{cert}
}

return credentials.NewTLS(tlsCfg), nil
}

func tlsEnabled(cfg config.CoreConfig) bool {
return cfg.SslEnabled || strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.PermifyURL)), "https://")
}

func normalizeEndpoint(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}

// If the user provided a URL with scheme, strip scheme/path/query for grpc.Dial endpoint.
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err == nil && u.Host != "" {
return u.Host
}
}

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

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

"github.com/Permify/permify-cli/core/config"
)

func TestNormalizeEndpoint(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", ""},
{" localhost:3478 ", "localhost:3478"},
{"https://example.com:3478", "example.com:3478"},
{"https://example.com:3478/some/path?x=1", "example.com:3478"},
{"http://127.0.0.1:3478", "127.0.0.1:3478"},
}
for _, tt := range tests {
if got := normalizeEndpoint(tt.in); got != tt.want {
t.Fatalf("normalizeEndpoint(%q)=%q want=%q", tt.in, got, tt.want)
}
}
}

func TestNewFromConfig_RequiresCertAndKeyTogether(t *testing.T) {
_, err := NewFromConfig(config.CoreConfig{
PermifyURL: "localhost:3478",
CertPath: "x.crt",
CertKeyPath: "",
})
if err == nil {
t.Fatalf("expected error when only cert_path is set")
}
}

func TestTransportCredentials_TLSWithoutFilesFromSslFlag(t *testing.T) {
creds, err := transportCredentials(config.CoreConfig{
PermifyURL: "https://example.com:3478",
SslEnabled: true,
CertPath: "",
CertKeyPath: "",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creds.Info().SecurityProtocol == "insecure" {
t.Fatalf("expected TLS creds, got insecure")
}
}

func TestTransportCredentials_TLSWithoutFilesFromEndpointScheme(t *testing.T) {
creds, err := transportCredentials(config.CoreConfig{
PermifyURL: "https://example.com:3478",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creds.Info().SecurityProtocol == "insecure" {
t.Fatalf("expected TLS creds, got insecure")
}
}

func TestTransportCredentials_BadCertFiles(t *testing.T) {
tmp := t.TempDir()
certPath := filepath.Join(tmp, "client.crt")
keyPath := filepath.Join(tmp, "client.key")
if err := os.WriteFile(certPath, []byte("not a cert"), 0600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, []byte("not a key"), 0600); err != nil {
t.Fatalf("write key: %v", err)
}

_, err := transportCredentials(config.CoreConfig{
SslEnabled: true,
CertPath: certPath,
CertKeyPath: keyPath,
})
if err == nil {
t.Fatalf("expected error for invalid cert/key pair")
}
}
6 changes: 3 additions & 3 deletions core/cmd/data/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 3 additions & 3 deletions core/cmd/permission/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 3 additions & 3 deletions core/cmd/schema/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 3 additions & 3 deletions core/cmd/tenancy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
14 changes: 11 additions & 3 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
CertKeyPath string `yaml:"cert_key_path,omitempty"`
SslEnabled bool `yaml:"-"`
}

// IsConfigured checks if permctl cli has been configured
Expand All @@ -50,6 +53,11 @@ func IsConfigured(file string, profile string) error {
if profileConfigs.Configs[profile].Tenant == "" {
return fmt.Errorf("tenant is empty for profile %s", profile)
}
certPath := profileConfigs.Configs[profile].CertPath
certKeyPath := profileConfigs.Configs[profile].CertKeyPath
if (certPath == "") != (certKeyPath == "") {
return fmt.Errorf("both cert_path and cert_key_path must be set for profile %s", profile)
}
return nil
}

Expand Down
20 changes: 20 additions & 0 deletions tui/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ 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
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()
Expand Down