Skip to content

Commit

Permalink
Add Azure data source that can validate new creds (hashicorp#713)
Browse files Browse the repository at this point in the history
* add retrying azure data source

* Update website/docs/d/azure_access_credentials.html.md

Co-Authored-By: Jim Kalafut <[email protected]>

* make comment more succinct

* update var name, nix unused data

* configure client without env vars

* fix parameter description

* add negative test

* support environment, pull vars from config

Co-authored-by: Jim Kalafut <[email protected]>
  • Loading branch information
tyrannosaurus-becks and Jim Kalafut authored Mar 30, 2020
1 parent bc675a5 commit b6d2461
Show file tree
Hide file tree
Showing 6 changed files with 436 additions and 0 deletions.
211 changes: 211 additions & 0 deletions vault/data_source_azure_access_credentials.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 80 additions & 0 deletions vault/data_source_azure_access_credentials_test.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions vault/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}"},
Expand Down
30 changes: 30 additions & 0 deletions vault/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading

0 comments on commit b6d2461

Please sign in to comment.