diff --git a/github/resource_github_emu_group_mapping.go b/github/resource_github_emu_group_mapping.go index 17235683c8..38d45a5de4 100644 --- a/github/resource_github_emu_group_mapping.go +++ b/github/resource_github_emu_group_mapping.go @@ -2,7 +2,7 @@ package github import ( "context" - "fmt" + "net/http" "strconv" "github.com/google/go-github/v82/github" @@ -13,14 +13,21 @@ import ( func resourceGithubEMUGroupMapping() *schema.Resource { return &schema.Resource{ - CreateContext: resourceGithubEMUGroupMappingCreateOrUpdate, + CreateContext: resourceGithubEMUGroupMappingCreate, ReadContext: resourceGithubEMUGroupMappingRead, - UpdateContext: resourceGithubEMUGroupMappingCreateOrUpdate, + UpdateContext: resourceGithubEMUGroupMappingUpdate, DeleteContext: resourceGithubEMUGroupMappingDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubEMUGroupMappingImport, }, + CustomizeDiff: diffTeam, + Description: "Manages the mapping of an external group to a GitHub team.", Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Computed: true, + Description: "ID of the GitHub team.", + }, "team_slug": { Type: schema.TypeString, Required: true, @@ -29,20 +36,32 @@ func resourceGithubEMUGroupMapping() *schema.Resource { "group_id": { Type: schema.TypeInt, Required: true, + ForceNew: true, Description: "Integer corresponding to the external group ID to be linked.", }, + "group_name": { + Type: schema.TypeString, + Computed: true, + Description: "Name of the external group.", + }, "etag": { Type: schema.TypeString, Computed: true, }, }, + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubEMUGroupMappingV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubEMUGroupMappingStateUpgradeV0, + Version: 0, + }, + }, } } -func resourceGithubEMUGroupMappingRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - tflog.Trace(ctx, "Reading EMU group mapping", map[string]any{ - "resource_id": d.Id(), - }) +func resourceGithubEMUGroupMappingCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + tflog.Trace(ctx, "Creating EMU group mapping") err := checkOrganization(meta) if err != nil { @@ -50,77 +69,67 @@ func resourceGithubEMUGroupMappingRead(ctx context.Context, d *schema.ResourceDa } client := meta.(*Owner).v3client orgName := meta.(*Owner).name + tflog.SetField(ctx, "org_name", orgName) - id, ok := d.GetOk("group_id") - if !ok { - return diag.Errorf("could not get group id from provided value") + teamSlug := d.Get("team_slug").(string) + tflog.SetField(ctx, "team_slug", teamSlug) + + groupID := toInt64(d.Get("group_id")) + tflog.SetField(ctx, "group_id", groupID) + eg := &github.ExternalGroup{ + GroupID: github.Ptr(groupID), } - id64, err := getInt64FromInterface(id) + + tflog.Debug(ctx, "Connecting external group to team via GitHub API") + + group, resp, err := client.Teams.UpdateConnectedExternalGroup(ctx, orgName, teamSlug, eg) if err != nil { return diag.FromErr(err) } - tflog.Debug(ctx, "Querying external group from GitHub API", map[string]any{ - "org_name": orgName, - "group_id": id64, - }) + tflog.Debug(ctx, "Successfully updated connected external group") - group, resp, err := client.Teams.GetExternalGroup(ctx, orgName, id64) + teamID, err := lookupTeamID(ctx, meta.(*Owner), teamSlug) if err != nil { - if resp != nil && resp.StatusCode == 404 { - // If the group is not found, remove it from state - tflog.Info(ctx, "Removing EMU group mapping from state because it no longer exists in GitHub", map[string]any{ - "org_name": orgName, - "group_id": id64, - "resource_id": d.Id(), - "status_code": resp.StatusCode, - }) - d.SetId("") - return nil - } return diag.FromErr(err) } - tflog.Debug(ctx, "Successfully retrieved external group from GitHub API", map[string]any{ - "org_name": orgName, - "group_id": id64, - "team_count": len(group.Teams), - }) + newResourceID, err := buildID(strconv.FormatInt(teamID, 10), teamSlug, strconv.FormatInt(groupID, 10)) + if err != nil { + return diag.FromErr(err) + } - if len(group.Teams) < 1 { - // if there's not a team linked, that means it was removed outside of terraform - // and we should remove it from our state - tflog.Info(ctx, "Removing EMU group mapping from state because no teams are linked", map[string]any{ - "org_name": orgName, - "group_id": id64, - "resource_id": d.Id(), - }) - d.SetId("") - return nil + if err := d.Set("team_id", teamID); err != nil { + return diag.FromErr(err) } + tflog.Trace(ctx, "Setting resource ID", map[string]any{ + "resource_id": newResourceID, + }) + d.SetId(newResourceID) + etag := resp.Header.Get("ETag") tflog.Trace(ctx, "Setting state attribute: etag", map[string]any{ "etag": etag, }) - if err = d.Set("etag", etag); err != nil { + if err := d.Set("etag", etag); err != nil { return diag.FromErr(err) } - groupIDInt := int(group.GetGroupID()) - tflog.Trace(ctx, "Setting state attribute: group_id", map[string]any{ - "group_id": groupIDInt, - }) - if err = d.Set("group_id", groupIDInt); err != nil { + if err := d.Set("group_name", group.GetGroupName()); err != nil { return diag.FromErr(err) } + + tflog.Trace(ctx, "Resource created or updated successfully", map[string]any{ + "resource_id": d.Id(), + }) + return nil } -func resourceGithubEMUGroupMappingCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - resourceID := d.Id() - tflog.Trace(ctx, "Creating or updating EMU group mapping", map[string]any{ - "resource_id": resourceID, +func resourceGithubEMUGroupMappingRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + tflog.Trace(ctx, "Reading EMU group mapping", map[string]any{ + "resource_id": d.Id(), }) err := checkOrganization(meta) @@ -130,60 +139,133 @@ func resourceGithubEMUGroupMappingCreateOrUpdate(ctx context.Context, d *schema. client := meta.(*Owner).v3client orgName := meta.(*Owner).name - teamSlug, ok := d.GetOk("team_slug") - if !ok { - return diag.Errorf("could not get team slug from provided value") + groupID := toInt64(d.Get("group_id")) + teamSlug := d.Get("team_slug").(string) + + tflog.SetField(ctx, "group_id", groupID) + tflog.SetField(ctx, "team_slug", teamSlug) + tflog.SetField(ctx, "org_name", orgName) + + tflog.Debug(ctx, "Querying external groups linked to team from GitHub API") + + groupsList, resp, err := client.Teams.ListExternalGroupsForTeamBySlug(ctx, orgName, teamSlug) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusBadRequest { + tflog.Info(ctx, "Removing EMU group mapping from state because the team has explicit members in GitHub", map[string]any{ + "resource_id": d.Id(), + }) + d.SetId("") + return nil + } + if resp != nil && (resp.StatusCode == http.StatusNotFound) { + // If the Group is not found, remove it from state + tflog.Info(ctx, "Removing EMU group mapping from state because team no longer exists in GitHub", map[string]any{ + "resource_id": d.Id(), + }) + d.SetId("") + return nil + } + return diag.FromErr(err) } - id, ok := d.GetOk("group_id") - if !ok { - return diag.Errorf("could not get group id from provided value") + if len(groupsList.Groups) < 1 { + tflog.Info(ctx, "Removing EMU group mapping from state because no external groups are linked to the team", map[string]any{ + "resource_id": d.Id(), + }) + d.SetId("") + return nil } - id64, err := getInt64FromInterface(id) - if err != nil { + + // A team can only be linked to one external group + group := groupsList.Groups[0] + + tflog.Debug(ctx, "Successfully retrieved external group from GitHub API", map[string]any{ + "group_id": group.GetGroupID(), + "group_name": group.GetGroupName(), + }) + + if group.GetGroupID() != groupID { + return diag.Errorf("group id mismatch: %d != %d", group.GetGroupID(), groupID) + } + + etag := resp.Header.Get("ETag") + if err := d.Set("etag", etag); err != nil { return diag.FromErr(err) } - teamSlugStr := teamSlug.(string) + if err := d.Set("group_id", int(group.GetGroupID())); err != nil { + return diag.FromErr(err) + } - eg := &github.ExternalGroup{ - GroupID: &id64, + if err := d.Set("group_name", group.GetGroupName()); err != nil { + return diag.FromErr(err) } - tflog.Debug(ctx, "Updating connected external group via GitHub API", map[string]any{ - "org_name": orgName, - "team_slug": teamSlugStr, - "group_id": id64, + return nil +} + +func resourceGithubEMUGroupMappingUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + tflog.Trace(ctx, "Updating EMU group mapping", map[string]any{ + "resource_id": d.Id(), }) - _, resp, err := client.Teams.UpdateConnectedExternalGroup(ctx, orgName, teamSlugStr, eg) + err := checkOrganization(meta) if err != nil { return diag.FromErr(err) } + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + tflog.SetField(ctx, "org_name", orgName) - tflog.Debug(ctx, "Successfully updated connected external group", map[string]any{ - "org_name": orgName, - "team_slug": teamSlugStr, - "group_id": id64, - }) + teamSlug := d.Get("team_slug").(string) + tflog.SetField(ctx, "team_slug", teamSlug) - newResourceID := fmt.Sprintf("teams/%s/external-groups", teamSlugStr) - tflog.Trace(ctx, "Setting resource ID", map[string]any{ - "resource_id": newResourceID, - }) - d.SetId(newResourceID) + groupID := toInt64(d.Get("group_id")) + tflog.SetField(ctx, "group_id", groupID) + eg := &github.ExternalGroup{ + GroupID: github.Ptr(groupID), + } - etag := resp.Header.Get("ETag") - tflog.Trace(ctx, "Setting state attribute: etag", map[string]any{ - "etag": etag, - }) - if err = d.Set("etag", etag); err != nil { - return diag.FromErr(err) + if d.HasChanges("group_id", "team_slug") { + + tflog.Debug(ctx, "Updating connected external group via GitHub API") + + group, resp, err := client.Teams.UpdateConnectedExternalGroup(ctx, orgName, teamSlug, eg) + if err != nil { + return diag.FromErr(err) + } + + tflog.Debug(ctx, "Successfully updated connected external group") + + etag := resp.Header.Get("ETag") + tflog.Trace(ctx, "Setting state attribute: etag", map[string]any{ + "etag": etag, + }) + if err := d.Set("etag", etag); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("group_name", group.GetGroupName()); err != nil { + return diag.FromErr(err) + } + + teamID := toInt64(d.Get("team_id")) + + newResourceID, err := buildID(strconv.FormatInt(teamID, 10), teamSlug, strconv.FormatInt(groupID, 10)) + if err != nil { + return diag.FromErr(err) + } + + tflog.Trace(ctx, "Setting resource ID", map[string]any{ + "resource_id": newResourceID, + }) + d.SetId(newResourceID) } - tflog.Trace(ctx, "Resource created or updated successfully", map[string]any{ - "resource_id": newResourceID, + tflog.Trace(ctx, "Updated successfully", map[string]any{ + "resource_id": d.Id(), }) + return nil } @@ -224,25 +306,6 @@ func resourceGithubEMUGroupMappingDelete(ctx context.Context, d *schema.Resource return nil } -func getInt64FromInterface(val any) (int64, error) { - var id64 int64 - switch val := val.(type) { - case int64: - id64 = val - case int: - id64 = int64(val) - case string: - var err error - id64, err = strconv.ParseInt(val, 10, 64) - if err != nil { - return 0, fmt.Errorf("could not parse id from string: %w", err) - } - default: - return 0, fmt.Errorf("unexpected type converting to int64 from interface") - } - return id64, nil -} - func resourceGithubEMUGroupMappingImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { importID := d.Id() tflog.Trace(ctx, "Importing EMU group mapping with two-part ID", map[string]any{ @@ -250,7 +313,8 @@ func resourceGithubEMUGroupMappingImport(ctx context.Context, d *schema.Resource "strategy": "two_part_id", }) - groupIDString, teamSlug, err := parseTwoPartID(d.Id(), "group_id", "team_slug") + // : + groupIDString, teamSlug, err := parseID2(d.Id()) if err != nil { return nil, err } @@ -265,6 +329,15 @@ func resourceGithubEMUGroupMappingImport(ctx context.Context, d *schema.Resource "team_slug": teamSlug, }) + teamID, err := lookupTeamID(ctx, meta.(*Owner), teamSlug) + if err != nil { + return nil, err + } + + if err := d.Set("team_id", teamID); err != nil { + return nil, err + } + if err := d.Set("group_id", groupID); err != nil { return nil, err } @@ -273,10 +346,15 @@ func resourceGithubEMUGroupMappingImport(ctx context.Context, d *schema.Resource return nil, err } - resourceID := fmt.Sprintf("teams/%s/external-groups", teamSlug) + resourceID, err := buildID(strconv.FormatInt(teamID, 10), teamSlug, groupIDString) + if err != nil { + return nil, err + } + tflog.Trace(ctx, "Setting resource ID", map[string]any{ "resource_id": resourceID, }) d.SetId(resourceID) + return []*schema.ResourceData{d}, nil } diff --git a/github/resource_github_emu_group_mapping_migration.go b/github/resource_github_emu_group_mapping_migration.go new file mode 100644 index 0000000000..691ad1a733 --- /dev/null +++ b/github/resource_github_emu_group_mapping_migration.go @@ -0,0 +1,68 @@ +package github + +import ( + "context" + "net/http" + "strconv" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEMUGroupMappingV0() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "team_slug": { + Type: schema.TypeString, + Required: true, + Description: "Slug of the GitHub team.", + }, + "group_id": { + Type: schema.TypeInt, + Required: true, + Description: "Integer corresponding to the external group ID to be linked.", + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubEMUGroupMappingStateUpgradeV0(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) { + orgName := meta.(*Owner).name + tflog.Trace(ctx, "GitHub EMU Group Mapping State before migration", map[string]any{"state": rawState, "owner": orgName}) + + client := meta.(*Owner).v3client + + teamSlug := rawState["team_slug"].(string) + // We need to bypass the etag because we need to get the latest group + ctx = context.WithValue(ctx, ctxEtag, nil) + groupsList, resp, err := client.Teams.ListExternalGroupsForTeamBySlug(ctx, orgName, teamSlug) + if err != nil { + if resp != nil && (resp.StatusCode == http.StatusNotFound) { + // If the Group is not found, remove it from state + tflog.Info(ctx, "Removing EMU group mapping from state because team no longer exists in GitHub", map[string]any{ + "resource_id": rawState["id"], + }) + return nil, err + } + return nil, err + } + + group := groupsList.Groups[0] + teamID, err := lookupTeamID(ctx, meta.(*Owner), teamSlug) + if err != nil { + return nil, err + } + rawState["team_id"] = teamID + resourceID, err := buildID(strconv.FormatInt(teamID, 10), teamSlug, strconv.FormatInt(group.GetGroupID(), 10)) + if err != nil { + return nil, err + } + rawState["id"] = resourceID + + tflog.Trace(ctx, "GitHub EMU Group Mapping State after migration", map[string]any{"state": rawState}) + return rawState, nil +} diff --git a/github/resource_github_emu_group_mapping_test.go b/github/resource_github_emu_group_mapping_test.go index 678a0c95ce..209572a27e 100644 --- a/github/resource_github_emu_group_mapping_test.go +++ b/github/resource_github_emu_group_mapping_test.go @@ -126,6 +126,51 @@ func TestAccGithubEMUGroupMapping(t *testing.T) { }, }) }) + + t.Run("forces new when switching to different team", func(t *testing.T) { + t.Skip("Skipping this test because we don't have terraform-plugin-testing available yet.") + randomID := acctest.RandString(5) + teamName1 := fmt.Sprintf("%semu1-%s", testResourcePrefix, randomID) + teamName2 := fmt.Sprintf("%semu2-%s", testResourcePrefix, randomID) + + config := ` +resource "github_team" "test1" { + name = "%s" + description = "EMU group mapping test team 1" +} +resource "github_team" "test2" { + name = "%s" + description = "EMU group mapping test team 2" +} +resource "github_emu_group_mapping" "test" { + team_slug = github_team.%s.slug + group_id = %d +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEMUGroupMappingDestroy, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, teamName1, teamName2, "test1", groupID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_emu_group_mapping.test", "team_slug", teamName1), + ), + }, + { + Config: fmt.Sprintf(config, teamName1, teamName2, "test2", groupID), + // ConfigPlanChecks: resource.ConfigPlanChecks{ + // PreApply: []plancheck.PlanCheck{ + // plancheckExpectKnownValues("github_emu_group_mapping.test", "team_slug", teamName2), + // plancheck.ExpectResourceAction("github_emu_group_mapping.test", plancheck.ResourceActionDestroyBeforeCreate), // Verify that ForceNew is triggered + // }, + // }, + }, + }, + }) + }) } func testAccCheckGithubEMUGroupMappingDestroy(s *terraform.State) error { diff --git a/github/util.go b/github/util.go index a8a2d96f71..e277299715 100644 --- a/github/util.go +++ b/github/util.go @@ -342,3 +342,43 @@ func deleteResourceOn404AndSwallow304OtherwiseReturnError(err error, d *schema.R } return err } + +// Helper function to safely convert interface{} to int, handling both int and float64. +func toInt(v any) int { + switch val := v.(type) { + case int: + return val + case float64: + return int(val) + case int64: + return int(val) + default: + return 0 + } +} + +// Helper function to safely convert interface{} to int64, handling both int and float64. +func toInt64(v any) int64 { + switch val := v.(type) { + case int: + return int64(val) + case int64: + return val + case float64: + return int64(val) + default: + return 0 + } +} + +// lookupTeamID looks up the ID of a team by its slug. +func lookupTeamID(ctx context.Context, meta *Owner, slug string) (int64, error) { + client := meta.v3client + owner := meta.name + + team, _, err := client.Teams.GetTeamBySlug(ctx, owner, slug) + if err != nil { + return 0, err + } + return team.GetID(), nil +} diff --git a/github/util_diff.go b/github/util_diff.go index 42f20d9e61..9b036d2f00 100644 --- a/github/util_diff.go +++ b/github/util_diff.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/google/go-github/v82/github" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -109,3 +110,53 @@ func diffSecretVariableVisibility(ctx context.Context, d *schema.ResourceDiff, _ return nil } + +// diffTeam compares the team_id and team_slug fields to determine if the team has changed. +func diffTeam(ctx context.Context, diff *schema.ResourceDiff, m any) error { + // Skip for new resources - no existing team_id to compare against + if len(diff.Id()) == 0 { + return nil + } + + if diff.HasChange("team_slug") { + if isNewTeamID(ctx, diff, m) { + return diff.ForceNew("team_slug") + } + } + + return nil +} + +// helper function to determine if the team has changed or was renamed. +func isNewTeamID(ctx context.Context, diff *schema.ResourceDiff, m any) bool { + // Get old team_id from state + oldTeamID := toInt64(diff.Get("team_id")) + if oldTeamID == 0 { + return false + } + meta := m.(*Owner) + + // Resolve new team_slug to team ID via API + oldTeamSlug, newTeamSlug := diff.GetChange("team_slug") + newTeamID, err := lookupTeamID(ctx, meta, newTeamSlug.(string)) + if err != nil { + // If team doesn't exist or API fails, skip ForceNew check and let Read handle it + tflog.Debug(ctx, "Unable to resolve new team_slug to team ID, skipping ForceNew check", map[string]any{ + "new_team_slug": newTeamSlug, + "error": err.Error(), + }) + return false + } + + if newTeamID != oldTeamID { + tflog.Debug(ctx, "Team ID changed, forcing new resource", map[string]any{ + "old_team_id": oldTeamID, + "new_team_id": newTeamID, + "new_team_slug": newTeamSlug, + "old_team_slug": oldTeamSlug, + }) + return true + } + + return false +} diff --git a/github/util_rules.go b/github/util_rules.go index 7e4418cb6e..339394d5ee 100644 --- a/github/util_rules.go +++ b/github/util_rules.go @@ -10,34 +10,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -// Helper function to safely convert interface{} to int, handling both int and float64. -func toInt(v any) int { - switch val := v.(type) { - case int: - return val - case float64: - return int(val) - case int64: - return int(val) - default: - return 0 - } -} - -// Helper function to safely convert interface{} to int64, handling both int and float64. -func toInt64(v any) int64 { - switch val := v.(type) { - case int: - return int64(val) - case int64: - return val - case float64: - return int64(val) - default: - return 0 - } -} - func toPullRequestMergeMethods(input any) []github.PullRequestMergeMethod { value, ok := input.([]any) if !ok || len(value) == 0 { diff --git a/go.mod b/go.mod index 7f0f22d0b8..f1ba41382d 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/integrations/terraform-provider-github/v6 -go 1.24.0 +go 1.24.4 require ( github.com/go-jose/go-jose/v3 v3.0.4 + github.com/google/go-cmp v0.7.0 github.com/google/go-github/v82 v82.0.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.5.0 @@ -21,7 +22,6 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect diff --git a/website/docs/r/emu_group_mapping.html.markdown b/website/docs/r/emu_group_mapping.html.markdown index e2819639e6..491515dcf1 100644 --- a/website/docs/r/emu_group_mapping.html.markdown +++ b/website/docs/r/emu_group_mapping.html.markdown @@ -9,8 +9,6 @@ description: |- This resource manages mappings between external groups for enterprise managed users and GitHub teams. It wraps the [Teams#ExternalGroups API](https://docs.github.com/en/rest/reference/teams#external-groups). Note that this is a distinct resource from `github_team_sync_group_mapping`. `github_emu_group_mapping` is special to the Enterprise Managed User (EMU) external group feature, whereas `github_team_sync_group_mapping` is specific to Identity Provider Groups. -!> **Warning:**: This resources `Read` function has a fundamental bug. It doesn't verify that the group is actually linked to the team. Someone could modify the linked group outside of Terraform and the resource would not detect it. - ## Example Usage ```hcl @@ -29,8 +27,8 @@ The following arguments are supported: ## Import -GitHub EMU External Group Mappings can be imported using the external `group_id` and `team_slug` separated by a colon, e.g. +GitHub EMU External Group Mappings can be imported using the `team_slug` and external `group_id` separated by a colon, e.g. ```sh -$ terraform import github_emu_group_mapping.example_emu_group_mapping 28836:emu-test-team +$ terraform import github_emu_group_mapping.example_emu_group_mapping emu-test-team:28836 ```