diff --git a/docs/resources/group_member.md b/docs/resources/group_member.md index 29e2406..9585673 100644 --- a/docs/resources/group_member.md +++ b/docs/resources/group_member.md @@ -9,6 +9,9 @@ description: |- Manage a Doppler user/group membership. +**Note:** You can also exclusively manage all memberships in a group with a single resource. +See the `doppler_group_members` resource for more information. + ## Example Usage ```terraform diff --git a/docs/resources/group_members.md b/docs/resources/group_members.md new file mode 100644 index 0000000..b0175d3 --- /dev/null +++ b/docs/resources/group_members.md @@ -0,0 +1,62 @@ +--- +page_title: "doppler_group_members Resource - terraform-provider-doppler" +subcategory: "" +description: |- + Manage a Doppler group's memberships. +--- + +# doppler_group_members (Resource) + +Manage a Doppler group's memberships. + +**Note:** The `doppler_group_members` resource will clear/replace all existing memberships. +Multiple `doppler_group_members` resources or combinations of `doppler_group_members` and `doppler_group_member` will produce inconsistent behavior. +To non-exclusively manage group memberships, use `doppler_group_member` only. + +## Example Usage + +```terraform +resource "doppler_group" "engineering" { + name = "engineering" +} + +data "doppler_user" "nic" { + email = "nic@doppler.com" +} + +data "doppler_user" "andre" { + email = "andre@doppler.com" +} + +resource "doppler_group_members" "engineering" { + group_slug = doppler_group.engineering.slug + user_slugs = [ + data.doppler_user.nic.slug, + data.doppler_user.andre.slug + ] +} +``` + + +## Schema + +### Required + +- `group_slug` (String) The slug of the group +- `user_slugs` (Set of String) A list of user slugs in the group + +### Read-Only + +- `id` (String) The ID of this resource. + +## Import + +Import is supported using the following syntax: + +```shell +# import using the group slug from the URL: +# https://dashboard.doppler.com/workplace/[workplace-slug]/team/groups/[group-slug] +# and the user slugs from the URL: +# https://dashboard.doppler.com/workplace/[workplace-slug]/team/users/[user-slug] +terraform import doppler_group_members.default +``` diff --git a/doppler/api.go b/doppler/api.go index a7058ae..b3a88bb 100644 --- a/doppler/api.go +++ b/doppler/api.go @@ -54,6 +54,11 @@ type QueryParam struct { Value string } +type PageOptions struct { + Page int + PerPage int +} + const MAX_RETRIES = 10 func (e *APIError) Error() string { @@ -1185,6 +1190,37 @@ func (client APIClient) DeleteGroupMember(ctx context.Context, group string, mem return nil } +func (client APIClient) GetGroupMembers(ctx context.Context, group string, pageOptions PageOptions) ([]GroupMember, error) { + params := []QueryParam{ + {Key: "page", Value: strconv.Itoa(pageOptions.Page)}, + {Key: "per_page", Value: strconv.Itoa(pageOptions.PerPage)}, + } + response, err := client.PerformRequestWithRetry(ctx, "GET", fmt.Sprintf("/v3/workplace/groups/group/%s/members", url.QueryEscape(group)), params, nil) + if err != nil { + return nil, err + } + var result GetGroupMembersResponse + if err = json.Unmarshal(response.Body, &result); err != nil { + return nil, &APIError{Err: err, Message: "Unable to parse group members"} + } + return result.Members, nil +} + +func (client APIClient) ReplaceGroupMembers(ctx context.Context, group string, members []GroupMember) error { + payload := map[string]interface{}{ + "members": members, + } + body, err := json.Marshal(payload) + if err != nil { + return &APIError{Err: err, Message: "Unable to serialize group members"} + } + _, err = client.PerformRequestWithRetry(ctx, "PUT", fmt.Sprintf("/v3/workplace/groups/group/%s/members", url.QueryEscape(group)), []QueryParam{}, body) + if err != nil { + return err + } + return nil +} + // Workplace Users func (client APIClient) GetWorkplaceUser(ctx context.Context, email string) (*WorkplaceUser, error) { diff --git a/doppler/models.go b/doppler/models.go index a1f10ac..a86b4fc 100644 --- a/doppler/models.go +++ b/doppler/models.go @@ -301,6 +301,15 @@ type GroupIsMemberResponse struct { IsMember bool `json:"isMember"` } +type GroupMember struct { + Type string `json:"type"` + Slug string `json:"slug"` +} + +type GetGroupMembersResponse struct { + Members []GroupMember `json:"members"` +} + type WorkplaceUser struct { Slug string `json:"id"` } diff --git a/doppler/provider.go b/doppler/provider.go index e056c59..244f06c 100644 --- a/doppler/provider.go +++ b/doppler/provider.go @@ -43,8 +43,9 @@ func Provider() *schema.Provider { "doppler_service_account": resourceServiceAccount(), "doppler_service_account_token": resourceServiceAccountToken(), - "doppler_group": resourceGroup(), - "doppler_group_member": resourceGroupMemberWorkplaceUser(), + "doppler_group": resourceGroup(), + "doppler_group_member": resourceGroupMemberWorkplaceUser(), + "doppler_group_members": resourceGroupMembers(), "doppler_webhook": resourceWebhook(), diff --git a/doppler/resource_group_members.go b/doppler/resource_group_members.go new file mode 100644 index 0000000..2c872ea --- /dev/null +++ b/doppler/resource_group_members.go @@ -0,0 +1,143 @@ +package doppler + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGroupMembers() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGroupMembersCreate, + ReadContext: resourceGroupMembersRead, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + UpdateContext: resourceGroupMembersUpdate, + DeleteContext: resourceGroupMembersDelete, + Schema: map[string]*schema.Schema{ + "group_slug": { + Description: "The slug of the group", + Type: schema.TypeString, + Required: true, + // Members cannot be moved directly from one group to another, they must be re-created + ForceNew: true, + }, + "user_slugs": { + Description: "A list of user slugs in the group", + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, + }, + } +} + +func resourceGroupMembersCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + var diags diag.Diagnostics + + groupSlug := d.Get("group_slug").(string) + // Just fetch one member to see if any exist + currentMembers, err := client.GetGroupMembers(ctx, groupSlug, PageOptions{Page: 1, PerPage: 1}) + if err != nil { + return diag.FromErr(err) + } + + if len(currentMembers) > 0 { + diags = append(diags, + diag.Diagnostic{ + Severity: diag.Warning, + Summary: "This group has existing members", + Detail: "This group has existing members. All group memberships have been overwritten by this resource.", + }) + } + + diags = append(diags, resourceGroupMembersUpdate(ctx, d, m)...) + + d.SetId(groupSlug) + + return diags +} + +func resourceGroupMembersUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + groupSlug := d.Get("group_slug").(string) + userSlugs := d.Get("user_slugs").(*schema.Set).List() + + members := make([]GroupMember, len(userSlugs)) + for i, v := range userSlugs { + members[i] = GroupMember{Type: "workplace_user", Slug: v.(string)} + } + + err := client.ReplaceGroupMembers(ctx, groupSlug, members) + if err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceGroupMembersRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + + groupSlug := d.Id() + + perPage := 1000 + maxPages := 5 + + members := []GroupMember{} + + for page := 1; page <= maxPages; page++ { + pageMembers, err := client.GetGroupMembers(ctx, groupSlug, PageOptions{Page: page, PerPage: perPage}) + if err != nil { + return handleNotFoundError(err, d) + } + members = append(members, pageMembers...) + if len(pageMembers) < perPage { + break + } else if page == maxPages { + return diag.Errorf("Exceeded max number of group members") + } + } + + userSlugs := []string{} + for _, v := range members { + if v.Type == "workplace_user" { + userSlugs = append(userSlugs, v.Slug) + } else { + return diag.Errorf("Actor type %s is not supported by this plugin version", v.Type) + } + } + + if err := d.Set("group_slug", groupSlug); err != nil { + return diag.FromErr((err)) + } + + if err := d.Set("user_slugs", userSlugs); err != nil { + return diag.FromErr((err)) + } + + return diags +} + +func resourceGroupMembersDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(APIClient) + + var diags diag.Diagnostics + groupSlug := d.Id() + + // Setting the members to an empty list effectively deletes the memberships + if err := client.ReplaceGroupMembers(ctx, groupSlug, []GroupMember{}); err != nil { + return diag.FromErr(err) + } + + return diags +} diff --git a/examples/resources/group_members.tf b/examples/resources/group_members.tf new file mode 100644 index 0000000..774d5f2 --- /dev/null +++ b/examples/resources/group_members.tf @@ -0,0 +1,20 @@ +resource "doppler_group" "engineering" { + name = "engineering" +} + +data "doppler_user" "nic" { + email = "nic@doppler.com" +} + +data "doppler_user" "andre" { + email = "andre@doppler.com" +} + +resource "doppler_group_members" "engineering" { + group_slug = doppler_group.engineering.slug + user_slugs = [ + data.doppler_user.nic.slug, + data.doppler_user.andre.slug + ] +} + diff --git a/templates/resources/group_member.md.tmpl b/templates/resources/group_member.md.tmpl index b8c111b..847bd73 100644 --- a/templates/resources/group_member.md.tmpl +++ b/templates/resources/group_member.md.tmpl @@ -9,6 +9,9 @@ description: |- Manage a Doppler user/group membership. +**Note:** You can also exclusively manage all memberships in a group with a single resource. +See the `doppler_group_members` resource for more information. + ## Example Usage {{tffile "examples/resources/group_member.tf"}} diff --git a/templates/resources/group_members.md.tmpl b/templates/resources/group_members.md.tmpl new file mode 100644 index 0000000..e8feebf --- /dev/null +++ b/templates/resources/group_members.md.tmpl @@ -0,0 +1,32 @@ +--- +page_title: "doppler_group_members Resource - terraform-provider-doppler" +subcategory: "" +description: |- + Manage a Doppler group's memberships. +--- + +# doppler_group_members (Resource) + +Manage a Doppler group's memberships. + +**Note:** The `doppler_group_members` resource will clear/replace all existing memberships. +Multiple `doppler_group_members` resources or combinations of `doppler_group_members` and `doppler_group_member` will produce inconsistent behavior. +To non-exclusively manage group memberships, use `doppler_group_member` only. + +## Example Usage + +{{tffile "examples/resources/group_members.tf"}} + +{{ .SchemaMarkdown | trimspace }} + +## Import + +Import is supported using the following syntax: + +```shell +# import using the group slug from the URL: +# https://dashboard.doppler.com/workplace/[workplace-slug]/team/groups/[group-slug] +# and the user slugs from the URL: +# https://dashboard.doppler.com/workplace/[workplace-slug]/team/users/[user-slug] +terraform import doppler_group_members.default +```