Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloudflare service token rotation #1057

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cloudflare.AccessServiceTokenCreateResponse doesn't have an ExpiresAt field - see https://github.com/cloudflare/cloudflare-go/blob/master/access_service_tokens.go#L37-L44. we'll need to get this into cloudflare-go first


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