Skip to content

Commit eb1453a

Browse files
committed
fix(iam): support setting multiple users and groups with appropriate endpoint
* use SetGroupMembers * use expo retry for setting the group membership as the API needs a bit of time to converge
1 parent 77939e4 commit eb1453a

File tree

3 files changed

+342
-258
lines changed

3 files changed

+342
-258
lines changed

internal/services/iam/group_membership.go

Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,27 @@ package iam
33
import (
44
"context"
55
"fmt"
6+
"slices"
7+
"sort"
68
"strings"
9+
"time"
710

811
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
912
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1013
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
1114
"github.com/scaleway/scaleway-sdk-go/scw"
1215
"github.com/scaleway/terraform-provider-scaleway/v2/internal/httperrors"
16+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/transport"
1317
"github.com/scaleway/terraform-provider-scaleway/v2/internal/types"
1418
)
1519

20+
type EntityKind string
21+
22+
const (
23+
EntityKindUser EntityKind = "user"
24+
EntityKindApplication EntityKind = "application"
25+
)
26+
1627
func ResourceGroupMembership() *schema.Resource {
1728
return &schema.Resource{
1829
CreateContext: resourceIamGroupMembershipCreate,
@@ -23,18 +34,20 @@ func ResourceGroupMembership() *schema.Resource {
2334
},
2435
SchemaVersion: 0,
2536
Schema: map[string]*schema.Schema{
26-
"user_id": {
27-
Type: schema.TypeString,
37+
"user_ids": {
38+
Type: schema.TypeList,
39+
Elem: &schema.Schema{Type: schema.TypeString},
2840
Optional: true,
29-
Description: "The ID of the user",
30-
ExactlyOneOf: []string{"application_id"},
41+
ExactlyOneOf: []string{"application_ids"},
42+
Description: "The IDs of the users",
3143
ForceNew: true,
3244
},
33-
"application_id": {
34-
Type: schema.TypeString,
45+
"application_ids": {
46+
Type: schema.TypeList,
47+
Elem: &schema.Schema{Type: schema.TypeString},
3548
Optional: true,
36-
Description: "The ID of the user",
37-
ExactlyOneOf: []string{"user_id"},
49+
ExactlyOneOf: []string{"user_ids"},
50+
Description: "The IDs of the applications",
3851
ForceNew: true,
3952
},
4053
"group_id": {
@@ -50,27 +63,27 @@ func ResourceGroupMembership() *schema.Resource {
5063
func resourceIamGroupMembershipCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
5164
api := NewAPI(m)
5265

53-
userID := types.ExpandStringPtr(d.Get("user_id"))
54-
applicationID := types.ExpandStringPtr(d.Get("application_id"))
66+
userIDs := types.ExpandStrings(d.Get("user_ids"))
67+
applicationIDs := types.ExpandStrings(d.Get("application_ids"))
5568

56-
group, err := api.AddGroupMember(&iam.AddGroupMemberRequest{
57-
GroupID: d.Get("group_id").(string),
58-
UserID: userID,
59-
ApplicationID: applicationID,
60-
}, scw.WithContext(ctx))
69+
group, err := MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
70+
GroupID: d.Get("group_id").(string),
71+
UserIDs: userIDs,
72+
ApplicationIDs: applicationIDs,
73+
})
6174
if err != nil {
6275
return diag.FromErr(err)
6376
}
6477

65-
d.SetId(GroupMembershipID(group.ID, userID, applicationID))
78+
d.SetId(SetGroupMembershipResourceID(group.ID, userIDs, applicationIDs))
6679

6780
return resourceIamGroupMembershipRead(ctx, d, m)
6881
}
6982

7083
func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
7184
api := NewAPI(m)
7285

73-
groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
86+
groupID, entityKind, entityIDs, err := ExpandGroupMembershipResourceID(d.Id())
7487
if err != nil {
7588
return diag.FromErr(err)
7689
}
@@ -88,58 +101,46 @@ func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData,
88101
return diag.FromErr(err)
89102
}
90103

91-
foundInGroup := false
92-
93-
if userID != "" {
94-
for _, groupUserID := range group.UserIDs {
95-
if groupUserID == userID {
96-
foundInGroup = true
104+
foundEntityIDs := make([]bool, len(entityIDs))
97105

98-
break
106+
if entityKind == EntityKindUser {
107+
for i, groupUserID := range group.UserIDs {
108+
if slices.Contains(entityIDs, groupUserID) {
109+
foundEntityIDs[i] = true
99110
}
100111
}
101-
} else if applicationID != "" {
102-
for _, groupApplicationID := range group.ApplicationIDs {
103-
if groupApplicationID == applicationID {
104-
foundInGroup = true
105-
106-
break
112+
} else if entityKind == EntityKindApplication {
113+
for i, groupApplicationID := range group.ApplicationIDs {
114+
if slices.Contains(entityIDs, groupApplicationID) {
115+
foundEntityIDs[i] = true
107116
}
108117
}
109118
}
110119

111-
if !foundInGroup {
120+
if slices.Contains(foundEntityIDs, false) {
112121
d.SetId("")
113122

114123
return nil
115124
}
116125

117126
_ = d.Set("group_id", groupID)
118-
_ = d.Set("user_id", userID)
119-
_ = d.Set("application_id", applicationID)
127+
_ = d.Set(fmt.Sprintf("%s_ids", entityKind), entityIDs)
120128

121129
return nil
122130
}
123131

124132
func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
125133
api := NewAPI(m)
126134

127-
groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
135+
groupID, _, _, err := ExpandGroupMembershipResourceID(d.Id())
128136
if err != nil {
129137
return diag.FromErr(err)
130138
}
131139

132-
req := &iam.RemoveGroupMemberRequest{
140+
_, err = MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
133141
GroupID: groupID,
134-
}
135-
136-
if userID != "" {
137-
req.UserID = &userID
138-
} else if applicationID != "" {
139-
req.ApplicationID = &applicationID
140-
}
142+
})
141143

142-
_, err = api.RemoveGroupMember(req, scw.WithContext(ctx))
143144
if err != nil {
144145
if httperrors.Is404(err) {
145146
d.SetId("")
@@ -153,28 +154,60 @@ func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceDat
153154
return nil
154155
}
155156

156-
func GroupMembershipID(groupID string, userID *string, applicationID *string) string {
157-
if userID != nil {
158-
return fmt.Sprintf("%s/user/%s", groupID, *userID)
157+
func SetGroupMembershipResourceID(groupID string, userIDs []string, applicationIDs []string) (resourceID string) {
158+
sort.Strings(userIDs)
159+
sort.Strings(applicationIDs)
160+
161+
if len(userIDs) > 0 {
162+
resourceID = fmt.Sprintf("%s/%s/%s", groupID, EntityKindUser, strings.Join(userIDs, ","))
163+
} else if len(applicationIDs) > 0 {
164+
resourceID = fmt.Sprintf("%s/%s/%s", groupID, EntityKindApplication, strings.Join(applicationIDs, ","))
159165
}
160166

161-
return fmt.Sprintf("%s/app/%s", groupID, *applicationID)
167+
return
162168
}
163169

164-
func ExpandGroupMembershipID(id string) (groupID string, userID string, applicationID string, err error) {
170+
func ExpandGroupMembershipResourceID(id string) (groupID string, kind EntityKind, entityIDs []string, err error) {
165171
elems := strings.Split(id, "/")
166172
if len(elems) != 3 {
167-
return "", "", "", fmt.Errorf("invalid group member id format, expected {groupID}/{type}/{memberID}, got: %s", id)
173+
return "", "", []string{}, fmt.Errorf("invalid group membership id format, expected {groupID}/{entityKind}/{entityIDs}, got: %s", id)
168174
}
169175

170176
groupID = elems[0]
171-
172-
switch elems[1] {
173-
case "user":
174-
userID = elems[2]
175-
case "app":
176-
applicationID = elems[2]
177+
kind = EntityKind(elems[1])
178+
if kind != EntityKindUser && kind != EntityKindApplication {
179+
return "", "", []string{}, fmt.Errorf("invalid entity kind, expected %s or %s, got: %s", EntityKindUser, EntityKindApplication, kind)
177180
}
181+
entityIDs = strings.Split(elems[2], ",")
178182

179183
return
180184
}
185+
186+
func MakeSetGroupMembershipRequest(ctx context.Context, api *iam.API, request *iam.SetGroupMembersRequest) (*iam.Group, error) {
187+
retryInterval := 250 * time.Millisecond
188+
maxRetries := 10
189+
190+
if transport.DefaultWaitRetryInterval != nil {
191+
retryInterval = *transport.DefaultWaitRetryInterval
192+
}
193+
194+
var response *iam.Group
195+
var err error
196+
197+
// exponential backoff
198+
for i := 0; i < maxRetries; i++ {
199+
response, err = api.SetGroupMembers(request, scw.WithContext(ctx))
200+
if err != nil {
201+
if httperrors.Is409(err) && strings.Contains(err.Error(), fmt.Sprintf("resource group with ID %s is in a transient state: updating", request.GroupID)) {
202+
time.Sleep(retryInterval * time.Duration(i))
203+
continue
204+
}
205+
206+
return nil, err
207+
}
208+
209+
return response, nil
210+
}
211+
212+
return nil, fmt.Errorf("failed to set group membership after %d retries", maxRetries)
213+
}

internal/services/iam/group_membership_test.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestAccGroupMembership_Basic(t *testing.T) {
3636
3737
resource scaleway_iam_group_membership main {
3838
group_id = scaleway_iam_group.main.id
39-
application_id = scaleway_iam_application.main.id
39+
application_ids = [scaleway_iam_application.main.id]
4040
}
4141
`,
4242
Check: resource.ComposeTestCheckFunc(
@@ -57,12 +57,12 @@ func TestAccGroupMembership_Basic(t *testing.T) {
5757
5858
resource scaleway_iam_group_membership main {
5959
group_id = scaleway_iam_group.main.id
60-
application_id = scaleway_iam_application.main.id
60+
application_ids = [scaleway_iam_application.main.id]
6161
}
6262
6363
resource scaleway_iam_group_membership import {
6464
group_id = scaleway_iam_group.main.id
65-
application_id = scaleway_iam_application.main.id
65+
application_ids = [scaleway_iam_application.main.id]
6666
}
6767
`,
6868
ImportState: true,
@@ -71,7 +71,7 @@ func TestAccGroupMembership_Basic(t *testing.T) {
7171
groupID := state.RootModule().Resources["scaleway_iam_group.main"].Primary.ID
7272
applicationID := state.RootModule().Resources["scaleway_iam_application.main"].Primary.ID
7373

74-
return iam.GroupMembershipID(groupID, nil, &applicationID), nil
74+
return iam.SetGroupMembershipResourceID(groupID, nil, []string{applicationID}), nil
7575
},
7676
ImportStatePersist: true,
7777
},
@@ -88,12 +88,12 @@ func TestAccGroupMembership_Basic(t *testing.T) {
8888
8989
resource scaleway_iam_group_membership main {
9090
group_id = scaleway_iam_group.main.id
91-
application_id = scaleway_iam_application.main.id
91+
application_ids = [scaleway_iam_application.main.id]
9292
}
9393
9494
resource scaleway_iam_group_membership import {
9595
group_id = scaleway_iam_group.main.id
96-
application_id = scaleway_iam_application.main.id
96+
application_ids = [scaleway_iam_application.main.id]
9797
}
9898
`,
9999
PlanOnly: true,
@@ -154,12 +154,12 @@ func testAccCheckIamGroupMembershipApplicationInGroup(tt *acctest.TestTools, n s
154154

155155
api := iam.NewAPI(tt.Meta)
156156

157-
groupID, _, applicationID, err := iam.ExpandGroupMembershipID(rs.Primary.ID)
157+
groupID, _, applicationID, err := iam.ExpandGroupMembershipResourceID(rs.Primary.ID)
158158
if err != nil {
159159
return err
160160
}
161161

162-
if applicationID != expectedApplicationID {
162+
if applicationID[0] != expectedApplicationID {
163163
return fmt.Errorf("group membership id does not contain expected application id, expected %s, got %s", expectedApplicationID, applicationID)
164164
}
165165

@@ -173,7 +173,7 @@ func testAccCheckIamGroupMembershipApplicationInGroup(tt *acctest.TestTools, n s
173173
foundInGroup := false
174174

175175
for _, groupApplicationID := range group.ApplicationIDs {
176-
if groupApplicationID == applicationID {
176+
if groupApplicationID == applicationID[0] {
177177
foundInGroup = true
178178
}
179179
}
@@ -202,12 +202,12 @@ func testAccCheckIamGroupMembershipUserInGroup(tt *acctest.TestTools, n string,
202202

203203
api := iam.NewAPI(tt.Meta)
204204

205-
groupID, userID, _, err := iam.ExpandGroupMembershipID(rs.Primary.ID)
205+
groupID, _, userID, err := iam.ExpandGroupMembershipResourceID(rs.Primary.ID)
206206
if err != nil {
207207
return err
208208
}
209209

210-
if userID != expectedUserID {
210+
if userID[0] != expectedUserID {
211211
return fmt.Errorf("group membership id does not contain expected user id, expected %s, got %s", expectedUserID, userID)
212212
}
213213

@@ -221,7 +221,7 @@ func testAccCheckIamGroupMembershipUserInGroup(tt *acctest.TestTools, n string,
221221
foundInGroup := false
222222

223223
for _, groupUserID := range group.UserIDs {
224-
if groupUserID == userID {
224+
if groupUserID == userID[0] {
225225
foundInGroup = true
226226
}
227227
}

0 commit comments

Comments
 (0)