svchost/auth: CredentialsSource that runs an external program
This CredentialsSource can serve as an extension point to pass credentials from an arbitrary external system to Terraform. For example, an external helper program could fetch limited-time credentials from HashiCorp Vault and return them, thus avoiding the need for any static configuration to be maintained locally (except a Vault token!). So far there are no real programs implementing this protocol, though this commit includes a basic implementation that we use for unit tests.
This commit is contained in:
parent
1b60e8fdb6
commit
981c95f699
|
@ -0,0 +1,80 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/svchost"
|
||||||
|
)
|
||||||
|
|
||||||
|
type helperProgramCredentialsSource struct {
|
||||||
|
executable string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelperProgramCredentialsSource returns a CredentialsSource that runs the
|
||||||
|
// given program with the given arguments in order to obtain credentials.
|
||||||
|
//
|
||||||
|
// The given executable path must be an absolute path; it is the caller's
|
||||||
|
// responsibility to validate and process a relative path or other input
|
||||||
|
// provided by an end-user. If the given path is not absolute, this
|
||||||
|
// function will panic.
|
||||||
|
//
|
||||||
|
// When credentials are requested, the program will be run in a child process
|
||||||
|
// with the given arguments along with two additional arguments added to the
|
||||||
|
// end of the list: the literal string "get", followed by the requested
|
||||||
|
// hostname in ASCII compatibility form (punycode form).
|
||||||
|
func HelperProgramCredentialsSource(executable string, args ...string) CredentialsSource {
|
||||||
|
if !filepath.IsAbs(executable) {
|
||||||
|
panic("NewCredentialsSourceHelperProgram requires absolute path to executable")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullArgs := make([]string, len(args)+1)
|
||||||
|
fullArgs[0] = executable
|
||||||
|
copy(fullArgs[1:], args)
|
||||||
|
|
||||||
|
return &helperProgramCredentialsSource{
|
||||||
|
executable: executable,
|
||||||
|
args: fullArgs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *helperProgramCredentialsSource) ForHost(host svchost.Hostname) (HostCredentials, error) {
|
||||||
|
args := make([]string, len(s.args), len(s.args)+2)
|
||||||
|
copy(args, s.args)
|
||||||
|
args = append(args, "get")
|
||||||
|
args = append(args, string(host))
|
||||||
|
|
||||||
|
outBuf := bytes.Buffer{}
|
||||||
|
errBuf := bytes.Buffer{}
|
||||||
|
|
||||||
|
cmd := exec.Cmd{
|
||||||
|
Path: s.executable,
|
||||||
|
Args: args,
|
||||||
|
Stdin: nil,
|
||||||
|
Stdout: &outBuf,
|
||||||
|
Stderr: &errBuf,
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if _, isExitErr := err.(*exec.ExitError); isExitErr {
|
||||||
|
errText := errBuf.String()
|
||||||
|
if errText == "" {
|
||||||
|
// Shouldn't happen for a well-behaved helper program
|
||||||
|
return nil, fmt.Errorf("error in %s, but it produced no error message", s.executable)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("error in %s: %s", s.executable, errText)
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to run %s: %s", s.executable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]interface{}
|
||||||
|
err = json.Unmarshal(outBuf.Bytes(), &m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("malformed output from %s: %s", s.executable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HostCredentialsFromMap(m), nil
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/svchost"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHelperProgramCredentialsSource(t *testing.T) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
program := filepath.Join(wd, "test-helper/test-helper")
|
||||||
|
t.Logf("testing with helper at %s", program)
|
||||||
|
|
||||||
|
src := HelperProgramCredentialsSource(program)
|
||||||
|
|
||||||
|
t.Run("happy path", func(t *testing.T) {
|
||||||
|
creds, err := src.ForHost(svchost.Hostname("example.com"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tokCreds, isTok := creds.(HostCredentialsToken); isTok {
|
||||||
|
if got, want := string(tokCreds), "example-token"; got != want {
|
||||||
|
t.Errorf("wrong token %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("wrong type of credentials %T", creds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("no credentials", func(t *testing.T) {
|
||||||
|
creds, err := src.ForHost(svchost.Hostname("nothing.example.com"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if creds != nil {
|
||||||
|
t.Errorf("got credentials; want nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unsupported credentials type", func(t *testing.T) {
|
||||||
|
creds, err := src.ForHost(svchost.Hostname("other-cred-type.example.com"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if creds != nil {
|
||||||
|
t.Errorf("got credentials; want nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("lookup error", func(t *testing.T) {
|
||||||
|
_, err := src.ForHost(svchost.Hostname("fail.example.com"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("completed successfully; want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
main
|
|
@ -0,0 +1,39 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a simple program that implements the "helper program" protocol
|
||||||
|
// for the svchost/auth package for unit testing purposes.
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
args := os.Args
|
||||||
|
|
||||||
|
if len(args) < 3 {
|
||||||
|
die("not enough arguments\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[1] != "get" {
|
||||||
|
die("unknown subcommand %q\n", args[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
host := args[2]
|
||||||
|
|
||||||
|
switch host {
|
||||||
|
case "example.com":
|
||||||
|
fmt.Print(`{"token":"example-token"}`)
|
||||||
|
case "other-cred-type.example.com":
|
||||||
|
fmt.Print(`{"username":"alfred"}`) // unrecognized by main program
|
||||||
|
case "fail.example.com":
|
||||||
|
die("failing because you told me to fail\n")
|
||||||
|
default:
|
||||||
|
fmt.Print("{}") // no credentials available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(f string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, fmt.Sprintf(f, args...))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$( dirname "${BASH_SOURCE[0]}" )"
|
||||||
|
[ -x main ] || go build -o main .
|
||||||
|
exec ./main "$@"
|
Loading…
Reference in New Issue