Skip to content

Commit 93c166e

Browse files
committed
fix(iam): support setting multiple users and groups with appropriate endpoint
* use SetGroupMembers * implement terraform update * use expo retry with SetGroupMembers as the API sometimes needs a bit of time to converge
1 parent 77939e4 commit 93c166e

File tree

6 files changed

+5149
-586
lines changed

6 files changed

+5149
-586
lines changed

internal/services/iam/group_membership.go

Lines changed: 145 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,74 +3,85 @@ package iam
33
import (
44
"context"
55
"fmt"
6+
"slices"
67
"strings"
8+
"time"
79

810
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
911
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1012
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
1113
"github.com/scaleway/scaleway-sdk-go/scw"
1214
"github.com/scaleway/terraform-provider-scaleway/v2/internal/httperrors"
15+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/transport"
1316
"github.com/scaleway/terraform-provider-scaleway/v2/internal/types"
1417
)
1518

19+
type EntityKind string
20+
21+
const (
22+
EntityKindUser EntityKind = "user"
23+
EntityKindApplication EntityKind = "application"
24+
)
25+
1626
func ResourceGroupMembership() *schema.Resource {
1727
return &schema.Resource{
1828
CreateContext: resourceIamGroupMembershipCreate,
1929
ReadContext: resourceIamGroupMembershipRead,
30+
UpdateContext: resourceIamGroupMembershipUpdate,
2031
DeleteContext: resourceIamGroupMembershipDelete,
2132
Importer: &schema.ResourceImporter{
2233
StateContext: schema.ImportStatePassthroughContext,
2334
},
2435
SchemaVersion: 0,
2536
Schema: map[string]*schema.Schema{
26-
"user_id": {
27-
Type: schema.TypeString,
28-
Optional: true,
29-
Description: "The ID of the user",
30-
ExactlyOneOf: []string{"application_id"},
31-
ForceNew: true,
32-
},
33-
"application_id": {
34-
Type: schema.TypeString,
35-
Optional: true,
36-
Description: "The ID of the user",
37-
ExactlyOneOf: []string{"user_id"},
38-
ForceNew: true,
39-
},
4037
"group_id": {
4138
Type: schema.TypeString,
4239
Required: true,
43-
Description: "The ID of the group to add the user to",
40+
Description: "The ID of the group to add the users or applications to",
4441
ForceNew: true,
4542
},
43+
"user_ids": {
44+
Type: schema.TypeList,
45+
Elem: &schema.Schema{Type: schema.TypeString},
46+
Optional: true,
47+
Description: "The IDs of the users to add to the group",
48+
AtLeastOneOf: []string{"application_ids"},
49+
},
50+
"application_ids": {
51+
Type: schema.TypeList,
52+
Elem: &schema.Schema{Type: schema.TypeString},
53+
Optional: true,
54+
Description: "The IDs of the applications to add to the group",
55+
AtLeastOneOf: []string{"user_ids"},
56+
},
4657
},
4758
}
4859
}
4960

5061
func resourceIamGroupMembershipCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
5162
api := NewAPI(m)
5263

53-
userID := types.ExpandStringPtr(d.Get("user_id"))
54-
applicationID := types.ExpandStringPtr(d.Get("application_id"))
64+
userIDs := types.ExpandStrings(d.Get("user_ids"))
65+
applicationIDs := types.ExpandStrings(d.Get("application_ids"))
5566

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

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

6778
return resourceIamGroupMembershipRead(ctx, d, m)
6879
}
6980

7081
func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
7182
api := NewAPI(m)
7283

73-
groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
84+
groupID, entityIDs, err := ExpandGroupMembershipResourceID(d.Id())
7485
if err != nil {
7586
return diag.FromErr(err)
7687
}
@@ -88,58 +99,76 @@ func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData,
8899
return diag.FromErr(err)
89100
}
90101

