diff --git a/docs/data-sources/team_member.md b/docs/data-sources/team_member.md index 254313a6..9d43ef01 100644 --- a/docs/data-sources/team_member.md +++ b/docs/data-sources/team_member.md @@ -28,6 +28,10 @@ data "launchdarkly_team_member" "example" { - `email` (String) The unique email address associated with the team member. +### Optional + +- `role_attributes` (Block Set) A role attributes block. One block must be defined per role attribute. The key is the role attribute key and the value is a string array of resource keys that apply. (see [below for nested schema](#nestedblock--role_attributes)) + ### Read-Only - `custom_roles` (Set of String) The list of custom roles keys associated with the team member. Custom roles are only available to customers on an Enterprise plan. To learn more, [read about our pricing](https://launchdarkly.com/pricing/). To upgrade your plan, [contact LaunchDarkly Sales](https://launchdarkly.com/contact-sales/). @@ -35,3 +39,11 @@ data "launchdarkly_team_member" "example" { - `id` (String) The 24 character alphanumeric ID of the team member. - `last_name` (String) The team member's family name. - `role` (String) The role associated with team member. Possible roles are `owner`, `reader`, `writer`, or `admin`. + + +### Nested Schema for `role_attributes` + +Required: + +- `key` (String) The key / name of your role attribute. In the example `$${roleAttribute/testAttribute}`, the key is `testAttribute`. +- `values` (List of String) A list of values for your role attribute. For example, if your policy statement defines the resource `"proj/$${roleAttribute/testAttribute}"`, the values would be the keys of the projects you wanted to assign access to. diff --git a/docs/data-sources/team_members.md b/docs/data-sources/team_members.md index 603b2b8c..71d5008e 100644 --- a/docs/data-sources/team_members.md +++ b/docs/data-sources/team_members.md @@ -58,3 +58,12 @@ Read-Only: - `id` (String) - `last_name` (String) - `role` (String) +- `role_attributes` (Set of Object) (see [below for nested schema](#nestedobjatt--team_members--role_attributes)) + + +### Nested Schema for `team_members.role_attributes` + +Read-Only: + +- `key` (String) +- `values` (List of String) diff --git a/docs/resources/team_member.md b/docs/resources/team_member.md index 8b7fea32..63d57049 100644 --- a/docs/resources/team_member.md +++ b/docs/resources/team_member.md @@ -42,11 +42,20 @@ resource "launchdarkly_team_member" "example" { - `first_name` (String) The team member's given name. Once created, this cannot be updated except by the team member. - `last_name` (String) TThe team member's family name. Once created, this cannot be updated except by the team member. - `role` (String) The role associated with team member. Supported roles are `reader`, `writer`, `no_access`, or `admin`. If you don't specify a role, `reader` is assigned by default. +- `role_attributes` (Block Set) A role attributes block. One block must be defined per role attribute. The key is the role attribute key and the value is a string array of resource keys that apply. (see [below for nested schema](#nestedblock--role_attributes)) ### Read-Only - `id` (String) The 24 character alphanumeric ID of the team member. + +### Nested Schema for `role_attributes` + +Required: + +- `key` (String) The key / name of your role attribute. In the example `$${roleAttribute/testAttribute}`, the key is `testAttribute`. +- `values` (List of String) A list of values for your role attribute. For example, if your policy statement defines the resource `"proj/$${roleAttribute/testAttribute}"`, the values would be the keys of the projects you wanted to assign access to. + ## Import Import is supported using the following syntax: diff --git a/launchdarkly/data_source_launchdarkly_team_member.go b/launchdarkly/data_source_launchdarkly_team_member.go index 70ccfb9b..7433ec0c 100644 --- a/launchdarkly/data_source_launchdarkly_team_member.go +++ b/launchdarkly/data_source_launchdarkly_team_member.go @@ -44,6 +44,7 @@ func memberSchema() map[string]*schema.Schema { Computed: true, Description: `The list of custom roles keys associated with the team member. Custom roles are only available to customers on an Enterprise plan. To learn more, [read about our pricing](https://launchdarkly.com/pricing/). To upgrade your plan, [contact LaunchDarkly Sales](https://launchdarkly.com/contact-sales/).`, }, + ROLE_ATTRIBUTES: roleAttributesSchema(true), } } @@ -63,7 +64,7 @@ func getTeamMemberByEmail(client *Client, memberEmail string) (*ldapi.Member, er teamMemberLimit := int64(1000) // After changing this to query by member email, we shouldn't need the limit and recursion on requests, but leaving it in just to be extra safe - members, _, err := client.ld.AccountMembersApi.GetMembers(client.ctx).Filter(fmt.Sprintf("query:%s", url.QueryEscape(memberEmail))).Execute() + members, _, err := client.ld.AccountMembersApi.GetMembers(client.ctx).Filter(fmt.Sprintf("query:%s", url.QueryEscape(memberEmail))).Expand("roleAttributes").Execute() if err != nil { return nil, fmt.Errorf("failed to read team member with email: %s: %v", memberEmail, handleLdapiErr(err)) @@ -111,6 +112,10 @@ func dataSourceTeamMemberRead(ctx context.Context, d *schema.ResourceData, meta if err != nil { return diag.Errorf("failed to set custom roles on team member with email %q: %v", member.Email, err) } + err = d.Set(ROLE_ATTRIBUTES, roleAttributesToResourceData(member.RoleAttributes)) + if err != nil { + return diag.Errorf("failed to set role attributes on team member with id %q: %v", member.Id, err) + } return diags } diff --git a/launchdarkly/data_source_launchdarkly_team_member_test.go b/launchdarkly/data_source_launchdarkly_team_member_test.go index 801c927b..698a9426 100644 --- a/launchdarkly/data_source_launchdarkly_team_member_test.go +++ b/launchdarkly/data_source_launchdarkly_team_member_test.go @@ -25,6 +25,9 @@ func testAccDataSourceTeamMemberCreate(client *Client, email string) (*ldapi.Mem Email: email, FirstName: ldapi.PtrString("Test"), LastName: ldapi.PtrString("Account"), + RoleAttributes: &map[string][]string{ + "testAttribute": []string{"testValue"}, + }, }} members, _, err := client.ld.AccountMembersApi.PostMembers(client.ctx).NewMemberForm(membersBody).Execute() if err != nil { @@ -92,6 +95,7 @@ func TestAccDataSourceTeamMember_exists(t *testing.T) { resource.TestCheckResourceAttr(resourceName, FIRST_NAME, *testMember.FirstName), resource.TestCheckResourceAttr(resourceName, LAST_NAME, *testMember.LastName), resource.TestCheckResourceAttr(resourceName, ID, testMember.Id), + resource.TestCheckResourceAttr(resourceName, "role_attributes.#", "1"), ), }, }, diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index 9bba43ea..d7769d29 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -94,6 +94,7 @@ const ( RESOURCE = "resource" RESOURCES = "resources" ROLE = "role" + ROLE_ATTRIBUTES = "role_attributes" ROLLOUT_CONTEXT_KIND = "rollout_context_kind" ROLLOUT_WEIGHTS = "rollout_weights" RULES = "rules" diff --git a/launchdarkly/resource_launchdarkly_team_member.go b/launchdarkly/resource_launchdarkly_team_member.go index 879db441..e6b578c3 100644 --- a/launchdarkly/resource_launchdarkly_team_member.go +++ b/launchdarkly/resource_launchdarkly_team_member.go @@ -62,6 +62,7 @@ func resourceTeamMember() *schema.Resource { Description: "The list of custom roles keys associated with the team member. Custom roles are only available to customers on an Enterprise plan. To learn more, [read about our pricing](https://launchdarkly.com/pricing/). To upgrade your plan, [contact LaunchDarkly Sales](https://launchdarkly.com/contact-sales/).\n\n-> **Note:** each `launchdarkly_team_member` must have either a `role` or `custom_roles` argument.", AtLeastOneOf: []string{ROLE, CUSTOM_ROLES}, }, + ROLE_ATTRIBUTES: roleAttributesSchema(false), }, Description: `Provides a LaunchDarkly team member resource. @@ -79,6 +80,7 @@ func resourceTeamMemberCreate(ctx context.Context, d *schema.ResourceData, metaR lastName := d.Get(LAST_NAME).(string) memberRole := d.Get(ROLE).(string) customRolesRaw := d.Get(CUSTOM_ROLES).(*schema.Set).List() + roleAttributes := roleAttributesFromResourceData(d.Get(ROLE_ATTRIBUTES).(*schema.Set).List()) customRoles := make([]string, len(customRolesRaw)) for i, cr := range customRolesRaw { @@ -86,13 +88,15 @@ func resourceTeamMemberCreate(ctx context.Context, d *schema.ResourceData, metaR } membersBody := ldapi.NewMemberForm{ - Email: memberEmail, - FirstName: &firstName, - LastName: &lastName, - Role: &memberRole, - CustomRoles: customRoles, + Email: memberEmail, + FirstName: &firstName, + LastName: &lastName, + Role: &memberRole, + CustomRoles: customRoles, + RoleAttributes: roleAttributes, } + // role attributes will not come back here because we have to set an expand query param members, _, err := client.ld.AccountMembersApi.PostMembers(client.ctx).NewMemberForm([]ldapi.NewMemberForm{membersBody}).Execute() if err != nil { return diag.Errorf("failed to create team member with email: %s: %v", memberEmail, handleLdapiErr(err)) @@ -108,7 +112,7 @@ func resourceTeamMemberRead(ctx context.Context, d *schema.ResourceData, metaRaw client := metaRaw.(*Client) memberID := d.Id() - member, res, err := client.ld.AccountMembersApi.GetMember(client.ctx, memberID).Execute() + member, res, err := client.ld.AccountMembersApi.GetMember(client.ctx, memberID).Expand("roleAttributes").Execute() if isStatusNotFound(res) { log.Printf("[WARN] failed to find member with id %q, removing from state", memberID) diags = append(diags, diag.Diagnostic{ @@ -136,6 +140,10 @@ func resourceTeamMemberRead(ctx context.Context, d *schema.ResourceData, metaRaw if err != nil { return diag.Errorf("failed to set custom roles on team member with id %q: %v", member.Id, err) } + err = d.Set(ROLE_ATTRIBUTES, roleAttributesToResourceData(member.RoleAttributes)) + if err != nil { + return diag.Errorf("failed to set role attributes on team member with id %q: %v", member.Id, err) + } return diags } @@ -159,6 +167,7 @@ func resourceTeamMemberUpdate(ctx context.Context, d *schema.ResourceData, metaR patchReplace("/role", &memberRole), patchReplace("/customRoles", &customRoleIds), } + patch = append(patch, getRoleAttributePatches(d)...) _, _, err = client.ld.AccountMembersApi.PatchMember(client.ctx, memberID).PatchOperation(patch).Execute() if err != nil { diff --git a/launchdarkly/resource_launchdarkly_team_member_test.go b/launchdarkly/resource_launchdarkly_team_member_test.go index f73bae81..597f5ee1 100644 --- a/launchdarkly/resource_launchdarkly_team_member_test.go +++ b/launchdarkly/resource_launchdarkly_team_member_test.go @@ -75,12 +75,85 @@ resource "launchdarkly_team_member" "custom_role_test" { email = "%s+wbteste2e@launchdarkly.com" first_name = "first" last_name = "last" - custom_roles = [launchdarkly_custom_role.test_2.key] + custom_roles = [launchdarkly_custom_role.test.key] +} +` + testAccTeamMemberCustomRoleWithRoleAttributes = ` +resource "launchdarkly_custom_role" "test" { + key = "%s" + name = "Updated - %s" + description= "Allow all actions on testAttribute environments" + policy_statements { + actions = ["*"] + effect = "allow" + resources = ["proj/*:env/$${roleAttribute/testAttribute}"] + } +} + +resource "launchdarkly_team_member" "custom_role_test" { + email = "%s+wbteste2e@launchdarkly.com" + first_name = "first" + last_name = "last" + custom_roles = [launchdarkly_custom_role.test.key] + role_attributes { + key = "testAttribute" + values = ["staging", "production"] + } + role_attributes { + key = "nonexistentAttribute" + values = ["someValue"] + } +} +` + testAccTeamMemberCustomRoleWithRoleAttributesUpdate = ` +resource "launchdarkly_custom_role" "test" { + key = "%s" + name = "Updated - %s" + description= "Allow all actions on testAttribute environments" + policy_statements { + actions = ["*"] + effect = "allow" + resources = ["proj/*:env/$${roleAttribute/testAttribute}"] + } +} + +resource "launchdarkly_team_member" "custom_role_test" { + email = "%s+wbteste2e@launchdarkly.com" + first_name = "first" + last_name = "last" + custom_roles = [launchdarkly_custom_role.test.key] + role_attributes { + key = "newAttribute" + values = ["value1", "value2"] + } + role_attributes { + key = "testAttribute" + values = ["staging"] + } +} +` + testAccTeamMemberCustomRoleWithRoleAttributesRemove = ` +resource "launchdarkly_custom_role" "test" { + key = "%s" + name = "Updated - %s" + description= "Allow all actions on testAttribute environments" + policy_statements { + actions = ["*"] + effect = "allow" + resources = ["proj/*:env/$${roleAttribute/testAttribute}"] + } +} + +resource "launchdarkly_team_member" "custom_role_test" { + email = "%s+wbteste2e@launchdarkly.com" + first_name = "first" + last_name = "last" + custom_roles = [launchdarkly_custom_role.test.key] } ` ) -func TestAccTeamMember_CreateGeneric(t *testing.T) { +func TestAccTeamMember_CreateAndUpdateGeneric(t *testing.T) { randomName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) resourceName := "launchdarkly_team_member.test" resource.ParallelTest(t, resource.TestCase{ @@ -105,30 +178,6 @@ func TestAccTeamMember_CreateGeneric(t *testing.T) { ImportState: true, ImportStateVerify: true, }, - }, - }) -} - -func TestAccTeamMember_UpdateGeneric(t *testing.T) { - randomName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) - resourceName := "launchdarkly_team_member.test" - resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { - testAccPreCheck(t) - }, - Providers: testAccProviders, - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(testAccTeamMemberCreate, randomName), - Check: resource.ComposeTestCheckFunc( - testAccCheckMemberExists(resourceName), - resource.TestCheckResourceAttr(resourceName, EMAIL, fmt.Sprintf("%s+wbteste2e@launchdarkly.com", randomName)), - resource.TestCheckResourceAttr(resourceName, FIRST_NAME, "first"), - resource.TestCheckResourceAttr(resourceName, LAST_NAME, "last"), - resource.TestCheckResourceAttr(resourceName, ROLE, "admin"), - resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "0"), - ), - }, { Config: fmt.Sprintf(testAccTeamMemberUpdate, randomName), Check: resource.ComposeTestCheckFunc( @@ -140,6 +189,11 @@ func TestAccTeamMember_UpdateGeneric(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "0"), ), }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, }, }) } @@ -183,7 +237,84 @@ func TestAccTeamMember_WithCustomRole(t *testing.T) { resource.TestCheckResourceAttr(resourceName, FIRST_NAME, "first"), resource.TestCheckResourceAttr(resourceName, LAST_NAME, "last"), resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "1"), - resource.TestCheckResourceAttr(resourceName, "custom_roles.0", roleKey2), + resource.TestCheckResourceAttr(resourceName, "custom_roles.0", roleKey1), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + // delete launchdarkly_custom_role.test_2, udpate launchdarkly_custom_role.test with role attributes + // and add role attribute values to the team member + Config: fmt.Sprintf(testAccTeamMemberCustomRoleWithRoleAttributes, roleKey1, roleKey1, randomName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCustomRoleExists(roleResourceName1), + testAccCheckMemberExists(resourceName), + resource.TestCheckResourceAttr(resourceName, EMAIL, fmt.Sprintf("%s+wbteste2e@launchdarkly.com", randomName)), + resource.TestCheckResourceAttr(resourceName, FIRST_NAME, "first"), + resource.TestCheckResourceAttr(resourceName, LAST_NAME, "last"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "1"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.0", roleKey1), + + resource.TestCheckResourceAttr(resourceName, "role_attributes.#", "2"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.key", "testAttribute"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.values.#", "2"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.values.0", "staging"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.values.1", "production"), + // we allow the setting of role attributes to be set even if they do not otherwise exist + // on a custom role + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.key", "nonexistentAttribute"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.values.#", "1"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.values.0", "someValue"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + // remove the nonexistentAttribute block, reorder testAttribute block and add a newAttribute block, + // and remove the production value from the testAttribute block + Config: fmt.Sprintf(testAccTeamMemberCustomRoleWithRoleAttributesUpdate, roleKey1, roleKey1, randomName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCustomRoleExists(roleResourceName1), + testAccCheckMemberExists(resourceName), + resource.TestCheckResourceAttr(resourceName, EMAIL, fmt.Sprintf("%s+wbteste2e@launchdarkly.com", randomName)), + resource.TestCheckResourceAttr(resourceName, FIRST_NAME, "first"), + resource.TestCheckResourceAttr(resourceName, LAST_NAME, "last"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "1"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.0", roleKey1), + + resource.TestCheckResourceAttr(resourceName, "role_attributes.#", "2"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.key", "testAttribute"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.values.#", "1"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.1.values.0", "staging"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.key", "newAttribute"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.values.#", "2"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.values.0", "value1"), + resource.TestCheckResourceAttr(resourceName, "role_attributes.0.values.1", "value2"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + // remove role attributes from the team member + Config: fmt.Sprintf(testAccTeamMemberCustomRoleWithRoleAttributesRemove, roleKey1, roleKey1, randomName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCustomRoleExists(roleResourceName1), + testAccCheckMemberExists(resourceName), + resource.TestCheckResourceAttr(resourceName, EMAIL, fmt.Sprintf("%s+wbteste2e@launchdarkly.com", randomName)), + resource.TestCheckResourceAttr(resourceName, FIRST_NAME, "first"), + resource.TestCheckResourceAttr(resourceName, LAST_NAME, "last"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "1"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.0", roleKey1), + resource.TestCheckResourceAttr(resourceName, "role_attributes.#", "0"), ), }, { diff --git a/launchdarkly/role_attributes_helper.go b/launchdarkly/role_attributes_helper.go new file mode 100644 index 00000000..5e5e0c0b --- /dev/null +++ b/launchdarkly/role_attributes_helper.go @@ -0,0 +1,80 @@ +package launchdarkly + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + ldapi "github.com/launchdarkly/api-client-go/v17" +) + +func roleAttributesSchema(isDataSource bool) *schema.Schema { + return &schema.Schema{ + Type: schema.TypeSet, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + KEY: { + Type: schema.TypeString, + Required: true, + Description: "The key / name of your role attribute. In the example `$${roleAttribute/testAttribute}`, the key is `testAttribute`.", + }, + VALUES: { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + Description: "A list of values for your role attribute. For example, if your policy statement defines the resource `\"proj/$${roleAttribute/testAttribute}\"`, the values would be the keys of the projects you wanted to assign access to.", + }, + }, + }, + Optional: true, + Computed: isDataSource, + Description: "A role attributes block. One block must be defined per role attribute. The key is the role attribute key and the value is a string array of resource keys that apply.", + } +} + +func roleAttributesFromResourceData(rawRoleAttributes []interface{}) *map[string][]string { + if len(rawRoleAttributes) == 0 { + return nil + } + roleAttributes := make(map[string][]string) + for _, attribute := range rawRoleAttributes { + roleAttribute := attribute.(map[string]interface{}) + key := roleAttribute[KEY].(string) + rawValues := roleAttribute[VALUES].([]interface{}) + roleAttributes[key] = make([]string, 0, len(rawValues)) + for _, v := range rawValues { + roleAttributes[key] = append(roleAttributes[key], v.(string)) + } + } + return &roleAttributes +} + +func roleAttributesToResourceData(roleAttributes *map[string][]string) *[]interface{} { + if roleAttributes == nil { + return nil + } + resourceData := make([]interface{}, 0, len(*roleAttributes)) + for key, values := range *roleAttributes { + rawValues := make([]interface{}, 0, len(values)) + for _, v := range values { + rawValues = append(rawValues, v) + } + resourceData = append(resourceData, map[string]interface{}{ + KEY: key, + VALUES: rawValues, + }) + } + return &resourceData +} + +func getRoleAttributePatches(d *schema.ResourceData) []ldapi.PatchOperation { + var patch []ldapi.PatchOperation + if o, n := d.GetChange(ROLE_ATTRIBUTES); o != n { + new := roleAttributesFromResourceData(d.Get(ROLE_ATTRIBUTES).(*schema.Set).List()) + if new != nil { + patch = append(patch, patchReplace("/roleAttributes", new)) + } else { + patch = append(patch, patchReplace("/roleAttributes", make(map[string][]string))) + } + } + return patch +}