From 981c95f699d3e13eceb12f90a79e447cd610d4ba Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 17 Oct 2017 18:05:51 -0700 Subject: [PATCH] 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. --- svchost/auth/helper_program.go | 80 ++++++++++++++++++++++++++++ svchost/auth/helper_program_test.go | 59 ++++++++++++++++++++ svchost/auth/test-helper/.gitignore | 1 + svchost/auth/test-helper/main.go | 39 ++++++++++++++ svchost/auth/test-helper/test-helper | 7 +++ 5 files changed, 186 insertions(+) create mode 100644 svchost/auth/helper_program.go create mode 100644 svchost/auth/helper_program_test.go create mode 100644 svchost/auth/test-helper/.gitignore create mode 100644 svchost/auth/test-helper/main.go create mode 100755 svchost/auth/test-helper/test-helper diff --git a/svchost/auth/helper_program.go b/svchost/auth/helper_program.go new file mode 100644 index 000000000..d72ffe3c9 --- /dev/null +++ b/svchost/auth/helper_program.go @@ -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 +} diff --git a/svchost/auth/helper_program_test.go b/svchost/auth/helper_program_test.go new file mode 100644 index 000000000..3fa1c3aa5 --- /dev/null +++ b/svchost/auth/helper_program_test.go @@ -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") + } + }) +} diff --git a/svchost/auth/test-helper/.gitignore b/svchost/auth/test-helper/.gitignore new file mode 100644 index 000000000..ba2906d06 --- /dev/null +++ b/svchost/auth/test-helper/.gitignore @@ -0,0 +1 @@ +main diff --git a/svchost/auth/test-helper/main.go b/svchost/auth/test-helper/main.go new file mode 100644 index 000000000..3b1b72fd5 --- /dev/null +++ b/svchost/auth/test-helper/main.go @@ -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) +} diff --git a/svchost/auth/test-helper/test-helper b/svchost/auth/test-helper/test-helper new file mode 100755 index 000000000..0ed3396c5 --- /dev/null +++ b/svchost/auth/test-helper/test-helper @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -eu + +cd "$( dirname "${BASH_SOURCE[0]}" )" +[ -x main ] || go build -o main . +exec ./main "$@"