From 87191a2c4e5943ce60649a85a0b1a012f19fcc58 Mon Sep 17 00:00:00 2001 From: arisn Date: Mon, 11 May 2026 07:56:16 -0400 Subject: [PATCH 1/2] feat: store endpoint/token/certs in config --- core/cli/configure.go | 42 +++++++++++++++-- core/client/grpc.go | 86 ++++++++++++++++++++++++++++++++++- core/client/grpc_test.go | 86 +++++++++++++++++++++++++++++++++++ core/cmd/data/client.go | 6 +-- core/cmd/permission/client.go | 6 +-- core/cmd/schema/client.go | 6 +-- core/cmd/tenancy/client.go | 6 +-- core/config/config.go | 14 ++++-- tui/prompt.go | 20 ++++++++ 9 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 core/client/grpc_test.go diff --git a/core/cli/configure.go b/core/cli/configure.go index a533aba..df34331 100644 --- a/core/cli/configure.go +++ b/core/cli/configure.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/Permify/permify-cli/core/client" "github.com/Permify/permify-cli/core/config" @@ -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{} @@ -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) diff --git a/core/client/grpc.go b/core/client/grpc.go index 11835df..706d2be 100644 --- a/core/client/grpc.go +++ b/core/client/grpc.go @@ -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 +} diff --git a/core/client/grpc_test.go b/core/client/grpc_test.go new file mode 100644 index 0000000..30c10ac --- /dev/null +++ b/core/client/grpc_test.go @@ -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") + } +} diff --git a/core/cmd/data/client.go b/core/cmd/data/client.go index a567b61..9abe712 100644 --- a/core/cmd/data/client.go +++ b/core/cmd/data/client.go @@ -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 -} \ No newline at end of file +} diff --git a/core/cmd/permission/client.go b/core/cmd/permission/client.go index 092f240..dad38ba 100644 --- a/core/cmd/permission/client.go +++ b/core/cmd/permission/client.go @@ -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 -} \ No newline at end of file +} diff --git a/core/cmd/schema/client.go b/core/cmd/schema/client.go index 6d0f3c1..a99401a 100644 --- a/core/cmd/schema/client.go +++ b/core/cmd/schema/client.go @@ -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 -} \ No newline at end of file +} diff --git a/core/cmd/tenancy/client.go b/core/cmd/tenancy/client.go index 74c8213..92c9b55 100644 --- a/core/cmd/tenancy/client.go +++ b/core/cmd/tenancy/client.go @@ -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 -} \ No newline at end of file +} diff --git a/core/config/config.go b/core/config/config.go index c9bdebb..9bc83eb 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -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 @@ -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 } diff --git a/tui/prompt.go b/tui/prompt.go index a8a23aa..dc239cb 100644 --- a/tui/prompt.go +++ b/tui/prompt.go @@ -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() From 8eddc54cb67af723530a685647ac0973478be6f3 Mon Sep 17 00:00:00 2001 From: arisnachy Date: Thu, 14 May 2026 09:26:27 -0400 Subject: [PATCH 2/2] fix: store CLI credentials separately --- core/config/config.go | 42 ++++++++--- core/config/credentials.go | 101 ++++++++++++++++++++++++++ core/config/credentials_test.go | 122 ++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 core/config/credentials.go create mode 100644 core/config/credentials_test.go diff --git a/core/config/config.go b/core/config/config.go index 9bc83eb..da2b9f3 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -25,11 +25,11 @@ type ProfileConfigs struct { // CoreConfig is the config struct type CoreConfig struct { - PermifyURL string `yaml:"permify_url"` + PermifyURL string `yaml:"permify_url,omitempty"` Tenant string `yaml:"tenant"` - Token string `yaml:"token,omitempty"` - CertPath string `yaml:"cert_path,omitempty"` - CertKeyPath string `yaml:"cert_key_path,omitempty"` + Token string `yaml:"-"` + CertPath string `yaml:"-"` + CertKeyPath string `yaml:"-"` SslEnabled bool `yaml:"-"` } @@ -47,15 +47,17 @@ func IsConfigured(file string, profile string) error { if err != nil { logger.Log.Fatal("Error unmarshaling yaml") } - if profileConfigs.Configs[profile].PermifyURL == "" { + cfg := profileConfigs.Configs[profile] + if err = applyStoredCredentials(profile, &cfg); err != nil { + return err + } + if cfg.PermifyURL == "" { return fmt.Errorf("permify url is empty for profile %s", profile) } - if profileConfigs.Configs[profile].Tenant == "" { + if cfg.Tenant == "" { return fmt.Errorf("tenant is empty for profile %s", profile) } - certPath := profileConfigs.Configs[profile].CertPath - certKeyPath := profileConfigs.Configs[profile].CertKeyPath - if (certPath == "") != (certKeyPath == "") { + if (cfg.CertPath == "") != (cfg.CertKeyPath == "") { return fmt.Errorf("both cert_path and cert_key_path must be set for profile %s", profile) } return nil @@ -78,6 +80,9 @@ func Load(file string, profile string) error { profileConfigs.File = file profileConfigs.Profile = profile CliConfig = profileConfigs.Configs[profile] + if err = applyStoredCredentials(profile, &CliConfig); err != nil { + return err + } CliConfig.SslEnabled = strings.HasPrefix(CliConfig.PermifyURL, "https") return err } @@ -104,10 +109,25 @@ func Write() error { } profile := profileConfigs.Profile profileConfigs.Configs[profile] = CliConfig - newConfigDataByte, err := yaml.Marshal(profileConfigs.Configs) + newConfigDataByte, err := yaml.Marshal(configsWithoutCredentials(profileConfigs.Configs)) if err != nil { return err } err = os.WriteFile(profileConfigs.File, newConfigDataByte, fs.FileMode(0644)) - return err + if err != nil { + return err + } + return writeStoredCredentials(profileConfigs.Configs) +} + +func configsWithoutCredentials(configs map[string]CoreConfig) map[string]CoreConfig { + clean := make(map[string]CoreConfig, len(configs)) + for profile, cfg := range configs { + cfg.PermifyURL = "" + cfg.Token = "" + cfg.CertPath = "" + cfg.CertKeyPath = "" + clean[profile] = cfg + } + return clean } diff --git a/core/config/credentials.go b/core/config/credentials.go new file mode 100644 index 0000000..c511ba8 --- /dev/null +++ b/core/config/credentials.go @@ -0,0 +1,101 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type storedCredentials struct { + PermifyURL string `yaml:"permify_url,omitempty"` + Token string `yaml:"token,omitempty"` + CertPath string `yaml:"cert_path,omitempty"` + CertKeyPath string `yaml:"cert_key_path,omitempty"` +} + +func credentialsFilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".permify", "credentials"), nil +} + +func applyStoredCredentials(profile string, cfg *CoreConfig) error { + credentials, ok, err := loadStoredCredentials(profile) + if err != nil { + return err + } + if !ok { + return nil + } + if credentials.PermifyURL != "" { + cfg.PermifyURL = credentials.PermifyURL + } + cfg.Token = credentials.Token + cfg.CertPath = credentials.CertPath + cfg.CertKeyPath = credentials.CertKeyPath + return nil +} + +func loadStoredCredentials(profile string) (storedCredentials, bool, error) { + credentialsFile, err := credentialsFilePath() + if err != nil { + return storedCredentials{}, false, err + } + data, err := os.ReadFile(credentialsFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return storedCredentials{}, false, nil + } + return storedCredentials{}, false, err + } + if strings.TrimSpace(string(data)) == "" { + return storedCredentials{}, false, nil + } + credentialsByProfile := map[string]storedCredentials{} + if err = yaml.Unmarshal(data, &credentialsByProfile); err != nil { + return storedCredentials{}, false, err + } + credentials, ok := credentialsByProfile[profile] + return credentials, ok, nil +} + +func writeStoredCredentials(configs map[string]CoreConfig) error { + credentialsFile, err := credentialsFilePath() + if err != nil { + return err + } + credentialsByProfile := map[string]storedCredentials{} + data, err := os.ReadFile(credentialsFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil && strings.TrimSpace(string(data)) != "" { + if err = yaml.Unmarshal(data, &credentialsByProfile); err != nil { + return err + } + } + for profile, cfg := range configs { + if cfg.PermifyURL == "" && cfg.Token == "" && cfg.CertPath == "" && cfg.CertKeyPath == "" { + continue + } + credentialsByProfile[profile] = storedCredentials{ + PermifyURL: cfg.PermifyURL, + Token: cfg.Token, + CertPath: cfg.CertPath, + CertKeyPath: cfg.CertKeyPath, + } + } + if err = os.MkdirAll(filepath.Dir(credentialsFile), 0700); err != nil { + return err + } + newCredentialsData, err := yaml.Marshal(credentialsByProfile) + if err != nil { + return err + } + return os.WriteFile(credentialsFile, newCredentialsData, 0600) +} diff --git a/core/config/credentials_test.go b/core/config/credentials_test.go new file mode 100644 index 0000000..83b7ae3 --- /dev/null +++ b/core/config/credentials_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func isolateConfigTest(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("USERPROFILE", tmp) + CliConfig = CoreConfig{} + profileConfigs = ProfileConfigs{} + return tmp +} + +func TestWriteStoresCredentialsSeparately(t *testing.T) { + tmp := isolateConfigTest(t) + configFile := filepath.Join(tmp, "config.yaml") + if err := os.WriteFile(configFile, []byte("dev:\n tenant: old\n"), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + CliConfig = CoreConfig{ + PermifyURL: "https://permify.example:3478", + Tenant: "tenant-1", + Token: "secret-token", + CertPath: "client.crt", + CertKeyPath: "client.key", + } + profileConfigs = ProfileConfigs{ + File: configFile, + Profile: "dev", + Configs: map[string]CoreConfig{"dev": CliConfig}, + } + + if err := Write(); err != nil { + t.Fatalf("write: %v", err) + } + + configData, err := os.ReadFile(configFile) + if err != nil { + t.Fatalf("read config: %v", err) + } + configText := string(configData) + for _, unexpected := range []string{"permify_url", "secret-token", "client.crt", "client.key"} { + if strings.Contains(configText, unexpected) { + t.Fatalf("config file contains credential data %q:\n%s", unexpected, configText) + } + } + if !strings.Contains(configText, "tenant-1") { + t.Fatalf("config file should retain tenant:\n%s", configText) + } + + credentialsData, err := os.ReadFile(filepath.Join(tmp, ".permify", "credentials")) + if err != nil { + t.Fatalf("read credentials: %v", err) + } + credentialsText := string(credentialsData) + for _, expected := range []string{"permify_url", "https://permify.example:3478", "secret-token", "client.crt", "client.key"} { + if !strings.Contains(credentialsText, expected) { + t.Fatalf("credentials file missing %q:\n%s", expected, credentialsText) + } + } +} + +func TestLoadMergesStoredCredentials(t *testing.T) { + tmp := isolateConfigTest(t) + configFile := filepath.Join(tmp, "config.yaml") + credentialsFile := filepath.Join(tmp, ".permify", "credentials") + if err := os.WriteFile(configFile, []byte("dev:\n tenant: tenant-1\n"), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.MkdirAll(filepath.Dir(credentialsFile), 0700); err != nil { + t.Fatalf("mkdir credentials: %v", err) + } + credentials := []byte("dev:\n permify_url: https://permify.example:3478\n token: secret-token\n cert_path: client.crt\n cert_key_path: client.key\n") + if err := os.WriteFile(credentialsFile, credentials, 0600); err != nil { + t.Fatalf("write credentials: %v", err) + } + + if err := Load(configFile, "dev"); err != nil { + t.Fatalf("load: %v", err) + } + if CliConfig.PermifyURL != "https://permify.example:3478" { + t.Fatalf("PermifyURL=%q", CliConfig.PermifyURL) + } + if CliConfig.Tenant != "tenant-1" { + t.Fatalf("Tenant=%q", CliConfig.Tenant) + } + if CliConfig.Token != "secret-token" { + t.Fatalf("Token=%q", CliConfig.Token) + } + if CliConfig.CertPath != "client.crt" || CliConfig.CertKeyPath != "client.key" { + t.Fatalf("cert paths=%q/%q", CliConfig.CertPath, CliConfig.CertKeyPath) + } + if !CliConfig.SslEnabled { + t.Fatalf("expected https endpoint to enable ssl") + } +} + +func TestIsConfiguredUsesStoredEndpoint(t *testing.T) { + tmp := isolateConfigTest(t) + configFile := filepath.Join(tmp, "config.yaml") + credentialsFile := filepath.Join(tmp, ".permify", "credentials") + if err := os.WriteFile(configFile, []byte("dev:\n tenant: tenant-1\n"), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.MkdirAll(filepath.Dir(credentialsFile), 0700); err != nil { + t.Fatalf("mkdir credentials: %v", err) + } + if err := os.WriteFile(credentialsFile, []byte("dev:\n permify_url: localhost:3478\n"), 0600); err != nil { + t.Fatalf("write credentials: %v", err) + } + + if err := IsConfigured(configFile, "dev"); err != nil { + t.Fatalf("expected configured profile: %v", err) + } +}