Skip to content

Commit

Permalink
Merge pull request #32462 from hashicorp/b-sweep-mfa-device
Browse files Browse the repository at this point in the history
resource/aws_iam_virtual_mfa_device: Correctly sweep associated devices and add `enable_date` and `user_name` attributes
  • Loading branch information
gdavison authored Jul 12, 2023
2 parents 4e662e1 + 9e74fb7 commit 5227f6b
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 19 deletions.
3 changes: 3 additions & 0 deletions .changelog/32462.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
resource/aws_iam_virtual_mfa_device: Add `enable_date` and `user_name` attributes
```
18 changes: 14 additions & 4 deletions internal/service/iam/sweep.go
Original file line number Diff line number Diff line change
Expand Up @@ -918,8 +918,10 @@ func sweepVirtualMFADevice(region string) error {
}
conn := client.IAMConn(ctx)
var sweeperErrs *multierror.Error
input := &iam.ListVirtualMFADevicesInput{}

accessDenied := regexp.MustCompile(`AccessDenied: .+ with an explicit deny`)

input := &iam.ListVirtualMFADevicesInput{}
err = conn.ListVirtualMFADevicesPagesWithContext(ctx, input, func(page *iam.ListVirtualMFADevicesOutput, lastPage bool) bool {
if len(page.VirtualMFADevices) == 0 {
log.Printf("[INFO] No IAM Virtual MFA Devices to sweep")
Expand All @@ -936,11 +938,19 @@ func sweepVirtualMFADevice(region string) error {
r := ResourceVirtualMFADevice()
d := r.Data(nil)
d.SetId(serialNum)

if err := sdk.ReadResource(ctx, r, d, client); err != nil {
sweeperErrs = multierror.Append(sweeperErrs, fmt.Errorf("reading IAM Virtual MFA Device (%s): %w", serialNum, err))
continue
}

err := sdk.DeleteResource(ctx, r, d, client)
if err != nil {
sweeperErr := fmt.Errorf("error deleting IAM Virtual MFA Device (%s): %w", device, err)
log.Printf("[ERROR] %s", sweeperErr)
sweeperErrs = multierror.Append(sweeperErrs, sweeperErr)
if accessDenied.MatchString(err.Error()) {
log.Printf("[DEBUG] Skipping IAM Virtual MFA Device (%s): %s", serialNum, err)
continue
}
sweeperErrs = multierror.Append(sweeperErrs, fmt.Errorf("deleting IAM Virtual MFA Device (%s): %w", serialNum, err))
continue
}
}
Expand Down
56 changes: 56 additions & 0 deletions internal/service/iam/virtual_mfa_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ package iam

import (
"context"
"fmt"
"log"
"regexp"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/arn"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
Expand Down Expand Up @@ -46,6 +49,10 @@ func ResourceVirtualMFADevice() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
"enable_date": {
Type: schema.TypeString,
Computed: true,
},
"path": {
Type: schema.TypeString,
Optional: true,
Expand All @@ -59,6 +66,10 @@ func ResourceVirtualMFADevice() *schema.Resource {
},
names.AttrTags: tftags.TagsSchema(),
names.AttrTagsAll: tftags.TagsSchemaComputed(),
"user_name": {
Type: schema.TypeString,
Computed: true,
},
"virtual_mfa_device_name": {
Type: schema.TypeString,
Required: true,
Expand Down Expand Up @@ -101,6 +112,7 @@ func resourceVirtualMFADeviceCreate(ctx context.Context, d *schema.ResourceData,
vMFA := output.VirtualMFADevice
d.SetId(aws.StringValue(vMFA.SerialNumber))

// Base32StringSeed and QRCodePNG must be read here, because they are not available via ListVirtualMFADevices
d.Set("base_32_string_seed", string(vMFA.Base32StringSeed))
d.Set("qr_code_png", string(vMFA.QRCodePNG))

Expand Down Expand Up @@ -139,6 +151,22 @@ func resourceVirtualMFADeviceRead(ctx context.Context, d *schema.ResourceData, m

d.Set("arn", vMFA.SerialNumber)

path, name, err := parseVirtualMFADeviceARN(aws.StringValue(vMFA.SerialNumber))
if err != nil {
return sdkdiag.AppendErrorf(diags, "reading IAM Virtual MFA Device (%s): %s", d.Id(), err)
}

d.Set("path", path)
d.Set("virtual_mfa_device_name", name)

if v := vMFA.EnableDate; v != nil {
d.Set("enable_date", aws.TimeValue(v).Format(time.RFC3339))
}

if u := vMFA.User; u != nil {
d.Set("user_name", u.UserName)
}

// The call above returns empty tags.
output, err := conn.ListMFADeviceTagsWithContext(ctx, &iam.ListMFADeviceTagsInput{
SerialNumber: aws.String(d.Id()),
Expand Down Expand Up @@ -177,6 +205,19 @@ func resourceVirtualMFADeviceDelete(ctx context.Context, d *schema.ResourceData,
var diags diag.Diagnostics
conn := meta.(*conns.AWSClient).IAMConn(ctx)

if v := d.Get("user_name"); v != "" {
_, err := conn.DeactivateMFADeviceWithContext(ctx, &iam.DeactivateMFADeviceInput{
UserName: aws.String(v.(string)),
SerialNumber: aws.String(d.Id()),
})
if tfawserr.ErrCodeEquals(err, iam.ErrCodeNoSuchEntityException) {
return diags
}
if err != nil {
return sdkdiag.AppendErrorf(diags, "deactivating IAM Virtual MFA Device (%s): %s", d.Id(), err)
}
}

log.Printf("[INFO] Deleting IAM Virtual MFA Device: %s", d.Id())
_, err := conn.DeleteVirtualMFADeviceWithContext(ctx, &iam.DeleteVirtualMFADeviceInput{
SerialNumber: aws.String(d.Id()),
Expand Down Expand Up @@ -222,3 +263,18 @@ func FindVirtualMFADeviceBySerialNumber(ctx context.Context, conn *iam.IAM, seri

return output, nil
}

func parseVirtualMFADeviceARN(s string) (path, name string, err error) {
arn, err := arn.Parse(s)
if err != nil {
return "", "", err
}

re := regexp.MustCompile(`^mfa(/|/[\x{0021}-\x{007E}]+/)([-A-Za-z0-9_+=,.@]+)$`)
matches := re.FindStringSubmatch(arn.Resource)
if len(matches) != 3 {
return "", "", fmt.Errorf("IAM Virtual MFA Device ARN: invalid resource section (%s)", arn.Resource)
}

return matches[1], matches[2], nil
}
82 changes: 68 additions & 14 deletions internal/service/iam/virtual_mfa_device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,60 @@ func TestAccIAMVirtualMFADevice_basic(t *testing.T) {
Steps: []resource.TestStep{
{
Config: testAccVirtualMFADeviceConfig_basic(rName),
Check: resource.ComposeTestCheckFunc(
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckVirtualMFADeviceExists(ctx, resourceName, &conf),
acctest.CheckResourceAttrGlobalARN(resourceName, "arn", "iam", fmt.Sprintf("mfa/%s", rName)),
resource.TestCheckResourceAttrSet(resourceName, "base_32_string_seed"),
resource.TestCheckNoResourceAttr(resourceName, "enable_date"),
resource.TestCheckResourceAttr(resourceName, "path", "/"),
resource.TestCheckResourceAttrSet(resourceName, "qr_code_png"),
resource.TestCheckNoResourceAttr(resourceName, "user_name"),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"path", "virtual_mfa_device_name", "base_32_string_seed", "qr_code_png"},
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{
"base_32_string_seed",
"qr_code_png",
},
},
},
})
}

func TestAccIAMVirtualMFADevice_path(t *testing.T) {
ctx := acctest.Context(t)
var conf iam.VirtualMFADevice
resourceName := "aws_iam_virtual_mfa_device.test"

path := "/path/"

rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix)

resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { acctest.PreCheck(ctx, t) },
ErrorCheck: acctest.ErrorCheck(t, iam.EndpointsID),
ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories,
CheckDestroy: testAccCheckVirtualMFADeviceDestroy(ctx),
Steps: []resource.TestStep{
{
Config: testAccVirtualMFADeviceConfig_path(rName, path),
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckVirtualMFADeviceExists(ctx, resourceName, &conf),
acctest.CheckResourceAttrGlobalARN(resourceName, "arn", "iam", fmt.Sprintf("mfa%s%s", path, rName)),
resource.TestCheckResourceAttr(resourceName, "path", path),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{
"base_32_string_seed",
"qr_code_png",
},
},
},
})
Expand All @@ -66,21 +108,24 @@ func TestAccIAMVirtualMFADevice_tags(t *testing.T) {
Steps: []resource.TestStep{
{
Config: testAccVirtualMFADeviceConfig_tags1(rName, "key1", "value1"),
Check: resource.ComposeTestCheckFunc(
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckVirtualMFADeviceExists(ctx, resourceName, &conf),
resource.TestCheckResourceAttr(resourceName, "tags.%", "1"),
resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1"),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{"path", "virtual_mfa_device_name", "base_32_string_seed", "qr_code_png"},
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{
"base_32_string_seed",
"qr_code_png",
},
},
{
Config: testAccVirtualMFADeviceConfig_tags2(rName, "key1", "value1updated", "key2", "value2"),
Check: resource.ComposeTestCheckFunc(
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckVirtualMFADeviceExists(ctx, resourceName, &conf),
resource.TestCheckResourceAttr(resourceName, "tags.%", "2"),
resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1updated"),
Expand All @@ -89,7 +134,7 @@ func TestAccIAMVirtualMFADevice_tags(t *testing.T) {
},
{
Config: testAccVirtualMFADeviceConfig_tags1(rName, "key2", "value2"),
Check: resource.ComposeTestCheckFunc(
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckVirtualMFADeviceExists(ctx, resourceName, &conf),
resource.TestCheckResourceAttr(resourceName, "tags.%", "1"),
resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"),
Expand All @@ -114,10 +159,9 @@ func TestAccIAMVirtualMFADevice_disappears(t *testing.T) {
Steps: []resource.TestStep{
{
Config: testAccVirtualMFADeviceConfig_basic(rName),
Check: resource.ComposeTestCheckFunc(
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckVirtualMFADeviceExists(ctx, resourceName, &conf),
acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceVirtualMFADevice(), resourceName),
acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceVirtualMFADevice(), resourceName),
),
ExpectNonEmptyPlan: true,
},
Expand Down Expand Up @@ -182,6 +226,16 @@ resource "aws_iam_virtual_mfa_device" "test" {
`, rName)
}

