From 22933f300b493e00388a3e9bafc6c6ffe155915f Mon Sep 17 00:00:00 2001 From: Francis Chuang Date: Fri, 17 Feb 2023 16:41:41 +1100 Subject: [PATCH 1/3] Add Oracle Cloud auth to the Vault Agent --- changelog/19260.txt | 3 + command/agent.go | 3 + command/agent/auth/oci/oci.go | 261 ++++++++++++++++++ command/agent/oci_end_to_end_test.go | 228 +++++++++++++++ .../docs/agent/autoauth/methods/oci.mdx | 43 +++ website/data/docs-nav-data.json | 4 + 6 files changed, 542 insertions(+) create mode 100644 changelog/19260.txt create mode 100644 command/agent/auth/oci/oci.go create mode 100644 command/agent/oci_end_to_end_test.go create mode 100644 website/content/docs/agent/autoauth/methods/oci.mdx diff --git a/changelog/19260.txt b/changelog/19260.txt new file mode 100644 index 000000000000..77138a38607c --- /dev/null +++ b/changelog/19260.txt @@ -0,0 +1,3 @@ +```release-note:feature +**agent/auto-auth:**: Add OCI (Oracle Cloud Infrastructure) auto-auth method +``` diff --git a/command/agent.go b/command/agent.go index 6bc896de5109..e6a4c0af3184 100644 --- a/command/agent.go +++ b/command/agent.go @@ -40,6 +40,7 @@ import ( "github.com/hashicorp/vault/command/agent/auth/jwt" "github.com/hashicorp/vault/command/agent/auth/kerberos" "github.com/hashicorp/vault/command/agent/auth/kubernetes" + "github.com/hashicorp/vault/command/agent/auth/oci" "github.com/hashicorp/vault/command/agent/cache" "github.com/hashicorp/vault/command/agent/cache/cacheboltdb" "github.com/hashicorp/vault/command/agent/cache/cachememdb" @@ -370,6 +371,8 @@ func (c *AgentCommand) Run(args []string) int { method, err = kubernetes.NewKubernetesAuthMethod(authConfig) case "approle": method, err = approle.NewApproleAuthMethod(authConfig) + case "oci": + method, err = oci.NewOCIAuthMethod(authConfig, config.Vault.Address) case "token_file": method, err = token_file.NewTokenFileAuthMethod(authConfig) case "pcf": // Deprecated. diff --git a/command/agent/auth/oci/oci.go b/command/agent/auth/oci/oci.go new file mode 100644 index 000000000000..e1cab32d06e6 --- /dev/null +++ b/command/agent/auth/oci/oci.go @@ -0,0 +1,261 @@ +package oci + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "os/user" + "path" + "sync" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/command/agent/auth" + "github.com/oracle/oci-go-sdk/common" + ociAuth "github.com/oracle/oci-go-sdk/common/auth" +) + +const ( + typeAPIKey = "apikey" + typeInstance = "instance" + + /* + + IAM creds can be inferred from instance metadata or the container + identity service, and those creds expire at varying intervals with + new creds becoming available at likewise varying intervals. Let's + default to polling once a minute so all changes can be picked up + rather quickly. This is configurable, however. + + */ + defaultCredCheckFreqSeconds = 60 + + defaultConfigFileName = "config" + defaultConfigDirName = ".oci" + configFilePathEnvVarName = "OCI_CONFIG_FILE" + secondaryConfigDirName = ".oraclebmc" +) + +func NewOCIAuthMethod(conf *auth.AuthConfig, vaultAddress string) (auth.AuthMethod, error) { + if conf == nil { + return nil, errors.New("empty config") + } + if conf.Config == nil { + return nil, errors.New("empty config data") + } + + a := &ociMethod{ + logger: conf.Logger, + vaultAddress: vaultAddress, + mountPath: conf.MountPath, + credsFound: make(chan struct{}), + stopCh: make(chan struct{}), + } + + typeRaw, ok := conf.Config["type"] + if !ok { + return nil, errors.New("missing 'type' value") + } + authType, ok := typeRaw.(string) + if !ok { + return nil, errors.New("could not convert 'type' config value to string") + } + + roleRaw, ok := conf.Config["role"] + if !ok { + return nil, errors.New("missing 'role' value") + } + a.role, ok = roleRaw.(string) + if !ok { + return nil, errors.New("could not convert 'role' config value to string") + } + + // Check for an optional custom frequency at which we should poll for creds. + credCheckFreqSec := defaultCredCheckFreqSeconds + if checkFreqRaw, ok := conf.Config["credential_poll_interval"]; ok { + if credFreq, ok := checkFreqRaw.(int); ok { + credCheckFreqSec = credFreq + } else { + return nil, errors.New("could not convert 'credential_poll_interval' config value to int") + } + } + + switch { + case a.role == "": + return nil, errors.New("'role' value is empty") + case authType == "": + return nil, errors.New("'type' value is empty") + case authType != typeAPIKey && authType != typeInstance: + return nil, errors.New("'type' value is invalid") + case authType == typeAPIKey: + defaultConfigFile := getDefaultConfigFilePath() + homeFolder := getHomeFolder() + secondaryConfigFile := path.Join(homeFolder, secondaryConfigDirName, defaultConfigFileName) + + environmentProvider := common.ConfigurationProviderEnvironmentVariables("OCI", "") + defaultFileProvider, _ := common.ConfigurationProviderFromFile(defaultConfigFile, "") + secondaryFileProvider, _ := common.ConfigurationProviderFromFile(secondaryConfigFile, "") + + provider, _ := common.ComposingConfigurationProvider([]common.ConfigurationProvider{environmentProvider, defaultFileProvider, secondaryFileProvider}) + a.configurationProvider = provider + case authType == typeInstance: + configurationProvider, err := ociAuth.InstancePrincipalConfigurationProvider() + if err != nil { + return nil, fmt.Errorf("failed to create instance principal configuration provider: %v", err) + } + a.configurationProvider = configurationProvider + } + + // Do an initial population of the creds because we want to err right away if we can't + // even get a first set. + creds, err := a.configurationProvider.KeyID() + if err != nil { + return nil, err + } + a.lastCreds = creds + + go a.pollForCreds(credCheckFreqSec) + + return a, nil +} + +type ociMethod struct { + logger hclog.Logger + vaultAddress string + mountPath string + + configurationProvider common.ConfigurationProvider + role string + + // These are used to share the latest creds safely across goroutines. + credLock sync.Mutex + lastCreds string + + // Notifies the outer environment that it should call Authenticate again. + credsFound chan struct{} + + // Detects that the outer environment is closing. + stopCh chan struct{} +} + +func (a *ociMethod) Authenticate(context.Context, *api.Client) (string, http.Header, map[string]interface{}, error) { + a.credLock.Lock() + defer a.credLock.Unlock() + + a.logger.Trace("beginning authentication") + + requestPath := fmt.Sprintf("/v1/%s/login/%s", a.mountPath, a.role) + requestURL := fmt.Sprintf("%s%s", a.vaultAddress, requestPath) + + request, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return "", nil, nil, fmt.Errorf("error creating authentication request: %w", err) + } + + request.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + + signer := common.DefaultRequestSigner(a.configurationProvider) + + err = signer.Sign(request) + + if err != nil { + return "", nil, nil, fmt.Errorf("error signing authentication request: %w", err) + } + + parsedVaultAddress, err := url.Parse(a.vaultAddress) + if err != nil { + return "", nil, nil, fmt.Errorf("unable to parse vault address: %w", err) + } + + request.Header.Set("Host", parsedVaultAddress.Host) + request.Header.Set("(request-target)", fmt.Sprintf("%s %s", "get", requestPath)) + + data := map[string]interface{}{ + "request_headers": request.Header, + } + + return fmt.Sprintf("%s/login/%s", a.mountPath, a.role), nil, data, nil +} + +func (a *ociMethod) NewCreds() chan struct{} { + return a.credsFound +} + +func (a *ociMethod) CredSuccess() {} + +func (a *ociMethod) Shutdown() { + close(a.credsFound) + close(a.stopCh) +} + +func (a *ociMethod) pollForCreds(frequencySeconds int) { + ticker := time.NewTicker(time.Duration(frequencySeconds) * time.Second) + defer ticker.Stop() + for { + select { + case <-a.stopCh: + a.logger.Trace("shutdown triggered, stopping OCI auth handler") + return + case <-ticker.C: + if err := a.checkCreds(); err != nil { + a.logger.Warn("unable to retrieve current creds, retaining last creds", "error", err) + } + } + } +} + +func (a *ociMethod) checkCreds() error { + a.credLock.Lock() + defer a.credLock.Unlock() + + a.logger.Trace("checking for new credentials") + currentCreds, err := a.configurationProvider.KeyID() + if err != nil { + return err + } + // These will always have different pointers regardless of whether their + // values are identical, hence the use of DeepEqual. + if currentCreds == a.lastCreds { + a.logger.Trace("credentials are unchanged") + return nil + } + a.lastCreds = currentCreds + a.logger.Trace("new credentials detected, triggering Authenticate") + a.credsFound <- struct{}{} + return nil +} + +func getHomeFolder() string { + current, e := user.Current() + if e != nil { + // Give up and try to return something sensible + home := os.Getenv("HOME") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return current.HomeDir +} + +func getDefaultConfigFilePath() string { + homeFolder := getHomeFolder() + defaultConfigFile := path.Join(homeFolder, defaultConfigDirName, defaultConfigFileName) + if _, err := os.Stat(defaultConfigFile); err == nil { + return defaultConfigFile + } + + // Read configuration file path from OCI_CONFIG_FILE env var + fallbackConfigFile, existed := os.LookupEnv(configFilePathEnvVarName) + if !existed { + return defaultConfigFile + } + if _, err := os.Stat(fallbackConfigFile); os.IsNotExist(err) { + return defaultConfigFile + } + return fallbackConfigFile +} diff --git a/command/agent/oci_end_to_end_test.go b/command/agent/oci_end_to_end_test.go new file mode 100644 index 000000000000..e1140585e954 --- /dev/null +++ b/command/agent/oci_end_to_end_test.go @@ -0,0 +1,228 @@ +package agent + +import ( + "context" + "io/ioutil" + "os" + "testing" + "time" + + hclog "github.com/hashicorp/go-hclog" + vaultoci "github.com/hashicorp/vault-plugin-auth-oci" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/command/agent/auth" + agentoci "github.com/hashicorp/vault/command/agent/auth/oci" + "github.com/hashicorp/vault/command/agent/sink" + "github.com/hashicorp/vault/command/agent/sink/file" + "github.com/hashicorp/vault/helper/testhelpers" + vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/sdk/helper/logging" + "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/vault" +) + +const ( + envVarOCITestTenancyOCID = "OCI_TEST_TENANCY_OCID" + envVarOCITestUserOCID = "OCI_TEST_USER_OCID" + envVarOCITestFingerprint = "OCI_TEST_FINGERPRINT" + envVarOCITestPrivateKeyPath = "OCI_TEST_PRIVATE_KEY_PATH" + envVAROCITestOCIDList = "OCI_TEST_OCID_LIST" + + // The OCI SDK doesn't export its standard env vars so they're captured here. + // These are used for the duration of the test to make sure the agent is able to + // pick up creds from the env. + // + // To run this test, do not set these. Only the above ones need to be set. + envVarOCITenancyOCID = "OCI_tenancy_ocid" + envVarOCIUserOCID = "OCI_user_ocid" + envVarOCIFingerprint = "OCI_fingerprint" + envVarOCIPrivateKeyPath = "OCI_private_key_path" +) + +func TestOCIEndToEnd(t *testing.T) { + if !runAcceptanceTests { + t.SkipNow() + } + + // Ensure each cred is populated. + credNames := []string{ + envVarOCITestTenancyOCID, + envVarOCITestUserOCID, + envVarOCITestFingerprint, + envVarOCITestPrivateKeyPath, + envVAROCITestOCIDList, + } + testhelpers.SkipUnlessEnvVarsSet(t, credNames) + + logger := logging.NewVaultLogger(hclog.Trace) + coreConfig := &vault.CoreConfig{ + Logger: logger, + CredentialBackends: map[string]logical.Factory{ + "oci": vaultoci.Factory, + }, + } + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + + vault.TestWaitActive(t, cluster.Cores[0].Core) + client := cluster.Cores[0].Client + + // Setup Vault + if err := client.Sys().EnableAuthWithOptions("oci", &api.EnableAuthOptions{ + Type: "oci", + }); err != nil { + t.Fatal(err) + } + + if _, err := client.Logical().Write("auth/oci/config", map[string]interface{}{ + "home_tenancy_id": os.Getenv(envVarOCITestTenancyOCID), + }); err != nil { + t.Fatal(err) + } + + if _, err := client.Logical().Write("auth/oci/role/test", map[string]interface{}{ + "ocid_list": os.Getenv(envVAROCITestOCIDList), + }); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + + // We're going to feed oci auth creds via env variables. + if err := setOCIEnvCreds(); err != nil { + t.Fatal(err) + } + defer func() { + if err := unsetOCIEnvCreds(); err != nil { + t.Fatal(err) + } + }() + + vaultAddr := "http://" + cluster.Cores[0].Listeners[0].Addr().String() + + am, err := agentoci.NewOCIAuthMethod(&auth.AuthConfig{ + Logger: logger.Named("auth.oci"), + MountPath: "auth/oci", + Config: map[string]interface{}{ + "type": "apikey", + "role": "test", + }, + }, vaultAddr) + if err != nil { + t.Fatal(err) + } + + ahConfig := &auth.AuthHandlerConfig{ + Logger: logger.Named("auth.handler"), + Client: client, + } + + ah := auth.NewAuthHandler(ahConfig) + errCh := make(chan error) + go func() { + errCh <- ah.Run(ctx, am) + }() + defer func() { + select { + case <-ctx.Done(): + case err := <-errCh: + if err != nil { + t.Fatal(err) + } + } + }() + + tmpFile, err := ioutil.TempFile("", "auth.tokensink.test.") + if err != nil { + t.Fatal(err) + } + tokenSinkFileName := tmpFile.Name() + tmpFile.Close() + os.Remove(tokenSinkFileName) + t.Logf("output: %s", tokenSinkFileName) + + config := &sink.SinkConfig{ + Logger: logger.Named("sink.file"), + Config: map[string]interface{}{ + "path": tokenSinkFileName, + }, + WrapTTL: 10 * time.Second, + } + + fs, err := file.NewFileSink(config) + if err != nil { + t.Fatal(err) + } + config.Sink = fs + + ss := sink.NewSinkServer(&sink.SinkServerConfig{ + Logger: logger.Named("sink.server"), + Client: client, + }) + go func() { + errCh <- ss.Run(ctx, ah.OutputCh, []*sink.SinkConfig{config}) + }() + defer func() { + select { + case <-ctx.Done(): + case err := <-errCh: + if err != nil { + t.Fatal(err) + } + } + }() + + // This has to be after the other defers so it happens first. It allows + // successful test runs to immediately cancel all of the runner goroutines + // and unblock any of the blocking defer calls by the runner's DoneCh that + // comes before this and avoid successful tests from taking the entire + // timeout duration. + defer cancel() + + if stat, err := os.Lstat(tokenSinkFileName); err == nil { + t.Fatalf("expected err but got %s", stat) + } else if !os.IsNotExist(err) { + t.Fatal("expected notexist err") + } + + // Wait 2 seconds for the env variables to be detected and an auth to be generated. + time.Sleep(time.Second * 2) + + token, err := readToken(tokenSinkFileName) + if err != nil { + t.Fatal(err) + } + + if token.Token == "" { + t.Fatal("expected token but didn't receive it") + } +} + +func setOCIEnvCreds() error { + if err := os.Setenv(envVarOCITenancyOCID, os.Getenv(envVarOCITestTenancyOCID)); err != nil { + return err + } + if err := os.Setenv(envVarOCIUserOCID, os.Getenv(envVarOCITestUserOCID)); err != nil { + return err + } + if err := os.Setenv(envVarOCIFingerprint, os.Getenv(envVarOCITestFingerprint)); err != nil { + return err + } + return os.Setenv(envVarOCIPrivateKeyPath, os.Getenv(envVarOCITestPrivateKeyPath)) +} + +func unsetOCIEnvCreds() error { + if err := os.Unsetenv(envVarOCITenancyOCID); err != nil { + return err + } + if err := os.Unsetenv(envVarOCIUserOCID); err != nil { + return err + } + if err := os.Unsetenv(envVarOCIFingerprint); err != nil { + return err + } + return os.Unsetenv(envVarOCIPrivateKeyPath) +} diff --git a/website/content/docs/agent/autoauth/methods/oci.mdx b/website/content/docs/agent/autoauth/methods/oci.mdx new file mode 100644 index 000000000000..445c7e879dbd --- /dev/null +++ b/website/content/docs/agent/autoauth/methods/oci.mdx @@ -0,0 +1,43 @@ +--- +layout: docs +page_title: Vault Agent Auto-Auth OCI (Oracle Cloud Infrastructure) Method +description: OCI (Oracle Cloud Infrastructure) Method for Vault Agent Auto-Auth +--- + +# Vault Agent Auto-Auth OCI (Oracle Cloud Infrastructure) Method + +The `oci` method performs authentication against the [OCI Auth +method](/vault/docs/auth/oci). + +## Credentials + +The method use to authenticate is set using the `type` parameter. Valid values are `apikey` to authenticate using +API Key credentials and `instance` for Instance Principal credentials. + +If `apikey` is used, the Vault agent will use the first credential it can successfully obtain in the following order: + +1. Environment variables: + - `OCI_tenancy_ocid` + - `OCI_user_ocid` + - `OCI_fingerprint` + - `OCI_private_key_path` +2. Configuration file in `$HOME/.oci/config` +3. Path to configuration file defined in the `OCI_CONFIG_FILE` environment variable +4. Configuration file in `$HOME/.obmcs/config` + +Wherever possible, we recommend using instance principal for credentials. These are rotated automatically by OCI +and require no effort on your part to provision, making instance principal the most secure of the three methods. If +using instance principal _and_ a custom `credential_poll_interval`, be sure the frequency is set to a value that is less +than OCI's rotation frequency. This is currently documented as +[multiple times a day](https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm#faq), +but from experience, credentials are rotated every 10 to 15 minutes. + +## Configuration + +### General + +- `type` `(string: required)` - The type of authentication to use. Valid values are `apikey` and `instance`. + +- `role` `(string: required)` - The role to authenticate against on Vault. + +- `credential_poll_interval` `(integer: optional)` - In seconds, how frequently the Vault agent should check for new credentials. diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 538aaa8c3261..13bc08eb00cf 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -928,6 +928,10 @@ "title": "Kubernetes", "path": "agent/autoauth/methods/kubernetes" }, + { + "title": "Oracle Cloud Infrastructure", + "path": "agent/autoauth/methods/oci" + }, { "title": "Token File", "path": "agent/autoauth/methods/token_file" From 5384508da750c564f45b11485b9e6136c76c899e Mon Sep 17 00:00:00 2001 From: Francis Chuang Date: Sat, 11 Mar 2023 16:49:23 +1100 Subject: [PATCH 2/3] Use ParseDurationSecond to parse credential_poll_interval --- command/agent/auth/oci/oci.go | 15 ++++++++------- .../content/docs/agent/autoauth/methods/oci.mdx | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/command/agent/auth/oci/oci.go b/command/agent/auth/oci/oci.go index e1cab32d06e6..7a295330b416 100644 --- a/command/agent/auth/oci/oci.go +++ b/command/agent/auth/oci/oci.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/hashicorp/go-secure-stdlib/parseutil" "net/http" "net/url" "os" @@ -32,7 +33,7 @@ const ( rather quickly. This is configurable, however. */ - defaultCredCheckFreqSeconds = 60 + defaultCredCheckFreqSeconds = 60 * time.Second defaultConfigFileName = "config" defaultConfigDirName = ".oci" @@ -77,11 +78,11 @@ func NewOCIAuthMethod(conf *auth.AuthConfig, vaultAddress string) (auth.AuthMeth // Check for an optional custom frequency at which we should poll for creds. credCheckFreqSec := defaultCredCheckFreqSeconds if checkFreqRaw, ok := conf.Config["credential_poll_interval"]; ok { - if credFreq, ok := checkFreqRaw.(int); ok { - credCheckFreqSec = credFreq - } else { - return nil, errors.New("could not convert 'credential_poll_interval' config value to int") + checkFreq, err := parseutil.ParseDurationSecond(checkFreqRaw) + if err != nil { + return nil, fmt.Errorf("could not parse credential_poll_interval: %v", err) } + credCheckFreqSec = checkFreq } switch { @@ -192,8 +193,8 @@ func (a *ociMethod) Shutdown() { close(a.stopCh) } -func (a *ociMethod) pollForCreds(frequencySeconds int) { - ticker := time.NewTicker(time.Duration(frequencySeconds) * time.Second) +func (a *ociMethod) pollForCreds(frequency time.Duration) { + ticker := time.NewTicker(frequency) defer ticker.Stop() for { select { diff --git a/website/content/docs/agent/autoauth/methods/oci.mdx b/website/content/docs/agent/autoauth/methods/oci.mdx index 445c7e879dbd..a60e1f318a05 100644 --- a/website/content/docs/agent/autoauth/methods/oci.mdx +++ b/website/content/docs/agent/autoauth/methods/oci.mdx @@ -40,4 +40,4 @@ but from experience, credentials are rotated every 10 to 15 minutes. - `role` `(string: required)` - The role to authenticate against on Vault. -- `credential_poll_interval` `(integer: optional)` - In seconds, how frequently the Vault agent should check for new credentials. +- `credential_poll_interval` `(duration: "60s", optional)` - In seconds, how frequently the Vault agent should check for new credentials. From fcf8fe42c3fcb45a6447fdf29640dfe632b64eb9 Mon Sep 17 00:00:00 2001 From: Francis Chuang Date: Tue, 14 Mar 2023 08:21:18 +1100 Subject: [PATCH 3/3] Use os.UserHomeDir() --- command/agent/auth/oci/oci.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/command/agent/auth/oci/oci.go b/command/agent/auth/oci/oci.go index 7a295330b416..29d311ca9b09 100644 --- a/command/agent/auth/oci/oci.go +++ b/command/agent/auth/oci/oci.go @@ -234,9 +234,9 @@ func getHomeFolder() string { current, e := user.Current() if e != nil { // Give up and try to return something sensible - home := os.Getenv("HOME") - if home == "" { - home = os.Getenv("USERPROFILE") + home, err := os.UserHomeDir() + if err != nil { + return "" } return home }