connect: Support auth methods for the vault connect CA provider (#11573)

* Support vault auth methods for the Vault connect CA provider
* Rotate the token (re-authenticate to vault using auth method) when the token can no longer be renewed
This commit is contained in:
Iryna Shustava 2021-11-18 13:15:28 -07:00 committed by GitHub
parent 07e7fdcd09
commit bd3fb0d0e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 406 additions and 15 deletions

5
.changelog/11573.txt Normal file
View File

@ -0,0 +1,5 @@
```release-note:improvement
connect: Support Vault auth methods for the Connect CA Vault provider. Currently, we support any non-deprecated auth methods
the latest version of Vault supports (v1.8.5), which include AppRole, AliCloud, AWS, Azure, Cloud Foundry, GitHub, Google Cloud,
JWT/OIDC, Kerberos, Kubernetes, LDAP, Oracle Cloud Infrastructure, Okta, Radius, TLS Certificates, and Username & Password.
```

View File

@ -8,9 +8,11 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"strings"
"time"
"github.com/hashicorp/consul/lib/decode"
"github.com/hashicorp/go-hclog"
vaultapi "github.com/hashicorp/vault/api"
"github.com/mitchellh/mapstructure"
@ -19,7 +21,29 @@ import (
"github.com/hashicorp/consul/agent/structs"
)
const VaultCALeafCertRole = "leaf-cert"
const (
VaultCALeafCertRole = "leaf-cert"
VaultAuthMethodTypeAliCloud = "alicloud"
VaultAuthMethodTypeAppRole = "approle"
VaultAuthMethodTypeAWS = "aws"
VaultAuthMethodTypeAzure = "azure"
VaultAuthMethodTypeCloudFoundry = "cf"
VaultAuthMethodTypeGitHub = "github"
VaultAuthMethodTypeGCP = "gcp"
VaultAuthMethodTypeJWT = "jwt"
VaultAuthMethodTypeKerberos = "kerberos"
VaultAuthMethodTypeKubernetes = "kubernetes"
VaultAuthMethodTypeLDAP = "ldap"
VaultAuthMethodTypeOCI = "oci"
VaultAuthMethodTypeOkta = "okta"
VaultAuthMethodTypeRadius = "radius"
VaultAuthMethodTypeTLS = "cert"
VaultAuthMethodTypeToken = "token"
VaultAuthMethodTypeUserpass = "userpass"
defaultK8SServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
)
var ErrBackendNotMounted = fmt.Errorf("backend not mounted")
var ErrBackendNotInitialized = fmt.Errorf("backend not initialized")
@ -74,6 +98,13 @@ func (v *VaultProvider) Configure(cfg ProviderConfig) error {
return err
}
if config.AuthMethod != nil {
loginResp, err := vaultLogin(client, config.AuthMethod)
if err != nil {
return err
}
config.Token = loginResp.Auth.ClientToken
}
client.SetToken(config.Token)
// We don't want to set the namespace if it's empty to prevent potential
@ -94,7 +125,7 @@ func (v *VaultProvider) Configure(cfg ProviderConfig) error {
if err != nil {
return err
} else if secret == nil {
return fmt.Errorf("Could not look up Vault provider token: not found")
return fmt.Errorf("could not look up Vault provider token: not found")
}
var token struct {
Renewable bool
@ -105,7 +136,7 @@ func (v *VaultProvider) Configure(cfg ProviderConfig) error {
}
// Set up a renewer to renew the token automatically, if supported.
if token.Renewable {
if token.Renewable || config.AuthMethod != nil {
lifetimeWatcher, err := client.NewLifetimeWatcher(&vaultapi.LifetimeWatcherInput{
Secret: &vaultapi.Secret{
Auth: &vaultapi.SecretAuth{
@ -118,10 +149,10 @@ func (v *VaultProvider) Configure(cfg ProviderConfig) error {
RenewBehavior: vaultapi.RenewBehaviorIgnoreErrors,
})
if err != nil {
return fmt.Errorf("Error beginning Vault provider token renewal: %v", err)
return fmt.Errorf("error beginning Vault provider token renewal: %v", err)
}
ctx, cancel := context.WithCancel(context.TODO())
ctx, cancel := context.WithCancel(context.Background())
v.shutdown = cancel
go v.renewToken(ctx, lifetimeWatcher)
}
@ -129,7 +160,9 @@ func (v *VaultProvider) Configure(cfg ProviderConfig) error {
return nil
}
// renewToken uses a vaultapi.Renewer to repeatedly renew our token's lease.
// renewToken uses a vaultapi.LifetimeWatcher to repeatedly renew our token's lease.
// If the token can no longer be renewed and auth method is set,
// it will re-authenticate to Vault using the auth method and restart the renewer with the new token.
func (v *VaultProvider) renewToken(ctx context.Context, watcher *vaultapi.LifetimeWatcher) {
go watcher.Start()
defer watcher.Stop()
@ -144,7 +177,35 @@ func (v *VaultProvider) renewToken(ctx context.Context, watcher *vaultapi.Lifeti
v.logger.Error("Error renewing token for Vault provider", "error", err)
}
// Watcher routine has finished, so start it again.
// If the watcher has exited and auth method is enabled,
// re-authenticate using the auth method and set up a new watcher.
if v.config.AuthMethod != nil {
// Login to Vault using the auth method.
loginResp, err := vaultLogin(v.client, v.config.AuthMethod)
if err != nil {
v.logger.Error("Error login in to Vault with %q auth method", v.config.AuthMethod.Type)
// Restart the watcher.
go watcher.Start()
continue
}
// Set the new token for the vault client.
v.client.SetToken(loginResp.Auth.ClientToken)
v.logger.Info("Successfully re-authenticated with Vault using auth method")
// Start the new watcher for the new token.
watcher, err = v.client.NewLifetimeWatcher(&vaultapi.LifetimeWatcherInput{
Secret: loginResp,
RenewBehavior: vaultapi.RenewBehaviorIgnoreErrors,
})
if err != nil {
v.logger.Error("Error starting token renewal process")
go watcher.Start()
continue
}
}
// Restart the watcher.
go watcher.Start()
case <-watcher.RenewCh():
@ -599,7 +660,10 @@ func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderCon
}
decodeConf := &mapstructure.DecoderConfig{
DecodeHook: structs.ParseDurationFunc(),
DecodeHook: mapstructure.ComposeDecodeHookFunc(
structs.ParseDurationFunc(),
decode.HookTranslateKeys,
),
Result: &config,
WeaklyTypedInput: true,
}
@ -613,8 +677,12 @@ func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderCon
return nil, fmt.Errorf("error decoding config: %s", err)
}
if config.Token == "" {
return nil, fmt.Errorf("must provide a Vault token")
if config.Token == "" && config.AuthMethod == nil {
return nil, fmt.Errorf("must provide a Vault token or configure a Vault auth method")
}
if config.Token != "" && config.AuthMethod != nil {
return nil, fmt.Errorf("only one of Vault token or Vault auth method can be provided, but not both")
}
if config.RootPKIPath == "" {
@ -637,3 +705,76 @@ func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderCon
return &config, nil
}
func vaultLogin(client *vaultapi.Client, authMethod *structs.VaultAuthMethod) (*vaultapi.Secret, error) {
// Adapted from https://www.vaultproject.io/docs/auth/kubernetes#code-example
loginPath, err := configureVaultAuthMethod(authMethod)
if err != nil {
return nil, err
}
resp, err := client.Logical().Write(loginPath, authMethod.Params)
if err != nil {
return nil, err
}
if resp == nil || resp.Auth == nil || resp.Auth.ClientToken == "" {
return nil, fmt.Errorf("login response did not return client token")
}
return resp, nil
}
func configureVaultAuthMethod(authMethod *structs.VaultAuthMethod) (loginPath string, err error) {
if authMethod.MountPath == "" {
authMethod.MountPath = authMethod.Type
}
switch authMethod.Type {
case VaultAuthMethodTypeKubernetes:
// For the Kubernetes Auth method, we will try to read the JWT token
// from the default service account file location if jwt was not provided.
if jwt, ok := authMethod.Params["jwt"]; !ok || jwt == "" {
serviceAccountToken, err := os.ReadFile(defaultK8SServiceAccountTokenPath)
if err != nil {
return "", err
}
authMethod.Params["jwt"] = string(serviceAccountToken)
}
loginPath = fmt.Sprintf("auth/%s/login", authMethod.MountPath)
// These auth methods require a username for the login API path.
case VaultAuthMethodTypeLDAP, VaultAuthMethodTypeUserpass, VaultAuthMethodTypeOkta, VaultAuthMethodTypeRadius:
// Get username from the params.
if username, ok := authMethod.Params["username"]; ok {
loginPath = fmt.Sprintf("auth/%s/login/%s", authMethod.MountPath, username)
} else {
return "", fmt.Errorf("failed to get 'username' from auth method params")
}
// This auth method requires a role for the login API path.
case VaultAuthMethodTypeOCI:
if role, ok := authMethod.Params["role"]; ok {
loginPath = fmt.Sprintf("auth/%s/login/%s", authMethod.MountPath, role)
} else {
return "", fmt.Errorf("failed to get 'role' from auth method params")
}
case VaultAuthMethodTypeToken:
return "", fmt.Errorf("'token' auth method is not supported via auth method configuration; " +
"please provide the token with the 'token' parameter in the CA configuration")
// The rest of the auth methods use auth/<auth method path> login API path.
case VaultAuthMethodTypeAliCloud,
VaultAuthMethodTypeAppRole,
VaultAuthMethodTypeAWS,
VaultAuthMethodTypeAzure,
VaultAuthMethodTypeCloudFoundry,
VaultAuthMethodTypeGitHub,
VaultAuthMethodTypeGCP,
VaultAuthMethodTypeJWT,
VaultAuthMethodTypeKerberos,
VaultAuthMethodTypeTLS:
loginPath = fmt.Sprintf("auth/%s/login", authMethod.MountPath)
default:
return "", fmt.Errorf("auth method %q is not supported", authMethod.Type)
}
return
}

View File

@ -18,6 +18,94 @@ import (
"github.com/hashicorp/consul/sdk/testutil/retry"
)
func TestVaultCAProvider_ParseVaultCAConfig(t *testing.T) {
cases := map[string]struct {
rawConfig map[string]interface{}
expConfig *structs.VaultCAProviderConfig
expError string
}{
"no token and no auth method provided": {
rawConfig: map[string]interface{}{},
expError: "must provide a Vault token or configure a Vault auth method",
},
"both token and auth method provided": {
rawConfig: map[string]interface{}{"Token": "test", "AuthMethod": map[string]interface{}{"Type": "test"}},
expError: "only one of Vault token or Vault auth method can be provided, but not both",
},
"no root PKI path": {
rawConfig: map[string]interface{}{"Token": "test"},
expError: "must provide a valid path to a root PKI backend",
},
"no root intermediate path": {
rawConfig: map[string]interface{}{"Token": "test", "RootPKIPath": "test"},
expError: "must provide a valid path for the intermediate PKI backend",
},
"adds a slash to RootPKIPath and IntermediatePKIPath": {
rawConfig: map[string]interface{}{"Token": "test", "RootPKIPath": "test", "IntermediatePKIPath": "test"},
expConfig: &structs.VaultCAProviderConfig{
CommonCAProviderConfig: defaultCommonConfig(),
Token: "test",
RootPKIPath: "test/",
IntermediatePKIPath: "test/",
},
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
config, err := ParseVaultCAConfig(c.rawConfig)
if c.expError != "" {
require.EqualError(t, err, c.expError)
} else {
require.NoError(t, err)
require.Equal(t, c.expConfig, config)
}
})
}
}
func TestVaultCAProvider_configureVaultAuthMethod(t *testing.T) {
cases := map[string]struct {
expLoginPath string
params map[string]interface{}
expError string
}{
"alicloud": {expLoginPath: "auth/alicloud/login"},
"approle": {expLoginPath: "auth/approle/login"},
"aws": {expLoginPath: "auth/aws/login"},
"azure": {expLoginPath: "auth/azure/login"},
"cf": {expLoginPath: "auth/cf/login"},
"github": {expLoginPath: "auth/github/login"},
"gcp": {expLoginPath: "auth/gcp/login"},
"jwt": {expLoginPath: "auth/jwt/login"},
"kerberos": {expLoginPath: "auth/kerberos/login"},
"kubernetes": {expLoginPath: "auth/kubernetes/login", params: map[string]interface{}{"jwt": "fake"}},
"ldap": {expLoginPath: "auth/ldap/login/foo", params: map[string]interface{}{"username": "foo"}},
"oci": {expLoginPath: "auth/oci/login/foo", params: map[string]interface{}{"role": "foo"}},
"okta": {expLoginPath: "auth/okta/login/foo", params: map[string]interface{}{"username": "foo"}},
"radius": {expLoginPath: "auth/radius/login/foo", params: map[string]interface{}{"username": "foo"}},
"cert": {expLoginPath: "auth/cert/login"},
"token": {expError: "'token' auth method is not supported via auth method configuration; please provide the token with the 'token' parameter in the CA configuration"},
"userpass": {expLoginPath: "auth/userpass/login/foo", params: map[string]interface{}{"username": "foo"}},
"unsupported": {expError: "auth method \"unsupported\" is not supported"},
}
for authMethodType, c := range cases {
t.Run(authMethodType, func(t *testing.T) {
loginPath, err := configureVaultAuthMethod(&structs.VaultAuthMethod{
Type: authMethodType,
Params: c.params,
})
if c.expError == "" {
require.NoError(t, err)
require.Equal(t, c.expLoginPath, loginPath)
} else {
require.EqualError(t, err, c.expError)
}
})
}
}
func TestVaultCAProvider_VaultTLSConfig(t *testing.T) {
config := &structs.VaultCAProviderConfig{
CAFile: "/capath/ca.pem",
@ -507,6 +595,138 @@ func TestVaultProvider_Cleanup(t *testing.T) {
})
}
func TestVaultProvider_ConfigureWithAuthMethod(t *testing.T) {
SkipIfVaultNotPresent(t)
cases := []struct {
authMethodType string
configureAuthMethodFunc func(t *testing.T, vaultClient *vaultapi.Client) map[string]interface{}
}{
{
authMethodType: "userpass",
configureAuthMethodFunc: func(t *testing.T, vaultClient *vaultapi.Client) map[string]interface{} {
_, err := vaultClient.Logical().Write("/auth/userpass/users/test",
map[string]interface{}{"password": "foo", "policies": "admins"})
require.NoError(t, err)
return map[string]interface{}{
"Type": "userpass",
"Params": map[string]interface{}{
"username": "test",
"password": "foo",
},
}
},
},
{
authMethodType: "approle",
configureAuthMethodFunc: func(t *testing.T, vaultClient *vaultapi.Client) map[string]interface{} {
_, err := vaultClient.Logical().Write("auth/approle/role/my-role", nil)
require.NoError(t, err)
resp, err := vaultClient.Logical().Read("auth/approle/role/my-role/role-id")
require.NoError(t, err)
roleID := resp.Data["role_id"]
resp, err = vaultClient.Logical().Write("auth/approle/role/my-role/secret-id", nil)
require.NoError(t, err)
secretID := resp.Data["secret_id"]
return map[string]interface{}{
"Type": "approle",
"Params": map[string]interface{}{
"role_id": roleID,
"secret_id": secretID,
},
}
},
},
}
for _, c := range cases {
t.Run(c.authMethodType, func(t *testing.T) {
testVault := NewTestVaultServer(t)
err := testVault.Client().Sys().EnableAuthWithOptions(c.authMethodType, &vaultapi.EnableAuthOptions{Type: c.authMethodType})
require.NoError(t, err)
authMethodConf := c.configureAuthMethodFunc(t, testVault.Client())
conf := map[string]interface{}{
"Address": testVault.Addr,
"RootPKIPath": "pki-root/",
"IntermediatePKIPath": "pki-intermediate/",
"AuthMethod": authMethodConf,
}
provider := NewVaultProvider(hclog.New(nil))
cfg := ProviderConfig{
ClusterID: connect.TestClusterID,
Datacenter: "dc1",
IsPrimary: true,
RawConfig: conf,
}
t.Cleanup(provider.Stop)
err = provider.Configure(cfg)
require.NoError(t, err)
require.NotEmpty(t, provider.client.Token())
})
}
}
func TestVaultProvider_RotateAuthMethodToken(t *testing.T) {
SkipIfVaultNotPresent(t)
testVault := NewTestVaultServer(t)
err := testVault.Client().Sys().EnableAuthWithOptions("approle", &vaultapi.EnableAuthOptions{Type: "approle"})
require.NoError(t, err)
_, err = testVault.Client().Logical().Write("auth/approle/role/my-role",
map[string]interface{}{"token_ttl": "2s", "token_explicit_max_ttl": "2s"})
require.NoError(t, err)
resp, err := testVault.Client().Logical().Read("auth/approle/role/my-role/role-id")
require.NoError(t, err)
roleID := resp.Data["role_id"]
resp, err = testVault.Client().Logical().Write("auth/approle/role/my-role/secret-id", nil)
require.NoError(t, err)
secretID := resp.Data["secret_id"]
conf := map[string]interface{}{
"Address": testVault.Addr,
"RootPKIPath": "pki-root/",
"IntermediatePKIPath": "pki-intermediate/",
"AuthMethod": map[string]interface{}{
"Type": "approle",
"Params": map[string]interface{}{
"role_id": roleID,
"secret_id": secretID,
},
},
}
provider := NewVaultProvider(hclog.New(nil))
cfg := ProviderConfig{
ClusterID: connect.TestClusterID,
Datacenter: "dc1",
IsPrimary: true,
RawConfig: conf,
}
t.Cleanup(provider.Stop)
err = provider.Configure(cfg)
require.NoError(t, err)
token := provider.client.Token()
require.NotEmpty(t, token)
// Check that the token is rotated after max_ttl time has passed.
require.Eventually(t, func() bool {
return provider.client.Token() != token
}, 10*time.Second, 100*time.Millisecond)
}
func getIntermediateCertTTL(t *testing.T, caConf *structs.CAConfiguration) time.Duration {
t.Helper()
@ -526,10 +746,6 @@ func getIntermediateCertTTL(t *testing.T, caConf *structs.CAConfiguration) time.
return dur
}
func testVaultProvider(t *testing.T) (*VaultProvider, *TestVaultServer) {
return testVaultProviderWithConfig(t, true, nil)
}
func testVaultProviderWithConfig(t *testing.T, isPrimary bool, rawConf map[string]interface{}) (*VaultProvider, *TestVaultServer) {
testVault, err := runTestVault(t)
if err != nil {
@ -573,6 +789,7 @@ func createVaultProvider(t *testing.T, isPrimary bool, addr, token string, rawCo
cfg.Datacenter = "dc2"
}
t.Cleanup(provider.Stop)
require.NoError(t, provider.Configure(cfg))
if isPrimary {
require.NoError(t, provider.GenerateRoot())

View File

@ -481,6 +481,14 @@ type VaultCAProviderConfig struct {
KeyFile string
TLSServerName string
TLSSkipVerify bool
AuthMethod *VaultAuthMethod `alias:"auth_method"`
}
type VaultAuthMethod struct {
Type string
MountPath string `alias:"mount_path"`
Params map[string]interface{}
}
type AWSCAProviderConfig struct {

View File

@ -88,13 +88,33 @@ The configuration options are listed below.
- `Address` / `address` (`string: <required>`) - The address of the Vault
server.
- `Token` / `token` (`string: <required>`) - A token for accessing Vault.
- `Token` / `token` (`string: ""`) - A token for accessing Vault.
This is write-only and will not be exposed when reading the CA configuration.
This token must have [proper privileges](#vault-acl-policies) for the PKI
paths configured. In Consul 1.8.5 and later, if the token has the [renewable](https://www.vaultproject.io/api-docs/auth/token#renewable)
flag set, Consul will attempt to renew its lease periodically after half the
duration has expired.
!> **Warning:** You must either provide a token or configure an auth method below.
- `AuthMethod` / `auth_method` (`map: nil`) - Vault auth method to use for logging in to Vault.
Please see [Vault Auth Methods](https://www.vaultproject.io/docs/auth) for more information
on how to configure individual auth methods. If auth method is provided, Consul will obtain a
a new token from Vault when the token can no longer be renewed.
- `Type`/ `type` (`string: ""`) - The type of Vault auth method.
- `MountPath`/ `mount_path` (`string: <AuthMethod.Type>`) - The mount path of the auth method.
If not provided the auth method type will be used as the mount path.
- `Params`/`params` (`map: nil`) - The parameters to configure the auth method. Please see
[Vault Auth Methods](https://www.vaultproject.io/docs/auth) for information on how to configure the
auth method you wish to use. If using the Kubernetes auth method,
Consul will read the service account token from the
default mount path `/var/run/secrets/kubernetes.io/serviceaccount/token` if the `jwt` parameter
is not provided.
- `RootPKIPath` / `root_pki_path` (`string: <required>`) - The path to
a PKI secrets engine for the root certificate. If the path does not
exist, Consul will mount a new PKI secrets engine at the specified path with the