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
152 changes: 152 additions & 0 deletions core/cli/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package cli

import (
"errors"
"fmt"

"github.com/Permify/permify-cli/core/credentials"
"github.com/Permify/permify-cli/core/logger"
"github.com/spf13/cobra"
)

// authPersistentPreRun is a stripped-down replacement for the root
// PersistentPreRun: it sets up logging based on --debug but skips the
// permctl config-file check so login/logout/whoami remain usable before
// `permctl configure` has ever been run.
func authPersistentPreRun(cmd *cobra.Command, _ []string) {
debugEnabled, _ := cmd.Flags().GetBool("debug")
logger.Update(debugEnabled)
}

// LoginCmd persists endpoint / token / mTLS material under the active
// profile. It writes to the credentials file with 0600 permissions; only
// the fields supplied on the command line are updated, so the command can
// also be used to rotate a single value (e.g. `permctl login --token new`).
func LoginCmd() *cobra.Command {
var (
endpoint string
token string
certPath string
certKey string
)
cmd := &cobra.Command{
Use: "login",
Short: "store permify credentials (endpoint, token, mTLS cert/key)",
Long: `Persist credentials used by permctl when talking to a Permify server.

Credentials are stored as YAML at $PERMIFY_CREDENTIALS_FILE if set,
otherwise at $HOME/.permctl-credentials.yaml, with 0600 file permissions.
Entries are keyed by profile (default: "default").`,
// Override the root PersistentPreRun: login must work before a
// permctl config file exists.
PersistentPreRun: authPersistentPreRun,
RunE: func(cmd *cobra.Command, _ []string) error {
if endpoint == "" && token == "" && certPath == "" && certKey == "" {
return errors.New("at least one of --endpoint, --token, --cert-path, --cert-key must be provided")
}
profile, _ := cmd.Flags().GetString("profile")

// Merge with any existing entry so single-flag rotation works.
existing, err := credentials.Load(profile)
if err != nil && !errors.Is(err, credentials.ErrNotFound) {
return err
}
if existing == nil {
existing = &credentials.Credentials{}
}
if endpoint != "" {
existing.Endpoint = endpoint
}
if token != "" {
existing.Token = token
}
if certPath != "" {
existing.CertPath = certPath
}
if certKey != "" {
existing.CertKey = certKey
}

if err := credentials.Save(profile, existing); err != nil {
return err
}
path, _ := credentials.GetCredentialsPath()
logger.Log.Info("credentials stored", "profile", profile, "file", path)
return nil
},
}
cmd.Flags().StringVar(&endpoint, "endpoint", "", "permify gRPC endpoint (host:port)")
cmd.Flags().StringVar(&token, "token", "", "bearer token for authenticated calls")
cmd.Flags().StringVar(&certPath, "cert-path", "", "path to client TLS certificate (PEM)")
cmd.Flags().StringVar(&certKey, "cert-key", "", "path to client TLS key (PEM)")
return cmd
}

// LogoutCmd deletes stored credentials for the active profile. If no other
// profiles remain in the file, the file itself is removed.
func LogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "remove stored permify credentials for the active profile",
PersistentPreRun: authPersistentPreRun,
RunE: func(cmd *cobra.Command, _ []string) error {
profile, _ := cmd.Flags().GetString("profile")
if err := credentials.Delete(profile); err != nil {
return err
}
logger.Log.Info("credentials removed", "profile", profile)
return nil
},
}
}

// WhoamiCmd prints the stored credentials for the active profile, masking
// the token so the value is safe to share in screenshots/bug reports.
func WhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "show stored permify credentials for the active profile",
PersistentPreRun: authPersistentPreRun,
RunE: func(cmd *cobra.Command, _ []string) error {
profile, _ := cmd.Flags().GetString("profile")
creds, err := credentials.Load(profile)
if err != nil {
if errors.Is(err, credentials.ErrNotFound) {
path, _ := credentials.GetCredentialsPath()
return fmt.Errorf("no credentials stored for profile %q (file: %s) - run `permctl login` to set them", profile, path)
}
return err
}
path, _ := credentials.GetCredentialsPath()
fmt.Printf("profile: %s\n", profile)
fmt.Printf("file: %s\n", path)
fmt.Printf("endpoint: %s\n", display(creds.Endpoint))
fmt.Printf("token: %s\n", maskToken(creds.Token))
fmt.Printf("cert_path: %s\n", display(creds.CertPath))
fmt.Printf("cert_key: %s\n", display(creds.CertKey))
return nil
},
}
}

// display returns "(unset)" for empty values so the whoami output makes the
// difference between unset and intentional empty obvious.
func display(s string) string {
if s == "" {
return "(unset)"
}
return s
}

