Repository: mrparkers/terraform-provider-keycloak
Pull request #253 (2020-03-16 16:56:48)

Role Scopes: Allow specifying client scope instead of client
My first shot at extending this great provider.
Following https://github.com/mrparkers/terraform-provider-keycloak/pull/242 In addition to clients, I want to extend the keycloak_generic_client_role_mapper resource to also manage client roles for client scopes. This allows consolidation of client roles in dedicated client scopes.
Im no go developer and still need to extend the 
* validation
* test cases
* documentation

mrparkers: Hey @dmeyerholt, thanks for opening this PR.  Your changes look good so far.

I could be wrong, but I don't think you need to add any more validation on top of what you already have -  all I would have expected to see was the `ConflictsWith` on the client id and client scope id attributes.

For the test cases, you should be able to copy/paste a lot of code in the `resource_keycloak_generic_client_role_mapper_test.go` file and replace the `client_id` attribute with `client_scope_id` in the HCL snippets, and add new assertions to make sure the correct value is set after `tf apply` runs.  I'd also appreciate it if you could add an example HCL configuration within `example/main.tf`.

Let me know if you need any assistance with this.
dmeyerholt: Thanks @mrparkers,
* validation using `ConflictsWith` should be sufficient, right
* will provide more testcases
* additional HCL example coming up as well
Hoping to finish in the next days :)
dmeyerholt: Looks okay so far. for documentation I extended the pet examples in roles.tf. For that to work properly those changes were done
* on the app client: disable full scope
* Now map all pet_api client roles into the app client (realm role missing though, maybe another pr ;) )
* new optional client scope makes it possible to map the (new) client role `read-pet-with-details` into the tokens only, when that client scope is requested in the token request.

Patch:
diff --git a/example/roles.tf b/example/roles.tf
index 1056194..da22957 100644
--- a/example/roles.tf
+++ b/example/roles.tf
@@ -15,6 +15,13 @@ resource "keycloak_openid_client" "pet_api" {
   access_type = "BEARER-ONLY"
 }
 
