diff --git a/.changelog/11573.txt b/.changelog/11573.txt new file mode 100644 index 000000000..de7840d84 --- /dev/null +++ b/.changelog/11573.txt @@ -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. +``` \ No newline at end of file diff --git a/agent/connect/ca/provider_vault.go b/agent/connect/ca/provider_vault.go index 0efd25a4a..adc9851d0 100644 --- a/agent/connect/ca/provider_vault.go +++ b/agent/connect/ca/provider_vault.go @@ -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/ 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 +} diff --git a/agent/connect/ca/provider_vault_test.go b/agent/connect/ca/provider_vault_test.go index ccb7fc01c..f09b7717e 100644 --- a/agent/connect/ca/provider_vault_test.go +++ b/agent/connect/ca/provider_vault_test.go @@ -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()) diff --git a/agent/structs/connect_ca.go b/agent/structs/connect_ca.go index e7e9822bc..91898e666 100644 --- a/agent/structs/connect_ca.go +++ b/agent/structs/connect_ca.go @@ -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 { diff --git a/website/content/docs/connect/ca/vault.mdx b/website/content/docs/connect/ca/vault.mdx index 0a011f465..c6dd47a79 100644 --- a/website/content/docs/connect/ca/vault.mdx +++ b/website/content/docs/connect/ca/vault.mdx @@ -88,13 +88,33 @@ The configuration options are listed below. - `Address` / `address` (`string: `) - The address of the Vault server. -- `Token` / `token` (`string: `) - 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: `) - 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: `) - 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