Repository: mrparkers/terraform-provider-keycloak
Pull request #315 (2020-06-09 16:05:08)

Add resource keycloak_user_roles
Hey,

I know there are already two open PRs to add user-role-support, but they both look stale and not ready…

This one is done, it's based heavily upon the `keycloak_group_roles`, although in some details the implementation differs, since the group-endpoint returns mapped roles, but the user-endpoint not.

On the other hand, explicitly querying the user's role-mapping endpoints returns full roles (opposed to role-names), thus the implementation is simpler, once the role-mapping endpoint has been queried.

I also tested the resource manually, so as far as I know it's done/it works, but I'm looking forward to your feedback.

n0rig: This looks great; this is something I was trying to solve. Can we get more eyes on this?
mrparkers: I think this can be merged as-is, but I'll give you the opportunity to make the improvement that @tomrutsaert suggested if you're interested.  If not, we can merge this as-is.

Thanks for the PR!
zauberpony: Thanks for your feedback!

Well I played around with the code, and yes, using a dedicated struct is much cleaner and should be simple. I will update this PR in the next few days :)
elmarx: OK, PR has been updated. 

Short summary of changes:

The map is gone and I replaced it with a dedicated structure `roleMapping`, which is similar to Keycloak's `UserRoleMapping`, but not the same (the map for client-roles uses the uuid instead of the clientid which keycloak uses).
Thus keycloak `UserRoleMapping` needs to be transformed into the internal `roleMapping`.

This of course means the previous "diff-function" `removeDuplicationRoles` from `resource_keycloak_group_roles` can't be reused, so there's `calculateRoleMappingUpdates` to figure out the updates.

Patch:
diff --git a/keycloak/user.go b/keycloak/user.go
index 951d017..02a2de2 100644
--- a/keycloak/user.go
+++ b/keycloak/user.go
@@ -90,31 +90,6 @@ func (keycloakClient *KeycloakClient) GetUsers(realmId string) ([]*User, error)
 	return users, nil
 }
 
