diff --git a/.changelog/16298.txt b/.changelog/16298.txt new file mode 100644 index 000000000..6d79987ef --- /dev/null +++ b/.changelog/16298.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ca: support Vault agent auto-auth config for Vault CA provider using Azure MSI authentication. +``` diff --git a/agent/connect/ca/provider_vault.go b/agent/connect/ca/provider_vault.go index c069e5aa9..7954e7ea0 100644 --- a/agent/connect/ca/provider_vault.go +++ b/agent/connect/ca/provider_vault.go @@ -931,6 +931,8 @@ func configureVaultAuthMethod(authMethod *structs.VaultAuthMethod) (VaultAuthent switch authMethod.Type { case VaultAuthMethodTypeAWS: return NewAWSAuthClient(authMethod), nil + case VaultAuthMethodTypeAzure: + return NewAzureAuthClient(authMethod) case VaultAuthMethodTypeGCP: return NewGCPAuthClient(authMethod) case VaultAuthMethodTypeKubernetes: @@ -968,7 +970,6 @@ func configureVaultAuthMethod(authMethod *structs.VaultAuthMethod) (VaultAuthent // The rest of the auth methods use auth/ login API path. case VaultAuthMethodTypeAliCloud, VaultAuthMethodTypeAppRole, - VaultAuthMethodTypeAzure, VaultAuthMethodTypeCloudFoundry, VaultAuthMethodTypeGitHub, VaultAuthMethodTypeJWT, diff --git a/agent/connect/ca/provider_vault_auth.go b/agent/connect/ca/provider_vault_auth.go index 51b9a1b09..f00e90b72 100644 --- a/agent/connect/ca/provider_vault_auth.go +++ b/agent/connect/ca/provider_vault_auth.go @@ -76,13 +76,14 @@ func toMapStringString(in map[string]interface{}) (map[string]string, error) { return out, nil } -// containsVaultLoginParams indicates if the provided auth method contains the -// config parameters needed to call the auth//login API directly. -// It compares the keys in the authMethod.Params struct to the provided slice of -// keys and if any of the keys match it returns true. -func containsVaultLoginParams(authMethod *structs.VaultAuthMethod, keys ...string) bool { - for _, key := range keys { - if _, exists := authMethod.Params[key]; exists { +// legacyCheck is used to see if all the parameters needed to /login have been +// hardcoded in the auth-method's config Parameters field. +// Note it returns true if any /login specific fields are found (vs. all). This +// is because the AWS client has multiple possible ways to call /login with +// different parameters. +func legacyCheck(params map[string]any, expectedKeys ...string) bool { + for _, key := range expectedKeys { + if v, ok := params[key]; ok && v != "" { return true } } diff --git a/agent/connect/ca/provider_vault_auth_aws.go b/agent/connect/ca/provider_vault_auth_aws.go index 6188b2cf2..6ef19f727 100644 --- a/agent/connect/ca/provider_vault_auth_aws.go +++ b/agent/connect/ca/provider_vault_auth_aws.go @@ -26,7 +26,7 @@ func NewAWSAuthClient(authMethod *structs.VaultAuthMethod) *VaultAuthClient { "pkcs7", // EC2 PKCS7 "iam_http_request_method", // IAM } - if containsVaultLoginParams(authMethod, keys...) { + if legacyCheck(authMethod.Params, keys...) { return authClient } diff --git a/agent/connect/ca/provider_vault_auth_azure.go b/agent/connect/ca/provider_vault_auth_azure.go new file mode 100644 index 000000000..e7f785388 --- /dev/null +++ b/agent/connect/ca/provider_vault_auth_azure.go @@ -0,0 +1,142 @@ +package ca + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/vault/sdk/helper/jsonutil" +) + +func NewAzureAuthClient(authMethod *structs.VaultAuthMethod) (*VaultAuthClient, error) { + params := authMethod.Params + authClient := NewVaultAPIAuthClient(authMethod, "") + // check for login data already in params (for backwards compability) + legacyKeys := []string{ + "vm_name", "vmss_name", "resource_group_name", "subscription_id", "jwt", + } + if legacyCheck(params, legacyKeys...) { + return authClient, nil + } + + role, ok := params["role"].(string) + if !ok || strings.TrimSpace(role) == "" { + return nil, fmt.Errorf("missing 'role' value") + } + resource, ok := params["resource"].(string) + if !ok || strings.TrimSpace(resource) == "" { + return nil, fmt.Errorf("missing 'resource' value") + } + + authClient.LoginDataGen = AzureLoginDataGen + return authClient, nil +} + +var ( // use variables so we can change these in tests + instanceEndpoint = "http://169.254.169.254/metadata/instance" + identityEndpoint = "http://169.254.169.254/metadata/identity/oauth2/token" + // minimum version 2018-02-01 needed for identity metadata + apiVersion = "2018-02-01" +) + +type instanceData struct { + Compute Compute +} +type Compute struct { + Name string + ResourceGroupName string + SubscriptionID string + VMScaleSetName string +} +type identityData struct { + AccessToken string `json:"access_token"` +} + +func AzureLoginDataGen(authMethod *structs.VaultAuthMethod) (map[string]any, error) { + params := authMethod.Params + role := params["role"].(string) + metaConf := map[string]string{ + "role": role, + "resource": params["resource"].(string), + } + if objectID, ok := params["object_id"].(string); ok { + metaConf["object_id"] = objectID + } + if clientID, ok := params["client_id"].(string); ok { + metaConf["client_id"] = clientID + } + + // Fetch instance data + var instance instanceData + body, err := getMetadataInfo(instanceEndpoint, nil) + if err != nil { + return nil, err + } + err = jsonutil.DecodeJSON(body, &instance) + if err != nil { + return nil, fmt.Errorf("error parsing instance metadata response: %w", err) + } + + // Fetch JWT + var identity identityData + body, err = getMetadataInfo(identityEndpoint, metaConf) + if err != nil { + return nil, err + } + err = jsonutil.DecodeJSON(body, &identity) + if err != nil { + return nil, fmt.Errorf("error parsing instance metadata response: %w", err) + } + + data := map[string]interface{}{ + "role": role, + "vm_name": instance.Compute.Name, + "vmss_name": instance.Compute.VMScaleSetName, + "resource_group_name": instance.Compute.ResourceGroupName, + "subscription_id": instance.Compute.SubscriptionID, + "jwt": identity.AccessToken, + } + + return data, nil +} + +func getMetadataInfo(endpoint string, query map[string]string) ([]byte, error) { + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("api-version", apiVersion) + for k, v := range query { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + req.Header.Set("Metadata", "true") + req.Header.Set("User-Agent", "Consul") + + client := cleanhttp.DefaultClient() + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error fetching metadata from %s: %w", endpoint, err) + } + + if resp == nil { + return nil, fmt.Errorf("empty response fetching metadata from %s", endpoint) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading metadata from %s: %w", endpoint, err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error response in metadata from %s: %s", endpoint, body) + } + + return body, nil +} diff --git a/agent/connect/ca/provider_vault_auth_gcp.go b/agent/connect/ca/provider_vault_auth_gcp.go index 38e544d48..d498a9830 100644 --- a/agent/connect/ca/provider_vault_auth_gcp.go +++ b/agent/connect/ca/provider_vault_auth_gcp.go @@ -16,7 +16,7 @@ func NewGCPAuthClient(authMethod *structs.VaultAuthMethod) (VaultAuthenticator, // perform a direct request to the login API with the config that is provided. // This supports the Vault CA config in a backwards compatible way so that we don't // break existing configurations. - if containsVaultLoginParams(authMethod, "jwt") { + if legacyCheck(authMethod.Params, "jwt") { return NewVaultAPIAuthClient(authMethod, ""), nil } diff --git a/agent/connect/ca/provider_vault_auth_test.go b/agent/connect/ca/provider_vault_auth_test.go index e1398eeb3..2b0a04a46 100644 --- a/agent/connect/ca/provider_vault_auth_test.go +++ b/agent/connect/ca/provider_vault_auth_test.go @@ -1,7 +1,10 @@ package ca import ( + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "strconv" "testing" @@ -10,6 +13,7 @@ import ( "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/go-secure-stdlib/awsutil" "github.com/hashicorp/vault/api/auth/gcp" + "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/stretchr/testify/require" ) @@ -301,3 +305,126 @@ func TestVaultCAProvider_AWSLoginDataGenerator(t *testing.T) { }) } } + +func TestVaultCAProvider_AzureAuthClient(t *testing.T) { + instance := instanceData{Compute: Compute{ + Name: "a", ResourceGroupName: "b", SubscriptionID: "c", VMScaleSetName: "d", + }} + instanceJSON, err := json.Marshal(instance) + require.NoError(t, err) + identity := identityData{AccessToken: "a-jwt-token"} + identityJSON, err := json.Marshal(identity) + require.NoError(t, err) + + msi := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + url := r.URL.Path + switch url { + case "/metadata/instance": + w.Write(instanceJSON) + case "/metadata/identity/oauth2/token": + w.Write(identityJSON) + default: + t.Errorf("unexpected testing URL: %s", url) + } + })) + + origIn, origId := instanceEndpoint, identityEndpoint + instanceEndpoint = msi.URL + "/metadata/instance" + identityEndpoint = msi.URL + "/metadata/identity/oauth2/token" + defer func() { + instanceEndpoint, identityEndpoint = origIn, origId + }() + + t.Run("get-metadata-instance-info", func(t *testing.T) { + md, err := getMetadataInfo(instanceEndpoint, nil) + require.NoError(t, err) + var testInstance instanceData + err = jsonutil.DecodeJSON(md, &testInstance) + require.NoError(t, err) + require.Equal(t, testInstance, instance) + }) + + t.Run("get-metadata-identity-info", func(t *testing.T) { + md, err := getMetadataInfo(identityEndpoint, nil) + require.NoError(t, err) + var testIdentity identityData + err = jsonutil.DecodeJSON(md, &testIdentity) + require.NoError(t, err) + require.Equal(t, testIdentity, identity) + }) + + cases := map[string]struct { + authMethod *structs.VaultAuthMethod + expData map[string]any + expErr error + }{ + "legacy-case": { + authMethod: &structs.VaultAuthMethod{ + Type: "azure", + Params: map[string]interface{}{ + "role": "a", + "vm_name": "b", + "vmss_name": "c", + "resource_group_name": "d", + "subscription_id": "e", + "jwt": "f", + }, + }, + expData: map[string]any{ + "role": "a", + "vm_name": "b", + "vmss_name": "c", + "resource_group_name": "d", + "subscription_id": "e", + "jwt": "f", + }, + }, + "base-case": { + authMethod: &structs.VaultAuthMethod{ + Type: "azure", + Params: map[string]interface{}{ + "role": "a-role", + "resource": "b-resource", + }, + }, + expData: map[string]any{ + "role": "a-role", + "jwt": "a-jwt-token", + }, + }, + "no-role": { + authMethod: &structs.VaultAuthMethod{ + Type: "azure", + Params: map[string]interface{}{ + "resource": "b-resource", + }, + }, + expErr: fmt.Errorf("missing 'role' value"), + }, + "no-resource": { + authMethod: &structs.VaultAuthMethod{ + Type: "azure", + Params: map[string]interface{}{ + "role": "a-role", + }, + }, + expErr: fmt.Errorf("missing 'resource' value"), + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + auth, err := NewAzureAuthClient(c.authMethod) + if c.expErr != nil { + require.EqualError(t, err, c.expErr.Error()) + return + } + require.NoError(t, err) + if auth.LoginDataGen != nil { + data, err := auth.LoginDataGen(c.authMethod) + require.NoError(t, err) + require.Subset(t, data, c.expData) + } + }) + } +} diff --git a/agent/connect/ca/provider_vault_test.go b/agent/connect/ca/provider_vault_test.go index f7375a017..173d2c554 100644 --- a/agent/connect/ca/provider_vault_test.go +++ b/agent/connect/ca/provider_vault_test.go @@ -107,7 +107,7 @@ func TestVaultCAProvider_configureVaultAuthMethod(t *testing.T) { "alicloud": {expLoginPath: "auth/alicloud/login"}, "approle": {expLoginPath: "auth/approle/login"}, "aws": {expLoginPath: "auth/aws/login", params: map[string]interface{}{"type": "iam"}, hasLDG: true}, - "azure": {expLoginPath: "auth/azure/login"}, + "azure": {expLoginPath: "auth/azure/login", params: map[string]interface{}{"role": "test-role", "resource": "test-resource"}, hasLDG: true}, "cf": {expLoginPath: "auth/cf/login"}, "github": {expLoginPath: "auth/github/login"}, "gcp": {expLoginPath: "auth/gcp/login", params: map[string]interface{}{"type": "iam", "role": "test-role"}},