Skip to content

Commit d6b22a6

Browse files
[FEAT] Support managing github_membership by stable user_id
Add an optional 'user_id' input (mutually exclusive with 'username', both ForceNew) so org memberships can be addressed by GitHub's stable numeric user ID. This makes the resource resilient to the user renaming their GitHub account: Read resolves the current login via GET /user/{id} and silently updates the 'username' attribute in state, producing no diff. Resource ID format changes from 'org:username' to 'org:user_id' for new resources. Read performs a lazy migration: when an existing state has an ID of the old shape, the username is resolved to its numeric ID and the ID is rewritten in place. Imports support both legacy 'org:username' and the new 'org:user_id' shape. Includes an acceptance test (TestAccGithubMembershipRenameResilience) that exercises the rename path end-to-end. The test requires GH_TEST_EXTERNAL_USER_TOKEN since PATCH /user only works for the authenticated user and skips otherwise. Closes #524
1 parent b0e73ff commit d6b22a6

5 files changed

Lines changed: 329 additions & 16 deletions

File tree

docs/resources/membership.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,46 @@ resource "github_membership" "membership_for_some_user" {
2020
}
2121
```
2222

23+
### Identifying the user by stable numeric ID
24+
25+
Using `user_id` instead of `username` makes the membership resilient to the user renaming their GitHub account. After a rename, the next `terraform refresh` updates the `username` attribute in state with no diff, and the resource continues to manage the same membership.
26+
27+
```terraform
28+
# Manage organization membership by stable GitHub user ID.
29+
# Recommended over `username` for production: if the user renames their
30+
# account, the membership stays in sync without drift.
31+
resource "github_membership" "membership_by_user_id" {
32+
user_id = 1
33+
role = "member"
34+
}
35+
```
36+
2337
## Argument Reference
2438

2539
The following arguments are supported:
2640

27-
- `username` - (Required) The user to add to the organization.
41+
Exactly one of:
42+
43+
- `username` - (Optional) The user (login) to add to the organization. Note: usernames can change; if the user renames themselves, the resource will recreate unless `user_id` is used instead.
44+
- `user_id` - (Optional) The GitHub numeric user ID to add to the organization. Stable across username changes. Recommended for production use.
45+
46+
Other arguments:
47+
2848
- `role` - (Optional) The role of the user within the organization. Must be one of `member` or `admin`. Defaults to `member`. `admin` role represents the `owner` role available via GitHub UI.
2949
- `downgrade_on_destroy` - (Optional) Defaults to `false`. If set to true, when this resource is destroyed, the member will not be removed from the organization. Instead, the member's role will be downgraded to 'member'.
3050

51+
## Attributes Reference
52+
53+
- `username` - The user's current login. When the resource is identified by `user_id`, this attribute tracks the user's live login at refresh time.
54+
- `user_id` - The GitHub numeric user ID.
55+
- `etag` - The etag of the membership object.
56+
3157
## Import
3258

33-
GitHub Membership can be imported using an ID made up of `organization:username`, e.g.
59+
GitHub Membership can be imported using an ID made up of `organization:user_id`, e.g.
3460

3561
```shell
36-
terraform import github_membership.member hashicorp:someuser
62+
terraform import github_membership.member hashicorp:12345
3763
```
64+
65+
Legacy IDs of the form `organization:username` are still accepted on import and will be migrated to the numeric form on the next refresh.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Manage organization membership by stable GitHub user ID.
2+
# Recommended over `username` for production: if the user renames their
3+
# account, the membership stays in sync without drift.
4+
resource "github_membership" "membership_by_user_id" {
5+
user_id = 1
6+
role = "member"
7+
}

github/resource_github_membership.go

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"strconv"
89

910
"github.com/google/go-github/v86/github"
1011
"github.com/hashicorp/terraform-plugin-log/tflog"
@@ -25,10 +26,20 @@ func resourceGithubMembership() *schema.Resource {
2526
Schema: map[string]*schema.Schema{
2627
"username": {
2728
Type: schema.TypeString,
28-
Required: true,
29+
Optional: true,
30+
Computed: true,
2931
ForceNew: true,
3032
DiffSuppressFunc: caseInsensitive(),
31-
Description: "The user to add to the organization.",
33+
ExactlyOneOf: []string{"username", "user_id"},
34+
Description: "The user (login) to add to the organization. Exactly one of `username` or `user_id` must be set.",
35+
},
36+
"user_id": {
37+
Type: schema.TypeInt,
38+
Optional: true,
39+
Computed: true,
40+
ForceNew: true,
41+
ExactlyOneOf: []string{"username", "user_id"},
42+
Description: "The GitHub numeric user ID to add to the organization. Stable across username changes; recommended over `username` for production usage. Exactly one of `username` or `user_id` must be set.",
3243
},
3344
"role": {
3445
Type: schema.TypeString,
@@ -58,14 +69,19 @@ func resourceGithubMembershipCreateOrUpdate(ctx context.Context, d *schema.Resou
5869
}
5970

6071
client := meta.(*Owner).v3client
61-
6272
orgName := meta.(*Owner).name
63-
username := d.Get("username").(string)
64-
roleName := d.Get("role").(string)
73+
6574
if !d.IsNewResource() {
6675
ctx = context.WithValue(ctx, ctxId, d.Id())
6776
}
6877

78+
username, userID, err := resolveMembershipIdentity(ctx, client, d)
79+
if err != nil {
80+
return diag.FromErr(err)
81+
}
82+
83+
roleName := d.Get("role").(string)
84+
6985
_, resp, err := client.Organizations.EditOrgMembership(ctx,
7086
username,
7187
orgName,
@@ -77,8 +93,14 @@ func resourceGithubMembershipCreateOrUpdate(ctx context.Context, d *schema.Resou
7793
return diag.FromErr(err)
7894
}
7995

80-
d.SetId(buildTwoPartID(orgName, username))
96+
d.SetId(buildTwoPartID(orgName, strconv.FormatInt(userID, 10)))
8197

98+
if err = d.Set("username", username); err != nil {
99+
return diag.FromErr(err)
100+
}
101+
if err = d.Set("user_id", userID); err != nil {
102+
return diag.FromErr(err)
103+
}
82104
if err = d.Set("etag", resp.Header.Get("ETag")); err != nil {
83105
return diag.FromErr(err)
84106
}
@@ -93,19 +115,38 @@ func resourceGithubMembershipRead(ctx context.Context, d *schema.ResourceData, m
93115
}
94116

95117
client := meta.(*Owner).v3client
96-
97118
orgName := meta.(*Owner).name
98-
_, username, err := parseID2(d.Id())
119+
120+
orgPart, secondPart, err := parseID2(d.Id())
99121
if err != nil {
100122
return diag.FromErr(err)
101123
}
124+
125+
username, userID, err := loginAndIDFromIDPart(ctx, client, secondPart)
126+
if err != nil {
127+
var ghErr *github.ErrorResponse
128+
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound {
129+
tflog.Info(ctx, fmt.Sprintf("Removing membership %s from state because the user no longer exists in GitHub", d.Id()), map[string]any{
130+
"membership_id": d.Id(),
131+
})
132+
d.SetId("")
133+
return nil
134+
}
135+
return diag.FromErr(err)
136+
}
137+
138+
// Lazily migrate legacy IDs of the form `org:username` to `org:user_id`.
139+
// New resources are always created with the numeric form (see Create).
140+
if secondPart != strconv.FormatInt(userID, 10) {
141+
d.SetId(buildTwoPartID(orgPart, strconv.FormatInt(userID, 10)))
142+
}
143+
102144
ctx = context.WithValue(ctx, ctxId, d.Id())
103145
if !d.IsNewResource() {
104146
ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string))
105147
}
106148

107-
membership, resp, err := client.Organizations.GetOrgMembership(ctx,
108-
username, orgName)
149+
membership, resp, err := client.Organizations.GetOrgMembership(ctx, username, orgName)
109150
if err != nil {
110151
var ghErr *github.ErrorResponse
111152
if errors.As(err, &ghErr) {
@@ -129,6 +170,9 @@ func resourceGithubMembershipRead(ctx context.Context, d *schema.ResourceData, m
129170
if err = d.Set("username", username); err != nil {
130171
return diag.FromErr(err)
131172
}
173+
if err = d.Set("user_id", userID); err != nil {
174+
return diag.FromErr(err)
175+
}
132176
if err = d.Set("role", membership.GetRole()); err != nil {
133177
return diag.FromErr(err)
134178
}
@@ -146,6 +190,8 @@ func resourceGithubMembershipDelete(ctx context.Context, d *schema.ResourceData,
146190
orgName := meta.(*Owner).name
147191
ctx = context.WithValue(ctx, ctxId, d.Id())
148192

193+
// Username in state is kept fresh by Read, so it reflects the user's
194+
// current login even after a rename.
149195
username := d.Get("username").(string)
150196
downgradeOnDestroy := d.Get("downgrade_on_destroy").(bool)
151197
downgradeTo := "member"
@@ -212,3 +258,44 @@ func resourceGithubMembershipDelete(ctx context.Context, d *schema.ResourceData,
212258

213259
return diag.FromErr(err)
214260
}
261+
262+
// resolveMembershipIdentity returns the (login, numeric_id) pair for the
263+
// configured membership, regardless of whether the user supplied `username`
264+
// or `user_id`. The GitHub org membership endpoints only accept the login, so
265+
// when `user_id` is provided we must resolve it via GET /user/{id} first.
266+
func resolveMembershipIdentity(ctx context.Context, client *github.Client, d *schema.ResourceData) (string, int64, error) {
267+
if v, ok := d.GetOk("user_id"); ok {
268+
userID := int64(v.(int))
269+
user, _, err := client.Users.GetByID(ctx, userID)
270+
if err != nil {
271+
return "", 0, err
272+
}
273+
return user.GetLogin(), user.GetID(), nil
274+
}
275+
276+
username := d.Get("username").(string)
277+
user, _, err := client.Users.Get(ctx, username)
278+
if err != nil {
279+
return "", 0, err
280+
}
281+
return user.GetLogin(), user.GetID(), nil
282+
}
283+
284+
// loginAndIDFromIDPart resolves the (login, numeric_id) pair from the second
285+
// segment of a resource ID. New resources use `org:<numeric_id>`; legacy
286+
// resources use `org:<username>` and are migrated on the next Read.
287+
func loginAndIDFromIDPart(ctx context.Context, client *github.Client, idPart string) (string, int64, error) {
288+
if userID, err := strconv.ParseInt(idPart, 10, 64); err == nil {
289+
user, _, err := client.Users.GetByID(ctx, userID)
290+
if err != nil {
291+
return "", 0, err
292+
}
293+
return user.GetLogin(), user.GetID(), nil
294+
}
295+
296+
user, _, err := client.Users.Get(ctx, idPart)
297+
if err != nil {
298+
return "", 0, err
299+
}
300+
return user.GetLogin(), user.GetID(), nil
301+
}

0 commit comments

Comments
 (0)