diff --git a/CHANGELOG.md b/CHANGELOG.md index a993c9c3d..0fe603988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,15 @@ FEATURES: * d/tfe_organization_members: Add datasource for organization_members that returns a list of active members and members with pending invite in an organization. ([#635](https://github.com/hashicorp/terraform-provider-tfe/pull/635)) +* d/tfe_organization_membership: Add new argument `username` to enable fetching an organization membership by username. ([#660](https://github.com/hashicorp/terraform-provider-tfe/pull/660)) +* r/tfe_organization_membership: Add new computed attribute `username`. ([#660](https://github.com/hashicorp/terraform-provider-tfe/pull/660)) +* r/tfe_team_organization_members: Add resource for managing team members via organization membership IDs ([#617](https://github.com/hashicorp/terraform-provider-tfe/pull/617)) +* d/tfe_oauth_client: Adds `name`, `service_provider`, `service_provider_display_name`, `organization`, `callback_url`, and `created_at` fields, and enables searching for an OAuth client with `organization`, `name`, and `service_provider`. ([#599](https://github.com/hashicorp/terraform-provider-tfe/pull/599)) BUG FIXES: * r/tfe_workspace: When assessments_enabled was the only change in to the resource the workspace was not being updated ([#641](https://github.com/hashicorp/terraform-provider-tfe/pull/641)) -FEATURES: -* r/tfe_team_organization_members: Add resource for managing team members via organization membership IDs ([#617](https://github.com/hashicorp/terraform-provider-tfe/pull/617)) -* d/tfe_oauth_client: Adds `name`, `service_provider`, `service_provider_display_name`, `organization`, `callback_url`, and `created_at` fields, and enables searching for an OAuth client with `organization`, `name`, and `service_provider`. ([#599](https://github.com/hashicorp/terraform-provider-tfe/pull/599)) ## v0.37.0 (September 28, 2022) diff --git a/go.mod b/go.mod index bd5926aa1..e8579fb5e 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.1 // indirect github.com/hashicorp/go-slug v0.10.0 - github.com/hashicorp/go-tfe v1.10.0 + github.com/hashicorp/go-tfe v1.11.0 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce github.com/hashicorp/hcl/v2 v2.14.0 // indirect @@ -28,7 +28,7 @@ require ( golang.org/x/oauth2 v0.0.0-20210622215436-a8dc77f794b6 // indirect golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect golang.org/x/text v0.3.7 // indirect - golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect + golang.org/x/time v0.1.0 // indirect google.golang.org/protobuf v1.28.1 // indirect ) diff --git a/go.sum b/go.sum index 2c2bd32db..e05f9d311 100644 --- a/go.sum +++ b/go.sum @@ -186,8 +186,8 @@ github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1 github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-slug v0.10.0 h1:mh4DDkBJTh9BuEjY/cv8PTo7k9OjT4PcW8PgZnJ4jTY= github.com/hashicorp/go-slug v0.10.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= -github.com/hashicorp/go-tfe v1.10.0 h1:mkEge/DSca8VQeBSAQbjEy8fWFHbrJA76M7dny5XlYc= -github.com/hashicorp/go-tfe v1.10.0/go.mod h1:uSWi2sPw7tLrqNIiASid9j3SprbbkPSJ/2s3X0mMemg= +github.com/hashicorp/go-tfe v1.11.0 h1:rubJ4Jfqg8luMc8znl4kzZHYcr9sIMZ4SI/m6uXuhZw= +github.com/hashicorp/go-tfe v1.11.0/go.mod h1:W9x3IWVZD3RWJ0EhsaZZfYBzJrWwdwjJn0HfeDvh6yU= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -484,8 +484,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= -golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/tfe/data_source_organization_membership.go b/tfe/data_source_organization_membership.go index d4b80c991..bfb1c3e76 100644 --- a/tfe/data_source_organization_membership.go +++ b/tfe/data_source_organization_membership.go @@ -1,6 +1,7 @@ package tfe import ( + "context" "fmt" "github.com/hashicorp/go-tfe" @@ -14,7 +15,13 @@ func dataSourceTFEOrganizationMembership() *schema.Resource { Schema: map[string]*schema.Schema{ "email": { Type: schema.TypeString, - Required: true, + Optional: true, + }, + + "username": { + Type: schema.TypeString, + Optional: true, + Computed: true, }, "organization": { @@ -35,56 +42,14 @@ func dataSourceTFEOrganizationMembershipRead(d *schema.ResourceData, meta interf // Get the user email and organization. email := d.Get("email").(string) + username := d.Get("username").(string) organization := d.Get("organization").(string) - // Create an options struct. - options := &tfe.OrganizationMembershipListOptions{ - Include: []tfe.OrgMembershipIncludeOpt{tfe.OrgMembershipUser}, - Emails: []string{email}, - } - - oml, err := tfeClient.OrganizationMemberships.List(ctx, organization, options) + orgMember, err := fetchOrganizationMemberByNameOrEmail(context.Background(), tfeClient, organization, username, email) if err != nil { - return fmt.Errorf("Error retrieving organization memberships: %w", err) - } - - switch len(oml.Items) { - case 0: - return fmt.Errorf("Could not find organization membership for organization %s and email %s", organization, email) - case 1: - // We check this just in case a user's TFE instance only has one organization member - // and doesn't support the filter query param - if oml.Items[0].User.Email != email { - return fmt.Errorf("Could not find organization membership for organization %s and email %s", organization, email) - } - - d.SetId(oml.Items[0].ID) - return resourceTFEOrganizationMembershipRead(d, meta) - default: - options = &tfe.OrganizationMembershipListOptions{ - Include: []tfe.OrgMembershipIncludeOpt{tfe.OrgMembershipUser}, - } - - for { - for _, member := range oml.Items { - if member.User.Email == email { - d.SetId(member.ID) - return resourceTFEOrganizationMembershipRead(d, meta) - } - } - - if oml.CurrentPage >= oml.TotalPages { - break - } - - options.PageNumber = oml.NextPage - - oml, err = tfeClient.OrganizationMemberships.List(ctx, organization, options) - if err != nil { - return fmt.Errorf("Error retrieving organization memberships: %w", err) - } - } + return fmt.Errorf("Could not find organization membership for organization %s: %w", organization, err) } - return fmt.Errorf("Could not find organization membership for organization %s and email %s", organization, email) + d.SetId(orgMember.ID) + return resourceTFEOrganizationMembershipRead(d, meta) } diff --git a/tfe/data_source_organization_membership_test.go b/tfe/data_source_organization_membership_test.go index 5aa475e18..f140a3c5e 100644 --- a/tfe/data_source_organization_membership_test.go +++ b/tfe/data_source_organization_membership_test.go @@ -3,6 +3,7 @@ package tfe import ( "fmt" "math/rand" + "regexp" "testing" "time" @@ -22,6 +23,8 @@ func TestAccTFEOrganizationMembershipDataSource_basic(t *testing.T) { Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr( "data.tfe_organization_membership.foobar", "email", "example@hashicorp.com"), + resource.TestCheckResourceAttr( + "data.tfe_organization_membership.foobar", "username", ""), resource.TestCheckResourceAttr( "data.tfe_organization_membership.foobar", "organization", orgName), resource.TestCheckResourceAttrSet("data.tfe_organization_membership.foobar", "user_id"), @@ -31,6 +34,52 @@ func TestAccTFEOrganizationMembershipDataSource_basic(t *testing.T) { }) } +func TestAccTFEOrganizationMembershipDataSource_findByName(t *testing.T) { + // This test requires a user that exists in a TFC organization called "hashicorp". + // Our CI instance has a default organization "hashicorp" and prepopulates it + // with users (i.e TFE_USER1, etc) since we are unable to create users via the API. + // In order to run this against your own organization, simply modify the organization + // attribute in the test step config and set TFE_USER1 to the desired user you want to fetch. + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + if TFE_USER1 == "" { + t.Skip("Please set TFE_USER1 to run this test") + } + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEOrganizationMembershipDataSourceSearchUsername(TFE_USER1), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet( + "data.tfe_organization_membership.foobar", "email"), + resource.TestCheckResourceAttr( + "data.tfe_organization_membership.foobar", "username", TFE_USER1), + resource.TestCheckResourceAttr( + "data.tfe_organization_membership.foobar", "organization", "hashicorp"), + resource.TestCheckResourceAttrSet("data.tfe_organization_membership.foobar", "user_id"), + ), + }, + }, + }) +} + +func TestAccTFEOrganizationMembershipDataSource_missingParams(t *testing.T) { + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFEOrganizationMembershipDataSourceMissingParams(rInt), + ExpectError: regexp.MustCompile("you must specify a username or email"), + }, + }, + }) +} + func testAccTFEOrganizationMembershipDataSourceConfig(rInt int) string { return fmt.Sprintf(` resource "tfe_organization" "foobar" { @@ -48,3 +97,28 @@ data "tfe_organization_membership" "foobar" { organization = tfe_organization.foobar.name }`, rInt) } + +func testAccTFEOrganizationMembershipDataSourceSearchUsername(username string) string { + return fmt.Sprintf(` +data "tfe_organization_membership" "foobar" { + username = "%s" + organization = "hashicorp" +}`, username) +} + +func testAccTFEOrganizationMembershipDataSourceMissingParams(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_organization_membership" "foobar" { + email = "example@hashicorp.com" + organization = tfe_organization.foobar.id +} + +data "tfe_organization_membership" "foobar" { + organization = tfe_organization.foobar.name +}`, rInt) +} diff --git a/tfe/organization_members_helpers.go b/tfe/organization_members_helpers.go index 7532f6981..54c343ca5 100644 --- a/tfe/organization_members_helpers.go +++ b/tfe/organization_members_helpers.go @@ -1,6 +1,7 @@ package tfe import ( + "context" "fmt" "log" @@ -40,3 +41,62 @@ func fetchOrganizationMembers(client *tfe.Client, orgName string) ([]map[string] return members, membersWaiting, nil } + +func fetchOrganizationMemberByNameOrEmail(ctx context.Context, client *tfe.Client, organization, username, email string) (*tfe.OrganizationMembership, error) { + if email == "" && username == "" { + return nil, fmt.Errorf("you must specify a username or email.") + } + + options := &tfe.OrganizationMembershipListOptions{ + Include: []tfe.OrgMembershipIncludeOpt{tfe.OrgMembershipUser}, + } + + if email != "" { + options.Emails = []string{email} + } + + if username != "" { + options.Query = username + } + + oml, err := client.OrganizationMemberships.List(ctx, organization, options) + if err != nil { + return nil, fmt.Errorf("failed to list organization memberships: %w", err) + } + + switch len(oml.Items) { + case 0: + return nil, tfe.ErrResourceNotFound + case 1: + user := oml.Items[0].User + + // We check this just in case a user's TFE instance only has one organization member + if user.Email != email && user.Username != username { + return nil, tfe.ErrResourceNotFound + } + + return oml.Items[0], nil + default: + for { + for _, member := range oml.Items { + if (len(email) > 0 && member.User.Email == email) || + (len(username) > 0 && member.User.Username == username) { + return member, nil + } + } + + if oml.CurrentPage >= oml.TotalPages { + break + } + + options.PageNumber = oml.NextPage + + oml, err = client.OrganizationMemberships.List(ctx, organization, options) + if err != nil { + return nil, fmt.Errorf("failed to list organization memberships: %w", err) + } + } + } + + return nil, tfe.ErrResourceNotFound +} diff --git a/tfe/resource_tfe_organization_membership.go b/tfe/resource_tfe_organization_membership.go index 5cbaf16c0..341eb9b99 100644 --- a/tfe/resource_tfe_organization_membership.go +++ b/tfe/resource_tfe_organization_membership.go @@ -34,6 +34,11 @@ func resourceTFEOrganizationMembership() *schema.Resource { Type: schema.TypeString, Computed: true, }, + + "username": { + Type: schema.TypeString, + Computed: true, + }, }, } } @@ -84,6 +89,7 @@ func resourceTFEOrganizationMembershipRead(d *schema.ResourceData, meta interfac d.Set("email", membership.Email) d.Set("organization", membership.Organization.Name) d.Set("user_id", membership.User.ID) + d.Set("username", membership.User.Username) return nil } diff --git a/tfe/resource_tfe_organization_membership_test.go b/tfe/resource_tfe_organization_membership_test.go index 35dfa289e..dba08b96d 100644 --- a/tfe/resource_tfe_organization_membership_test.go +++ b/tfe/resource_tfe_organization_membership_test.go @@ -32,6 +32,8 @@ func TestAccTFEOrganizationMembership_basic(t *testing.T) { resource.TestCheckResourceAttr( "tfe_organization_membership.foobar", "organization", orgName), resource.TestCheckResourceAttrSet("tfe_organization_membership.foobar", "user_id"), + resource.TestCheckResourceAttr( + "tfe_organization_membership.foobar", "username", ""), ), }, }, diff --git a/website/docs/d/organization_membership.html.markdown b/website/docs/d/organization_membership.html.markdown index 78405d543..ed7b4a48d 100644 --- a/website/docs/d/organization_membership.html.markdown +++ b/website/docs/d/organization_membership.html.markdown @@ -18,6 +18,8 @@ be updated manually. ## Example Usage +### Fetch by email + ```hcl data "tfe_organization_membership" "test" { organization = "my-org-name" @@ -25,12 +27,24 @@ data "tfe_organization_membership" "test" { } ``` +### Fetch by username + +``` +data "tfe_organization_membership" "test" { + organization = "my-org-name" + username = "my-username" +} +``` + ## Argument Reference The following arguments are supported: * `organization` - (Required) Name of the organization. -* `email` - (Required) Email of the user. +* `email` - (Optional) Email of the user. +* `username` - (Optional) The username of the user. + +~> **NOTE:** While `email` and `username` are optional arguments, one or the other is required. ## Attributes Reference @@ -38,3 +52,4 @@ In addition to all arguments above, the following attributes are exported: * `id` - The organization membership ID. * `user_id` - The ID of the user associated with the organization membership. +* `username` - The username of the user associated with the organization membership. diff --git a/website/docs/r/organization_membership.html.markdown b/website/docs/r/organization_membership.html.markdown index 835fdfd2e..657528253 100644 --- a/website/docs/r/organization_membership.html.markdown +++ b/website/docs/r/organization_membership.html.markdown @@ -42,6 +42,7 @@ In addition to all arguments above, the following attributes are exported: * `id` - The organization membership ID. * `user_id` - The ID of the user associated with the organization membership. +* `username` - The username of the user associated with the organization membership. Organization memberships can be imported; use `` as the import ID. For example: