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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,27 @@ If you like Permify, please consider giving us a :star:
<a href="https://www.linkedin.com/company/permifyco">
<img height="70px" width="70px" alt="permify | Linkedin" src="https://user-images.githubusercontent.com/39353278/187209321-03293a24-6f63-4321-b362-b0fc89fdd879.png" />
</a>
</p>
</p>

## Configuration

Run `permctl configure` to create/update your config file and select a tenant.

Example config file:

```yaml
default:
permify_url: "localhost:3478"
tenant: "t1"
token: "YOUR_TOKEN" # optional
cert_path: "/path/to/client.crt" # optional (mTLS)
cert_key_path: "/path/to/client.key" # optional (mTLS)
```

Environment variable overrides (not written back to file):

- `PERMCTL_PERMIFY_URL`
- `PERMCTL_TENANT`
- `PERMCTL_TOKEN`
- `PERMCTL_CERT_PATH`
- `PERMCTL_CERT_KEY_PATH`
23 changes: 22 additions & 1 deletion core/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,25 @@ func runE(cmd *cobra.Command, _ []string) error {
return err
}

resp, err := client.New(url)
token, err := tui.SecretPrompt("enter permify 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
}

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

// Todo: Implement pagination
tenants, err := resp.Tenancy.List(context.Background(), &v1.TenantListRequest{})
Expand All @@ -123,6 +141,9 @@ func runE(cmd *cobra.Command, _ []string) error {
logger.Log.Error(err)
}
config.CliConfig.PermifyURL = url
config.CliConfig.Token = token
config.CliConfig.CertPath = certPath
config.CliConfig.CertKeyPath = certKeyPath
config.CliConfig.Tenant = tenantIds[tenant]
err = config.Write()
if err != nil {
Expand Down
88 changes: 85 additions & 3 deletions core/client/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,101 @@
package client

import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"strings"

permify "github.com/Permify/permify-go/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)

// New initializes a new permify client
func New(endpoint string) (*permify.Client, error) {
func New(endpoint string, token string, certPath string, certKeyPath string) (*permify.Client, error) {
dialOptions := []grpc.DialOption{
grpc.WithUnaryInterceptor(authUnaryInterceptor(token)),
grpc.WithStreamInterceptor(authStreamInterceptor(token)),
}

transportCredentials, err := transportCredentials(endpoint, certPath, certKeyPath)
if err != nil {
return nil, err
}
dialOptions = append(dialOptions, grpc.WithTransportCredentials(transportCredentials))

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

func transportCredentials(endpoint string, certPath string, certKeyPath string) (credentials.TransportCredentials, error) {
if certPath != "" || certKeyPath != "" {
if certPath == "" || certKeyPath == "" {
return nil, fmt.Errorf("both cert_path and cert_key_path must be set")
}
cert, err := tls.LoadX509KeyPair(certPath, certKeyPath)
if err != nil {
return nil, err
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
return credentials.NewTLS(&tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{cert},
}), nil
}

// Keep backwards-compatible default. Many local/dev Permify instances use plaintext gRPC.
// If the endpoint includes an https scheme, prefer TLS (grpc itself typically uses host:port).
if strings.HasPrefix(strings.ToLower(endpoint), "https://") {
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
return credentials.NewTLS(&tls.Config{RootCAs: pool}), nil
}

return insecure.NewCredentials(), nil
}

func authUnaryInterceptor(token string) grpc.UnaryClientInterceptor {
value := bearerValue(token)
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if value != "" {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", value)
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}

func authStreamInterceptor(token string) grpc.StreamClientInterceptor {
value := bearerValue(token)
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
if value != "" {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", value)
}
return streamer(ctx, desc, cc, method, opts...)
}
}

func bearerValue(token string) string {
t := strings.TrimSpace(token)
if t == "" {
return ""
}
// If user already included "Bearer", don't double-prefix.
if strings.HasPrefix(strings.ToLower(t), "bearer ") {
return t
}
return "Bearer " + t
}
4 changes: 2 additions & 2 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath)
if err != nil {
log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`")
os.Exit(-1)
}
return c.Data
}
}
4 changes: 2 additions & 2 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath)
if err != nil {
log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`")
os.Exit(-1)
}
return c.Permission
}
}
4 changes: 2 additions & 2 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath)
if err != nil {
log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`")
os.Exit(-1)
}
return c.Schema
}
}
4 changes: 2 additions & 2 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.New(config.CliConfig.PermifyURL, config.CliConfig.Token, config.CliConfig.CertPath, config.CliConfig.CertKeyPath)
if err != nil {
log.Error("Error initializing permify client. Check the configuration or rerun `permify configure`")
os.Exit(-1)
}
return c.Tenancy
}
}
43 changes: 38 additions & 5 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,23 @@ 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:"-"`
}