+// Optional client scope for mapping additional client role
+resource "keycloak_openid_client_scope" "extended_pet_details" {
+  realm_id  = "${keycloak_realm.roles_example.id}"
+  name      = "extended-pet-details"
+  description = "Optional scope offering additional information when getting pets"
+}
+
 resource "keycloak_role" "pet_api_create_pet" {
   name        = "create-pet"
   realm_id    = "${keycloak_realm.roles_example.id}"
@@ -43,6 +50,20 @@ resource "keycloak_role" "pet_api_delete_pet" {
   description = "Ability to delete a pet"
 }
 
+resource "keycloak_role" "pet_api_read_pet_details" {
+  name        = "read-pet-with-details"
+  realm_id    = "${keycloak_realm.roles_example.id}"
+  client_id   = "${keycloak_openid_client.pet_api.id}"
+  description = "Ability to read / list pets with further details"
+}
+
+// Map a role from the "pet_api" api client to the "extended_pet_details" client scope
+resource "keycloak_generic_client_role_mapper" "pet_api_read_pet_details_role_mapping" {
+  realm_id  = "${keycloak_realm.roles_example.id}"
+  client_scope_id = "${keycloak_openid_client_scope.extended_pet_details.id}"
+  role_id   = "${keycloak_role.pet_api_read_pet_details.id}"
+}
+
 resource "keycloak_role" "pet_api_admin" {
   name      = "admin"
   realm_id  = "${keycloak_realm.roles_example.id}"
@@ -76,6 +97,18 @@ resource "keycloak_openid_client" "pet_app" {
   valid_redirect_uris = [
     "http://localhost:5555/openid-callback",
   ]
+
+  // disable full scope, roles are assigned via keycloak_generic_client_role_mapper
+  full_scope_allowed = false
+}
+
+resource "keycloak_openid_client_optional_scopes" "pet_app_optional_scopes" {
+  realm_id       = "${keycloak_realm.roles_example.id}"
+  client_id      = "${keycloak_openid_client.pet_app.id}"
+
+  optional_scopes = [
+    "${keycloak_openid_client_scope.extended_pet_details.name}"
+  ]
 }
 
 // The app will always need access to the API, so this audience should be used regardless of auth type
@@ -96,13 +129,37 @@ resource "keycloak_openid_hardcoded_role_protocol_mapper" "pet_app_pet_api_read_
   role_id = "${keycloak_role.pet_api_read_pet.id}"
 }
 
-// Map a role from the "pet_api" api client to the "pet_app" consumer client
+// Map all roles from the "pet_api" api client to the "pet_app" consumer client, read_pet_details comes via client scope
 resource "keycloak_generic_client_role_mapper" "pet_app_pet_api_read_role_mapping" {
   realm_id  = "${keycloak_realm.roles_example.id}"
   client_id = "${keycloak_openid_client.pet_app.id}"
   role_id   = "${keycloak_role.pet_api_read_pet.id}"
 }
 
+resource "keycloak_generic_client_role_mapper" "pet_app_pet_api_delete_role_mapping" {
+  realm_id  = "${keycloak_realm.roles_example.id}"
+  client_id = "${keycloak_openid_client.pet_app.id}"
+  role_id   = "${keycloak_role.pet_api_delete_pet.id}"
+}
+
+resource "keycloak_generic_client_role_mapper" "pet_app_pet_api_create_role_mapping" {
+  realm_id  = "${keycloak_realm.roles_example.id}"
+  client_id = "${keycloak_openid_client.pet_app.id}"
+  role_id   = "${keycloak_role.pet_api_create_pet.id}"
+}
+
+resource "keycloak_generic_client_role_mapper" "pet_app_pet_api_update_role_mapping" {
+  realm_id  = "${keycloak_realm.roles_example.id}"
+  client_id = "${keycloak_openid_client.pet_app.id}"
+  role_id   = "${keycloak_role.pet_api_update_pet.id}"
+}
+
+resource "keycloak_generic_client_role_mapper" "pet_app_pet_api_admin_role_mapping" {
+  realm_id  = "${keycloak_realm.roles_example.id}"
+  client_id = "${keycloak_openid_client.pet_app.id}"
+  role_id   = "${keycloak_role.pet_api_admin.id}"
+}
+
 // Users and groups
 
 resource "keycloak_group" "pet_api_base" {
@@ -133,6 +190,7 @@ resource "keycloak_group_roles" "admin_roles" {
 
   role_ids = [
     "${keycloak_role.pet_api_read_pet.id}",
+    "${keycloak_role.pet_api_read_pet_details.id}",
     "${keycloak_role.pet_api_delete_pet.id}",
     "${keycloak_role.pet_api_create_pet.id}",
     "${data.keycloak_role.realm_offline_access.id}",
@@ -145,6 +203,7 @@ resource "keycloak_group_roles" "front_desk_roles" {
 
   role_ids = [
     "${keycloak_role.pet_api_read_pet.id}",
+    "${keycloak_role.pet_api_read_pet_details.id}",
     "${keycloak_role.pet_api_create_pet.id}",
     "${data.keycloak_role.realm_offline_access.id}",
   ]
diff --git a/keycloak/role_scope_mapping.go b/keycloak/role_scope_mapping.go
index 5cfdffa..4aabd4f 100644
--- a/keycloak/role_scope_mapping.go
+++ b/keycloak/role_scope_mapping.go
@@ -4,12 +4,16 @@ import (
 	"fmt"
 )
 
-func roleScopeMappingUrl(realmId, clientId string, role *Role) string {
-	return fmt.Sprintf("/realms/%s/clients/%s/scope-mappings/clients/%s", realmId, clientId, role.ClientId)
+func roleScopeMappingUrl(realmId, clientId string, clientScopeId string, role *Role) string {
+	if clientId != "" {
+		return fmt.Sprintf("/realms/%s/clients/%s/scope-mappings/clients/%s", realmId, clientId, role.ClientId)
+	} else {
+		return fmt.Sprintf("/realms/%s/client-scopes/%s/scope-mappings/clients/%s", realmId, clientScopeId, role.ClientId)
+	}
 }
 
-func (keycloakClient *KeycloakClient) CreateRoleScopeMapping(realmId string, clientId string, role *Role) error {
-	roleUrl := roleScopeMappingUrl(realmId, clientId, role)
+func (keycloakClient *KeycloakClient) CreateRoleScopeMapping(realmId string, clientId string, clientScopeId string, role *Role) error {
+	roleUrl := roleScopeMappingUrl(realmId, clientId, clientScopeId, role)
 
 	_, _, err := keycloakClient.post(roleUrl, []Role{*role})
 	if err != nil {
@@ -19,8 +23,8 @@ func (keycloakClient *KeycloakClient) CreateRoleScopeMapping(realmId string, cli
 	return nil
 }
 
-func (keycloakClient *KeycloakClient) GetRoleScopeMapping(realmId string, clientId string, role *Role) (*Role, error) {
-	roleUrl := roleScopeMappingUrl(realmId, clientId, role)
+func (keycloakClient *KeycloakClient) GetRoleScopeMapping(realmId string, clientId string, clientScopeId string, role *Role) (*Role, error) {
+	roleUrl := roleScopeMappingUrl(realmId, clientId, clientScopeId, role)
 	var roles []Role
 
 	err := keycloakClient.get(roleUrl, &roles, nil)
@@ -37,7 +41,7 @@ func (keycloakClient *KeycloakClient) GetRoleScopeMapping(realmId string, client
 	return nil, nil
 }
 
-func (keycloakClient *KeycloakClient) DeleteRoleScopeMapping(realmId string, clientId string, role *Role) error {
-	roleUrl := roleScopeMappingUrl(realmId, clientId, role)
+func (keycloakClient *KeycloakClient) DeleteRoleScopeMapping(realmId string, clientId string, clientScopeId string, role *Role) error {
+	roleUrl := roleScopeMappingUrl(realmId, clientId, clientScopeId, role)
 	return keycloakClient.delete(roleUrl, nil)
 }
diff --git a/provider/resource_keycloak_generic_client_role_mapper.go b/provider/resource_keycloak_generic_client_role_mapper.go
index f2e1043..9b39c87 100644
--- a/provider/resource_keycloak_generic_client_role_mapper.go
+++ b/provider/resource_keycloak_generic_client_role_mapper.go
@@ -15,19 +15,30 @@ func resourceKeycloakGenericClientRoleMapper() *schema.Resource {
 
 		Schema: map[string]*schema.Schema{
 			"realm_id": {
-				Type:     schema.TypeString,
-				Required: true,
-				ForceNew: true,
+				Type:        schema.TypeString,
+				Required:    true,
+				ForceNew:    true,
+				Description: "The realm id where the associated client or client scope exists.",
 			},
 			"client_id": {
-				Type:     schema.TypeString,
-				Required: true,
-				ForceNew: true,
+				Type:          schema.TypeString,
+				Optional:      true,
+				ForceNew:      true,
+				Description:   "The destination client of the client role. 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 destination client scope of the client role. Cannot be used at the same time as client_id.",
+				ConflictsWith: []string{"client_id"},
 			},
 			"role_id": {
-				Type:     schema.TypeString,
-				Required: true,
-				ForceNew: true,
+				Type:        schema.TypeString,
+				Required:    true,
+				ForceNew:    true,
+				Description: "Id of the role to assign",
 			},
 		},
 	}
@@ -38,6 +49,7 @@ func resourceKeycloakGenericClientRoleMapperCreate(data *schema.ResourceData, me
 
 	realmId := data.Get("realm_id").(string)
 	clientId := data.Get("client_id").(string)
+	clientScopeId := data.Get("client_scope_id").(string)
 	roleId := data.Get("role_id").(string)
 
 	role, err := keycloakClient.GetRole(realmId, roleId)
@@ -45,12 +57,16 @@ func resourceKeycloakGenericClientRoleMapperCreate(data *schema.ResourceData, me
 		return err
 	}
 
-	err = keycloakClient.CreateRoleScopeMapping(realmId, clientId, role)
+	err = keycloakClient.CreateRoleScopeMapping(realmId, clientId, clientScopeId, role)
 	if err != nil {
 		return err
 	}
 
-	data.SetId(fmt.Sprintf("%s/client/%s/scope-mappings/%s/%s", realmId, clientId, role.ClientId, role.Id))
+	if clientId != "" {
+		data.SetId(fmt.Sprintf("%s/client/%s/scope-mappings/%s/%s", realmId, clientId, role.ClientId, role.Id))
+	} else {
+		data.SetId(fmt.Sprintf("%s/client-scope/%s/scope-mappings/%s/%s", realmId, clientScopeId, role.ClientId, role.Id))
+	}
 
 	return resourceKeycloakGenericClientRoleMapperRead(data, meta)
 }
@@ -60,6 +76,7 @@ func resourceKeycloakGenericClientRoleMapperRead(data *schema.ResourceData, meta
 
 	realmId := data.Get("realm_id").(string)
 	clientId := data.Get("client_id").(string)
+	clientScopeId := data.Get("client_scope_id").(string)
 	roleId := data.Get("role_id").(string)
 
 	role, err := keycloakClient.GetRole(realmId, roleId)
@@ -67,7 +84,7 @@ func resourceKeycloakGenericClientRoleMapperRead(data *schema.ResourceData, meta
 		return err
 	}
 
-	mappedRole, err := keycloakClient.GetRoleScopeMapping(realmId, clientId, role)
+	mappedRole, err := keycloakClient.GetRoleScopeMapping(realmId, clientId, clientScopeId, role)
 
 	if mappedRole == nil {
 		data.SetId("")
@@ -81,6 +98,7 @@ func resourceKeycloakGenericClientRoleMapperDelete(data *schema.ResourceData, me
 
 	realmId := data.Get("realm_id").(string)
 	clientId := data.Get("client_id").(string)
+	clientScopeId := data.Get("client_scope_id").(string)
 	roleId := data.Get("role_id").(string)
 
 	role, err := keycloakClient.GetRole(realmId, roleId)
@@ -88,5 +106,5 @@ func resourceKeycloakGenericClientRoleMapperDelete(data *schema.ResourceData, me
 		return err
 	}
 
-	return keycloakClient.DeleteRoleScopeMapping(realmId, clientId, role)
+	return keycloakClient.DeleteRoleScopeMapping(realmId, clientId, clientScopeId, role)
 }


Test patch:
diff --git a/provider/resource_keycloak_generic_client_role_mapper_test.go b/provider/resource_keycloak_generic_client_role_mapper_test.go
index 5260f3a..7983f5c 100644
--- a/provider/resource_keycloak_generic_client_role_mapper_test.go
+++ b/provider/resource_keycloak_generic_client_role_mapper_test.go
@@ -27,6 +27,24 @@ func TestGenericRoleMapper_basic(t *testing.T) {
 	})
 }
 
+func TestGenericRoleMapperClientScope_basic(t *testing.T) {
+	realmName := "terraform-" + acctest.RandString(10)
+	clientName := "client-" + acctest.RandString(10)
+	roleName := "role-" + acctest.RandString(10)
+	clientScopeName := "clientscope-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakGenericRoleMappingClientScope_basic(realmName, clientName, roleName, clientScopeName),
+				Check:  testAccCheckKeycloakScopeMappingExists("keycloak_generic_client_role_mapper.clientscope-with-client-role"),
+			},
+		},
+	})
+}
+
 func TestGenericRoleMapper_createAfterManualDestroy(t *testing.T) {
 	var role = &keycloak.Role{}
 	var childClient = &keycloak.GenericClient{}
@@ -52,7 +70,7 @@ func TestGenericRoleMapper_createAfterManualDestroy(t *testing.T) {
 				PreConfig: func() {
 					keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
 
-					err := keycloakClient.DeleteRoleScopeMapping(childClient.RealmId, childClient.Id, role)
+					err := keycloakClient.DeleteRoleScopeMapping(childClient.RealmId, childClient.Id, "", role)
 					if err != nil {
 						t.Fatal(err)
 					}
@@ -64,6 +82,43 @@ func TestGenericRoleMapper_createAfterManualDestroy(t *testing.T) {
 	})
 }
 
+func TestGenericRoleMapperClientScope_createAfterManualDestroy(t *testing.T) {
+	var role = &keycloak.Role{}
+	var clientScope = &keycloak.OpenidClientScope{}
+
+	realmName := "terraform-" + acctest.RandString(10)
+	clientName := "client-" + acctest.RandString(10)
+	roleName := "role-" + acctest.RandString(10)
+	clientScopeName := "clientscope-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakGenericRoleMappingClientScope_basic(realmName, clientName, roleName, clientScopeName),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckKeycloakScopeMappingExists("keycloak_generic_client_role_mapper.clientscope-with-client-role"),
+					testAccCheckKeycloakRoleFetch("keycloak_role.role", role),
+					testAccCheckKeycloakOpenidClientScopeFetch("keycloak_openid_client_scope.clientscope", clientScope),
+				),
+			},
+			{
+				PreConfig: func() {
+					keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+					err := keycloakClient.DeleteRoleScopeMapping(clientScope.RealmId, "", clientScope.Id, role)
+					if err != nil {
+						t.Fatal(err)
+					}
+				},
+				Config: testKeycloakGenericRoleMappingClientScope_basic(realmName, clientName, roleName, clientScopeName),
+				Check:  testAccCheckKeycloakScopeMappingExists("keycloak_generic_client_role_mapper.clientscope-with-client-role"),
+			},
+		},
+	})
+}
+
 func testKeycloakGenericRoleMapping_basic(realmName, parentClientName, parentRoleName, childClientName string) string {
 	return fmt.Sprintf(`
 resource "keycloak_realm" "realm" {
@@ -96,6 +151,37 @@ resource "keycloak_generic_client_role_mapper" "child-client-with-parent-client-
 	`, realmName, parentClientName, parentRoleName, childClientName)
 }
 
+func testKeycloakGenericRoleMappingClientScope_basic(realmName, clientName, roleName, clientScopeName string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	realm_id    = "${keycloak_realm.realm.id}"
+	client_id   = "%s"
+	access_type = "PUBLIC"
+}
+
+resource "keycloak_role" "role" {
+  realm_id  = "${keycloak_realm.realm.id}"
+  client_id = "${keycloak_openid_client.client.id}"
+  name      = "%s"
+}
+
+resource "keycloak_openid_client_scope" "clientscope" {
+	realm_id    = "${keycloak_realm.realm.id}"
+	name        = "%s"
+}
+
+resource "keycloak_generic_client_role_mapper" "clientscope-with-client-role" {
+  realm_id  = "${keycloak_realm.realm.id}"
+  client_scope_id = "${keycloak_openid_client_scope.clientscope.id}"
+  role_id   = "${keycloak_role.role.id}"
+}
+	`, realmName, clientName, roleName, clientScopeName)
+}
+
 func testAccCheckKeycloakScopeMappingExists(resourceName string) resource.TestCheckFunc {
 	return func(s *terraform.State) error {
 		_, ok := s.RootModule().Resources[resourceName]
@@ -122,6 +208,20 @@ func testAccCheckKeycloakGenericClientFetch(resourceName string, client *keycloa
 	}
 }
 
+func testAccCheckKeycloakOpenidClientScopeFetch(resourceName string, clientScope *keycloak.OpenidClientScope) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		fetchedClientScope, err := getOpenidClientScopeFromState(s, resourceName)
+		if err != nil {
+			return err
+		}
+
+		clientScope.Id = fetchedClientScope.Id
+		clientScope.RealmId = fetchedClientScope.RealmId
+
+		return nil
+	}
+}
+
 func getGenericClientFromState(s *terraform.State, resourceName string) (*keycloak.GenericClient, error) {
 	keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
 
@@ -140,3 +240,22 @@ func getGenericClientFromState(s *terraform.State, resourceName string) (*keyclo
 
 	return client, nil
 }
+
+func getOpenidClientScopeFromState(s *terraform.State, resourceName string) (*keycloak.OpenidClientScope, error) {
+	keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+	rs, ok := s.RootModule().Resources[resourceName]
+	if !ok {
+		return nil, fmt.Errorf("resource not found: %s", resourceName)
+	}
+
+	id := rs.Primary.ID
+	realm := rs.Primary.Attributes["realm_id"]
+
+	client, err := keycloakClient.GetOpenidClientScope(realm, id)
+	if err != nil {
+		return nil, fmt.Errorf("error getting client scope %s: %s", id, err)
+	}
+
+	return client, nil
+}