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
43 changes: 36 additions & 7 deletions core/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,42 @@ func validateFlags(cmd *cobra.Command, args []string) error {

func runE(cmd *cobra.Command, _ []string) error {
configFile, _ := cmd.Flags().GetString("config")
profile, _ := cmd.Flags().GetString("profile")

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

resp, err := client.New(url)
token, err := tui.StringPrompt("enter bearer token (optional)", "", config.CliConfig.Token)
if err != nil {
return err
}

certPath, err := tui.StringPrompt("enter client certificate path (optional)", "", config.CliConfig.CertPath)
if err != nil {
return err
}

certKeyPath, err := tui.StringPrompt("enter client certificate key path (optional)", "", config.CliConfig.CertKeyPath)
if err != nil {
return err
}

resp, err := client.New(client.Params{
Endpoint: 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)
return err
}

tenantNames := []string{}
Expand All @@ -117,16 +141,21 @@ 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]
err = config.Write()
if err != nil {
logger.Log.Error(err)
config.CliConfig.Token = token
config.CliConfig.CertPath = certPath
config.CliConfig.CertKeyPath = certKeyPath
if err := config.Write(); err != nil {
return err
}
if err := config.WriteStoredCredentials(profile, config.CliConfig); err != nil {
return err
}
logger.Log.Info("successfully configured ", "config file", configFile)
return nil
Expand Down
122 changes: 118 additions & 4 deletions core/client/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,133 @@
package client

import (
"crypto/tls"
"fmt"
"net"
"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"
)

type Params struct {
Endpoint string
Token string
CertPath string
CertKeyPath string
}

type parsedEndpoint struct {
Target string
UseTLS bool
}

func parseEndpoint(endpoint string) parsedEndpoint {
trimmed := strings.TrimSpace(endpoint)
lower := strings.ToLower(trimmed)

switch {
case strings.HasPrefix(lower, "https://"):
return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("https://"):]), UseTLS: true}
case strings.HasPrefix(lower, "grpcs://"):
return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("grpcs://"):]), UseTLS: true}
case strings.HasPrefix(lower, "http://"):
return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("http://"):])}
case strings.HasPrefix(lower, "grpc://"):
return parsedEndpoint{Target: strings.TrimSpace(trimmed[len("grpc://"):])}
default:
return parsedEndpoint{Target: trimmed}
}
}

func trimBearerPrefix(token string) string {
trimmed := strings.TrimSpace(token)
if len(trimmed) >= 7 && strings.EqualFold(trimmed[:7], "bearer ") {
return strings.TrimSpace(trimmed[7:])
}
return trimmed
}

func serverName(target string) string {
host, _, err := net.SplitHostPort(target)
if err != nil {
return target
}
return host
}

func dialOptions(params Params) ([]grpc.DialOption, error) {
parsed := parseEndpoint(params.Endpoint)
useTLS := parsed.UseTLS

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

var options []grpc.DialOption
if params.CertPath != "" && params.CertKeyPath != "" {
cert, err := tls.LoadX509KeyPair(params.CertPath, params.CertKeyPath)
if err != nil {
return nil, fmt.Errorf("load client certificate: %w", err)
}

options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
ServerName: serverName(parsed.Target),
})))
useTLS = true
} else if useTLS {
options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: serverName(parsed.Target),
})))
} else {
options = append(options, grpc.WithTransportCredentials(insecure.NewCredentials()))
}

token := trimBearerPrefix(params.Token)
if token != "" {
if !useTLS {
return nil, fmt.Errorf("bearer token requires TLS; use an https:// or grpcs:// endpoint, or configure mTLS credentials")
}
options = append(options, grpc.WithPerRPCCredentials(secureTokenCredentials{
"authorization": "Bearer " + token,
}))
}

return options, nil
}

