Repository: mrparkers/terraform-provider-keycloak
Pull request #304 (2020-05-27 16:27:48)

fix: surface keycloak api error messages to user
cc @hawknewton

I'll cut a new release after this PR and a few more are merged


Patch:
diff --git a/docs/resources/keycloak_openid_user_client_role_protocol_mapper.md b/docs/resources/keycloak_openid_user_client_role_protocol_mapper.md
new file mode 100644
index 0000000..1052f45
--- /dev/null
+++ b/docs/resources/keycloak_openid_user_client_role_protocol_mapper.md
@@ -0,0 +1,83 @@
+# keycloak_openid_user_client_role_protocol_mapper
+
+Allows for creating and managing user client role protocol mappers within
+Keycloak.
+
+User client role protocol mappers allow you to define a claim containing the list of a client roles.
+Protocol mappers can be defined for a single client, or they can
+be defined for a client scope which can be shared between multiple different
+clients.
+
+### Example Usage (Client)
+
+```hcl
+resource "keycloak_realm" "realm" {
+    realm   = "my-realm"
+    enabled = true
+}
+resource "keycloak_openid_client" "openid_client" {
+    realm_id            = "${keycloak_realm.realm.id}"
+    client_id           = "test-client"
+    name                = "test client"
+    enabled             = true
+    access_type         = "CONFIDENTIAL"
+    valid_redirect_uris = [
+        "http://localhost:8080/openid-callback"
+    ]
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper" {
+    realm_id    = "${keycloak_realm.realm.id}"
+    client_id   = "${keycloak_openid_client.openid_client.id}"
+    name        = "user-client-role-mapper"
+    claim_name  = "foo"
+}
+```
+
+### Example Usage (Client Scope)
+
+```hcl
+resource "keycloak_realm" "realm" {
+    realm   = "my-realm"
+    enabled = true
+}
+resource "keycloak_openid_client_scope" "client_scope" {
+    realm_id = "${keycloak_realm.realm.id}"
+    name     = "test-client-scope"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper" {
+    realm_id        = "${keycloak_realm.realm.id}"
+    client_scope_id = "${keycloak_openid_client_scope.client_scope.id}"
+    name            = "user-client-role-mapper"
+    claim_name      = "foo"
+}
+```
+
+### Argument Reference
+
+The following arguments are supported:
+
+- `realm_id` - (Required) The realm this protocol mapper exists within.
+- `client_id` - (Required if `client_scope_id` is not specified) The client this protocol mapper is attached to.
+- `client_scope_id` - (Required if `client_id` is not specified) The client scope this protocol mapper is attached to.
+- `name` - (Required) The display name of this protocol mapper in the GUI.
+- `claim_name` - (Required) The name of the claim to insert into a token.
+- `claim_value_type` - (Optional) The claim type used when serializing JSON tokens. Can be one of `String`, `JSON`, `long`, `int`, or `boolean`. Defaults to `String`.
+- `multivalued` - (Optional) Indicates if attribute supports multiple values. If true, then the list of all values of this attribute will be set as claim. If false, then just first value will be set as claim. Defaults to `false`.
+- `client_id_for_role_mappings` - (Optional) The Client ID for role mappings. Just client roles of this client will be added to the token. If this is unset, client roles of all clients will be added to the token. 
+- `client_role_prefix` - (Optional) A prefix for each Client Role.
+- `add_to_id_token` - (Optional) Indicates if the property should be added as a claim to the id token. Defaults to `true`.
+- `add_to_access_token` - (Optional) Indicates if the property should be added as a claim to the access token. Defaults to `true`.
+- `add_to_userinfo` - (Optional) Indicates if the property should be added as a claim to the UserInfo response body. Defaults to `true`.
+
+### Import
+
+Protocol mappers can be imported using one of the following formats:
+- Client: `{{realm_id}}/client/{{client_keycloak_id}}/{{protocol_mapper_id}}`
+- Client Scope: `{{realm_id}}/client-scope/{{client_scope_keycloak_id}}/{{protocol_mapper_id}}`
+
+Example:
+
+```bash
+$ terraform import keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper my-realm/client/a7202154-8793-4656-b655-1dd18c181e14/71602afa-f7d1-4788-8c49-ef8fd00af0f4
+$ terraform import keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper my-realm/client-scope/b799ea7e-73ee-4a73-990a-1eafebe8e20a/71602afa-f7d1-4788-8c49-ef8fd00af0f4
+```
\ No newline at end of file
diff --git a/example/main.tf b/example/main.tf
index 5e09517..6e12c7f 100644
--- a/example/main.tf
+++ b/example/main.tf
@@ -395,6 +395,40 @@ resource "keycloak_openid_user_realm_role_protocol_mapper" "user_realm_role_clie
   claim_name = "foo"
 }
 
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_client" {
+  name      = "tf-test-open-id-user-client-role-claim-protocol-mapper-client"
+  realm_id  = "${keycloak_realm.test.id}"
+  client_id = "${keycloak_openid_client.test_client.id}"
+
+  claim_name = "foo"
+  claim_value = "bar"
+  multivalued = false
+
+  client_id_for_role_mappings   = "${keycloak_openid_client.bearer_only_client.client_id}"
+  client_role_prefix = "prefixValue"
+
+  add_to_id_token     = true
+  add_to_access_token = false
+  add_to_user_info = false
+}
+
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_client_scope" {
+  name            = "tf-test-open-id-user-client-role-protocol-mapper-client-scope"
+  realm_id        = "${keycloak_realm.test.id}"
+  client_scope_id = "${keycloak_openid_client_scope.test_default_client_scope.id}"
+
+  claim_name  = "foo"
+  claim_value = "bar"
+  multivalued = false
+
+  client_id_for_role_mappings   = "${keycloak_openid_client.bearer_only_client.client_id}"
+  client_role_prefix = "prefixValue"
+
+  add_to_id_token     = true
+  add_to_access_token = false
+  add_to_user_info    = false
+}
+
 resource "keycloak_openid_client" "bearer_only_client" {
   client_id   = "test-bearer-only-client"
   name        = "test-bearer-only-client"
diff --git a/keycloak/keycloak_client.go b/keycloak/keycloak_client.go
index 570b1dd..a0db76a 100644
--- a/keycloak/keycloak_client.go
+++ b/keycloak/keycloak_client.go
@@ -286,9 +286,15 @@ func (keycloakClient *KeycloakClient) sendRequest(request *http.Request) ([]byte
 	}
 
 	if response.StatusCode >= 400 {
+		errorMessage := fmt.Sprintf("error sending %s request to %s: %s.", request.Method, request.URL.Path, response.Status)
+
+		if len(body) != 0 {
+			errorMessage = fmt.Sprintf("%s Response body: %s", errorMessage, body)
+		}
+
 		return nil, "", &ApiError{
 			Code:    response.StatusCode,
-			Message: fmt.Sprintf("error sending %s request to %s: %s", request.Method, request.URL.Path, response.Status),
+			Message: errorMessage,
 		}
 	}
 
diff --git a/keycloak/openid_user_client_role_protocol_mapper.go b/keycloak/openid_user_client_role_protocol_mapper.go
new file mode 100644
index 0000000..327d6ff
--- /dev/null
+++ b/keycloak/openid_user_client_role_protocol_mapper.go
@@ -0,0 +1,137 @@
+package keycloak
+
+import (
+	"fmt"
+	"strconv"
+)
+
+type OpenIdUserClientRoleProtocolMapper struct {
+	Id            string
+	Name          string
+	RealmId       string
+	ClientId      string
+	ClientScopeId string
+
+	AddToIdToken     bool
+	AddToAccessToken bool
+	AddToUserInfo    bool
+
+	ClaimName               string
+	ClaimValueType          string
+	Multivalued             bool
+	ClientIdForRoleMappings string
+	ClientRolePrefix        string
+}
+
+func (mapper *OpenIdUserClientRoleProtocolMapper) convertToGenericProtocolMapper() *protocolMapper {
+	return &protocolMapper{
+		Id:             mapper.Id,
+		Name:           mapper.Name,
+		Protocol:       "openid-connect",
+		ProtocolMapper: "oidc-usermodel-client-role-mapper",
+		Config: map[string]string{
+			addToIdTokenField:     strconv.FormatBool(mapper.AddToIdToken),
+			addToAccessTokenField: strconv.FormatBool(mapper.AddToAccessToken),
+			addToUserInfoField:    strconv.FormatBool(mapper.AddToUserInfo),
+
+			claimNameField:                       mapper.ClaimName,
+			claimValueTypeField:                  mapper.ClaimValueType,
+			multivaluedField:                     strconv.FormatBool(mapper.Multivalued),
+			userClientRoleMappingClientIdField:   mapper.ClientIdForRoleMappings,
+			userClientRoleMappingRolePrefixField: mapper.ClientRolePrefix,
+		},
+	}
+}
+
+func (protocolMapper *protocolMapper) convertToOpenIdUserClientRoleProtocolMapper(realmId, clientId, clientScopeId string) (*OpenIdUserClientRoleProtocolMapper, error) {
+	addToIdToken, err := strconv.ParseBool(protocolMapper.Config[addToIdTokenField])
+	if err != nil {
+		return nil, err
+	}
+
+	addToAccessToken, err := strconv.ParseBool(protocolMapper.Config[addToAccessTokenField])
+	if err != nil {
+		return nil, err
+	}
+
+	addToUserInfo, err := strconv.ParseBool(protocolMapper.Config[addToUserInfoField])
+	if err != nil {
+		return nil, err
+	}
+
+	multivalued, err := strconv.ParseBool(protocolMapper.Config[multivaluedField])
+	if err != nil {
+		return nil, err
+	}
+
+	return &OpenIdUserClientRoleProtocolMapper{
+		Id:            protocolMapper.Id,
+		Name:          protocolMapper.Name,
+		RealmId:       realmId,
+		ClientId:      clientId,
+		ClientScopeId: clientScopeId,
+
+		AddToIdToken:     addToIdToken,
+		AddToAccessToken: addToAccessToken,
+		AddToUserInfo:    addToUserInfo,
+
+		ClaimName:               protocolMapper.Config[claimNameField],
+		ClaimValueType:          protocolMapper.Config[claimValueTypeField],
+		Multivalued:             multivalued,
+		ClientIdForRoleMappings: protocolMapper.Config[userClientRoleMappingClientIdField],
+		ClientRolePrefix:        protocolMapper.Config[userClientRoleMappingRolePrefixField],
+	}, nil
+}
+
+func (keycloakClient *KeycloakClient) GetOpenIdUserClientRoleProtocolMapper(realmId, clientId, clientScopeId, mapperId string) (*OpenIdUserClientRoleProtocolMapper, error) {
+	var protocolMapper *protocolMapper
+
+	err := keycloakClient.get(individualProtocolMapperPath(realmId, clientId, clientScopeId, mapperId), &protocolMapper, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return protocolMapper.convertToOpenIdUserClientRoleProtocolMapper(realmId, clientId, clientScopeId)
+}
+
+func (keycloakClient *KeycloakClient) DeleteOpenIdUserClientRoleProtocolMapper(realmId, clientId, clientScopeId, mapperId string) error {
+	return keycloakClient.delete(individualProtocolMapperPath(realmId, clientId, clientScopeId, mapperId), nil)
+}
+
+func (keycloakClient *KeycloakClient) NewOpenIdUserClientRoleProtocolMapper(mapper *OpenIdUserClientRoleProtocolMapper) error {
+	path := protocolMapperPath(mapper.RealmId, mapper.ClientId, mapper.ClientScopeId)
+
+	_, location, err := keycloakClient.post(path, mapper.convertToGenericProtocolMapper())
+	if err != nil {
+		return err
+	}
+
+	mapper.Id = getIdFromLocationHeader(location)
+
+	return nil
+}
+
+func (keycloakClient *KeycloakClient) UpdateOpenIdUserClientRoleProtocolMapper(mapper *OpenIdUserClientRoleProtocolMapper) error {
+	path := individualProtocolMapperPath(mapper.RealmId, mapper.ClientId, mapper.ClientScopeId, mapper.Id)
+
+	return keycloakClient.put(path, mapper.convertToGenericProtocolMapper())
+}
+
+func (keycloakClient *KeycloakClient) ValidateOpenIdUserClientRoleProtocolMapper(mapper *OpenIdUserClientRoleProtocolMapper) error {
+	if mapper.ClientId == "" && mapper.ClientScopeId == "" {
+		return fmt.Errorf("validation error: one of ClientId or ClientScopeId must be set")
+	}
+
+	protocolMappers, err := keycloakClient.listGenericProtocolMappers(mapper.RealmId, mapper.ClientId, mapper.ClientScopeId)
+	if err != nil {
+		return err
+	}
+
+	for _, protocolMapper := range protocolMappers {
+		if protocolMapper.Name == mapper.Name && protocolMapper.Id != mapper.Id {
+			return fmt.Errorf("validation error: a protocol mapper with name %s already exists for this client", mapper.Name)
+		}
+	}
+
+	return nil
+}
diff --git a/keycloak/protocol_mapper.go b/keycloak/protocol_mapper.go
index b041e36..0a08579 100644
--- a/keycloak/protocol_mapper.go
+++ b/keycloak/protocol_mapper.go
@@ -12,23 +12,25 @@ type protocolMapper struct {
 }
 
 var (
-	addToAccessTokenField               = "access.token.claim"
-	addToIdTokenField                   = "id.token.claim"
-	addToUserInfoField                  = "userinfo.token.claim"
-	attributeNameField                  = "attribute.name"
-	attributeNameFormatField            = "attribute.nameformat"
-	claimNameField                      = "claim.name"
-	claimValueField                     = "claim.value"
-	claimValueTypeField                 = "jsonType.label"
-	friendlyNameField                   = "friendly.name"
-	fullPathField                       = "full.path"
-	includedClientAudienceField         = "included.client.audience"
-	includedCustomAudienceField         = "included.custom.audience"
-	multivaluedField                    = "multivalued"
-	userAttributeField                  = "user.attribute"
-	userPropertyField                   = "user.attribute"
-	userRealmRoleMappingRolePrefixField = "usermodel.realmRoleMapping.rolePrefix"
-	aggregateAttributeValuesField       = "aggregate.attrs"
+	addToAccessTokenField                = "access.token.claim"
+	addToIdTokenField                    = "id.token.claim"
+	addToUserInfoField                   = "userinfo.token.claim"
+	attributeNameField                   = "attribute.name"
+	attributeNameFormatField             = "attribute.nameformat"
+	claimNameField                       = "claim.name"
+	claimValueField                      = "claim.value"
+	claimValueTypeField                  = "jsonType.label"
+	friendlyNameField                    = "friendly.name"
+	fullPathField                        = "full.path"
+	includedClientAudienceField          = "included.client.audience"
+	includedCustomAudienceField          = "included.custom.audience"
+	multivaluedField                     = "multivalued"
+	userAttributeField                   = "user.attribute"
+	userPropertyField                    = "user.attribute"
+	userRealmRoleMappingRolePrefixField  = "usermodel.realmRoleMapping.rolePrefix"
+	userClientRoleMappingClientIdField   = "usermodel.clientRoleMapping.clientId"
+	userClientRoleMappingRolePrefixField = "usermodel.clientRoleMapping.rolePrefix"
+	aggregateAttributeValuesField        = "aggregate.attrs"
 )
 
 func protocolMapperPath(realmId, clientId, clientScopeId string) string {
diff --git a/mkdocs.yml b/mkdocs.yml
index 68b4ad7..d0c1b1d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -31,6 +31,7 @@ nav:
   - keycloak_openid_audience_protocol_mapper: resources/keycloak_openid_audience_protocol_mapper.md
   - keycloak_openid_hardcoded_role_protocol_mapper: resources/keycloak_openid_hardcoded_role_protocol_mapper.md
   - keycloak_openid_user_realm_role_protocol_mapper: resources/keycloak_openid_user_realm_role_protocol_mapper.md
+  - keycloak_openid_user_client_role_protocol_mapper: resources/keycloak_openid_user_client_role_protocol_mapper.md
   - keycloak_saml_client: resources/keycloak_saml_client.md
   - keycloak_saml_user_attribute_protocol_mapper: resources/keycloak_saml_user_attribute_protocol_mapper.md
   - keycloak_saml_user_property_protocol_mapper: resources/keycloak_saml_user_property_protocol_mapper.md
diff --git a/provider/provider.go b/provider/provider.go
index f9dcb68..aed886d 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -46,6 +46,7 @@ func KeycloakProvider() *schema.Provider {
 			"keycloak_openid_audience_protocol_mapper":                 resourceKeycloakOpenIdAudienceProtocolMapper(),
 			"keycloak_openid_hardcoded_role_protocol_mapper":           resourceKeycloakOpenIdHardcodedRoleProtocolMapper(),
 			"keycloak_openid_user_realm_role_protocol_mapper":          resourceKeycloakOpenIdUserRealmRoleProtocolMapper(),
+			"keycloak_openid_user_client_role_protocol_mapper":         resourceKeycloakOpenIdUserClientRoleProtocolMapper(),
 			"keycloak_openid_client_default_scopes":                    resourceKeycloakOpenidClientDefaultScopes(),
 			"keycloak_openid_client_optional_scopes":                   resourceKeycloakOpenidClientOptionalScopes(),
 			"keycloak_saml_client":                                     resourceKeycloakSamlClient(),
diff --git a/provider/resource_keycloak_openid_user_client_role_protocol_mapper.go b/provider/resource_keycloak_openid_user_client_role_protocol_mapper.go
new file mode 100644
index 0000000..ea60708
--- /dev/null
+++ b/provider/resource_keycloak_openid_user_client_role_protocol_mapper.go
@@ -0,0 +1,199 @@
+package provider
+
+import (
+	"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/helper/validation"
+	"github.com/mrparkers/terraform-provider-keycloak/keycloak"
+)
+
+func resourceKeycloakOpenIdUserClientRoleProtocolMapper() *schema.Resource {
+	return &schema.Resource{
+		Create: resourceKeycloakOpenIdUserClientRoleProtocolMapperCreate,
+		Read:   resourceKeycloakOpenIdUserClientRoleProtocolMapperRead,
+		Update: resourceKeycloakOpenIdUserClientRoleProtocolMapperUpdate,
+		Delete: resourceKeycloakOpenIdUserClientRoleProtocolMapperDelete,
+		Importer: &schema.ResourceImporter{
+			// import a mapper tied to a client:
+			// {{realmId}}/client/{{clientId}}/{{protocolMapperId}}
+			// or a client scope:
+			// {{realmId}}/client-scope/{{clientScopeId}}/{{protocolMapperId}}
+			State: genericProtocolMapperImport,
+		},
+		Schema: map[string]*schema.Schema{
+			"name": {
+				Type:        schema.TypeString,
+				Required:    true,
+				Description: "A human-friendly name that will appear in the Keycloak console.",
+			},
+			"realm_id": {
+				Type:        schema.TypeString,
+				Required:    true,
+				ForceNew:    true,
+				Description: "The realm id where the associated client or client scope exists.",
+			},
+			"client_id": {
+				Type:          schema.TypeString,
+				Optional:      true,
+				ForceNew:      true,
+				Description:   "The mapper's associated client. Cannot be used at the same time as client_scope_id.",
+				ConflictsWith: []string{"client_scope_id"},
+			},
+			"client_scope_id": {
+				Type:          schema.TypeString,
+				Optional:      true,
+				ForceNew:      true,
+				Description:   "The mapper's associated client scope. Cannot be used at the same time as client_id.",
+				ConflictsWith: []string{"client_id"},
+			},
+			"add_to_id_token": {
+				Type:        schema.TypeBool,
+				Optional:    true,
+				Default:     true,
+				Description: "Indicates if the attribute should be a claim in the id token.",
+			},
+			"add_to_access_token": {
+				Type:        schema.TypeBool,
+				Optional:    true,
+				Default:     true,
+				Description: "Indicates if the attribute should be a claim in the access token.",
+			},
+			"add_to_userinfo": {
+				Type:        schema.TypeBool,
+				Optional:    true,
+				Default:     true,
+				Description: "Indicates if the attribute should appear in the userinfo response body.",
+			},
+			"claim_name": {
+				Type:     schema.TypeString,
+				Required: true,
+			},
+			"claim_value_type": {
+				Type:         schema.TypeString,
+				Optional:     true,
+				Description:  "Claim type used when serializing tokens.",
+				Default:      "String",
+				ValidateFunc: validation.StringInSlice([]string{"JSON", "String", "long", "int", "boolean"}, true),
+			},
+			"multivalued": {
+				Type:        schema.TypeBool,
+				Optional:    true,
+				Default:     false,
+				Description: "Indicates whether this attribute is a single value or an array of values.",
+			},
+			"client_id_for_role_mappings": {
+				Type:        schema.TypeString,
+				Optional:    true,
+				Description: "Client ID for role mappings.",
+			},
+			"client_role_prefix": {
+				Type:        schema.TypeString,
+				Optional:    true,
+				Description: "Prefix that will be added to each client role.",
+			},
+		},
+	}
+}
+
+func mapFromDataToOpenIdUserClientRoleProtocolMapper(data *schema.ResourceData) *keycloak.OpenIdUserClientRoleProtocolMapper {
+	return &keycloak.OpenIdUserClientRoleProtocolMapper{
+		Id:               data.Id(),
+		Name:             data.Get("name").(string),
+		RealmId:          data.Get("realm_id").(string),
+		ClientId:         data.Get("client_id").(string),
+		ClientScopeId:    data.Get("client_scope_id").(string),
+		AddToIdToken:     data.Get("add_to_id_token").(bool),
+		AddToAccessToken: data.Get("add_to_access_token").(bool),
+		AddToUserInfo:    data.Get("add_to_userinfo").(bool),
+
+		ClaimName:               data.Get("claim_name").(string),
+		ClaimValueType:          data.Get("claim_value_type").(string),
+		Multivalued:             data.Get("multivalued").(bool),
+		ClientIdForRoleMappings: data.Get("client_id_for_role_mappings").(string),
+		ClientRolePrefix:        data.Get("client_role_prefix").(string),
+	}
+}
+
+func mapFromOpenIdUserClientRoleMapperToData(mapper *keycloak.OpenIdUserClientRoleProtocolMapper, data *schema.ResourceData) {
+	data.SetId(mapper.Id)
+	data.Set("name", mapper.Name)
+	data.Set("realm_id", mapper.RealmId)
+
+	if mapper.ClientId != "" {
+		data.Set("client_id", mapper.ClientId)
+	} else {
+		data.Set("client_scope_id", mapper.ClientScopeId)
+	}
+
+	data.Set("add_to_id_token", mapper.AddToIdToken)
+	data.Set("add_to_access_token", mapper.AddToAccessToken)
+	data.Set("add_to_userinfo", mapper.AddToUserInfo)
+	data.Set("claim_name", mapper.ClaimName)
+	data.Set("claim_value_type", mapper.ClaimValueType)
+	data.Set("multivalued", mapper.Multivalued)
+	data.Set("client_id_for_role_mappings", mapper.ClientIdForRoleMappings)
+	data.Set("client_role_prefix", mapper.ClientRolePrefix)
+}
+
+func resourceKeycloakOpenIdUserClientRoleProtocolMapperCreate(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	openIdUserClientRoleMapper := mapFromDataToOpenIdUserClientRoleProtocolMapper(data)
+
+	err := keycloakClient.ValidateOpenIdUserClientRoleProtocolMapper(openIdUserClientRoleMapper)
+	if err != nil {
+		return err
+	}
+
+	err = keycloakClient.NewOpenIdUserClientRoleProtocolMapper(openIdUserClientRoleMapper)
+	if err != nil {
+		return err
+	}
+
+	mapFromOpenIdUserClientRoleMapperToData(openIdUserClientRoleMapper, data)
+
+	return resourceKeycloakOpenIdUserClientRoleProtocolMapperRead(data, meta)
+}
+
+func resourceKeycloakOpenIdUserClientRoleProtocolMapperRead(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+	realmId := data.Get("realm_id").(string)
+	clientId := data.Get("client_id").(string)
+	clientScopeId := data.Get("client_scope_id").(string)
+
+	openIdUserClientRoleMapper, err := keycloakClient.GetOpenIdUserClientRoleProtocolMapper(realmId, clientId, clientScopeId, data.Id())
+	if err != nil {
+		return handleNotFoundError(err, data)
+	}
+
+	mapFromOpenIdUserClientRoleMapperToData(openIdUserClientRoleMapper, data)
+
+	return nil
+}
+
+func resourceKeycloakOpenIdUserClientRoleProtocolMapperUpdate(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	openIdUserClientRoleMapper := mapFromDataToOpenIdUserClientRoleProtocolMapper(data)
+
+	err := keycloakClient.ValidateOpenIdUserClientRoleProtocolMapper(openIdUserClientRoleMapper)
+	if err != nil {
+		return err
+	}
+
+	err = keycloakClient.UpdateOpenIdUserClientRoleProtocolMapper(openIdUserClientRoleMapper)
+	if err != nil {
+		return err
+	}
+
+	return resourceKeycloakOpenIdUserClientRoleProtocolMapperRead(data, meta)
+}
+
+func resourceKeycloakOpenIdUserClientRoleProtocolMapperDelete(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	clientId := data.Get("client_id").(string)
+	clientScopeId := data.Get("client_scope_id").(string)
+
+	return keycloakClient.DeleteOpenIdUserClientRoleProtocolMapper(realmId, clientId, clientScopeId, data.Id())
+}


Test patch:
diff --git a/provider/generic_protocol_mapper_validation_test.go b/provider/generic_protocol_mapper_validation_test.go
index 28ed0fb..3fb23fc 100644
--- a/provider/generic_protocol_mapper_validation_test.go
+++ b/provider/generic_protocol_mapper_validation_test.go
@@ -281,6 +281,30 @@ func TestAccKeycloakOpenIdUserRealmRoleProtocolMapper_clientScopeDuplicateNameVa
 	})
 }
 
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_clientScopeDuplicateNameValidation(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-user-client-role-mapper-" + acctest.RandString(5)
+
+	userClientRoleProtocolMapperResourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client_scope"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testGenericProtocolMapperValidation_clientScopeUserClientRoleMapper(realmName, clientId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(userClientRoleProtocolMapperResourceName),
+			},
+			{
+				Config:      testGenericProtocolMapperValidation_clientScopeUserClientRoleAndHardcodedClaimMapper(realmName, clientId, mapperName),
+				ExpectError: regexp.MustCompile("validation error: a protocol mapper with name .+ already exists for this client"),
+			},
+		},
+	})
+}
+
 /*
  * Protocol mappers must be attached to either a client or client scope.  The following tests assert that errors are raised
  * if neither are specified.
@@ -382,6 +406,22 @@ func TestAccKeycloakOpenIdUserRealmRoleProtocolMapper_validateClientOrClientScop
 	})
 }
 
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_validateClientOrClientScopeSet(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config:      testKeycloakOpenIdUserClientRoleProtocolMapper_parentResourceValidation(realmName, mapperName),
+				ExpectError: regexp.MustCompile("validation error: one of ClientId or ClientScopeId must be set"),
+			},
+		},
+	})
+}
+
 func testGenericProtocolMapperValidation_clientGroupMembershipMapper(realmName, clientId, mapperName string) string {
 	return fmt.Sprintf(`
 resource "keycloak_realm" "realm" {
@@ -630,6 +670,29 @@ resource "keycloak_openid_user_realm_role_protocol_mapper" "user_realm_role_mapp
 }`, realmName, clientId, mapperName)
 }
 
+func testGenericProtocolMapperValidation_clientUserClientRoleMapper(realmName, clientId, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "openid_client" {
+	realm_id    = "${keycloak_realm.realm.id}"
+	client_id   = "%s"
+
+	access_type = "BEARER-ONLY"
+}
+
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_id        = "${keycloak_openid_client.openid_client.id}"
+
+	claim_name       = "foo"
+	claim_value_type = "String"
+}`, realmName, clientId, mapperName)
+}
+
 func testGenericProtocolMapperValidation_clientScopeFullNameMapper(realmName, clientId, mapperName string) string {
 	return fmt.Sprintf(`
 resource "keycloak_realm" "realm" {
@@ -707,6 +770,25 @@ resource "keycloak_openid_user_realm_role_protocol_mapper" "user_realm_role_mapp
 }`, realmName, clientId, mapperName)
 
 }
+
+func testGenericProtocolMapperValidation_clientScopeUserClientRoleMapper(realmName, clientId, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client_scope" "client_scope" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client_scope" {
+	name            = "%s"
+	realm_id        = "${keycloak_realm.realm.id}"
+	client_scope_id = "${keycloak_openid_client_scope.client_scope.id}"
+	claim_name      = "bar-property"
+}`, realmName, clientId, mapperName)
+
+}
+
 func testGenericProtocolMapperValidation_clientScopeFullNameAndGroupMembershipMapper(realmName, clientScopeId, mapperName string) string {
 	return fmt.Sprintf(`
 resource "keycloak_realm" "realm" {
@@ -846,6 +928,31 @@ resource "keycloak_openid_hardcoded_claim_protocol_mapper" "hardcoded_claim_mapp
 }`, realmName, clientId, mapperName, mapperName)
 }
 
+func testGenericProtocolMapperValidation_clientScopeUserClientRoleAndHardcodedClaimMapper(realmName, clientId, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client_scope" "client_scope" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client_scope" {
+	name            = "%s"
+	realm_id        = "${keycloak_realm.realm.id}"
+	client_scope_id = "${keycloak_openid_client_scope.client_scope.id}"
+	claim_name      = "bar-property"
+}
+resource "keycloak_openid_hardcoded_claim_protocol_mapper" "hardcoded_claim_mapper_client_scope" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_scope_id  = "${keycloak_openid_client_scope.client_scope.id}"
+	claim_name       = "foo"
+	claim_value      = "bar"
+	claim_value_type = "String"
+}`, realmName, clientId, mapperName, mapperName)
+}
+
 func testKeycloakOpenIdFullNameProtocolMapper_parentResourceValidation(realmName, mapperName string) string {
 	return fmt.Sprintf(`
 resource "keycloak_realm" "realm" {
@@ -929,3 +1036,16 @@ resource "keycloak_openid_user_realm_role_protocol_mapper" "user_realm_role_mapp
 	claim_value_type = "String"
 }`, realmName, mapperName)
 }
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_parentResourceValidation(realmName, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client_scope" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	claim_name       = "foo"
+	claim_value_type = "String"
+}`, realmName, mapperName)
+}
diff --git a/provider/resource_keycloak_openid_user_client_role_protocol_mapper_test.go b/provider/resource_keycloak_openid_user_client_role_protocol_mapper_test.go
new file mode 100644
index 0000000..0230b83
--- /dev/null
+++ b/provider/resource_keycloak_openid_user_client_role_protocol_mapper_test.go
@@ -0,0 +1,520 @@
+package provider
+
+import (
+	"fmt"
+	"regexp"
+	"testing"
+
+	"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"
+)
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_basicClient(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_basic_client(realmName, clientId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_basicClientScope(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientScopeId := "terraform-client-scope-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client_scope"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_basic_clientScope(realmName, clientScopeId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_import(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-openid-client-" + acctest.RandString(10)
+	clientScopeId := "terraform-client-scope-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	clientResourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client"
+	clientScopeResourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client_scope"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_import(realmName, clientId, clientScopeId, mapperName),
+				Check: resource.ComposeTestCheckFunc(
+					testKeycloakOpenIdUserClientRoleProtocolMapperExists(clientResourceName),
+					testKeycloakOpenIdUserClientRoleProtocolMapperExists(clientScopeResourceName),
+				),
+			},
+			{
+				ResourceName:      clientResourceName,
+				ImportState:       true,
+				ImportStateVerify: true,
+				ImportStateIdFunc: getGenericProtocolMapperIdForClient(clientResourceName),
+			},
+			{
+				ResourceName:      clientScopeResourceName,
+				ImportState:       true,
+				ImportStateVerify: true,
+				ImportStateIdFunc: getGenericProtocolMapperIdForClientScope(clientScopeResourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_update(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	claimName := "claim-name-" + acctest.RandString(10)
+	updatedClaimName := "claim-name-update-" + acctest.RandString(10)
+
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_claim(realmName, clientId, mapperName, claimName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_claim(realmName, clientId, mapperName, updatedClaimName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_createAfterManualDestroy(t *testing.T) {
+	var mapper = &keycloak.OpenIdUserClientRoleProtocolMapper{}
+
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_basic_client(realmName, clientId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperFetch(resourceName, mapper),
+			},
+			{
+				PreConfig: func() {
+					keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+					err := keycloakClient.DeleteOpenIdUserClientRoleProtocolMapper(mapper.RealmId, mapper.ClientId, mapper.ClientScopeId, mapper.Id)
+					if err != nil {
+						t.Error(err)
+					}
+				},
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_basic_client(realmName, clientId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_validateClaimValueType(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(10)
+	invalidClaimValueType := acctest.RandString(5)
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config:      testKeycloakOpenIdUserClientRoleProtocolMapper_validateClaimValueType(realmName, mapperName, invalidClaimValueType),
+				ExpectError: regexp.MustCompile("expected claim_value_type to be one of .+ got " + invalidClaimValueType),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_updateClientIdForceNew(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	updatedClientId := "terraform-client-update-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	claimName := "claim-name-" + acctest.RandString(10)
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_claim(realmName, clientId, mapperName, claimName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_claim(realmName, updatedClientId, mapperName, claimName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_updateClientScopeForceNew(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+	clientScopeId := "terraform-client-" + acctest.RandString(10)
+	newClientScopeId := "terraform-client-scope-" + acctest.RandString(10)
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_client_scope"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_basic_clientScope(realmName, clientScopeId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_basic_clientScope(realmName, newClientScopeId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_updateRealmIdForceNew(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	newRealmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+
+	claimName := "claim-name-" + acctest.RandString(10)
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_claim(realmName, clientId, mapperName, claimName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_claim(newRealmName, clientId, mapperName, claimName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignment(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	assignedClientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_validation"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignment(realmName, clientId, assignedClientId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignment(realmName, clientId, assignedClientId, mapperName),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignmentRolePrefix(t *testing.T) {
+	realmName := "terraform-realm-" + acctest.RandString(10)
+	clientId := "terraform-client-" + acctest.RandString(10)
+	assignedClientId := "terraform-client-" + acctest.RandString(10)
+	mapperName := "terraform-openid-connect-user-client-role-mapper-" + acctest.RandString(5)
+	rolePrefix := "role-prefix-" + acctest.RandString(10)
+	resourceName := "keycloak_openid_user_client_role_protocol_mapper.user_client_role_mapper_validation"
+
+	resource.Test(t, resource.TestCase{
+		Providers:    testAccProviders,
+		PreCheck:     func() { testAccPreCheck(t) },
+		CheckDestroy: testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignmentRolePrefix(realmName, clientId, assignedClientId, mapperName, rolePrefix),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+			{
+				Config: testKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignmentRolePrefix(realmName, clientId, assignedClientId, mapperName, rolePrefix),
+				Check:  testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName),
+			},
+		},
+	})
+}
+
+func testAccKeycloakOpenIdUserClientRoleProtocolMapperDestroy() resource.TestCheckFunc {
+	return func(state *terraform.State) error {
+		for resourceName, rs := range state.RootModule().Resources {
+			if rs.Type != "keycloak_openid_user_client_role_protocol_mapper" {
+				continue
+			}
+
+			mapper, _ := getUserClientRoleMapperUsingState(state, resourceName)
+
+			if mapper != nil {
+				return fmt.Errorf("openid user attribute protocol mapper with id %s still exists", rs.Primary.ID)
+			}
+		}
+
+		return nil
+	}
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapperExists(resourceName string) resource.TestCheckFunc {
+	return func(state *terraform.State) error {
+		_, err := getUserClientRoleMapperUsingState(state, resourceName)
+		if err != nil {
+			return err
+		}
+
+		return nil
+	}
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapperFetch(resourceName string, mapper *keycloak.OpenIdUserClientRoleProtocolMapper) resource.TestCheckFunc {
+	return func(state *terraform.State) error {
+		fetchedMapper, err := getUserClientRoleMapperUsingState(state, resourceName)
+		if err != nil {
+			return err
+		}
+
+		mapper.Id = fetchedMapper.Id
+		mapper.ClientId = fetchedMapper.ClientId
+		mapper.ClientScopeId = fetchedMapper.ClientScopeId
+		mapper.RealmId = fetchedMapper.RealmId
+
+		return nil
+	}
+}
+
+func getUserClientRoleMapperUsingState(state *terraform.State, resourceName string) (*keycloak.OpenIdUserClientRoleProtocolMapper, error) {
+	rs, ok := state.RootModule().Resources[resourceName]
+	if !ok {
+		return nil, fmt.Errorf("resource not found in TF state: %s ", resourceName)
+	}
+
+	id := rs.Primary.ID
+	realm := rs.Primary.Attributes["realm_id"]
+	clientId := rs.Primary.Attributes["client_id"]
+	clientScopeId := rs.Primary.Attributes["client_scope_id"]
+
+	keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+	return keycloakClient.GetOpenIdUserClientRoleProtocolMapper(realm, clientId, clientScopeId, id)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_basic_client(realmName, clientId, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client" "openid_client" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "%s"
+	access_type = "BEARER-ONLY"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_id        = "${keycloak_openid_client.openid_client.id}"
+	claim_name       = "foo"
+	claim_value_type = "String"
+}`, realmName, clientId, mapperName)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_basic_clientScope(realmName, clientScopeId, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client_scope" "client_scope" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client_scope" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_scope_id  = "${keycloak_openid_client_scope.client_scope.id}"
+	claim_name       = "foo"
+	claim_value_type = "String"
+}`, realmName, clientScopeId, mapperName)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_claim(realmName, clientId, mapperName, claimName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client" "openid_client" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "%s"
+	access_type = "BEARER-ONLY"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_id        = "${keycloak_openid_client.openid_client.id}"
+	claim_name       = "%s"
+	claim_value_type = "String"
+}`, realmName, clientId, mapperName, claimName)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_import(realmName, clientId, clientScopeId, mapperName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client" "openid_client" {
+	realm_id    = "${keycloak_realm.realm.id}"
+	client_id   = "%s"
+	access_type = "BEARER-ONLY"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_id        = "${keycloak_openid_client.openid_client.id}"
+	claim_name       = "foo"
+	claim_value_type = "String"
+}
+resource "keycloak_openid_client_scope" "client_scope" {
+	name     = "%s"
+	realm_id = "${keycloak_realm.realm.id}"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_client_scope" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_scope_id  = "${keycloak_openid_client_scope.client_scope.id}"
+	claim_name       = "foo"
+	claim_value_type = "String"
+}`, realmName, clientId, mapperName, clientScopeId, mapperName)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_validateClaimValueType(realmName, mapperName, claimValueType string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+resource "keycloak_openid_client" "openid_client" {
+	realm_id  = "${keycloak_realm.realm.id}"
+	client_id = "openid-client"
+	access_type = "BEARER-ONLY"
+}
+resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_validation" {
+	name             = "%s"
+	realm_id         = "${keycloak_realm.realm.id}"
+	client_id        = "${keycloak_openid_client.openid_client.id}"
+	claim_name      = "foo"
+	claim_value_type = "%s"
+}`, realmName, mapperName, claimValueType)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignment(realmName, clientId, assignedClientId, mapperName string) string {
+	return fmt.Sprintf(`
+	resource "keycloak_realm" "realm" {
+		realm = "%s"
+	}
+	
+	resource "keycloak_openid_client" "openid_client" {
+		realm_id  = "${keycloak_realm.realm.id}"
+		client_id = "%s"
+	
+		access_type = "BEARER-ONLY"
+	}
+	resource "keycloak_openid_client" "openid_client_assigned" {
+		realm_id  = "${keycloak_realm.realm.id}"
+		client_id = "%s"
+	
+		access_type = "BEARER-ONLY"
+	}
+	
+	resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_validation" {
+		name             = "%s"
+		realm_id         = "${keycloak_realm.realm.id}"
+		client_id        = "${keycloak_openid_client.openid_client.id}"
+	
+		claim_name      = "foo"
+		claim_value_type = "String"
+		client_id_for_role_mappings = "${keycloak_openid_client.openid_client_assigned.id}"
+	}`, realmName, clientId, assignedClientId, mapperName)
+}
+
+func testKeycloakOpenIdUserClientRoleProtocolMapper_clientAssignmentRolePrefix(realmName, clientId, assignedClientId, mapperName, rolePrefix string) string {
+	return fmt.Sprintf(`
+	resource "keycloak_realm" "realm" {
+		realm = "%s"
+	}
+	
+	resource "keycloak_openid_client" "openid_client" {
+		realm_id  = "${keycloak_realm.realm.id}"
+		client_id = "%s"
+	
+		access_type = "BEARER-ONLY"
+	}
+	resource "keycloak_openid_client" "openid_client_assigned" {
+		realm_id  = "${keycloak_realm.realm.id}"
+		client_id = "%s"
+	
+		access_type = "BEARER-ONLY"
+	}
+	
+	resource "keycloak_openid_user_client_role_protocol_mapper" "user_client_role_mapper_validation" {
+		name             = "%s"
+		realm_id         = "${keycloak_realm.realm.id}"
+		client_id        = "${keycloak_openid_client.openid_client.id}"
+	
+		claim_name      = "foo"
+		claim_value_type = "String"
+		client_id_for_role_mappings = "${keycloak_openid_client.openid_client_assigned.id}"
+		client_role_prefix= "%s"
+	}`, realmName, clientId, assignedClientId, mapperName, rolePrefix)
+}