Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions docs/resources/iam_group_membership.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ resource "scaleway_iam_application" "app" {

resource "scaleway_iam_group_membership" "member" {
group_id = scaleway_iam_group.group.id
application_id = scaleway_iam_application.app.id
application_ids = [scaleway_iam_application.app.id]
}
```

Expand All @@ -51,31 +51,30 @@ resource "scaleway_iam_group" "group" {
resource "scaleway_iam_group_membership" "members" {
for_each = data.scaleway_iam_user.users
group_id = scaleway_iam_group.group.id
user_id = each.value.id
user_ids = [each.value.id]
}
```

## Argument Reference

- `group_id` - (Required) ID of the group to add members to.

- `application_id` - (Optional) The ID of the application that will be added to the group.
- `application_ids` - (Optional) The IDs of the applications that will be added to the group.

- `user_id` - (Optional) The ID of the user that will be added to the group
- `user_ids` - (Optional) The IDs of the users that will be added to the group

-> **Note** You must specify at least one: `application_id` and/or `user_id`.
-> **Note** You must specify at least one: `application_ids` and/or `user_ids`.

## Attributes Reference

No additional attributes are exported.

## Import

IAM group memberships can be imported using two format:
IAM group memberships can be imported using the following format:

- For user: `{group_id}/user/{user_id}`
- For application: `{group_id}/app/{application_id}`
- For user: `{group_id}/user:userID,application:applicationID,...`

```bash
terraform import scaleway_iam_group_membership.app 11111111-1111-1111-1111-111111111111/app/11111111-1111-1111-1111-111111111111
terraform import scaleway_iam_group_membership.members 11111111-1111-1111-1111-111111111111/user:11111111-1111-1111-1111-111111111111,application:11111111-1111-1111-1111-111111111111
```
208 changes: 140 additions & 68 deletions internal/services/iam/group_membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,85 @@ package iam
import (
"context"
"fmt"
"slices"
"strings"
"time"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
"github.com/scaleway/scaleway-sdk-go/scw"
"github.com/scaleway/terraform-provider-scaleway/v2/internal/httperrors"
"github.com/scaleway/terraform-provider-scaleway/v2/internal/transport"
"github.com/scaleway/terraform-provider-scaleway/v2/internal/types"
)

type EntityKind string

const (
EntityKindUser EntityKind = "user"
EntityKindApplication EntityKind = "application"
)

func ResourceGroupMembership() *schema.Resource {
return &schema.Resource{
CreateContext: resourceIamGroupMembershipCreate,
ReadContext: resourceIamGroupMembershipRead,
UpdateContext: resourceIamGroupMembershipUpdate,
DeleteContext: resourceIamGroupMembershipDelete,
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
SchemaVersion: 0,
Schema: map[string]*schema.Schema{
"user_id": {
Type: schema.TypeString,
Optional: true,
Description: "The ID of the user",
ExactlyOneOf: []string{"application_id"},
ForceNew: true,
},
"application_id": {
Type: schema.TypeString,
Optional: true,
Description: "The ID of the user",
ExactlyOneOf: []string{"user_id"},
ForceNew: true,
},
"group_id": {
Type: schema.TypeString,
Required: true,
Description: "The ID of the group to add the user to",
Description: "The ID of the group to add the users or applications to",
ForceNew: true,
},
"user_ids": {
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true,
Description: "The IDs of the users to add to the group",
AtLeastOneOf: []string{"application_ids"},
},
"application_ids": {
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true,
Description: "The IDs of the applications to add to the group",
AtLeastOneOf: []string{"user_ids"},
},
},
}
}

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

userID := types.ExpandStringPtr(d.Get("user_id"))
applicationID := types.ExpandStringPtr(d.Get("application_id"))
userIDs := types.ExpandStrings(d.Get("user_ids"))
applicationIDs := types.ExpandStrings(d.Get("application_ids"))

group, err := api.AddGroupMember(&iam.AddGroupMemberRequest{
GroupID: d.Get("group_id").(string),
UserID: userID,
ApplicationID: applicationID,
}, scw.WithContext(ctx))
group, err := MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
GroupID: d.Get("group_id").(string),
UserIDs: userIDs,
ApplicationIDs: applicationIDs,
})
if err != nil {
return diag.FromErr(err)
}

d.SetId(GroupMembershipID(group.ID, userID, applicationID))
d.SetId(SetGroupMembershipResourceID(group.ID, userIDs, applicationIDs))

return resourceIamGroupMembershipRead(ctx, d, m)
}

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

groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
groupID, entityIDs, err := ExpandGroupMembershipResourceID(d.Id())
if err != nil {
return diag.FromErr(err)
}
Expand All @@ -88,58 +99,69 @@ func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData,
return diag.FromErr(err)
}

foundInGroup := false

