Repository: cloudflare/cloudflare-go
Pull request #1095 (2022-09-29 22:26:12)

ACCT-4459: Support for domain scoped roles
As we [begin to GA domain-scoped roles](https://blog.cloudflare.com/domain-scoped-roles-ga/), we'd like to add support for these new controls to the Cloudflare Go SDK.

## Description

The primary changes here are:
- Update AccountMember to contain both AccountRoles and Policies, our new struct for representing access control
- Add structs for Policies and all required sub-structs

## Has your change been tested?

Yes, we have extended existing tests, added new tests, and written a verification script

## Screenshots (if appropriate):

n/a

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented (api.cloudflare.com or developers.cloudflare.com) and stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/



Patch:
diff --git a/.changelog/1095.txt b/.changelog/1095.txt
new file mode 100644
index 000000000..aeab97d4a
--- /dev/null
+++ b/.changelog/1095.txt
@@ -0,0 +1,7 @@
+```release-note:enhancement
+account_member: add support for domain scoped roles
+```
+
+```release-note:breaking-change
+account_member: `CreateAccountMember` has been updated to accept a `CreateAccountMemberParams` struct instead of multiple parameters
+```
diff --git a/account_members.go b/account_members.go
index 052bcf54a..d197fda1d 100644
--- a/account_members.go
+++ b/account_members.go
@@ -3,17 +3,19 @@ package cloudflare
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 )
 
 // AccountMember is the definition of a member of an account.
 type AccountMember struct {
-	ID     string                   `json:"id"`
-	Code   string                   `json:"code"`
-	User   AccountMemberUserDetails `json:"user"`
-	Status string                   `json:"status"`
-	Roles  []AccountRole            `json:"roles"`
+	ID       string                   `json:"id"`
+	Code     string                   `json:"code"`
+	User     AccountMemberUserDetails `json:"user"`
+	Status   string                   `json:"status"`
+	Roles    []AccountRole            `json:"roles,omitempty"`
+	Policies []Policy                 `json:"policies,omitempty"`
 }
 
 // AccountMemberUserDetails outlines all the personal information about