91-
foundInGroup := false
92-
93-
if userID != "" {
94-
for _, groupUserID := range group.UserIDs {
95-
if groupUserID == userID {
96-
foundInGroup = true
97-
98-
break
99-
}
100-
}
101-
} else if applicationID != "" {
102-
for _, groupApplicationID := range group.ApplicationIDs {
103-
if groupApplicationID == applicationID {
104-
foundInGroup = true
102+
foundUserIDs := make([]bool, len(entityIDs[EntityKindUser]))
103+
foundApplicationIDs := make([]bool, len(entityIDs[EntityKindApplication]))
105104

106-
break
107-
}
105+
for i, userID := range entityIDs[EntityKindUser] {
106+
if slices.Contains(group.UserIDs, userID) {
107+
foundUserIDs[i] = true
108+
} else {
109+
return diag.FromErr(fmt.Errorf("user %s not found in group %s", userID, groupID))
108110
}
109111
}
110112

111-
if !foundInGroup {
112-
d.SetId("")
113-
114-
return nil
113+
for i, applicationID := range entityIDs[EntityKindApplication] {
114+
if slices.Contains(group.ApplicationIDs, applicationID) {
115+
foundApplicationIDs[i] = true
116+
} else {
117+
return diag.FromErr(fmt.Errorf("application %s not found in group %s", applicationID, groupID))
118+
}
115119
}
116120

117121
_ = d.Set("group_id", groupID)
118-
_ = d.Set("user_id", userID)
119-
_ = d.Set("application_id", applicationID)
122+
_ = d.Set("user_ids", entityIDs[EntityKindUser])
123+
_ = d.Set("application_ids", entityIDs[EntityKindApplication])
120124

121125
return nil
122126
}
123127

124-
func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
128+
func resourceIamGroupMembershipUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
125129
api := NewAPI(m)
126130

127-
groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
131+
groupID, _, err := ExpandGroupMembershipResourceID(d.Id())
128132
if err != nil {
129133
return diag.FromErr(err)
130134
}
131135

132-
req := &iam.RemoveGroupMemberRequest{
133-
GroupID: groupID,
136+
userIDs := types.ExpandStrings(d.Get("user_ids"))
137+
applicationIDs := types.ExpandStrings(d.Get("application_ids"))
138+
139+
request := &iam.SetGroupMembersRequest{
140+
GroupID: groupID,
141+
UserIDs: userIDs,
142+
ApplicationIDs: applicationIDs,
134143
}
135144

136-
if userID != "" {
137-
req.UserID = &userID
138-
} else if applicationID != "" {
139-
req.ApplicationID = &applicationID
145+
if d.HasChanges("user_ids", "application_ids") {
146+
group, err := MakeSetGroupMembershipRequest(ctx, api, request)
147+
if err != nil {
148+
return diag.FromErr(err)
149+
}
150+
151+
if group.ID != groupID {
152+
return diag.FromErr(fmt.Errorf("group id changed from %s to %s", groupID, group.ID))
153+
}
154+
155+
d.SetId(SetGroupMembershipResourceID(groupID, userIDs, applicationIDs))
140156
}
141157

142-
_, err = api.RemoveGroupMember(req, scw.WithContext(ctx))
158+
return resourceIamGroupMembershipRead(ctx, d, m)
159+
}
160+
161+
func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
162+
api := NewAPI(m)
163+
164+
groupID, _, err := ExpandGroupMembershipResourceID(d.Id())
165+
if err != nil {
166+
return diag.FromErr(err)
167+
}
168+
169+
_, err = MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
170+
GroupID: groupID,
171+
})
143172
if err != nil {
144173
if httperrors.Is404(err) {
145174
d.SetId("")
@@ -153,28 +182,77 @@ func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceDat
153182
return nil
154183
}
155184

156-
func GroupMembershipID(groupID string, userID *string, applicationID *string) string {
157-
if userID != nil {
158-
return fmt.Sprintf("%s/user/%s", groupID, *userID)
185+
// Depending on the kind of entity, it will generate a parsable state like:
186+
// groupID/user:userID,application:applicationID
187+
func SetGroupMembershipResourceID(groupID string, userIDs []string, applicationIDs []string) (resourceID string) {
188+
entityIDs := make([]string, 0)
189+
190+
for _, userID := range userIDs {
191+
entityIDs = append(entityIDs, fmt.Sprintf("%s:%s", EntityKindUser, userID))
159192
}
160193

161-
return fmt.Sprintf("%s/app/%s", groupID, *applicationID)
194+
for _, applicationID := range applicationIDs {
195+
entityIDs = append(entityIDs, fmt.Sprintf("%s:%s", EntityKindApplication, applicationID))
196+
}
197+
198+
resourceID = fmt.Sprintf("%s/%s", groupID, strings.Join(entityIDs, ","))
199+
200+
return
162201
}
163202

164-
func ExpandGroupMembershipID(id string) (groupID string, userID string, applicationID string, err error) {
203+
func ExpandGroupMembershipResourceID(id string) (groupID string, entityIDs map[EntityKind][]string, err error) {
165204
elems := strings.Split(id, "/")
166-
if len(elems) != 3 {
167-
return "", "", "", fmt.Errorf("invalid group member id format, expected {groupID}/{type}/{memberID}, got: %s", id)
205+
if len(elems) != 2 {
206+
return "", nil, fmt.Errorf("invalid group membership id format, expected {groupID}/{entityKind}:{entityIDs}, got: %s", id)
168207
}
169208

170209
groupID = elems[0]
171210

172-
switch elems[1] {
173-
case "user":
174-
userID = elems[2]
175-
case "app":
176-
applicationID = elems[2]
211+
// entityKind:entityID,entityKind:entityID
212+
entityKindAndIDs := strings.Split(elems[1], ",")
213+
entityIDs = make(map[EntityKind][]string)
214+
215+
for _, entityKindAndID := range entityKindAndIDs {
216+
splitted := strings.Split(entityKindAndID, ":")
217+
if len(splitted) != 2 {
218+
return "", nil, fmt.Errorf("invalid entity kind and id format, expected {entityKind}:{entityID}, got: %s", entityKindAndID)
219+
}
220+
221+
entityKind, entityID := EntityKind(splitted[0]), splitted[1]
222+
if entityKind != EntityKindUser && entityKind != EntityKindApplication {
223+
return "", nil, fmt.Errorf("invalid entity kind, expected %s or %s, got: %s", EntityKindUser, EntityKindApplication, entityKind)
224+
}
225+
226+
entityIDs[entityKind] = append(entityIDs[entityKind], entityID)
177227
}
178228

179229
return
180230
}
231+
232+
func MakeSetGroupMembershipRequest(ctx context.Context, api *iam.API, request *iam.SetGroupMembersRequest) (*iam.Group, error) {
233+
retryInterval := 250 * time.Millisecond
234+
maxRetries := 10
235+
236+
if transport.DefaultWaitRetryInterval != nil {
237+
retryInterval = *transport.DefaultWaitRetryInterval
238+
}
239+
240+
// the IAM API often returns a 409 when the group is in a transient state
241+
// so we retry with an exponential backoff
242+
for i := range maxRetries {
243+
response, err := api.SetGroupMembers(request, scw.WithContext(ctx))
244+
if err != nil {
245+
if httperrors.Is409(err) && strings.Contains(err.Error(), fmt.Sprintf("resource group with ID %s is in a transient state: updating", request.GroupID)) {
246+
time.Sleep(retryInterval * time.Duration(i)) // lintignore: R018
247+
248+
continue
249+
}
250+
251+
return nil, err
252+
}
253+
254+
return response, nil
255+
}
256+
257+
return nil, fmt.Errorf("failed to set group membership after %d retries", maxRetries)
258+
}

0 commit comments

Comments
 (0)