@@ -3,74 +3,85 @@ package iam
33import (
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+
1626func 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
5061func 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
7081func 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