@@ -46,9 +48,21 @@ type AccountMemberDetailResponse struct {
 // AccountMemberInvitation represents the invitation for a new member to
 // the account.
 type AccountMemberInvitation struct {
-	Email  string   `json:"email"`
-	Roles  []string `json:"roles"`
-	Status string   `json:"status,omitempty"`
+	Email    string   `json:"email"`
+	Roles    []string `json:"roles,omitempty"`
+	Policies []Policy `json:"policies,omitempty"`
+	Status   string   `json:"status,omitempty"`
+}
+
+const errMissingMemberRolesOrPolicies = "account member must be created with roles or policies (not both)"
+
+var ErrMissingMemberRolesOrPolicies = errors.New(errMissingMemberRolesOrPolicies)
+
+type CreateAccountMemberParams struct {
+	EmailAddress string
+	Roles        []string
+	Policies     []Policy
+	Status       string
 }
 
 // AccountMembers returns all members of an account.
@@ -79,20 +93,54 @@ func (api *API) AccountMembers(ctx context.Context, accountID string, pageOpts P
 //
 // Refer to the API reference for valid statuses.
 //
+// Deprecated: Use `CreateAccountMember` with a `Status` field instead.
+//
 // API reference: https://api.cloudflare.com/#account-members-add-member
 func (api *API) CreateAccountMemberWithStatus(ctx context.Context, accountID string, emailAddress string, roles []string, status string) (AccountMember, error) {
-	if accountID == "" {
+	return api.CreateAccountMember(ctx, AccountIdentifier(accountID), CreateAccountMemberParams{
+		EmailAddress: emailAddress,
+		Roles:        roles,
+		Status:       status,
+	})
+}
+
+// CreateAccountMember invites a new member to join an account with roles.
+// The member will be placed into "pending" status and receive an email confirmation.
+// NOTE: If you are currently enrolled in Domain Scoped Roles, your roles will
+// be converted to policies upon member invitation.
+//
+// API reference: https://api.cloudflare.com/#account-members-add-member
+func (api *API) CreateAccountMember(ctx context.Context, rc *ResourceContainer, params CreateAccountMemberParams) (AccountMember, error) {
+	if rc.Level != AccountRouteLevel {
+		return AccountMember{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
 		return AccountMember{}, ErrMissingAccountID
 	}
 
-	uri := fmt.Sprintf("/accounts/%s/members", accountID)
+	invite := AccountMemberInvitation{
+		Email:  params.EmailAddress,
+		Status: params.Status,
+	}
 
-	newMember := AccountMemberInvitation{
-		Email:  emailAddress,
-		Roles:  roles,
-		Status: status,
+	roles := []AccountRole{}
+	for i := 0; i < len(params.Roles); i++ {
+		roles = append(roles, AccountRole{ID: params.Roles[i]})
 	}
-	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, newMember)
+	err := validateRolesAndPolicies(roles, params.Policies)
+	if err != nil {
+		return AccountMember{}, err
+	}
+
+	if params.Roles != nil {
+		invite.Roles = params.Roles
+	} else if params.Policies != nil {
+		invite.Policies = params.Policies
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/members", rc.Identifier)
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, invite)
 	if err != nil {
 		return AccountMember{}, err
 	}
@@ -106,14 +154,6 @@ func (api *API) CreateAccountMemberWithStatus(ctx context.Context, accountID str
 	return accountMemberListResponse.Result, nil
 }
 
-// CreateAccountMember invites a new member to join an account.
-// The member will be placed into "pending" status and receive an email confirmation.
-//
-// API reference: https://api.cloudflare.com/#account-members-add-member
-func (api *API) CreateAccountMember(ctx context.Context, accountID string, emailAddress string, roles []string) (AccountMember, error) {
-	return api.CreateAccountMemberWithStatus(ctx, accountID, emailAddress, roles, "")
-}
-
 // DeleteAccountMember removes a member from an account.
 //
 // API reference: https://api.cloudflare.com/#account-members-remove-member
@@ -140,6 +180,11 @@ func (api *API) UpdateAccountMember(ctx context.Context, accountID string, userI
 		return AccountMember{}, ErrMissingAccountID
 	}
 
+	err := validateRolesAndPolicies(member.Roles, member.Policies)
+	if err != nil {
+		return AccountMember{}, err
+	}
+
 	uri := fmt.Sprintf("/accounts/%s/members/%s", accountID, userID)
 
 	res, err := api.makeRequestContext(ctx, http.MethodPut, uri, member)
@@ -183,3 +228,17 @@ func (api *API) AccountMember(ctx context.Context, accountID string, memberID st
 
 	return accountMemberResponse.Result, nil
 }
+
+// validateRolesAndPolicies ensures either roles or policies are provided in
+// CreateAccountMember requests, but not both.
+func validateRolesAndPolicies(roles []AccountRole, policies []Policy) error {
+	hasRoles := len(roles) > 0
+	hasPolicies := len(policies) > 0
+	hasRolesOrPolicies := hasRoles || hasPolicies
+	hasRolesAndPolicies := hasRoles && hasPolicies
+	hasCorrectPermissions := hasRolesOrPolicies && !hasRolesAndPolicies
+	if !hasCorrectPermissions {
+		return ErrMissingMemberRolesOrPolicies
+	}
+	return nil
+}
diff --git a/permission_group.go b/permission_group.go
new file mode 100644
index 000000000..c6468d006
--- /dev/null
+++ b/permission_group.go
@@ -0,0 +1,98 @@
+package cloudflare
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+)
+
+type PermissionGroup struct {
+	ID          string            `json:"id"`
+	Name        string            `json:"name"`
+	Meta        map[string]string `json:"meta"`
+	Permissions []Permission      `json:"permissions"`
+}
+
+type Permission struct {
+	ID         string            `json:"id"`
+	Key        string            `json:"key"`
+	Attributes map[string]string `json:"attributes,omitempty"` // same as Meta in other structs
+}
+
+type PermissionGroupListResponse struct {
+	Success  bool              `json:"success"`
+	Errors   []string          `json:"errors"`
+	Messages []string          `json:"messages"`
+	Result   []PermissionGroup `json:"result"`
+}
+
+type PermissionGroupDetailResponse struct {
+	Success  bool            `json:"success"`
+	Errors   []string        `json:"errors"`
+	Messages []string        `json:"messages"`
+	Result   PermissionGroup `json:"result"`
+}
+
+type ListPermissionGroupParams struct {
+	Depth    int    `url:"depth,omitempty"`
+	RoleName string `url:"name,omitempty"`
+}
+
+const errMissingPermissionGroupID = "missing required permission group ID"
+
+var ErrMissingPermissionGroupID = errors.New(errMissingPermissionGroupID)
+
+// GetPermissionGroup returns a specific permission group from the API given
+// the account ID and permission group ID.
+func (api *API) GetPermissionGroup(ctx context.Context, rc *ResourceContainer, permissionGroupId string) (PermissionGroup, error) {
+	if rc.Level != AccountRouteLevel {
+		return PermissionGroup{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	if rc.Identifier == "" {
+		return PermissionGroup{}, ErrMissingAccountID
+	}
+
+	if permissionGroupId == "" {
+		return PermissionGroup{}, ErrMissingPermissionGroupID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/iam/permission_groups/%s?depth=2", rc.Identifier, permissionGroupId)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return PermissionGroup{}, err
+	}
+
+	var permissionGroupResponse PermissionGroupDetailResponse
+	err = json.Unmarshal(res, &permissionGroupResponse)
+	if err != nil {
+		return PermissionGroup{}, err
+	}
+
+	return permissionGroupResponse.Result, nil
+}
+
+// ListPermissionGroups returns all valid permission groups for the provided
+// parameters.
+func (api *API) ListPermissionGroups(ctx context.Context, rc *ResourceContainer, params ListPermissionGroupParams) ([]PermissionGroup, error) {
+	if rc.Level != AccountRouteLevel {
+		return []PermissionGroup{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level)
+	}
+
+	params.Depth = 2
+	uri := buildURI(fmt.Sprintf("/accounts/%s/iam/permission_groups", rc.Identifier), params)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return []PermissionGroup{}, err
+	}
+
+	var permissionGroupResponse PermissionGroupListResponse
+	err = json.Unmarshal(res, &permissionGroupResponse)
+	if err != nil {
+		return []PermissionGroup{}, err
+	}
+
+	return permissionGroupResponse.Result, nil
+}
diff --git a/policy.go b/policy.go
new file mode 100644
index 000000000..807724120
--- /dev/null
+++ b/policy.go
@@ -0,0 +1,8 @@
+package cloudflare
+
+type Policy struct {
+	ID               string            `json:"id"`
+	PermissionGroups []PermissionGroup `json:"permission_groups"`
+	ResourceGroups   []ResourceGroup   `json:"resource_groups"`
+	Access           string            `json:"access"`
+}
diff --git a/resource_group.go b/resource_group.go
new file mode 100644
index 000000000..6d25ffb5e
--- /dev/null
+++ b/resource_group.go
@@ -0,0 +1,53 @@
+package cloudflare
+
+import "fmt"
+
+type ResourceGroup struct {
+	ID    string            `json:"id"`
+	Name  string            `json:"name"`
+	Meta  map[string]string `json:"meta"`
+	Scope Scope             `json:"scope"`
+}
+
+type Scope struct {
+	Key          string        `json:"key"`
+	ScopeObjects []ScopeObject `json:"objects"`
+}
+
+type ScopeObject struct {
+	Key string `json:"key"`
+}
+
+// NewResourceGroupForZone takes an existing zone and provides a resource group
+// to be used within a Policy that allows access to that zone.
+func NewResourceGroupForZone(zone Zone) ResourceGroup {
+	return NewResourceGroup(fmt.Sprintf("com.cloudflare.api.account.zone.%s", zone.ID))
+}
+
+// NewResourceGroupForAccount takes an existing zone and provides a resource group
+// to be used within a Policy that allows access to that account.
+func NewResourceGroupForAccount(account Account) ResourceGroup {
+	return NewResourceGroup(fmt.Sprintf("com.cloudflare.api.account.%s", account.ID))
+}
+
+// NewResourceGroup takes a Cloudflare-formatted key (e.g. 'com.cloudflare.api.%s') and
+// returns a resource group to be used within a Policy to allow access to that resource.
+func NewResourceGroup(key string) ResourceGroup {
+	scope := Scope{
+		Key: key,
+		ScopeObjects: []ScopeObject{
+			{
+				Key: "*",
+			},
+		},
+	}
+	resourceGroup := ResourceGroup{
+		ID:   "",
+		Name: key,
+		Meta: map[string]string{
+			"editable": "false",
+		},
+		Scope: scope,
+	}
+	return resourceGroup
+}


Test patch:
diff --git a/account_members_test.go b/account_members_test.go
index 418b62284..b4d8d8348 100644
--- a/account_members_test.go
+++ b/account_members_test.go
@@ -99,6 +99,43 @@ var newUpdatedAccountMemberStruct = AccountMember{
 	},
 }
 
+var mockPolicy = Policy{
+	ID: "mock-policy-id",
+	PermissionGroups: []PermissionGroup{{
+		ID:   "mock-permission-group-id",
+		Name: "mock-permission-group-name",
+		Permissions: []Permission{{
+			ID:  "mock-permission-id",
+			Key: "mock-permission-key",
+		}},
+	}},
+	ResourceGroups: []ResourceGroup{{
+		ID:   "mock-resource-group-id",
+		Name: "mock-resource-group-name",
+		Scope: Scope{
+			Key: "mock-resource-group-name",
+			ScopeObjects: []ScopeObject{{
+				Key: "*",
+			}},
+		},
+	}},
+	Access: "allow",
+}
+
+var expectedNewAccountMemberWithPoliciesStruct = AccountMember{
+	ID:   "new-member-with-polcies-id",
+	Code: "new-member-with-policies-code",
+	User: AccountMemberUserDetails{
+		ID:                             "new-member-with-policies-user-id",
+		FirstName:                      "John",
+		LastName:                       "Appleseed",
+		Email:                          "user@example.com",
+		TwoFactorAuthenticationEnabled: false,
+	},
+	Status:   "accepted",
+	Policies: []Policy{mockPolicy},
+}
+
 func TestAccountMembers(t *testing.T) {
 	setup()
 	defer teardown()
@@ -151,7 +188,7 @@ func TestAccountMembers(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/members", handler)
+	mux.HandleFunc("/accounts/"+testAccountID+"/members", handler)
 	want := []AccountMember{expectedAccountMemberStruct}
 
 	actual, _, err := client.AccountMembers(context.Background(), "01a7362d577a6c3019a474fd6f485823", PaginationOptions{})
@@ -214,7 +251,7 @@ func TestCreateAccountMemberWithStatus(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/members", handler)
+	mux.HandleFunc("/accounts/"+testAccountID+"/members", handler)
 
 	actual, err := client.CreateAccountMemberWithStatus(context.Background(), "01a7362d577a6c3019a474fd6f485823", "user@example.com", []string{"3536bcfad5faccb999b47003c79917fb"}, "accepted")
 
@@ -265,20 +302,115 @@ func TestCreateAccountMember(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/members", handler)
+	mux.HandleFunc("/accounts/"+testAccountID+"/members", handler)
+
+	createAccountParams := CreateAccountMemberParams{
+		EmailAddress: "user@example.com",
+		Roles:        []string{"3536bcfad5faccb999b47003c79917fb"},
+	}
 
-	actual, err := client.CreateAccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "user@example.com", []string{"3536bcfad5faccb999b47003c79917fb"})
+	actual, err := client.CreateAccountMember(context.Background(), AccountIdentifier("01a7362d577a6c3019a474fd6f485823"), createAccountParams)
 
 	if assert.NoError(t, err) {
 		assert.Equal(t, expectedNewAccountMemberStruct, actual)
 	}
 }
 
+func TestCreateAccountMemberWithPolicies(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+				"id": "new-member-with-polcies-id",
+				"code": "new-member-with-policies-code",
+				"user": {
+					"id": "new-member-with-policies-user-id",
+					"first_name": "John",
+					"last_name": "Appleseed",
+					"email": "user@example.com",
+					"two_factor_authentication_enabled": false
+				},
+				"status": "accepted",
+				"policies": [{
+					"id": "mock-policy-id",
+					"permission_groups": [{
+						"id": "mock-permission-group-id",
+						"name": "mock-permission-group-name",
+						"permissions": [{
+							"id": "mock-permission-id",
+							"key": "mock-permission-key"
+						}]
+					}],
+					"resource_groups": [{
+						"id": "mock-resource-group-id",
+						"name": "mock-resource-group-name",
+						"scope": {
+							"key": "mock-resource-group-name",
+							"objects": [{
+								"key": "*"
+							}]
+						}
+					}],
+					"access": "allow"
+				}]
+			}
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/members", handler)
+	actual, err := client.CreateAccountMember(context.Background(), AccountIdentifier("01a7362d577a6c3019a474fd6f485823"), CreateAccountMemberParams{
+		EmailAddress: "user@example.com",
+		Roles:        nil,
+		Policies:     []Policy{mockPolicy},
+		Status:       "",
+	})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedNewAccountMemberWithPoliciesStruct, actual)
+	}
+}
+
+func TestCreateAccountMemberWithRolesAndPoliciesErr(t *testing.T) {
+	setup()
+	defer teardown()
+
+	accountResource := &ResourceContainer{
+		Level:      AccountRouteLevel,
+		Identifier: "01a7362d577a6c3019a474fd6f485823",
+	}
+	_, err := client.CreateAccountMember(context.Background(), accountResource, CreateAccountMemberParams{
+		EmailAddress: "user@example.com",
+		Roles:        []string{"fake-role-id"},
+		Policies:     []Policy{mockPolicy},
+		Status:       "active",
+	})
+
+	if assert.Error(t, err) {
+		assert.Equal(t, err, ErrMissingMemberRolesOrPolicies)
+	}
+}
+
 func TestCreateAccountMemberWithoutAccountID(t *testing.T) {
 	setup()
 	defer teardown()
 
-	_, err := client.CreateAccountMember(context.Background(), "", "user@example.com", []string{"3536bcfad5faccb999b47003c79917fb"})
+	accountResource := &ResourceContainer{
+		Level:      AccountRouteLevel,
+		Identifier: "",
+	}
+	_, err := client.CreateAccountMember(context.Background(), accountResource, CreateAccountMemberParams{
+		EmailAddress: "user@example.com",
+		Roles:        []string{"fake-role-id"},
+		Status:       "active",
+	})
 
 	if assert.Error(t, err) {
 		assert.Equal(t, err.Error(), errMissingAccountID)
@@ -333,7 +465,7 @@ func TestUpdateAccountMember(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/members/4536bcfad5faccb111b47003c79917fa", handler)
+	mux.HandleFunc("/accounts/"+testAccountID+"/members/4536bcfad5faccb111b47003c79917fa", handler)
 
 	actual, err := client.UpdateAccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "4536bcfad5faccb111b47003c79917fa", newUpdatedAccountMemberStruct)
 
@@ -342,6 +474,89 @@ func TestUpdateAccountMember(t *testing.T) {
 	}
 }
 
+func TestUpdateAccountMemberWithPolicies(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+				"id": "new-member-with-polcies-id",
+				"code": "new-member-with-policies-code",
+				"user": {
+					"id": "new-member-with-policies-user-id",
+					"first_name": "John",
+					"last_name": "Appleseed",
+					"email": "user@example.com",
+					"two_factor_authentication_enabled": false
+				},
+				"status": "accepted",
+				"policies": [{
+					"id": "mock-policy-id",
+					"permission_groups": [{
+						"id": "mock-permission-group-id",
+						"name": "mock-permission-group-name",
+						"permissions": [{
+							"id": "mock-permission-id",
+							"key": "mock-permission-key"
+						}]
+					}],
+					"resource_groups": [{
+						"id": "mock-resource-group-id",
+						"name": "mock-resource-group-name",
+						"scope": {
+							"key": "mock-resource-group-name",
+							"objects": [{
+								"key": "*"
+							}]
+						}
+					}],
+					"access": "allow"
+				}]
+			},
+			"result_info": {
+				"page": 1,
+				"per_page": 20,
+				"count": 1,
+				"total_count": 2000
+			}
+		}`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/members/new-member-with-polcies-id", handler)
+
+	actual, err := client.UpdateAccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "new-member-with-polcies-id", expectedNewAccountMemberWithPoliciesStruct)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expectedNewAccountMemberWithPoliciesStruct, actual)
+	}
+}
+
+func UpdateAccountMemberWithRolesAndPoliciesErr(t *testing.T) {
+	setup()
+	defer teardown()
+
+	_, err := client.UpdateAccountMember(context.Background(),
+		"01a7362d577a6c3019a474fd6f485823",
+		"",
+		AccountMember{
+			Roles: []AccountRole{{
+				ID: "some-role",
+			}},
+			Policies: []Policy{mockPolicy},
+		},
+	)
+
+	if assert.Error(t, err) {
+		assert.Equal(t, err, ErrMissingMemberRolesOrPolicies)
+	}
+}
+
 func TestUpdateAccountMemberWithoutAccountID(t *testing.T) {
 	setup()
 	defer teardown()
@@ -397,7 +612,7 @@ func TestAccountMember(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/members/4536bcfad5faccb111b47003c79917fa", handler)
+	mux.HandleFunc("/accounts/"+testAccountID+"/members/4536bcfad5faccb111b47003c79917fa", handler)
 
 	actual, err := client.AccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "4536bcfad5faccb111b47003c79917fa")
 
diff --git a/permission_group_test.go b/permission_group_test.go
new file mode 100644
index 000000000..d3520db9e
--- /dev/null
+++ b/permission_group_test.go
@@ -0,0 +1,152 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var mockPermissionGroup = PermissionGroup{
+	ID:   "f08020434ba14a0bb46bd9ff52f23b04",
+	Name: "Fake Permission Group",
+	Meta: map[string]string{
+		"description": "Can represent a permission group",
+	},
+	Permissions: []Permission{{
+		ID:  "7d42552322884d19bb63ed7f69b5ac21",
+		Key: "com.cloudflare.api.account.fake.permission",
+		Attributes: map[string]string{
+			"legacy_name": "#fake:permission",
+		},
+	}},
+}
+
+func TestListPermissionGroup_ByName(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "result": [
+				{
+				  "id": "f08020434ba14a0bb46bd9ff52f23b04",
+				  "name": "Fake Permission Group",
+				  "status": "V",
+				  "meta": {
+					"description": "Can represent a permission group"
+				  },
+				  "created_on": "2022-08-29T16:58:29.745574Z",
+				  "modified_on": "2022-09-12T08:26:37.255907Z",
+				  "permissions": [
+					{
+					  "id": "7d42552322884d19bb63ed7f69b5ac21",
+					  "key": "com.cloudflare.api.account.fake.permission",
+					  "attributes": {
+						"legacy_name": "#fake:permission"
+					  }
+					}
+				  ]
+				}
+			  ],
+			  "success": true,
+			  "errors": [],
+			  "messages": []
+			}`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/iam/permission_groups", handler)
+
+	result, err := client.ListPermissionGroups(context.Background(), AccountIdentifier(testAccountID), ListPermissionGroupParams{RoleName: "fake-permission-group-name"})
+	if assert.NoError(t, err) {
+		assert.Equal(t, result, []PermissionGroup{mockPermissionGroup})
+	}
+}
+
+func TestGetPermissionGroup(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"result": {
+				"id": "f08020434ba14a0bb46bd9ff52f23b04",
+				"name": "Fake Permission Group",
+				"status": "V",
+				"meta": {
+					"description": "Can represent a permission group"
+				},
+				"created_on": "2022-08-29T16:58:29.745574Z",
+				"modified_on": "2022-09-12T08:26:37.255907Z",
+				"permissions": [
+					{
+						"id": "7d42552322884d19bb63ed7f69b5ac21",
+						"key": "com.cloudflare.api.account.fake.permission",
+						"attributes": {
+							"legacy_name": "#fake:permission"
+						}
+					}
+				]
+			},
+			"success": true,
+			"errors": [],
+			"messages": []
+			}`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/iam/permission_groups/fake-permission-group-id", handler)
+
+	result, err := client.GetPermissionGroup(context.Background(), AccountIdentifier(testAccountID), "fake-permission-group-id")
+	if assert.NoError(t, err) {
+		assert.Equal(t, result, mockPermissionGroup)
+	}
+}
+
+func TestPermissionGroups(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "result": [
+				{
+				  "id": "f08020434ba14a0bb46bd9ff52f23b04",
+				  "name": "Fake Permission Group",
+				  "status": "V",
+				  "meta": {
+					"description": "Can represent a permission group"
+				  },
+				  "created_on": "2022-08-29T16:58:29.745574Z",
+				  "modified_on": "2022-09-12T08:26:37.255907Z",
+				  "permissions": [
+					{
+					  "id": "7d42552322884d19bb63ed7f69b5ac21",
+					  "key": "com.cloudflare.api.account.fake.permission",
+					  "attributes": {
+						"legacy_name": "#fake:permission"
+					  }
+					}
+				  ]
+				}
+			  ],
+			  "success": true,
+			  "errors": [],
+			  "messages": []
+			}`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/iam/permission_groups", handler)
+
+	result, err := client.ListPermissionGroups(context.Background(), AccountIdentifier(testAccountID), ListPermissionGroupParams{})
+	if assert.NoError(t, err) {
+		assert.Equal(t, result, []PermissionGroup{mockPermissionGroup})
+	}
+}
diff --git a/resource_group_test.go b/resource_group_test.go
new file mode 100644
index 000000000..ca4b6dc20
--- /dev/null
+++ b/resource_group_test.go
@@ -0,0 +1,46 @@
+package cloudflare
+
+import (
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestNewResourceGroup(t *testing.T) {
+	setup()
+	defer teardown()
+
+	key := "com.cloudflare.test.1"
+	rg := NewResourceGroup(key)
+
+	assert.Equal(t, rg.Name, key)
+	assert.Equal(t, rg.Scope.Key, key)
+}
+
+func TestNewResourceGroupForAccount(t *testing.T) {
+	setup()
+	defer teardown()
+
+	id := "some-fake-account-id"
+	rg := NewResourceGroupForAccount(Account{
+		ID: id,
+	})
+
+	key := fmt.Sprintf("com.cloudflare.api.account.%s", id)
+	assert.Equal(t, rg.Name, key)
+	assert.Equal(t, rg.Scope.Key, key)
+}
+
+func TestNewResourceGroupForZone(t *testing.T) {
+	setup()
+	defer teardown()
+
+	id := "some-fake-zone-id"
+	rg := NewResourceGroupForZone(Zone{
+		ID: id,
+	})
+
+	key := fmt.Sprintf("com.cloudflare.api.account.zone.%s", id)
+	assert.Equal(t, rg.Name, key)
+	assert.Equal(t, rg.Scope.Key, key)
+}