// maskToken keeps the first 4 and last 4 characters of long tokens and
// replaces the middle with asterisks; short tokens are fully masked so they
// cannot be reconstructed from the output.
func maskToken(t string) string {
if t == "" {
return "(unset)"
}
if len(t) <= 8 {
return "****"
}
return t[:4] + "..." + t[len(t)-4:]
}
3 changes: 3 additions & 0 deletions core/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func New(name, shortDescription, defaultConfigPath string) *Cli {
//disable help sub command
c.Cmd.SetHelpCommand(&cobra.Command{Hidden: true})
c.Cmd.AddCommand(ConfigureCmd())
c.Cmd.AddCommand(LoginCmd())
c.Cmd.AddCommand(LogoutCmd())
c.Cmd.AddCommand(WhoamiCmd())
c.Cmd.PersistentFlags().Bool("debug", false, "verbose logging")
c.Cmd.PersistentFlags().String("config", defaultConfigPath, fmt.Sprintf("%s config file", c.Name))
c.Cmd.PersistentFlags().String("profile", "default", "profile name for config")
Expand Down
101 changes: 92 additions & 9 deletions core/client/grpc.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,103 @@
// Package client handles the permify client to connect with the server
// Package client handles the permify client to connect with the server.
//
// The New function accepts an endpoint argument but also consults the local
// credential store (see core/credentials). Any value left blank in the
// argument falls back to the stored value, so callers can keep calling
// client.New(config.CliConfig.PermifyURL) unchanged while still benefiting
// from token authentication and mTLS configured via `permctl login`.
package client

import (
"errors"
"fmt"
"os"

"github.com/Permify/permify-cli/core/credentials"
"github.com/Permify/permify-cli/core/logger"
permify "github.com/Permify/permify-go/v1"
"google.golang.org/grpc"
grpccreds "google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)

// New initializes a new permify client
// profileEnv lets the active profile be overridden without plumbing it
// through every call site. Falls back to credentials.DefaultProfile.
const profileEnv = "PERMIFY_PROFILE"

// New initializes a new permify client. The endpoint argument takes
// precedence; if empty, the endpoint stored under the active profile is
// used. Stored token and mTLS material (if present) are always applied.
//
// New returns an error when:
// - no endpoint is supplied either by argument or by storage
// - stored mTLS certificate material is referenced but unreadable
// - the underlying grpc.Dial fails
func New(endpoint string) (*permify.Client, error) {
client, err := permify.NewClient(
permify.Config{
Endpoint: endpoint,
},
// Todo: Implement secure call with tls certificate
grpc.WithTransportCredentials(insecure.NewCredentials()),
profile := os.Getenv(profileEnv)
stored, err := credentials.Load(profile)
if err != nil && !errors.Is(err, credentials.ErrNotFound) {
// A real error (insecure perms, corrupt YAML, ...) is surfaced.
// ErrNotFound is intentionally swallowed so the CLI keeps working
// without a credential file.
return nil, fmt.Errorf("load stored credentials: %w", err)
}
if stored == nil {
stored = &credentials.Credentials{}
}

if endpoint == "" {
endpoint = stored.Endpoint
}
if endpoint == "" {
return nil, errors.New("permify endpoint is empty: pass one to client.New or run `permctl login --endpoint <host:port>`")
}

dialOpts, err := buildDialOptions(stored)
if err != nil {
return nil, err
}

logger.Log.Debug("dialing permify", "endpoint", endpoint, "tls", stored.CertPath != "", "token", stored.Token != "")

return permify.NewClient(
permify.Config{Endpoint: endpoint},
dialOpts...,
)
return client, err
}

// buildDialOptions translates stored credentials into grpc.DialOptions:
//
// - cert_path + cert_key => mTLS transport credentials
// - cert_path only => TLS with server cert as CA (rare but supported)
// - neither => insecure transport (preserves prior behavior)
// - token => per-RPC bearer token; secure flavor over TLS,
// non-secure flavor over plaintext
func buildDialOptions(c *credentials.Credentials) ([]grpc.DialOption, error) {
var opts []grpc.DialOption

tlsConfigured := c.CertPath != "" || c.CertKey != ""

if tlsConfigured {
if c.CertPath == "" || c.CertKey == "" {
return nil, errors.New("incomplete TLS credentials: both cert_path and cert_key must be set")
}
creds, err := grpccreds.NewClientTLSFromFile(c.CertPath, "")
if err != nil {
return nil, fmt.Errorf("load TLS credentials from %s: %w", c.CertPath, err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}

if c.Token != "" {
md := map[string]string{"authorization": "Bearer " + c.Token}
if tlsConfigured {
opts = append(opts, grpc.WithPerRPCCredentials(secureTokenCredentials(md)))
} else {
opts = append(opts, grpc.WithPerRPCCredentials(nonSecureTokenCredentials(md)))
}
}

return opts, nil
}
Loading