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
27 changes: 25 additions & 2 deletions core/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,31 @@ func runE(cmd *cobra.Command, _ []string) error {
return err
}

token, err := tui.SensitiveStringPrompt("enter access 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
}

config.CliConfig.PermifyURL = url
config.CliConfig.Token = token
config.CliConfig.CertPath = certPath
config.CliConfig.CertKeyPath = certKeyPath
config.CliConfig.SslEnabled = certPath != ""

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

// Todo: Implement pagination
tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{})
Expand All @@ -117,12 +141,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
83 changes: 80 additions & 3 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"
"fmt"
"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) {
transportCredentials, err := transportCredentials(config.CliConfig)
if err != nil {
return nil, err
}

opts := []grpc.DialOption{grpc.WithTransportCredentials(transportCredentials)}
if tokenCredentials := tokenCredentials(config.CliConfig); tokenCredentials != nil {
opts = append(opts, grpc.WithPerRPCCredentials(tokenCredentials))
}

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

func transportCredentials(cfg config.CoreConfig) (credentials.TransportCredentials, error) {
if cfg.CertPath == "" {
if strings.TrimSpace(cfg.CertKeyPath) != "" {
return nil, fmt.Errorf("cert key path requires cert path")
}
return insecure.NewCredentials(), nil
}

if cfg.CertKeyPath == "" {
return credentials.NewClientTLSFromFile(cfg.CertPath, "")
}

certPool := x509.NewCertPool()
certPEM, err := os.ReadFile(cfg.CertPath)
if err != nil {
return nil, err
}
if !certPool.AppendCertsFromPEM(certPEM) {
return nil, fmt.Errorf("failed to parse certificate at %s", cfg.CertPath)
}

clientCertificate, err := tls.LoadX509KeyPair(cfg.CertPath, cfg.CertKeyPath)
if err != nil {
return nil, err
}

return credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: certPool,
Certificates: []tls.Certificate{clientCertificate},
}), nil
}

func tokenCredentials(cfg config.CoreConfig) credentials.PerRPCCredentials {
token := strings.TrimSpace(cfg.Token)
if token == "" {
return nil
}

metadata := map[string]string{
"authorization": bearerToken(token),
}
if cfg.SslEnabled || cfg.CertPath != "" {
return secureTokenCredentials(metadata)
}
return nonSecureTokenCredentials(metadata)
}

func bearerToken(token string) string {
if strings.HasPrefix(strings.ToLower(token), "bearer ") {
return token
}
return fmt.Sprintf("Bearer %s", token)
}

func normalizeEndpoint(endpoint string) string {
withoutHTTPS := strings.TrimPrefix(endpoint, "https://")
return strings.TrimPrefix(withoutHTTPS, "http://")
}
156 changes: 156 additions & 0 deletions core/client/grpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package client

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"

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

func TestTokenCredentialsAddsBearerPrefix(t *testing.T) {
creds := tokenCredentials(config.CoreConfig{
Token: "secret-token",
CertPath: "certs/client.pem",
})

if creds == nil {
t.Fatal("tokenCredentials() = nil, want credentials")
}

metadata, err := creds.GetRequestMetadata(context.Background())
if err != nil {
t.Fatalf("GetRequestMetadata() error = %v", err)
}

if got, want := metadata["authorization"], "Bearer secret-token"; got != want {
t.Fatalf("authorization metadata = %q, want %q", got, want)
}
if !creds.RequireTransportSecurity() {
t.Fatal("RequireTransportSecurity() = false, want true for TLS connections")
}
}

func TestTokenCredentialsKeepsExistingBearerPrefix(t *testing.T) {
creds := tokenCredentials(config.CoreConfig{
Token: "Bearer already-prefixed",
})

if creds == nil {
t.Fatal("tokenCredentials() = nil, want credentials")
}

metadata, err := creds.GetRequestMetadata(context.Background())
if err != nil {
t.Fatalf("GetRequestMetadata() error = %v", err)
}

if got, want := metadata["authorization"], "Bearer already-prefixed"; got != want {
t.Fatalf("authorization metadata = %q, want %q", got, want)
}
if creds.RequireTransportSecurity() {
t.Fatal("RequireTransportSecurity() = true, want false for insecure connections")
}
}

func TestTransportCredentialsDefaultsToInsecure(t *testing.T) {
creds, err := transportCredentials(config.CoreConfig{})
if err != nil {
t.Fatalf("transportCredentials() error = %v", err)
}

if got, want := creds.Info().SecurityProtocol, "insecure"; got != want {
t.Fatalf("SecurityProtocol = %q, want %q", got, want)
}
}

func TestTransportCredentialsUsesTLSCertificate(t *testing.T) {
certPath, keyPath := writeTestCertificate(t)

creds, err := transportCredentials(config.CoreConfig{
CertPath: certPath,
CertKeyPath: keyPath,
})
if err != nil {
t.Fatalf("transportCredentials() error = %v", err)
}

if got, want := creds.Info().SecurityProtocol, "tls"; got != want {
t.Fatalf("SecurityProtocol = %q, want %q", got, want)
}
}

func TestTransportCredentialsRejectsKeyWithoutCertificate(t *testing.T) {
_, err := transportCredentials(config.CoreConfig{
CertKeyPath: "certs/client.key",
})
if err == nil {
t.Fatal("transportCredentials() error = nil, want validation error")
}
}

func TestNormalizeEndpointStripsHTTPAndHTTPSSchemes(t *testing.T) {
testCases := map[string]string{
"http://localhost:3478": "localhost:3478",
"https://permify.example": "permify.example",
"permify.internal:3478": "permify.internal:3478",
"https://localhost:3478/v1": "localhost:3478/v1",
}

for input, want := range testCases {
if got := normalizeEndpoint(input); got != want {
t.Fatalf("normalizeEndpoint(%q) = %q, want %q", input, got, want)
}
}
}

func writeTestCertificate(t *testing.T) (string, string) {
t.Helper()

privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}

template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "localhost",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
}

certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("CreateCertificate() error = %v", err)
}

dir := t.TempDir()
certPath := filepath.Join(dir, "client.pem")
keyPath := filepath.Join(dir, "client.key")

certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
t.Fatalf("WriteFile(cert) error = %v", err)
}

keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("WriteFile(key) error = %v", err)
}

return certPath, keyPath
}
Loading