Skip to content

Commit

Permalink
Merge pull request #1057 from kudelskisecurity/cloudflare_service_tok…
Browse files Browse the repository at this point in the history
…en_rotation

Cloudflare service token rotation
  • Loading branch information
jacobbednarz authored May 21, 2021
2 parents 7025c9f + cdaca76 commit 5659bb6
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 10 deletions.
38 changes: 38 additions & 0 deletions cloudflare/resource_cloudflare_access_service_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/cloudflare/cloudflare-go"
"github.com/hashicorp/terraform-plugin-sdk/helper/customdiff"
"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
)

Expand All @@ -19,6 +21,8 @@ func resourceCloudflareAccessServiceToken() *schema.Resource {
State: resourceCloudflareAccessServiceTokenImport,
},

CustomizeDiff: customdiff.ComputedIf("expires_at", resourceCloudflareAccessServiceTokenExpireDiff),

Schema: map[string]*schema.Schema{
"account_id": {
Type: schema.TypeString,
Expand All @@ -37,16 +41,48 @@ func resourceCloudflareAccessServiceToken() *schema.Resource {
"client_id": {
Type: schema.TypeString,
Computed: true,
ForceNew: true,
},
"client_secret": {
Type: schema.TypeString,
Computed: true,
Sensitive: true,
ForceNew: true,
},
"expires_at": {
Type: schema.TypeString,
Computed: true,
ForceNew: true,
},
"min_days_for_renewal": {
Type: schema.TypeInt,
Optional: true,
Default: 0,
},
},
}
}

func resourceCloudflareAccessServiceTokenExpireDiff(d *schema.ResourceDiff, m interface{}) bool {
mindays := d.Get("min_days_for_renewal").(int)
if mindays > 0 {
expires_at := d.Get("expires_at").(string)

if expires_at != "" {
expected_expiration_date, _ := time.Parse(time.RFC3339, expires_at)

expiration_date := time.Now().Add(time.Duration(mindays) * 24 * time.Hour)

if expiration_date.After(expected_expiration_date) {
d.SetNewComputed("client_secret")
return true
}
}
}

return false
}

func resourceCloudflareAccessServiceTokenRead(d *schema.ResourceData, meta interface{}) error {
client := meta.(*cloudflare.API)

Expand All @@ -71,6 +107,7 @@ func resourceCloudflareAccessServiceTokenRead(d *schema.ResourceData, meta inter
if token.ID == d.Id() {
d.Set("name", token.Name)
d.Set("client_id", token.ClientID)
d.Set("expires_at", token.ExpiresAt.Format(time.RFC3339))
}
}

Expand Down Expand Up @@ -100,6 +137,7 @@ func resourceCloudflareAccessServiceTokenCreate(d *schema.ResourceData, meta int
d.Set("name", serviceToken.Name)
d.Set("client_id", serviceToken.ClientID)
d.Set("client_secret", serviceToken.ClientSecret)
d.Set("expires_at", serviceToken.ExpiresAt.Format(time.RFC3339))

resourceCloudflareAccessServiceTokenRead(d, meta)

Expand Down
107 changes: 97 additions & 10 deletions cloudflare/resource_cloudflare_access_service_tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -32,12 +33,13 @@ func TestAccAccessServiceTokenCreate(t *testing.T) {
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: AccountType, Value: accountID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: AccountType, Value: accountID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "account_id", accountID),
resource.TestCheckResourceAttr(name, "name", resourceName),
resource.TestCheckResourceAttrSet(name, "client_id"),
resource.TestCheckResourceAttrSet(name, "client_secret"),
resource.TestCheckResourceAttrSet(name, "expires_at"),
),
},
},
Expand All @@ -48,12 +50,13 @@ func TestAccAccessServiceTokenCreate(t *testing.T) {
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "zone_id", zoneID),
resource.TestCheckResourceAttr(name, "name", resourceName),
resource.TestCheckResourceAttrSet(name, "client_id"),
resource.TestCheckResourceAttrSet(name, "client_secret"),
resource.TestCheckResourceAttrSet(name, "expires_at"),
),
},
},
Expand Down Expand Up @@ -83,13 +86,13 @@ func TestAccAccessServiceTokenUpdate(t *testing.T) {
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: AccountType, Value: accountID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: AccountType, Value: accountID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "name", resourceName),
),
},
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName+"-updated", AccessIdentifier{Type: AccountType, Value: accountID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName+"-updated", AccessIdentifier{Type: AccountType, Value: accountID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "name", resourceName+"-updated"),
),
Expand All @@ -102,13 +105,13 @@ func TestAccAccessServiceTokenUpdate(t *testing.T) {
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "name", resourceName),
),
},
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName+"-updated", AccessIdentifier{Type: ZoneType, Value: zoneID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName+"-updated", AccessIdentifier{Type: ZoneType, Value: zoneID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "name", resourceName+"-updated"),
),
Expand All @@ -117,6 +120,87 @@ func TestAccAccessServiceTokenUpdate(t *testing.T) {
})
}