const (
// Env overrides (do not get written back to config automatically)
PermifyURLEnv = "PERMCTL_PERMIFY_URL"
PermifyTenantEnv = "PERMCTL_TENANT"
PermifyTokenEnv = "PERMCTL_TOKEN"
PermifyCertEnv = "PERMCTL_CERT_PATH"
PermifyCertKeyEnv = "PERMCTL_CERT_KEY_PATH"
)

// IsConfigured checks if permctl cli has been configured
func IsConfigured(file string, profile string) error {
_, err := os.Stat(file)
Expand Down Expand Up @@ -70,10 +82,29 @@ func Load(file string, profile string) error {
profileConfigs.File = file
profileConfigs.Profile = profile
CliConfig = profileConfigs.Configs[profile]
applyEnvOverrides(&CliConfig)
CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https")
return err
}

func applyEnvOverrides(cfg *CoreConfig) {
if v := strings.TrimSpace(os.Getenv(PermifyURLEnv)); v != "" {
cfg.PermifyURL = v
}
if v := strings.TrimSpace(os.Getenv(PermifyTenantEnv)); v != "" {
cfg.Tenant = v
}
if v := strings.TrimSpace(os.Getenv(PermifyTokenEnv)); v != "" {
cfg.Token = v
}
if v := strings.TrimSpace(os.Getenv(PermifyCertEnv)); v != "" {
cfg.CertPath = v
}
if v := strings.TrimSpace(os.Getenv(PermifyCertKeyEnv)); v != "" {
cfg.CertKeyPath = v
}
}

// New initializes a new config file for permctl with the mentioned profile
func New(file string, profile string) error {
profileConfigs.Profile = profile
Expand All @@ -84,7 +115,8 @@ func New(file string, profile string) error {
if err != nil {
return err
}
err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0644))
// config may contain secrets (token), so default to owner-read/write.
err = os.WriteFile(file, newConfigDataByte, fs.FileMode(0600))
return err
}

Expand All @@ -100,6 +132,7 @@ func Write() error {
if err != nil {
return err
}
err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644))
// config may contain secrets (token), so default to owner-read/write.
err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0600))
return err
}
60 changes: 60 additions & 0 deletions core/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package config

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

func TestLoad_AppliesEnvOverrides(t *testing.T) {
t.Setenv(PermifyURLEnv, "")
t.Setenv(PermifyTenantEnv, "")
t.Setenv(PermifyTokenEnv, "")
t.Setenv(PermifyCertEnv, "")
t.Setenv(PermifyCertKeyEnv, "")

dir := t.TempDir()
cfgPath := filepath.Join(dir, "permctl.yaml")
err := os.WriteFile(cfgPath, []byte(`
default:
permify_url: "localhost:3478"
tenant: "t1"
token: "fromfile"
cert_path: "/tmp/client.crt"
cert_key_path: "/tmp/client.key"
`), 0600)
if err != nil {
t.Fatal(err)
}

// reset globals between loads
CliConfig = CoreConfig{}
profileConfigs = ProfileConfigs{}

if err := Load(cfgPath, "default"); err != nil {
t.Fatal(err)
}
if CliConfig.Token != "fromfile" {
t.Fatalf("expected token from file, got %q", CliConfig.Token)
}

t.Setenv(PermifyTokenEnv, "fromenv")
t.Setenv(PermifyCertEnv, "/env/client.crt")
t.Setenv(PermifyCertKeyEnv, "/env/client.key")

CliConfig = CoreConfig{}
profileConfigs = ProfileConfigs{}
if err := Load(cfgPath, "default"); err != nil {
t.Fatal(err)
}
if CliConfig.Token != "fromenv" {
t.Fatalf("expected token from env, got %q", CliConfig.Token)
}
if CliConfig.CertPath != "/env/client.crt" {
t.Fatalf("expected cert_path from env, got %q", CliConfig.CertPath)
}
if CliConfig.CertKeyPath != "/env/client.key" {
t.Fatalf("expected cert_key_path from env, got %q", CliConfig.CertKeyPath)
}
}

21 changes: 21 additions & 0 deletions tui/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ 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
prompt.EchoCharacter = '*'
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