diff --git a/aws/resource_aws_iam_user.go b/aws/resource_aws_iam_user.go index 830674ebe05..c3a622a9214 100644 --- a/aws/resource_aws_iam_user.go +++ b/aws/resource_aws_iam_user.go @@ -62,6 +62,7 @@ func resourceAwsIamUser() *schema.Resource { Default: false, Description: "Delete user even if it has non-Terraform-managed IAM access keys, login profile or MFA devices", }, + "tags": tagsSchema(), }, } } @@ -80,6 +81,11 @@ func resourceAwsIamUserCreate(d *schema.ResourceData, meta interface{}) error { request.PermissionsBoundary = aws.String(v.(string)) } + if v, ok := d.GetOk("tags"); ok { + tags := tagsFromMapIAM(v.(map[string]interface{})) + request.Tags = tags + } + log.Println("[DEBUG] Create IAM User request:", request) createResp, err := iamconn.CreateUser(request) if err != nil { @@ -121,6 +127,9 @@ func resourceAwsIamUserRead(d *schema.ResourceData, meta interface{}) error { d.Set("permissions_boundary", output.User.PermissionsBoundary.PermissionsBoundaryArn) } d.Set("unique_id", output.User.UserId) + if err := d.Set("tags", tagsToMapIAM(output.User.Tags)); err != nil { + return fmt.Errorf("error setting tags: %s", err) + } return nil } @@ -174,6 +183,35 @@ func resourceAwsIamUserUpdate(d *schema.ResourceData, meta interface{}) error { } } + if d.HasChange("tags") { + // Reset all tags to empty set + oraw, nraw := d.GetChange("tags") + o := oraw.(map[string]interface{}) + n := nraw.(map[string]interface{}) + c, r := diffTagsIAM(tagsFromMapIAM(o), tagsFromMapIAM(n)) + + if len(r) > 0 { + _, err := iamconn.UntagUser(&iam.UntagUserInput{ + UserName: aws.String(d.Id()), + TagKeys: tagKeysIam(r), + }) + if err != nil { + return fmt.Errorf("error deleting IAM user tags: %s", err) + } + } + + if len(c) > 0 { + input := &iam.TagUserInput{ + UserName: aws.String(d.Id()), + Tags: c, + } + _, err := iamconn.TagUser(input) + if err != nil { + return fmt.Errorf("error update IAM user tags: %s", err) + } + } + } + return resourceAwsIamUserRead(d, meta) } diff --git a/aws/resource_aws_iam_user_test.go b/aws/resource_aws_iam_user_test.go index 7ff2d308a96..3156afc4ba3 100644 --- a/aws/resource_aws_iam_user_test.go +++ b/aws/resource_aws_iam_user_test.go @@ -345,6 +345,38 @@ func TestAccAWSUser_permissionsBoundary(t *testing.T) { }) } +func TestAccAWSUser_tags(t *testing.T) { + var user iam.GetUserOutput + + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_iam_user.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSUserDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSUserConfig_tags(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSUserExists(resourceName, &user), + resource.TestCheckResourceAttr(resourceName, "tags.%", "2"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", "test-Name"), + resource.TestCheckResourceAttr(resourceName, "tags.tag2", "test-tag2"), + ), + }, + { + Config: testAccAWSUserConfig_tagsUpdate(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSUserExists(resourceName, &user), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.tag2", "test-tagUpdate"), + ), + }, + }, + }) +} + func testAccCheckAWSUserDestroy(s *terraform.State) error { iamconn := testAccProvider.Meta().(*AWSClient).iamconn @@ -566,3 +598,26 @@ resource "aws_iam_user" "test" { } `, rName) } + +func testAccAWSUserConfig_tags(rName string) string { + return fmt.Sprintf(` +resource "aws_iam_user" "test" { + name = %q + tags { + Name = "test-Name" + tag2 = "test-tag2" + } +} +`, rName) +} + +func testAccAWSUserConfig_tagsUpdate(rName string) string { + return fmt.Sprintf(` +resource "aws_iam_user" "test" { + name = %q + tags { + tag2 = "test-tagUpdate" + } +} +`, rName) +} diff --git a/aws/tagsIAM.go b/aws/tagsIAM.go new file mode 100644 index 00000000000..17a547c5420 --- /dev/null +++ b/aws/tagsIAM.go @@ -0,0 +1,85 @@ +package aws + +import ( + "log" + "regexp" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" +) + +// diffTags takes our tags locally and the ones remotely and returns +// the set of tags that must be created, and the set of tags that must +// be destroyed. +func diffTagsIAM(oldTags, newTags []*iam.Tag) ([]*iam.Tag, []*iam.Tag) { + // First, we're creating everything we have + create := make(map[string]interface{}) + for _, t := range newTags { + create[aws.StringValue(t.Key)] = aws.StringValue(t.Value) + } + + // Build the list of what to remove + var remove []*iam.Tag + for _, t := range oldTags { + old, ok := create[aws.StringValue(t.Key)] + if !ok || old != aws.StringValue(t.Value) { + // Delete it! + remove = append(remove, t) + } else if ok { + delete(create, aws.StringValue(t.Key)) + } + } + + return tagsFromMapIAM(create), remove +} + +// tagsFromMapIAM returns the tags for the given map of data for IAM. +func tagsFromMapIAM(m map[string]interface{}) []*iam.Tag { + result := make([]*iam.Tag, 0, len(m)) + for k, v := range m { + t := &iam.Tag{ + Key: aws.String(k), + Value: aws.String(v.(string)), + } + if !tagIgnoredIAM(t) { + result = append(result, t) + } + } + + return result +} + +// tagsToMapIAM turns the list of IAM tags into a map. +func tagsToMapIAM(ts []*iam.Tag) map[string]string { + result := make(map[string]string) + for _, t := range ts { + if !tagIgnoredIAM(t) { + result[aws.StringValue(t.Key)] = aws.StringValue(t.Value) + } + } + + return result +} + +// compare a tag against a list of strings and checks if it should +// be ignored or not +func tagIgnoredIAM(t *iam.Tag) bool { + filter := []string{"^aws:"} + for _, v := range filter { + log.Printf("[DEBUG] Matching %v with %v\n", v, *t.Key) + if r, _ := regexp.MatchString(v, *t.Key); r == true { + log.Printf("[DEBUG] Found AWS specific tag %s (val: %s), ignoring.\n", *t.Key, *t.Value) + return true + } + } + return false +} + +// tagKeysIam returns the keys for the list of IAM tags +func tagKeysIam(ts []*iam.Tag) []*string { + result := make([]*string, 0, len(ts)) + for _, t := range ts { + result = append(result, t.Key) + } + return result +} diff --git a/aws/tagsIAM_test.go b/aws/tagsIAM_test.go new file mode 100644 index 00000000000..4a74062f5e9 --- /dev/null +++ b/aws/tagsIAM_test.go @@ -0,0 +1,111 @@ +package aws + +import ( + "reflect" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" +) + +// go test -v -run="TestDiffIAMTags" +func TestDiffIAMTags(t *testing.T) { + cases := []struct { + Old, New map[string]interface{} + Create, Remove map[string]string + }{ + // Add + { + Old: map[string]interface{}{ + "foo": "bar", + }, + New: map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + Create: map[string]string{ + "bar": "baz", + }, + Remove: map[string]string{}, + }, + + // Modify + { + Old: map[string]interface{}{ + "foo": "bar", + }, + New: map[string]interface{}{ + "foo": "baz", + }, + Create: map[string]string{ + "foo": "baz", + }, + Remove: map[string]string{ + "foo": "bar", + }, + }, + + // Overlap + { + Old: map[string]interface{}{ + "foo": "bar", + "hello": "world", + }, + New: map[string]interface{}{ + "foo": "baz", + "hello": "world", + }, + Create: map[string]string{ + "foo": "baz", + }, + Remove: map[string]string{ + "foo": "bar", + }, + }, + + // Remove + { + Old: map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + New: map[string]interface{}{ + "foo": "bar", + }, + Create: map[string]string{}, + Remove: map[string]string{ + "bar": "baz", + }, + }, + } + + for i, tc := range cases { + c, r := diffTagsIAM(tagsFromMapIAM(tc.Old), tagsFromMapIAM(tc.New)) + cm := tagsToMapIAM(c) + rm := tagsToMapIAM(r) + if !reflect.DeepEqual(cm, tc.Create) { + t.Fatalf("%d: bad create: %#v", i, cm) + } + if !reflect.DeepEqual(rm, tc.Remove) { + t.Fatalf("%d: bad remove: %#v", i, rm) + } + } +} + +// go test -v -run="TestIgnoringTagsIAM" +func TestIgnoringTagsIAM(t *testing.T) { + var ignoredTags []*iam.Tag + ignoredTags = append(ignoredTags, &iam.Tag{ + Key: aws.String("aws:cloudformation:logical-id"), + Value: aws.String("foo"), + }) + ignoredTags = append(ignoredTags, &iam.Tag{ + Key: aws.String("aws:foo:bar"), + Value: aws.String("baz"), + }) + for _, tag := range ignoredTags { + if !tagIgnoredIAM(tag) { + t.Fatalf("Tag %v with value %v not ignored, but should be!", *tag.Key, *tag.Value) + } + } +} diff --git a/website/docs/r/iam_user.html.markdown b/website/docs/r/iam_user.html.markdown index 3c6a29f58d8..14d76280f09 100644 --- a/website/docs/r/iam_user.html.markdown +++ b/website/docs/r/iam_user.html.markdown @@ -16,6 +16,9 @@ Provides an IAM user. resource "aws_iam_user" "lb" { name = "loadbalancer" path = "/system/" + tags = { + tag-key = "tag-value" + } } resource "aws_iam_access_key" "lb" { @@ -53,6 +56,7 @@ The following arguments are supported: * `force_destroy` - (Optional, default false) When destroying this user, destroy even if it has non-Terraform-managed IAM access keys, login profile or MFA devices. Without `force_destroy` a user with non-Terraform-managed access keys and login profile will fail to be destroyed. +* `tags` - Key-value mapping of tags for the IAM user ## Attributes Reference