diff --git a/CHANGELOG.md b/CHANGELOG.md index df80cde..13ce269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2023-02-14 + +### Added + +- Ability to specify `credential_process` option in the config to source Vault token from an external process + ## [0.2.1] - 2020-07-28 ### Changed diff --git a/README.md b/README.md index 2db5fdb..375b124 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,9 @@ exclude_paths = [] kv_version = "" # Enables or disables SSL Verification insecure = false +# External process for sourcing Vault token +# If specified, `token` will be ignored +# credential_process = "get-vault-token -f credential-process" [list] # Default path to use for listing @@ -327,6 +330,38 @@ As a last option, token can be retrieved from the `~/.vault-token` helper file. NOTE: If the token are specified in both the vault token helper and configuration files the latter has higher precedence. +### Credential Process + +You could also specify an external process for sourcing Vault token with the `credential_process` config option. + +```toml +[global] +# Vault address +address = "http://127.0.0.1:8200" +# External process for sourcing Vault token +credential_process = "get-vault-token -f credential-process" +``` + +The mechanic works in the same way as the sourcing credentials with an external process +[work in AWS](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html): + +- The external process should print the token to stdout in the following format: + +```json +{ + "Version": "1", + "Token": "a Vault access token" +} +``` + +- **RVault** will run the process, parse the JSON from stdout, and add the token to the global configuration. +- While running an external process the `VAULT_ADDR` environment variable will be set to its actual value from the config. + +The credentials gotten from the external process are not cacheable by design. +So, if you want to cache and renew the token later, you should implement such a logic in the external process. + +NOTE: If the `credential_process` option is specified, it takes priority over all other token sources. + ## Running in Docker A Docker image is built with this repository and pushed to Docker Hub diff --git a/cmd/root.go b/cmd/root.go index 53888bd..5f50b10 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,9 @@ import ( "flag" "fmt" "os" + "strings" + + "rvault/internal/pkg/credential" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -95,6 +98,16 @@ func initConfig() { klog.V(1).Infof("Using config file: '%s'", viper.ConfigFileUsed()) } + // Try sourcing token with an external credential process + if viper.IsSet("global.credential_process") { + process := viper.GetString("global.credential_process") + token, err := credential.GetToken(strings.Fields(process)) + if err != nil { + klog.Exitf("Can't execute credential process '%s': %v", process, err) + } + viper.Set("global.token", token) + } + // Read ~/.vault-token file if !viper.IsSet("global.token") { home, err := homedir.Dir() diff --git a/internal/pkg/credential/process.go b/internal/pkg/credential/process.go new file mode 100644 index 0000000..b6c009c --- /dev/null +++ b/internal/pkg/credential/process.go @@ -0,0 +1,60 @@ +package credential + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/spf13/viper" +) + +const ( + DefaultVersion = "1" +) + +var ( + ErrNoCommandPassed = fmt.Errorf("expected at least a single command to execute") +) + +type ( + Data struct { + Version string + Token string + } +) + +func GetToken(command []string) (string, error) { + if len(command) == 0 { + return "", ErrNoCommandPassed + } + + stdout := new(strings.Builder) + stderr := new(strings.Builder) + + cmd := exec.Command(command[0], command[1:]...) + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = []string{ + fmt.Sprintf("VAULT_ADDR=%s", viper.GetString("global.address")), + } + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("failed to exec process %v: %w, stdout: %s, stderr: %s", command, err, stdout.String(), stderr.String()) + } + + raw := stdout.String() + + var data Data + err = json.Unmarshal([]byte(raw), &data) + if err != nil { + return "", fmt.Errorf("failed to parse process output %s: %w", raw, err) + } + + if data.Version != DefaultVersion { + return "", fmt.Errorf("unexpected credentail process format version %s, expected %s", data.Version, DefaultVersion) + } + + return data.Token, nil +} diff --git a/internal/pkg/credential/process_test.go b/internal/pkg/credential/process_test.go new file mode 100644 index 0000000..33306ab --- /dev/null +++ b/internal/pkg/credential/process_test.go @@ -0,0 +1,31 @@ +package credential + +import ( + "errors" + "strings" + "testing" +) + +func TestGetToken_SingleCommand(t *testing.T) { + _, err := GetToken([]string{"test"}) + if !strings.Contains(err.Error(), "exit status 1") { + t.Errorf("GetToken got error = %v, want exit status 1", err) + } +} + +func TestGetToken_NoCommand(t *testing.T) { + _, err := GetToken([]string{}) + if !errors.Is(err, NoCommandPassed) { + t.Errorf("GetToken got error = %v, want NoCommandPassed", err) + } +} + +func TestGetToken_HappyPath(t *testing.T) { + token, err := GetToken([]string{"sh", "-c", `echo '{"Version":"1","Token":"123"}'`}) + if err != nil { + t.Errorf("GetToken got error = %v, want nil", err) + } + if token != "123" { + t.Errorf("GetToken got token = %v, want 123", token) + } +}