@@ -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,69 @@ 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
105-
106- break
107- }
102+ for _ , userID := range entityIDs [EntityKindUser ] {
103+ if ! slices .Contains (group .UserIDs , userID ) {
104+ return diag .FromErr (fmt .Errorf ("user %s not found in group %s" , userID , groupID ))
108105 }
109106 }
110107
111- if ! foundInGroup {
112- d . SetId ( "" )
113-
114- return nil
108+ for _ , applicationID := range entityIDs [ EntityKindApplication ] {
109+ if ! slices . Contains ( group . ApplicationIDs , applicationID ) {
110+ return diag . FromErr ( fmt . Errorf ( "application %s not found in group %s" , applicationID , groupID ))
111+ }
115112 }
116113
117114 _ = d .Set ("group_id" , groupID )
118- _ = d .Set ("user_id " , userID )
119- _ = d .Set ("application_id " , applicationID )
115+ _ = d .Set ("user_ids " , entityIDs [ EntityKindUser ] )
116+ _ = d .Set ("application_ids " , entityIDs [ EntityKindApplication ] )
120117
121118 return nil
122119}
123120
124- func resourceIamGroupMembershipDelete (ctx context.Context , d * schema.ResourceData , m interface {}) diag.Diagnostics {
121+ func resourceIamGroupMembershipUpdate (ctx context.Context , d * schema.ResourceData , m interface {}) diag.Diagnostics {
125122 api := NewAPI (m )
126123
127- groupID , userID , applicationID , err := ExpandGroupMembershipID (d .Id ())
124+ groupID , _ , err := ExpandGroupMembershipResourceID (d .Id ())
128125 if err != nil {
129126 return diag .FromErr (err )
130127 }
131128
132- req := & iam.RemoveGroupMemberRequest {
133- GroupID : groupID ,
129+ userIDs := types .ExpandStrings (d .Get ("user_ids" ))
130+ applicationIDs := types .ExpandStrings (d .Get ("application_ids" ))
131+
132+ request := & iam.SetGroupMembersRequest {
133+ GroupID : groupID ,
134+ UserIDs : userIDs ,
135+ ApplicationIDs : applicationIDs ,
134136 }
135137
136- if userID != "" {
137- req .UserID = & userID
138- } else if applicationID != "" {
139- req .ApplicationID = & applicationID
138+ if d .HasChanges ("user_ids" , "application_ids" ) {
139+ group , err := MakeSetGroupMembershipRequest (ctx , api , request )
140+ if err != nil {
141+ return diag .FromErr (err )
142+ }
143+
144+ if group .ID != groupID {
145+ return diag .FromErr (fmt .Errorf ("group id changed from %s to %s" , groupID , group .ID ))
146+ }
147+
148+ d .SetId (SetGroupMembershipResourceID (groupID , userIDs , applicationIDs ))
149+ }
150+
151+ return resourceIamGroupMembershipRead (ctx , d , m )
152+ }
153+
154+ func resourceIamGroupMembershipDelete (ctx context.Context , d * schema.ResourceData , m interface {}) diag.Diagnostics {
155+ api := NewAPI (m )
156+
157+ groupID , _ , err := ExpandGroupMembershipResourceID (d .Id ())
158+ if err != nil {
159+ return diag .FromErr (err )
140160 }
141161
142- _ , err = api .RemoveGroupMember (req , scw .WithContext (ctx ))
162+ _ , err = MakeSetGroupMembershipRequest (ctx , api , & iam.SetGroupMembersRequest {
163+ GroupID : groupID ,
164+ })
143165 if err != nil {
144166 if httperrors .Is404 (err ) {
145167 d .SetId ("" )
@@ -153,28 +175,78 @@ func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceDat
153175 return nil
154176}
155177
156- func GroupMembershipID (groupID string , userID * string , applicationID * string ) string {
157- if userID != nil {
158- return fmt .Sprintf ("%s/user/%s" , groupID , * userID )
178+ // Build a parsable state with the following format:
179+ // groupID/user:userID,application:applicationID
180+ func SetGroupMembershipResourceID (groupID string , userIDs []string , applicationIDs []string ) (resourceID string ) {
181+ entityIDs := make ([]string , 0 )
182+
183+ for _ , userID := range userIDs {
184+ entityIDs = append (entityIDs , fmt .Sprintf ("%s:%s" , EntityKindUser , userID ))
185+ }
186+
187+ for _ , applicationID := range applicationIDs {
188+ entityIDs = append (entityIDs , fmt .Sprintf ("%s:%s" , EntityKindApplication , applicationID ))
159189 }
160190
161- return fmt .Sprintf ("%s/app/%s" , groupID , * applicationID )
191+ resourceID = fmt .Sprintf ("%s/%s" , groupID , strings .Join (entityIDs , "," ))
192+
193+ return
162194}
163195
164- func ExpandGroupMembershipID (id string ) (groupID string , userID string , applicationID string , err error ) {
196+ // Parse the group membership resource id and return the group id and the map of entity ids by kind
197+ func ExpandGroupMembershipResourceID (id string ) (groupID string , entityIDs map [EntityKind ][]string , err error ) {
165198 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 )
199+ if len (elems ) != 2 {
200+ return "" , nil , fmt .Errorf ("invalid group membership id format, expected {groupID}/{entityKind}:{entityIDs }, got: %s" , id )
168201 }
169202
170203 groupID = elems [0 ]
171204
172- switch elems [1 ] {
173- case "user" :
174- userID = elems [2 ]
175- case "app" :
176- applicationID = elems [2 ]
205+ // entityKind:entityID,entityKind:entityID
206+ entityKindAndIDs := strings .Split (elems [1 ], "," )
207+ entityIDs = make (map [EntityKind ][]string )
208+
209+ for _ , entityKindAndID := range entityKindAndIDs {
210+ splitted := strings .Split (entityKindAndID , ":" )
211+ if len (splitted ) != 2 {
212+ return "" , nil , fmt .Errorf ("invalid entity kind and id format, expected {entityKind}:{entityID}, got: %s" , entityKindAndID )
213+ }
214+
215+ entityKind , entityID := EntityKind (splitted [0 ]), splitted [1 ]
216+ if entityKind != EntityKindUser && entityKind != EntityKindApplication {
217+ return "" , nil , fmt .Errorf ("invalid entity kind, expected %s or %s, got: %s" , EntityKindUser , EntityKindApplication , entityKind )
218+ }
219+
220+ entityIDs [entityKind ] = append (entityIDs [entityKind ], entityID )
177221 }
178222
179223 return
180224}
225+
226+ func MakeSetGroupMembershipRequest (ctx context.Context , api * iam.API , request * iam.SetGroupMembersRequest ) (* iam.Group , error ) {
227+ retryInterval := 250 * time .Millisecond
228+ maxRetries := 10
229+
230+ if transport .DefaultWaitRetryInterval != nil {
231+ retryInterval = * transport .DefaultWaitRetryInterval
232+ }
233+
234+ // the IAM API often returns a 409 when the group is in a transient state
235+ // so we retry with an exponential backoff
236+ for i := range maxRetries {
237+ response , err := api .SetGroupMembers (request , scw .WithContext (ctx ))
238+ if err != nil {
239+ if httperrors .Is409 (err ) && strings .Contains (err .Error (), fmt .Sprintf ("resource group with ID %s is in a transient state: updating" , request .GroupID )) {
240+ time .Sleep (retryInterval * time .Duration (i )) // lintignore: R018
241+
242+ continue
243+ }
244+
245+ return nil , err
246+ }
247+
248+ return response , nil
249+ }
250+
251+ return nil , fmt .Errorf ("failed to set group membership after %d retries" , maxRetries )
252+ }
0 commit comments