if userID != "" {
for _, groupUserID := range group.UserIDs {
if groupUserID == userID {
foundInGroup = true

break
}
}
} else if applicationID != "" {
for _, groupApplicationID := range group.ApplicationIDs {
if groupApplicationID == applicationID {
foundInGroup = true

break
}
for _, userID := range entityIDs[EntityKindUser] {
if !slices.Contains(group.UserIDs, userID) {
return diag.FromErr(fmt.Errorf("user %s not found in group %s", userID, groupID))
}
}

if !foundInGroup {
d.SetId("")

return nil
for _, applicationID := range entityIDs[EntityKindApplication] {
if !slices.Contains(group.ApplicationIDs, applicationID) {
return diag.FromErr(fmt.Errorf("application %s not found in group %s", applicationID, groupID))
}
}

_ = d.Set("group_id", groupID)
_ = d.Set("user_id", userID)
_ = d.Set("application_id", applicationID)
_ = d.Set("user_ids", entityIDs[EntityKindUser])
_ = d.Set("application_ids", entityIDs[EntityKindApplication])

return nil
}

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

groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
groupID, _, err := ExpandGroupMembershipResourceID(d.Id())
if err != nil {
return diag.FromErr(err)
}

req := &iam.RemoveGroupMemberRequest{
GroupID: groupID,
userIDs := types.ExpandStrings(d.Get("user_ids"))
applicationIDs := types.ExpandStrings(d.Get("application_ids"))

request := &iam.SetGroupMembersRequest{
GroupID: groupID,
UserIDs: userIDs,
ApplicationIDs: applicationIDs,
}

if userID != "" {
req.UserID = &userID
} else if applicationID != "" {
req.ApplicationID = &applicationID
if d.HasChanges("user_ids", "application_ids") {
group, err := MakeSetGroupMembershipRequest(ctx, api, request)
if err != nil {
return diag.FromErr(err)
}

if group.ID != groupID {
return diag.FromErr(fmt.Errorf("group id changed from %s to %s", groupID, group.ID))
}

d.SetId(SetGroupMembershipResourceID(groupID, userIDs, applicationIDs))
}

return resourceIamGroupMembershipRead(ctx, d, m)
}

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

groupID, _, err := ExpandGroupMembershipResourceID(d.Id())
if err != nil {
return diag.FromErr(err)
}

_, err = api.RemoveGroupMember(req, scw.WithContext(ctx))
_, err = MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
GroupID: groupID,
})
if err != nil {
if httperrors.Is404(err) {
d.SetId("")
Expand All @@ -153,28 +175,78 @@ func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceDat
return nil
}

func GroupMembershipID(groupID string, userID *string, applicationID *string) string {
if userID != nil {
return fmt.Sprintf("%s/user/%s", groupID, *userID)
// Build a parsable state with the following format:
// groupID/user:userID,application:applicationID
func SetGroupMembershipResourceID(groupID string, userIDs []string, applicationIDs []string) (resourceID string) {
entityIDs := make([]string, 0)

for _, userID := range userIDs {
entityIDs = append(entityIDs, fmt.Sprintf("%s:%s", EntityKindUser, userID))
}

for _, applicationID := range applicationIDs {
entityIDs = append(entityIDs, fmt.Sprintf("%s:%s", EntityKindApplication, applicationID))
}

return fmt.Sprintf("%s/app/%s", groupID, *applicationID)
resourceID = fmt.Sprintf("%s/%s", groupID, strings.Join(entityIDs, ","))

return
}

func ExpandGroupMembershipID(id string) (groupID string, userID string, applicationID string, err error) {
// Parse the group membership resource id and return the group id and the map of entity ids by kind
func ExpandGroupMembershipResourceID(id string) (groupID string, entityIDs map[EntityKind][]string, err error) {
elems := strings.Split(id, "/")
if len(elems) != 3 {
return "", "", "", fmt.Errorf("invalid group member id format, expected {groupID}/{type}/{memberID}, got: %s", id)
if len(elems) != 2 {
return "", nil, fmt.Errorf("invalid group membership id format, expected {groupID}/{entityKind}:{entityIDs}, got: %s", id)
}

groupID = elems[0]

switch elems[1] {
case "user":
userID = elems[2]
case "app":
applicationID = elems[2]
// entityKind:entityID,entityKind:entityID
entityKindAndIDs := strings.Split(elems[1], ",")
entityIDs = make(map[EntityKind][]string)

for _, entityKindAndID := range entityKindAndIDs {
splitted := strings.Split(entityKindAndID, ":")
if len(splitted) != 2 {
return "", nil, fmt.Errorf("invalid entity kind and id format, expected {entityKind}:{entityID}, got: %s", entityKindAndID)
}

entityKind, entityID := EntityKind(splitted[0]), splitted[1]
if entityKind != EntityKindUser && entityKind != EntityKindApplication {
return "", nil, fmt.Errorf("invalid entity kind, expected %s or %s, got: %s", EntityKindUser, EntityKindApplication, entityKind)
}

entityIDs[entityKind] = append(entityIDs[entityKind], entityID)
}

return
}

func MakeSetGroupMembershipRequest(ctx context.Context, api *iam.API, request *iam.SetGroupMembersRequest) (*iam.Group, error) {
retryInterval := 250 * time.Millisecond
maxRetries := 10

if transport.DefaultWaitRetryInterval != nil {
retryInterval = *transport.DefaultWaitRetryInterval
}

// the IAM API often returns a 409 when the group is in a transient state
// so we retry with an exponential backoff
for i := range maxRetries {
response, err := api.SetGroupMembers(request, scw.WithContext(ctx))
if err != nil {
if httperrors.Is409(err) && strings.Contains(err.Error(), fmt.Sprintf("resource group with ID %s is in a transient state: updating", request.GroupID)) {
time.Sleep(retryInterval * time.Duration(i)) // lintignore: R018

continue
}

return nil, err
}

return response, nil
}

return nil, fmt.Errorf("failed to set group membership after %d retries", maxRetries)
}
Loading
Loading