// New initializes a new permify client
func New(endpoint string) (*permify.Client, error) {
func New(params Params) (*permify.Client, error) {
parsed := parseEndpoint(params.Endpoint)
if parsed.Target == "" {
return nil, fmt.Errorf("endpoint is empty")
}

options, err := dialOptions(params)
if err != nil {
return nil, err
}

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

func NewFromCLIConfig() (*permify.Client, error) {
return New(Params{
Endpoint: config.CliConfig.PermifyURL,
Token: config.CliConfig.Token,
CertPath: config.CliConfig.CertPath,
CertKeyPath: config.CliConfig.CertKeyPath,
})
}
79 changes: 79 additions & 0 deletions core/client/grpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package client

import (
"strings"
"testing"
)

func TestParseEndpoint(t *testing.T) {
tests := []struct {
name string
endpoint string
wantTarget string
wantTLS bool
}{
{name: "plain host", endpoint: "localhost:3478", wantTarget: "localhost:3478"},
{name: "https scheme", endpoint: "https://api.example.com:443", wantTarget: "api.example.com:443", wantTLS: true},
{name: "grpc scheme", endpoint: "grpc://127.0.0.1:3478", wantTarget: "127.0.0.1:3478"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parsed := parseEndpoint(test.endpoint)
if parsed.Target != test.wantTarget || parsed.UseTLS != test.wantTLS {
t.Fatalf("parseEndpoint(%q) = %#v, want target=%q tls=%t", test.endpoint, parsed, test.wantTarget, test.wantTLS)
}
})
}
}

func TestTrimBearerPrefix(t *testing.T) {
tests := []struct {
token string
want string
}{
{token: "", want: ""},
{token: "token", want: "token"},
{token: "Bearer abc", want: "abc"},
{token: "bearer abc ", want: "abc"},
}

for _, test := range tests {
if got := trimBearerPrefix(test.token); got != test.want {
t.Fatalf("trimBearerPrefix(%q) = %q, want %q", test.token, got, test.want)
}
}
}

func TestDialOptionsRequireBothCertFiles(t *testing.T) {
_, err := dialOptions(Params{
Endpoint: "localhost:3478",
CertPath: "/tmp/client.crt",
})
if err == nil || !strings.Contains(err.Error(), "both cert_path and cert_key_path") {
t.Fatalf("dialOptions() error = %v, want missing key error", err)
}
}

func TestDialOptionsRequireTLSForToken(t *testing.T) {
_, err := dialOptions(Params{
Endpoint: "localhost:3478",
Token: "secret",
})
if err == nil || !strings.Contains(err.Error(), "bearer token requires TLS") {
t.Fatalf("dialOptions() error = %v, want TLS error", err)
}
}

func TestDialOptionsAllowTokenWithTLSScheme(t *testing.T) {
options, err := dialOptions(Params{
Endpoint: "https://permify.example:3478",
Token: "secret",
})
if err != nil {
t.Fatalf("dialOptions() error = %v", err)
}
if len(options) < 2 {
t.Fatalf("dialOptions() returned %d options, want at least 2", len(options))
}
}
14 changes: 0 additions & 14 deletions core/client/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,3 @@ func (c secureTokenCredentials) RequireTransportSecurity() bool {
func (c secureTokenCredentials) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return c, nil // Returns the secure tokens as metadata with no error.
}

// nonSecureTokenCredentials represents a map used for storing non-secure tokens.
// These tokens do not require transport security.
type nonSecureTokenCredentials map[string]string

// RequireTransportSecurity indicates that transport security is not required for these credentials.
func (c nonSecureTokenCredentials) RequireTransportSecurity() bool {
return false // Transport security is not required for non-secure tokens.
}

// GetRequestMetadata retrieves the current metadata (non-secure tokens) for a request.
func (c nonSecureTokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return c, nil // Returns the non-secure tokens as metadata with no error.
}
7 changes: 3 additions & 4 deletions core/cmd/data/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import (
"os"

"github.com/Permify/permify-cli/core/client"
"github.com/Permify/permify-cli/core/config"
v1 "github.com/Permify/permify-go/generated/base/v1"
"github.com/charmbracelet/log"
)

func Client() v1.DataClient {
c, err := client.New(config.CliConfig.PermifyURL)
c, err := client.NewFromCLIConfig()
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
}
}
7 changes: 3 additions & 4 deletions core/cmd/permission/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import (
"os"

"github.com/Permify/permify-cli/core/client"
"github.com/Permify/permify-cli/core/config"
v1 "github.com/Permify/permify-go/generated/base/v1"
"github.com/charmbracelet/log"
)

func Client() v1.PermissionClient {
c, err := client.New(config.CliConfig.PermifyURL)
c, err := client.NewFromCLIConfig()
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
}
}
7 changes: 3 additions & 4 deletions core/cmd/schema/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import (
"os"

"github.com/Permify/permify-cli/core/client"
"github.com/Permify/permify-cli/core/config"
v1 "github.com/Permify/permify-go/generated/base/v1"
"github.com/charmbracelet/log"
)

func Client() v1.SchemaClient {
c, err := client.New(config.CliConfig.PermifyURL)
c, err := client.NewFromCLIConfig()
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
}
}
7 changes: 3 additions & 4 deletions core/cmd/tenancy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import (
"os"

"github.com/Permify/permify-cli/core/client"
"github.com/Permify/permify-cli/core/config"
v1 "github.com/Permify/permify-go/generated/base/v1"
"github.com/charmbracelet/log"
)

func Client() v1.TenancyClient {
c, err := client.New(config.CliConfig.PermifyURL)
c, err := client.NewFromCLIConfig()
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
}
}
Loading