diff --git a/vault/data_source_azure_access_credentials.go b/vault/data_source_azure_access_credentials.go new file mode 100644 index 000000000..5dfcf0bda --- /dev/null +++ b/vault/data_source_azure_access_credentials.go @@ -0,0 +1,211 @@ +package vault + +import ( + "fmt" + "log" + "net/http" + "time" + + "github.com/Azure/azure-sdk-for-go/services/network/mgmt/2017-09-01/network" + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/azure" + "github.com/Azure/go-autorest/autorest/azure/auth" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/vault/api" +) + +func azureAccessCredentialsDataSource() *schema.Resource { + return &schema.Resource{ + Read: azureAccessCredentialsDataSourceRead, + + Schema: map[string]*schema.Schema{ + "backend": { + Type: schema.TypeString, + Required: true, + Description: "Azure Secret Backend to read credentials from.", + }, + "role": { + Type: schema.TypeString, + Required: true, + Description: "Azure Secret Role to read credentials from.", + }, + "validate_creds": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether generated credentials should be validated before being returned.", + Default: false, + }, + "num_sequential_successes": { + Type: schema.TypeInt, + Optional: true, + Default: 8, + Description: `If 'validate_creds' is true, the number of sequential successes required to validate generated credentials.`, + }, + "num_seconds_between_tests": { + Type: schema.TypeInt, + Optional: true, + Default: 7, + Description: `If 'validate_creds' is true, the number of seconds to wait between each test of generated credentials.`, + }, + "max_cred_validation_seconds": { + Type: schema.TypeInt, + Optional: true, + Default: 20 * 60, // 20 minutes + Description: `If 'validate_creds' is true, the number of seconds after which to give up validating credentials.`, + }, + "client_id": { + Type: schema.TypeString, + Computed: true, + Description: "The client id for credentials to query the Azure APIs.", + }, + "client_secret": { + Type: schema.TypeString, + Computed: true, + Description: "The client secret for credentials to query the Azure APIs.", + }, + "lease_id": { + Type: schema.TypeString, + Computed: true, + Description: "Lease identifier assigned by vault.", + }, + "lease_duration": { + Type: schema.TypeInt, + Computed: true, + Description: "Lease duration in seconds relative to the time in lease_start_time.", + }, + "lease_start_time": { + Type: schema.TypeString, + Computed: true, + Description: "Time at which the lease was read, using the clock of the system where Terraform was running", + }, + "lease_renewable": { + Type: schema.TypeBool, + Computed: true, + Description: "True if the duration of this lease can be extended through renewal.", + }, + }, + } +} + +func azureAccessCredentialsDataSourceRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*api.Client) + + backend := d.Get("backend").(string) + role := d.Get("role").(string) + + configPath := backend + "/config" + credsPath := backend + "/creds/" + role + + secret, err := client.Logical().Read(credsPath) + if err != nil { + return fmt.Errorf("error reading from Vault: %s", err) + } + log.Printf("[DEBUG] Read %q from Vault", credsPath) + + if secret == nil { + return fmt.Errorf("no role found at credsPath %q", credsPath) + } + + clientID := secret.Data["client_id"].(string) + clientSecret := secret.Data["client_secret"].(string) + + d.SetId(secret.LeaseID) + _ = d.Set("client_id", secret.Data["client_id"]) + _ = d.Set("client_secret", secret.Data["client_secret"]) + _ = d.Set("lease_id", secret.LeaseID) + _ = d.Set("lease_duration", secret.LeaseDuration) + _ = d.Set("lease_start_time", time.Now().Format(time.RFC3339)) + _ = d.Set("lease_renewable", secret.Renewable) + + // If we're not supposed to validate creds, or we don't have enough + // information to do it, there's nothing further to do here. + validateCreds := d.Get("validate_creds").(bool) + if !validateCreds { + // We're done. + return nil + } + + secret, err = client.Logical().Read(configPath) + if err != nil { + return fmt.Errorf("error reading from Vault: %s", err) + } + log.Printf("[DEBUG] Read %q from Vault", configPath) + + subscriptionID := "" + if subscriptionIDIfc, ok := secret.Data["subscription_id"]; ok { + subscriptionID = subscriptionIDIfc.(string) + } + if subscriptionID == "" { + return fmt.Errorf(`unable to parse 'subscription_id' from %s`, configPath) + } + + tenantID := "" + if tenantIDIfc, ok := secret.Data["tenant_id"]; ok { + tenantID = tenantIDIfc.(string) + } + if tenantID == "" { + return fmt.Errorf(`unable to parse 'tenant_id' from %s`, configPath) + } + + environment := "" + if environmentIfc, ok := secret.Data["environment"]; ok { + environment = environmentIfc.(string) + } + + // Let's, test the credentials before returning them. + vnetClient := network.NewVirtualNetworksClient(subscriptionID) + config := auth.NewClientCredentialsConfig(clientID, clientSecret, tenantID) + if environment != "" { + env, err := azure.EnvironmentFromName(environment) + if err != nil { + return err + } + config.AADEndpoint = env.ActiveDirectoryEndpoint + } + authorizer, err := config.Authorizer() + if err != nil { + return nil + } + vnetClient.Authorizer = authorizer + + credValidationTimeoutSecs := d.Get("max_cred_validation_seconds").(int) + sequentialSuccessesRequired := d.Get("num_sequential_successes").(int) + secBetweenTests := d.Get("num_seconds_between_tests").(int) + + startTime := time.Now() + endTime := startTime.Add(time.Duration(credValidationTimeoutSecs) * time.Second) + + // Please see this data source's documentation for an explanation of the + // default parameters used here and why they were selected. + sequentialSuccesses := 0 + overallSuccess := false + for { + if time.Now().After(endTime) { + log.Printf("[DEBUG] giving up due to only having %d sequential successes and running out of time", sequentialSuccesses) + break + } + log.Printf("[DEBUG] %d sequential successes obtained, waiting %d seconds to next test client ID and secret", sequentialSuccesses, secBetweenTests) + time.Sleep(time.Duration(secBetweenTests) * time.Second) + + // The request we provide here is immaterial because the client is only going to refresh the + // token it's using for calls. + if _, err := autorest.Prepare(&http.Request{}, vnetClient.WithAuthorization(), vnetClient.WithInspection()); err != nil { + // If the creds haven't propagated, we receive an error showing we failed to refresh token. + sequentialSuccesses = 0 + continue + } + // If the creds have propagated to the server where we're checking, we receive no error from the above Prepare call. + sequentialSuccesses++ + if sequentialSuccesses == sequentialSuccessesRequired { + overallSuccess = true + break + } + } + if !overallSuccess { + // We hit the maximum number of retries without ever getting the + // number of sequential successes we needed. + return fmt.Errorf("despite trying for %d seconds, %d seconds apart, we were never able to get %d successes in a row", + credValidationTimeoutSecs, secBetweenTests, sequentialSuccessesRequired) + } + return nil +} diff --git a/vault/data_source_azure_access_credentials_test.go b/vault/data_source_azure_access_credentials_test.go new file mode 100644 index 000000000..1807ebc87 --- /dev/null +++ b/vault/data_source_azure_access_credentials_test.go @@ -0,0 +1,80 @@ +package vault + +import ( + "regexp" + "strconv" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccDataSourceAzureAccessCredentials_basic(t *testing.T) { + // This test takes a while because it's testing a loop that + // retries real credentials until they're eventually consistent. + if testing.Short() { + t.SkipNow() + } + mountPath := acctest.RandomWithPrefix("tf-test-azure") + conf := getTestAzureConf(t) + resource.Test(t, resource.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceAzureAccessCredentialsConfigBasic(mountPath, conf, 2, 20), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.vault_azure_access_credentials.test", "client_id"), + resource.TestCheckResourceAttrSet("data.vault_azure_access_credentials.test", "client_secret"), + resource.TestCheckResourceAttrSet("data.vault_azure_access_credentials.test", "lease_id"), + ), + }, + { + Config: testAccDataSourceAzureAccessCredentialsConfigBasic(mountPath, conf, 1000, 5), + ExpectError: regexp.MustCompile(`despite trying for 5 seconds, 1 seconds apart, we were never able to get 1000 successes in a row`), + }, + }, + }) +} + +func testAccDataSourceAzureAccessCredentialsConfigBasic(mountPath string, conf *azureTestConf, numSuccesses, maxSecs int) string { + template := ` +resource "vault_azure_secret_backend" "test" { + path = "{{mountPath}}" + subscription_id = "{{subscriptionID}}" + tenant_id = "{{tenantID}}" + client_id = "{{clientID}}" + client_secret = "{{clientSecret}}" +} + +resource "vault_azure_secret_backend_role" "test" { + backend = "${vault_azure_secret_backend.test.path}" + role = "my-role" + azure_roles { + role_name = "Reader" + scope = "{{scope}}" + } + ttl = 300 + max_ttl = 600 +} + +data "vault_azure_access_credentials" "test" { + backend = "${vault_azure_secret_backend.test.path}" + role = "${vault_azure_secret_backend_role.test.role}" + validate_creds = true + num_sequential_successes = {{numSequentialSuccesses}} + num_seconds_between_tests = 1 + max_cred_validation_seconds = {{maxCredValidationSeconds}} +}` + + parsed := strings.Replace(template, "{{mountPath}}", mountPath, -1) + parsed = strings.Replace(parsed, "{{subscriptionID}}", conf.SubscriptionID, -1) + parsed = strings.Replace(parsed, "{{tenantID}}", conf.TenantID, -1) + parsed = strings.Replace(parsed, "{{clientID}}", conf.ClientID, -1) + parsed = strings.Replace(parsed, "{{clientSecret}}", conf.ClientSecret, -1) + parsed = strings.Replace(parsed, "{{scope}}", conf.Scope, -1) + parsed = strings.Replace(parsed, "{{numSequentialSuccesses}}", strconv.Itoa(numSuccesses), -1) + parsed = strings.Replace(parsed, "{{maxCredValidationSeconds}}", strconv.Itoa(maxSecs), -1) + return parsed +} diff --git a/vault/provider.go b/vault/provider.go index 76ae0f0e7..973b222e3 100644 --- a/vault/provider.go +++ b/vault/provider.go @@ -204,6 +204,10 @@ var ( Resource: awsAccessCredentialsDataSource(), PathInventory: []string{"/aws/creds"}, }, + "vault_azure_access_credentials": { + Resource: azureAccessCredentialsDataSource(), + PathInventory: []string{"/azure/creds/{role}"}, + }, "vault_generic_secret": { Resource: genericSecretDataSource(), PathInventory: []string{"/secret/data/{path}"}, diff --git a/vault/provider_test.go b/vault/provider_test.go index 95354cfe1..3414d9277 100644 --- a/vault/provider_test.go +++ b/vault/provider_test.go @@ -79,6 +79,36 @@ func getTestAWSCreds(t *testing.T) (string, string) { return accessKey, secretKey } +type azureTestConf struct { + SubscriptionID, TenantID, ClientID, ClientSecret, Scope string +} + +func getTestAzureConf(t *testing.T) *azureTestConf { + conf := &azureTestConf{ + SubscriptionID: os.Getenv("AZURE_SUBSCRIPTION_ID"), + TenantID: os.Getenv("AZURE_TENANT_ID"), + ClientID: os.Getenv("AZURE_CLIENT_ID"), + ClientSecret: os.Getenv("AZURE_CLIENT_SECRET"), + Scope: os.Getenv("AZURE_ROLE_SCOPE"), + } + if conf.SubscriptionID == "" { + t.Skip("AZURE_SUBSCRIPTION_ID not set") + } + if conf.TenantID == "" { + t.Skip("AZURE_TENANT_ID not set") + } + if conf.ClientID == "" { + t.Skip("AZURE_CLIENT_ID not set") + } + if conf.ClientSecret == "" { + t.Skip("AZURE_CLIENT_SECRET not set") + } + if conf.Scope == "" { + t.Skip("AZURE_ROLE_SCOPE not set") + } + return conf +} + func getTestGCPCreds(t *testing.T) (string, string) { credentials := os.Getenv("GOOGLE_CREDENTIALS") project := os.Getenv("GOOGLE_PROJECT") diff --git a/website/docs/d/azure_access_credentials.html.md b/website/docs/d/azure_access_credentials.html.md new file mode 100644 index 000000000..2e2044160 --- /dev/null +++ b/website/docs/d/azure_access_credentials.html.md @@ -0,0 +1,107 @@ +--- +layout: "vault" +page_title: "Vault: vault_azure_access_credentials data source" +sidebar_current: "docs-vault-datasource-azure-access-credentials" +description: |- + Reads Azure credentials from an Azure secret backend in Vault +--- + +# vault\_azure\_access\_credentials + +Reads Azure credentials from an Azure secret backend in Vault. + +~> **Important** All data retrieved from Vault will be +written in cleartext to state file generated by Terraform, will appear in +the console output when Terraform runs, and may be included in plan files +if secrets are interpolated into any resource attributes. +Protect these artifacts accordingly. See +[the main provider documentation](../index.html) +for more details. + +## Description + +The Azure Active Directory data source exists to easily pull short-lived +credentials from Vault for use in Terraform. By default, it returns a +dynamically generated `client_id` and `client_secret` without testing +whether they've fully propagated for use in Azure Active Directory. However, +by activating `validate_creds`, credentials will be tested before being +returned. This will, however, increase the time it takes for the credentials +to be returned, blocking Terraform's execution until they are ready. + +If `validate_creds` is used, by default, credentials will be validated by +making a test call to Azure every 7 seconds. When we have received 8 +successes in a row, the credentials will be returned. We have seen propagation +times take up to 15 minutes, so the maximum length of time for the check defaults +to 20 minutes. However, propagation times will vary widely based on each company's Azure +usage, so all these settings are configurable. + +Credentials are tested by attempting to refresh a client token with them. + +## Example Usage + +```hcl +data "vault_azure_access_credentials" "creds" { + role = "my-role" + validate_creds = true + num_sequential_successes = 8 + num_seconds_between_tests = 7 + max_cred_validation_seconds = 1200 // 20 minutes +} + +provider "azure" { + client_id = "${data.vault_azure_access_credentials.creds.client_id}" + client_secret = "${data.vault_azure_access_credentials.creds.client_secret}" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `backend` - (Required) The path to the Azure secret backend to +read credentials from, with no leading or trailing `/`s. + +* `role` - (Required) The name of the Azure secret backend role to read +credentials from, with no leading or trailing `/`s. + +* `validate_creds` - (Optional) Whether generated credentials should be +validated before being returned. Defaults to `false`, which returns +credentials without checking whether they have fully propagated throughout +Azure Active Directory. Designating `true` activates testing. + +* `num_sequential_successes` - (Optional) If 'validate_creds' is true, +the number of sequential successes required to validate generated +credentials. Defaults to 8. + +* `num_seconds_between_tests` - (Optional) If 'validate_creds' is true, +the number of seconds to wait between each test of generated credentials. +Defaults to 7. + +* `max_cred_validation_seconds` - (Optional) If 'validate_creds' is true, +the number of seconds after which to give up validating credentials. Defaults +to 1,200 (20 minutes). + +## Attributes Reference + +In addition to the arguments above, the following attributes are exported: + +* `client_id` - The client id for credentials to query the Azure APIs. + +* `client_secret` - The client secret for credentials to query the Azure APIs. + +* `lease_id` - The lease identifier assigned by Vault. + +* `lease_duration` - The duration of the secret lease, in seconds relative +to the time the data was requested. Once this time has passed any plan +generated with this data may fail to apply. + +* `lease_start_time` - As a convenience, this records the current time +on the computer where Terraform is running when the data is requested. +This can be used to approximate the absolute time represented by +`lease_duration`, though users must allow for any clock drift and response +latency relative to the Vault server. + +* `lease_renewable` - `true` if the lease can be renewed using Vault's +`sys/renew/{lease-id}` endpoint. Terraform does not currently support lease +renewal, and so it will request a new lease each time this data source is +refreshed. diff --git a/website/vault.erb b/website/vault.erb index 79bb09cc8..e0a2f2de7 100644 --- a/website/vault.erb +++ b/website/vault.erb @@ -29,6 +29,10 @@ vault_aws_access_credentials + > + vault_azure_access_credentials + + > vault_generic_secret