-func (keycloakClient *KeycloakClient) GetUsersRoles(realmId string) ([]*Role, error) {
-	var roles []*Role
-	var users []*User
-
-	err := keycloakClient.get(fmt.Sprintf("/realms/%s/users", realmId), &users, nil)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, user := range users {
-		var roles_user []*Role
-		err = keycloakClient.get(fmt.Sprintf("/realms/%s/users/%s/roles", realmId, user.Id), &roles_user, nil)
-		if err != nil {
-			return nil, err
-		}
-		roles = append(roles, roles_user...)
-	}
-
-	for _, role := range roles {
-		role.RealmId = realmId
-	}
-
-	return roles, nil
-}
-
 func (keycloakClient *KeycloakClient) GetUser(realmId, id string) (*User, error) {
 	var user User
 
diff --git a/keycloak/user_role_mappings.go b/keycloak/user_role_mappings.go
new file mode 100644
index 0000000..ac90da8
--- /dev/null
+++ b/keycloak/user_role_mappings.go
@@ -0,0 +1,52 @@
+package keycloak
+
+import "fmt"
+
+// struct for the MappingRepresentation
+// https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_mappingsrepresentation
+type UserRoleMapping struct {
+	ClientMappings map[string]*ClientRoleMapping `json:"clientMappings"`
+	RealmMappings  []*Role                       `json:"realmMappings"`
+}
+
+// struct for the ClientMappingRepresentation
+// https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_clientmappingsrepresentation
+type ClientRoleMapping struct {
+	Client   string  `json:"client"`
+	Id       string  `json:"id"`
+	Mappings []*Role `json:"mappings"`
+}
+
+func (keycloakClient *KeycloakClient) GetUserRoleMappings(realmId string, userId string) (*UserRoleMapping, error) {
+	var roleMapping *UserRoleMapping
+	err := keycloakClient.get(fmt.Sprintf("/realms/%s/users/%s/role-mappings", realmId, userId), &roleMapping, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return roleMapping, nil
+}
+
+func (keycloakClient *KeycloakClient) AddRealmRolesToUser(realmId, userId string, roles []*Role) error {
+	_, _, err := keycloakClient.post(fmt.Sprintf("/realms/%s/users/%s/role-mappings/realm", realmId, userId), roles)
+
+	return err
+}
+
+func (keycloakClient *KeycloakClient) AddClientRolesToUser(realmId, userId, clientId string, roles []*Role) error {
+	_, _, err := keycloakClient.post(fmt.Sprintf("/realms/%s/users/%s/role-mappings/clients/%s", realmId, userId, clientId), roles)
+
+	return err
+}
+
+func (keycloakClient *KeycloakClient) RemoveRealmRolesFromUser(realmId, userId string, roles []*Role) error {
+	err := keycloakClient.delete(fmt.Sprintf("/realms/%s/users/%s/role-mappings/realm", realmId, userId), roles)
+
+	return err
+}
+
+func (keycloakClient *KeycloakClient) RemoveClientRolesFromUser(realmId, userId, clientId string, roles []*Role) error {
+	err := keycloakClient.delete(fmt.Sprintf("/realms/%s/users/%s/role-mappings/clients/%s", realmId, userId, clientId), roles)
+
+	return err
+}
diff --git a/provider/provider.go b/provider/provider.go
index acbfaaa..cf8d512 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -27,6 +27,7 @@ func KeycloakProvider() *schema.Provider {
 			"keycloak_default_groups":                                  resourceKeycloakDefaultGroups(),
 			"keycloak_group_roles":                                     resourceKeycloakGroupRoles(),
 			"keycloak_user":                                            resourceKeycloakUser(),
+			"keycloak_user_roles":                                      resourceKeycloakUserRoles(),
 			"keycloak_openid_client":                                   resourceKeycloakOpenidClient(),
 			"keycloak_openid_client_scope":                             resourceKeycloakOpenidClientScope(),
 			"keycloak_ldap_user_federation":                            resourceKeycloakLdapUserFederation(),
diff --git a/provider/resource_keycloak_user_roles.go b/provider/resource_keycloak_user_roles.go
new file mode 100644
index 0000000..39e368b
--- /dev/null
+++ b/provider/resource_keycloak_user_roles.go
@@ -0,0 +1,347 @@
+package provider
+
+import (
+	"fmt"
+	"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
+	"github.com/mrparkers/terraform-provider-keycloak/keycloak"
+	"strings"
+)
+
+func resourceKeycloakUserRoles() *schema.Resource {
+	return &schema.Resource{
+		Create: resourceKeycloakUserRolesCreate,
+		Read:   resourceKeycloakUserRolesRead,
+		Update: resourceKeycloakUserRolesUpdate,
+		Delete: resourceKeycloakUserRolesDelete,
+		// This resource can be imported using {{realm}}/{{userId}}.
+		Importer: &schema.ResourceImporter{
+			State: resourceKeycloakUserRolesImport,
+		},
+		Schema: map[string]*schema.Schema{
+			"realm_id": {
+				Type:     schema.TypeString,
+				Required: true,
+				ForceNew: true,
+			},
+			"user_id": {
+				Type:     schema.TypeString,
+				Required: true,
+				ForceNew: true,
+			},
+			"role_ids": {
+				Type:     schema.TypeSet,
+				Elem:     &schema.Schema{Type: schema.TypeString},
+				Set:      schema.HashString,
+				Required: true,
+			},
+		},
+	}
+}
+
+func userRolesId(realmId, userId string) string {
+	return fmt.Sprintf("%s/%s", realmId, userId)
+}
+
+func addRolesToUser(keycloakClient *keycloak.KeycloakClient, clientRolesToAdd map[string][]*keycloak.Role, realmRolesToAdd []*keycloak.Role, user *keycloak.User) error {
+	if len(realmRolesToAdd) != 0 {
+		err := keycloakClient.AddRealmRolesToUser(user.RealmId, user.Id, realmRolesToAdd)
+		if err != nil {
+			return err
+		}
+	}
+
+	for k, roles := range clientRolesToAdd {
+		if len(roles) != 0 {
+			err := keycloakClient.AddClientRolesToUser(user.RealmId, user.Id, k, roles)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func removeRolesFromUser(keycloakClient *keycloak.KeycloakClient, clientRolesToRemove map[string][]*keycloak.Role, realmRolesToRemove []*keycloak.Role, user *keycloak.User) error {
+	if len(realmRolesToRemove) != 0 {
+		err := keycloakClient.RemoveRealmRolesFromUser(user.RealmId, user.Id, realmRolesToRemove)
+		if err != nil {
+			return err
+		}
+	}
+
+	for k, roles := range clientRolesToRemove {
+		if len(roles) != 0 {
+			err := keycloakClient.RemoveClientRolesFromUser(user.RealmId, user.Id, k, roles)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func resourceKeycloakUserRolesCreate(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	userId := data.Get("user_id").(string)
+
+	user, err := keycloakClient.GetUser(realmId, userId)
+	if err != nil {
+		return err
+	}
+
+	roleIds := interfaceSliceToStringSlice(data.Get("role_ids").(*schema.Set).List())
+	tfRoles, err := getExtendedRoleMapping(keycloakClient, realmId, roleIds)
+	if err != nil {
+		return err
+	}
+
+	// get the list of currently assigned roles. Due to default-realm- and client-roles
+	// (e.g. roles of the account-client) this is probably not empty upon resource creation
+	roleMappings, err := keycloakClient.GetUserRoleMappings(realmId, userId)
+
+	// sort into roles we need to add and roles we need to remove
+	updates := calculateRoleMappingUpdates(tfRoles, intoRoleMapping(roleMappings))
+
+	// add roles
+	err = addRolesToUser(keycloakClient, updates.clientRolesToAdd, updates.realmRolesToAdd, user)
+	if err != nil {
+		return err
+	}
+
+	// remove roles
+	err = removeRolesFromUser(keycloakClient, updates.clientRolesToRemove, updates.realmRolesToRemove, user)
+	if err != nil {
+		return err
+	}
+
+	data.SetId(userRolesId(realmId, userId))
+	return resourceKeycloakUserRolesRead(data, meta)
+}
+
+func resourceKeycloakUserRolesRead(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	userId := data.Get("user_id").(string)
+
+	roles, err := keycloakClient.GetUserRoleMappings(realmId, userId)
+	if err != nil {
+		return err
+	}
+
+	var roleIds []string
+
+	for _, realmRole := range roles.RealmMappings {
+		roleIds = append(roleIds, realmRole.Id)
+	}
+
+	for _, clientRoleMapping := range roles.ClientMappings {
+		for _, clientRole := range clientRoleMapping.Mappings {
+			roleIds = append(roleIds, clientRole.Id)
+		}
+	}
+
+	data.Set("role_ids", roleIds)
+	data.SetId(userRolesId(realmId, userId))
+
+	return nil
+}
+
+func resourceKeycloakUserRolesUpdate(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	userId := data.Get("user_id").(string)
+
+	user, err := keycloakClient.GetUser(realmId, userId)
+	if err != nil {
+		return err
+	}
+
+	roleIds := interfaceSliceToStringSlice(data.Get("role_ids").(*schema.Set).List())
+	tfRoles, err := getExtendedRoleMapping(keycloakClient, realmId, roleIds)
+	if err != nil {
+		return err
+	}
+
+	roleMappings, err := keycloakClient.GetUserRoleMappings(realmId, userId)
+	if err != nil {
+		return err
+	}
+
+	updates := calculateRoleMappingUpdates(tfRoles, intoRoleMapping(roleMappings))
+
+	// `tfRoles` contains all roles that need to be added
+	// `remoteRoles` contains all roles that need to be removed
+
+	err = addRolesToUser(keycloakClient, updates.clientRolesToAdd, updates.realmRolesToAdd, user)
+	if err != nil {
+		return err
+	}
+
+	err = removeRolesFromUser(keycloakClient, updates.clientRolesToRemove, updates.realmRolesToRemove, user)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func resourceKeycloakUserRolesDelete(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	userId := data.Get("user_id").(string)
+
+	user, err := keycloakClient.GetUser(realmId, userId)
+
+	roleIds := interfaceSliceToStringSlice(data.Get("role_ids").(*schema.Set).List())
+	rolesToRemove, err := getExtendedRoleMapping(keycloakClient, realmId, roleIds)
+	if err != nil {
+		return err
+	}
+
+	err = removeRolesFromUser(keycloakClient, rolesToRemove.clientRoles, rolesToRemove.realmRoles, user)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func resourceKeycloakUserRolesImport(d *schema.ResourceData, _ interface{}) ([]*schema.ResourceData, error) {
+	parts := strings.Split(d.Id(), "/")
+
+	if len(parts) != 2 {
+		return nil, fmt.Errorf("Invalid import. Supported import format: {{realm}}/{{userId}}.")
+	}
+
+	d.Set("realm_id", parts[0])
+	d.Set("user_id", parts[1])
+
+	d.SetId(userRolesId(parts[0], parts[1]))
+
+	return []*schema.ResourceData{d}, nil
+}
+
+// a struct that represents the "desired" state configured via terraform
+// the key for 'clientRoles' is keycloak's client-id (the uuid, not to be confused with the OAuth Client Id)
+type roleMapping struct {
+	clientRoles map[string][]*keycloak.Role
+	realmRoles  []*keycloak.Role
+}
+
+// transform the keycloak response UserRoleMapping into the internal roleMapping
+// so we can compare the current state (keycloak) against the desired state (terraform)
+func intoRoleMapping(userRoleMapping *keycloak.UserRoleMapping) *roleMapping {
+	clientRoles := make(map[string][]*keycloak.Role)
+	for _, clientRoleMapping := range userRoleMapping.ClientMappings {
+		clientRoles[clientRoleMapping.Id] = clientRoleMapping.Mappings
+	}
+
+	mapping := roleMapping{
+		clientRoles: clientRoles,
+		realmRoles:  userRoleMapping.RealmMappings,
+	}
+
+	return &mapping
+}
+
+// given a list or roleIds, query keycloak for role-details to find out if a role is a client-role or a
+// realm-rule (which is required to POST the role-assignment to the correct API-endpoint)
+func getExtendedRoleMapping(keycloakClient *keycloak.KeycloakClient, realmId string, roleIds []string) (*roleMapping, error) {
+	clientRoles := make(map[string][]*keycloak.Role)
+	var realmRoles []*keycloak.Role
+
+	for _, roleId := range roleIds {
+		role, err := keycloakClient.GetRole(realmId, roleId)
+		if err != nil {
+			return nil, err
+		}
+
+		if role.ClientRole {
+			clientRoles[role.ClientId] = append(clientRoles[role.ClientId], role)
+		} else {
+			realmRoles = append(realmRoles, role)
+		}
+	}
+
+	mapping := roleMapping{
+		clientRoles: clientRoles,
+		realmRoles:  realmRoles,
+	}
+
+	return &mapping, nil
+}
+
+type terraformRoleMappingUpdates struct {
+	realmRolesToRemove  []*keycloak.Role
+	realmRolesToAdd     []*keycloak.Role
+	clientRolesToRemove map[string][]*keycloak.Role
+	clientRolesToAdd    map[string][]*keycloak.Role
+}
+
+// given the existing roles (queried from keycloak) and the requested roles (via tf)
+// calculate the required updates, i.e. roles to remove and roles to add
+func calculateRoleMappingUpdates(requestedRoles *roleMapping, existingRoles *roleMapping) *terraformRoleMappingUpdates {
+	clientRolesToRemove := make(map[string][]*keycloak.Role)
+	clientRolesToAdd := make(map[string][]*keycloak.Role)
+
+	realmRolesToRemove := minusRoles(existingRoles.realmRoles, requestedRoles.realmRoles)
+	realmRolesToAdd := minusRoles(requestedRoles.realmRoles, existingRoles.realmRoles)
+
+	for clientId, requestedClientRoles := range requestedRoles.clientRoles {
+		if existingClientRoles, ok := existingRoles.clientRoles[clientId]; ok {
+			clientRolesToAdd[clientId] = minusRoles(requestedClientRoles, existingClientRoles)
+			clientRolesToRemove[clientId] = minusRoles(existingClientRoles, requestedClientRoles)
+		} else {
+			// if no roles for this client exist yet, then of course, all requested roles need to be created
+			clientRolesToAdd[clientId] = requestedClientRoles
+		}
+	}
+
+	// now check all existing roles, if there are even any roles configured for each client
+	for clientId, existingClientRoles := range existingRoles.clientRoles {
+		if _, ok := requestedRoles.clientRoles[clientId]; !ok {
+			// no role requested for this client? -> remove all existing client-roles
+			clientRolesToRemove[clientId] = existingClientRoles
+		}
+	}
+
+	updates := terraformRoleMappingUpdates{
+		realmRolesToRemove:  realmRolesToRemove,
+		realmRolesToAdd:     realmRolesToAdd,
+		clientRolesToRemove: clientRolesToRemove,
+		clientRolesToAdd:    clientRolesToAdd,
+	}
+
+	return &updates
+}
+
+// check if given role exists in a list of roles
+func roleExists(roles []*keycloak.Role, role *keycloak.Role) bool {
+	for _, r := range roles {
+		if r.Id == role.Id {
+			return true
+		}
+	}
+
+	return false
+}
+
+// calculate the set difference: returns `a \ b`, i.e. every role that exist in a, but not in b
+func minusRoles(a, b []*keycloak.Role) []*keycloak.Role {
+	var aWithoutB []*keycloak.Role
+
+	for _, role := range a {
+		if !roleExists(b, role) {
+			aWithoutB = append(aWithoutB, role)
+		}
+	}
+
+	return aWithoutB
+}


Test patch:
diff --git a/provider/resource_keycloak_user_roles_test.go b/provider/resource_keycloak_user_roles_test.go
new file mode 100644
index 0000000..9ae9b6f
--- /dev/null
+++ b/provider/resource_keycloak_user_roles_test.go
@@ -0,0 +1,457 @@
+package provider
+
+import (
+	"fmt"
+	"github.com/hashicorp/terraform-plugin-sdk/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-sdk/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/terraform"
+	"github.com/mrparkers/terraform-provider-keycloak/keycloak"
+	"regexp"
+	"testing"
+)
+
+func TestAccKeycloakUserRoles_basic(t *testing.T) {
+	realmName := "terraform-" + acctest.RandString(10)
+	realmRoleName := "terraform-role-" + acctest.RandString(10)
+	openIdClientName := "terraform-openid-client-" + acctest.RandString(10)
+	openIdRoleName := "terraform-role-" + acctest.RandString(10)
+	samlClientName := "terraform-saml-client-" + acctest.RandString(10)
+	samlRoleName := "terraform-role-" + acctest.RandString(10)
+	userName := "terraform-user-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakUserRoles_basic(realmName, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, userName),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			{
+				ResourceName:      "keycloak_user_roles.user_roles",
+				ImportState:       true,
+				ImportStateVerify: true,
+			},
+			// check destroy
+			{
+				Config: testKeycloakUserRoles_noUserRoles(realmName, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, userName),
+				Check:  testAccCheckKeycloakUserHasNoRoles("keycloak_user.user"),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakUserRoles_update(t *testing.T) {
+	realmName := "terraform-" + acctest.RandString(10)
+
+	realmRoleOneName := "terraform-role-" + acctest.RandString(10)
+	realmRoleTwoName := "terraform-role-" + acctest.RandString(10)
+	openIdClientName := "terraform-openid-client-" + acctest.RandString(10)
+	openIdRoleOneName := "terraform-role-" + acctest.RandString(10)
+	openIdRoleTwoName := "terraform-role-" + acctest.RandString(10)
+	samlClientName := "terraform-saml-client-" + acctest.RandString(10)
+	samlRoleOneName := "terraform-role-" + acctest.RandString(10)
+	samlRoleTwoName := "terraform-role-" + acctest.RandString(10)
+	userName := "terraform-user-" + acctest.RandString(10)
+
+	allRoleIds := []string{
+		"${keycloak_role.realm_role_one.id}",
+		"${keycloak_role.realm_role_two.id}",
+		"${keycloak_role.openid_client_role_one.id}",
+		"${keycloak_role.openid_client_role_two.id}",
+		"${keycloak_role.saml_client_role_one.id}",
+		"${keycloak_role.saml_client_role_two.id}",
+		"${data.keycloak_role.offline_access.id}",
+	}
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			// initial setup, resource is defined but no roles are specified
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, []string{}),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// add all roles
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, allRoleIds),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// remove some
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, []string{
+					"${keycloak_role.realm_role_two.id}",
+					"${keycloak_role.openid_client_role_one.id}",
+					"${keycloak_role.openid_client_role_two.id}",
+					"${data.keycloak_role.offline_access.id}",
+				}),
+				Check: testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// add some and remove some
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, []string{
+					"${keycloak_role.saml_client_role_one.id}",
+					"${keycloak_role.saml_client_role_two.id}",
+					"${keycloak_role.realm_role_one.id}",
+				}),
+				Check: testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// add some and remove some again
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, []string{
+					"${keycloak_role.saml_client_role_one.id}",
+					"${keycloak_role.openid_client_role_two.id}",
+					"${keycloak_role.realm_role_two.id}",
+					"${data.keycloak_role.offline_access.id}",
+				}),
+				Check: testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// add all back
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, allRoleIds),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// random scenario 1
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, randomStringSliceSubset(allRoleIds)),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// random scenario 2
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, randomStringSliceSubset(allRoleIds)),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// random scenario 3
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, randomStringSliceSubset(allRoleIds)),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+			// remove all
+			{
+				Config: testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, []string{}),
+				Check:  testAccCheckKeycloakUserHasRoles("keycloak_user_roles.user_roles"),
+			},
+		},
+	})
+}
+
+func flattenUserRoles(userRoleMapping *keycloak.UserRoleMapping) ([]string, error) {
+	var roles []string
+
+	for _, realmRole := range userRoleMapping.RealmMappings {
+		roles = append(roles, realmRole.Name)
+	}
+
+	for _, clientRoleMapping := range userRoleMapping.ClientMappings {
+		for _, clientRole := range clientRoleMapping.Mappings {
+			roles = append(roles, fmt.Sprintf("%s/%s", clientRoleMapping.Id, clientRole.Name))
+		}
+	}
+
+	return roles, nil
+}
+
+func testAccCheckKeycloakUserHasRoles(resourceName string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+		rs, ok := s.RootModule().Resources[resourceName]
+		if !ok {
+			return fmt.Errorf("resource not found: %s", resourceName)
+		}
+
+		realm := rs.Primary.Attributes["realm_id"]
+		userId := rs.Primary.Attributes["user_id"]
+
+		var roles []*keycloak.Role
+		for k, v := range rs.Primary.Attributes {
+			if match, _ := regexp.MatchString("role_ids\\.[^#]+", k); !match {
+				continue
+			}
+
+			role, err := keycloakClient.GetRole(realm, v)
+			if err != nil {
+				return err
+			}
+
+			roles = append(roles, role)
+		}
+
+		user, err := keycloakClient.GetUser(realm, userId)
+		if err != nil {
+			return err
+		}
+
+		userRoleMappings, err := keycloakClient.GetUserRoleMappings(realm, userId)
+		if err != nil {
+			return err
+		}
+
+		userRoles, err := flattenUserRoles(userRoleMappings)
+		if err != nil {
+			return err
+		}
+
+		if len(userRoles) != len(roles) {
+			return fmt.Errorf("expected number of user roles to be %d, got %d", len(roles), len(userRoles))
+		}
+
+		for _, role := range roles {
+			var expectedRoleString string
+			if role.ClientRole {
+				expectedRoleString = fmt.Sprintf("%s/%s", role.ClientId, role.Name)
+			} else {
+				expectedRoleString = role.Name
+			}
+
+			found := false
+
+			for _, userRole := range userRoles {
+				if userRole == expectedRoleString {
+					found = true
+					break
+				}
+			}
+
+			if !found {
+				return fmt.Errorf("expected to find role %s assigned to user %s", expectedRoleString, user.Username)
+			}
+		}
+
+		return nil
+	}
+}
+
+func testAccCheckKeycloakUserHasNoRoles(resourceName string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+		rs, ok := s.RootModule().Resources[resourceName]
+		if !ok {
+			return fmt.Errorf("resource not found: %s", resourceName)
+		}
+
+		realm := rs.Primary.Attributes["realm_id"]
+		id := rs.Primary.ID
+
+		user, err := keycloakClient.GetUser(realm, id)
+		if err != nil {
+			return err
+		}
+
+		userRoleMapping, err := keycloakClient.GetUserRoleMappings(realm, id)
+		if err != nil {
+			return err
+		}
+
+		if len(userRoleMapping.RealmMappings) != 0 || len(userRoleMapping.ClientMappings) != 0 {
+			return fmt.Errorf("expected user %s to have no roles", user.Username)
+		}
+
+		return nil
+	}
+}
+
+func testKeycloakUserRoles_basic(realmName, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, userName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "openid_client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "CONFIDENTIAL"
+}
+
+resource "keycloak_saml_client" "saml_client" {
+	client_id = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "realm_role" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "openid_client_role" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_openid_client.openid_client.id}"
+}
+
+resource "keycloak_role" "saml_client_role" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_saml_client.saml_client.id}"
+}
+
+data "keycloak_openid_client" "account" {
+	realm_id = "${keycloak_realm.realm.id}"
+	client_id = "account"
+}
+
+data "keycloak_role" "manage_account" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${data.keycloak_openid_client.account.id}"
+	name 	  = "manage-account"
+}
+
+data "keycloak_role" "view_profile" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${data.keycloak_openid_client.account.id}"
+	name 	  = "view-profile"
+}
+
+data "keycloak_role" "offline_access" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	name      = "offline_access"
+}
+
+data "keycloak_role" "uma_authorization" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	name      = "uma_authorization"
+}
+
+resource "keycloak_user" "user" {
+	realm_id = "${keycloak_realm.realm.id}"
+	username = "%s"
+}
+
+resource "keycloak_user_roles" "user_roles" {
+	realm_id = "${keycloak_realm.realm.id}"
+	user_id = "${keycloak_user.user.id}"
+
+	role_ids = [
+		"${keycloak_role.realm_role.id}",
+		"${keycloak_role.openid_client_role.id}",
+		"${keycloak_role.saml_client_role.id}",
+
+		# default roles
+		"${data.keycloak_role.offline_access.id}",
+		"${data.keycloak_role.uma_authorization.id}",
+		"${data.keycloak_role.manage_account.id}",
+		"${data.keycloak_role.view_profile.id}",
+	]
+}
+	`, realmName, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, userName)
+}
+
+func testKeycloakUserRoles_noUserRoles(realmName, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, userName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "openid_client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "CONFIDENTIAL"
+}
+
+resource "keycloak_saml_client" "saml_client" {
+	client_id = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "realm_role" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "openid_client_role" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_openid_client.openid_client.id}"
+}
+
+resource "keycloak_role" "saml_client_role" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_saml_client.saml_client.id}"
+}
+
+data "keycloak_role" "offline_access" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	name      = "offline_access"
+}
+
+resource "keycloak_user" "user" {
+	realm_id = "${keycloak_realm.realm.id}"
+	username = "%s"
+}
+	`, realmName, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, userName)
+}
+
+func testKeycloakUserRoles_update(realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName string, roleIds []string) string {
+	tfRoleIds := fmt.Sprintf("role_ids = %s", arrayOfStringsForTerraformResource(roleIds))
+
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "openid_client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "CONFIDENTIAL"
+}
+
+resource "keycloak_saml_client" "saml_client" {
+	client_id = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "realm_role_one" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "realm_role_two" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+
+resource "keycloak_role" "openid_client_role_one" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_openid_client.openid_client.id}"
+}
+
+resource "keycloak_role" "openid_client_role_two" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_openid_client.openid_client.id}"
+}
+
+resource "keycloak_role" "saml_client_role_one" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_saml_client.saml_client.id}"
+}
+
+resource "keycloak_role" "saml_client_role_two" {
+	name      = "%s"
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "${keycloak_saml_client.saml_client.id}"
+}
+
+data "keycloak_role" "offline_access" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	name      = "offline_access"
+}
+
+resource "keycloak_user" "user" {
+	realm_id = "${keycloak_realm.realm.id}"
+	username = "%s"
+}
+
+resource "keycloak_user_roles" "user_roles" {
+	realm_id = "${keycloak_realm.realm.id}"
+	user_id = "${keycloak_user.user.id}"
+
+	%s
+}
+	`, realmName, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, userName, tfRoleIds)
+}