func testAccVirtualMFADeviceConfig_path(rName, path string) string {
return fmt.Sprintf(`
resource "aws_iam_virtual_mfa_device" "test" {
virtual_mfa_device_name = %[1]q
path = %[2]q
}
`, rName, path)
}

func testAccVirtualMFADeviceConfig_tags1(rName, tagKey1, tagValue1 string) string {
return fmt.Sprintf(`
resource "aws_iam_virtual_mfa_device" "test" {
Expand Down
8 changes: 7 additions & 1 deletion website/docs/r/iam_virtual_mfa_device.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ Provides an IAM Virtual MFA Device.
~> **Note:** All attributes will be stored in the raw state as plain-text.
[Read more about sensitive data in state](https://www.terraform.io/docs/state/sensitive-data.html).

~> **Note:** A virtual MFA device cannot be directly associated with an IAM User from Terraform.
To associate the virtual MFA device with a user and enable it, use the code returned in either `base_32_string_seed` or `qr_code_png` to generate TOTP authentication codes.
The authentication codes can then be used with the AWS CLI command [`aws iam enable-mfa-device`](https://docs.aws.amazon.com/cli/latest/reference/iam/enable-mfa-device.html) or the AWS API call [`EnableMFADevice`](https://docs.aws.amazon.com/IAM/latest/APIReference/API_EnableMFADevice.html).

## Example Usage

**Using certs on file:**
Expand All @@ -37,8 +41,10 @@ In addition to all arguments above, the following attributes are exported:

* `arn` - The Amazon Resource Name (ARN) specifying the virtual mfa device.
* `base_32_string_seed` - The base32 seed defined as specified in [RFC3548](https://tools.ietf.org/html/rfc3548.txt). The `base_32_string_seed` is base64-encoded.
* `qr_code_png` - A QR code PNG image that encodes `otpauth://totp/$virtualMFADeviceName@$AccountName?secret=$Base32String` where `$virtualMFADeviceName` is one of the create call arguments. AccountName is the user name if set (otherwise, the account ID otherwise), and Base32String is the seed in base32 format.
* `enable_date` - The date and time when the virtual MFA device was enabled.
* `qr_code_png` - A QR code PNG image that encodes `otpauth://totp/$virtualMFADeviceName@$AccountName?secret=$Base32String` where `$virtualMFADeviceName` is one of the create call arguments. AccountName is the user name if set (otherwise, the account ID), and Base32String is the seed in base32 format.
* `tags_all` - A map of tags assigned to the resource, including those inherited from the provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block).
* `user_name` - The associated IAM User name if the virtual MFA device is enabled.

## Import

Expand Down

0 comments on commit 5227f6b

Please sign in to comment.