Add Oracle Cloud auth to the Vault Agent (#19260)

* Add Oracle Cloud auth to the Vault Agent

* Use ParseDurationSecond to parse credential_poll_interval

* Use os.UserHomeDir()
This commit is contained in:
Francis Chuang 2023-03-16 00:08:52 +11:00 committed by GitHub
parent bd8d3d4e07
commit 74c3697144
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 543 additions and 0 deletions

3
changelog/19260.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
**agent/auto-auth:**: Add OCI (Oracle Cloud Infrastructure) auto-auth method
```

View File

@ -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.

View File

@ -0,0 +1,262 @@
package oci
import (
"context"
"errors"
"fmt"
"github.com/hashicorp/go-secure-stdlib/parseutil"
"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 * time.Second
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 {
checkFreq, err := parseutil.ParseDurationSecond(checkFreqRaw)
if err != nil {
return nil, fmt.Errorf("could not parse credential_poll_interval: %v", err)
}
credCheckFreqSec = checkFreq
}
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(frequency time.Duration) {
ticker := time.NewTicker(frequency)
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, err := os.UserHomeDir()
if err != nil {
return ""
}
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
}

View File

@ -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)
}

View File

@ -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` `(duration: "60s", optional)` - In seconds, how frequently the Vault agent should check for new credentials.

View File

@ -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"