func TestAccAccessServiceTokenUpdateWithExpiration(t *testing.T) {
// Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the Access
// Service Tokens endpoint does not yet support the API tokens and it
// results in misleading state error messages.
if os.Getenv("CLOUDFLARE_API_TOKEN") != "" {
defer func(apiToken string) {
os.Setenv("CLOUDFLARE_API_TOKEN", apiToken)
}(os.Getenv("CLOUDFLARE_API_TOKEN"))
os.Setenv("CLOUDFLARE_API_TOKEN", "")
}

rnd := generateRandomResourceName()
var initialState terraform.ResourceState

name := fmt.Sprintf("cloudflare_access_service_token.tf-acc-%s", rnd)
resourceName := strings.Split(name, ".")[1]
expirationTime := 365

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccessAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}, expirationTime),
Check: resource.ComposeTestCheckFunc(
testAccCheckCloudflareAccessServiceTokenSaved(name, &initialState),
resource.TestCheckResourceAttr(name, "min_days_for_renewal", strconv.Itoa(expirationTime)),
),
//Expiration of 365 will always force a new resource as long as the tokens expire in 365 days in cloudflare
ExpectNonEmptyPlan: true,
},
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}, expirationTime),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "min_days_for_renewal", strconv.Itoa(expirationTime)),
testAccCheckCloudflareAccessServiceTokenRenewed(name, &initialState),
),
ExpectNonEmptyPlan: true,
},
},
})
}

func testAccCheckCloudflareAccessServiceTokenSaved(n string, resourceState *terraform.ResourceState) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[n]
if !ok {
return fmt.Errorf("Not found: %s", n)
}

if rs.Primary.ID == "" {
return fmt.Errorf("No Access Token ID is set")
}

*resourceState = *rs

return nil
}
}

func testAccCheckCloudflareAccessServiceTokenRenewed(n string, oldResourceState *terraform.ResourceState) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[n]
if !ok {
return fmt.Errorf("Not found: %s", n)
}

if rs.Primary.ID == "" {
return fmt.Errorf("No Access Token ID is set")
}

for _, attribute := range []string{"expires_at", "client_secret"} {
if rs.Primary.Attributes[attribute] == oldResourceState.Primary.Attributes[attribute] {
return fmt.Errorf("Resource attribute '%s' has not changed. Expected change between old state and new", attribute)
}
}

return nil
}
}

func TestAccAccessServiceTokenDelete(t *testing.T) {
// Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the Access
// Service Tokens endpoint does not yet support the API tokens and it
Expand All @@ -141,12 +225,13 @@ func TestAccAccessServiceTokenDelete(t *testing.T) {
CheckDestroy: testAccCheckCloudflareAccessServiceTokenDestroy,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: AccountType, Value: accountID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: AccountType, Value: accountID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "account_id", accountID),
resource.TestCheckResourceAttr(name, "name", resourceName),
resource.TestCheckResourceAttrSet(name, "client_id"),
resource.TestCheckResourceAttrSet(name, "client_secret"),
resource.TestCheckResourceAttrSet(name, "expires_at"),
),
},
},
Expand All @@ -158,24 +243,26 @@ func TestAccAccessServiceTokenDelete(t *testing.T) {
CheckDestroy: testAccCheckCloudflareAccessServiceTokenDestroy,
Steps: []resource.TestStep{
{
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}),
Config: testCloudflareAccessServiceTokenBasicConfig(resourceName, resourceName, AccessIdentifier{Type: ZoneType, Value: zoneID}, 0),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(name, "zone_id", zoneID),
resource.TestCheckResourceAttr(name, "name", resourceName),
resource.TestCheckResourceAttrSet(name, "client_id"),
resource.TestCheckResourceAttrSet(name, "client_secret"),
resource.TestCheckResourceAttrSet(name, "expires_at"),
),
},
},
})
}

func testCloudflareAccessServiceTokenBasicConfig(resourceName string, tokenName string, identifier AccessIdentifier) string {
func testCloudflareAccessServiceTokenBasicConfig(resourceName string, tokenName string, identifier AccessIdentifier, minDaysForRenewal int) string {
return fmt.Sprintf(`
resource "cloudflare_access_service_token" "%[1]s" {
%[3]s_id = "%[4]s"
name = "%[2]s"
}`, resourceName, tokenName, identifier.Type, identifier.Value)
min_days_for_renewal ="%[5]d"
}`, resourceName, tokenName, identifier.Type, identifier.Value, minDaysForRenewal)
}

func testAccCheckCloudflareAccessServiceTokenDestroy(s *terraform.State) error {
Expand Down
17 changes: 17 additions & 0 deletions website/docs/r/access_service_token.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ resource "cloudflare_access_service_token" "my_app" {
account_id = "d41d8cd98f00b204e9800998ecf8427e"
name = "CI/CD app"
}
# Generate a service token that will renew if terraform is ran within 30 days of expiration
resource "cloudflare_access_service_token" "my_app" {
account_id = "d41d8cd98f00b204e9800998ecf8427e"
name = "CI/CD app renewed"
min_days_for_renewal = 30
# This flag is important to set if min_days_for_renewal is defined otherwise
# there will be a brief period where the service relying on that token
# will not have access due to the resource being deleted
lifecycle {
create_before_destroy = true
}
}
```

## Argument Reference
Expand All @@ -29,13 +44,15 @@ The following arguments are supported:
* `account_id` - (Optional) The ID of the account where the Access Service is being created. Conflicts with `zone_id`.
* `zone_id` - (Optional) The ID of the zone where the Access Service is being created. Conflicts with `account_id`.
* `name` - (Required) Friendly name of the token's intent.
* `min_days_for_renewal` - (Optional) Regenerates the token if terraform is run within the specified amount of days before expiration

## Attributes Reference

The following attributes are exported:

* `client_id` - UUID client ID associated with the Service Token.
* `client_secret` - A secret for interacting with Access protocols.
* `expires_at` - Date when the token expires

## Import

Expand Down

0 comments on commit 5659bb6

Please sign in to comment.