diff --git a/docs/resources/group_roles.md b/docs/resources/group_roles.md index d3508471a..4dad5f707 100644 --- a/docs/resources/group_roles.md +++ b/docs/resources/group_roles.md @@ -6,14 +6,15 @@ page_title: "keycloak_group_roles Resource" Allows you to manage roles assigned to a Keycloak group. -Note that this resource attempts to be an **authoritative** source over group roles. When this resource takes control over -a group's roles, roles that are manually added to the group will be removed, and roles that are manually removed from the +If `exhaustive` is true, this resource attempts to be an **authoritative** source over group roles: roles that are manually added to the group will be removed, and roles that are manually removed from the group will be added upon the next run of `terraform apply`. +If `exhaustive` is false, this resource is a partial assignation of roles to a group. As a result, you can get multiple `keycloak_group_roles` for the same `group_id`. Note that when assigning composite roles to a group, you may see a non-empty plan following a `terraform apply` if you assign a role and a composite that includes that role to the same group. -## Example Usage +## Example Usage (exhaustive roles) + ```hcl resource "keycloak_realm" "realm" { @@ -60,11 +61,69 @@ resource "keycloak_group_roles" "group_roles" { } ``` +## Example Usage (non exhaustive roles) + +```hcl +resource "keycloak_realm" "realm" { + realm = "my-realm" + enabled = true +} + +resource "keycloak_role" "realm_role" { + realm_id = keycloak_realm.realm.id + name = "my-realm-role" + description = "My Realm Role" +} + +resource "keycloak_openid_client" "client" { + realm_id = keycloak_realm.realm.id + client_id = "client" + name = "client" + + enabled = true + + access_type = "BEARER-ONLY" +} + +resource "keycloak_role" "client_role" { + realm_id = keycloak_realm.realm.id + client_id = keycloak_client.client.id + name = "my-client-role" + description = "My Client Role" +} + +resource "keycloak_group" "group" { + realm_id = keycloak_realm.realm.id + name = "my-group" +} + +resource "keycloak_group_roles" "group_role_association1" { + realm_id = keycloak_realm.realm.id + group_id = keycloak_group.group.id + exhaustive = false + + role_ids = [ + keycloak_role.realm_role.id, + ] +} + +resource "keycloak_group_roles" "group_role_association2" { + realm_id = keycloak_realm.realm.id + group_id = keycloak_group.group.id + exhaustive = false + + role_ids = [ + keycloak_role.client_role.id, + ] +} + +``` ## Argument Reference - `realm_id` - (Required) The realm this group exists in. - `group_id` - (Required) The ID of the group this resource should manage roles for. -- `role_ids` - (Required) A list of role IDs to map to the group +- `role_ids` - (Required) A list of role IDs to map to the group. +- `exhaustive` - (Optional) Indicate if the list of roles is exhaustive. In this case, roles that are manually added to the group will be removed. Default `true`. ## Import diff --git a/provider/resource_keycloak_group_roles.go b/provider/resource_keycloak_group_roles.go index e806d0480..579190687 100644 --- a/provider/resource_keycloak_group_roles.go +++ b/provider/resource_keycloak_group_roles.go @@ -34,6 +34,11 @@ func resourceKeycloakGroupRoles() *schema.Resource { Set: schema.HashString, Required: true, }, + "exhaustive": { + Type: schema.TypeBool, + Optional: true, + Default: true, + }, }, } } @@ -87,13 +92,30 @@ func resourceKeycloakGroupRolesReconcile(data *schema.ResourceData, meta interfa realmId := data.Get("realm_id").(string) groupId := data.Get("group_id").(string) + roleIds := interfaceSliceToStringSlice(data.Get("role_ids").(*schema.Set).List()) + exhaustive := data.Get("exhaustive").(bool) group, err := keycloakClient.GetGroup(realmId, groupId) if err != nil { return err } - roleIds := interfaceSliceToStringSlice(data.Get("role_ids").(*schema.Set).List()) + if data.HasChange("role_ids") { + o, n := data.GetChange("role_ids") + os := o.(*schema.Set) + ns := n.(*schema.Set) + remove := interfaceSliceToStringSlice(os.Difference(ns).List()) + + tfRolesToRemove, err := getExtendedRoleMapping(keycloakClient, realmId, remove) + if err != nil { + return err + } + + if err = removeRolesFromGroup(keycloakClient, tfRolesToRemove.clientRoles, tfRolesToRemove.realmRoles, group); err != nil { + return err + } + } + tfRoles, err := getExtendedRoleMapping(keycloakClient, realmId, roleIds) if err != nil { return err @@ -111,21 +133,34 @@ func resourceKeycloakGroupRolesReconcile(data *schema.ResourceData, meta interfa return err } - // remove roles - err = removeRolesFromGroup(keycloakClient, updates.clientRolesToRemove, updates.realmRolesToRemove, group) - if err != nil { - return err + if exhaustive { + // remove roles + err = removeRolesFromGroup(keycloakClient, updates.clientRolesToRemove, updates.realmRolesToRemove, group) + if err != nil { + return err + } } - data.SetId(groupRolesId(realmId, groupId)) return resourceKeycloakGroupRolesRead(data, meta) } +// Helper function +func containsAString(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + func resourceKeycloakGroupRolesRead(data *schema.ResourceData, meta interface{}) error { keycloakClient := meta.(*keycloak.KeycloakClient) realmId := data.Get("realm_id").(string) groupId := data.Get("group_id").(string) + sortedRoleIds := interfaceSliceToStringSlice(data.Get("role_ids").(*schema.Set).List()) + exhaustive := data.Get("exhaustive").(bool) // check if group exists, remove from state if not found if _, err := keycloakClient.GetGroup(realmId, groupId); err != nil { @@ -140,12 +175,16 @@ func resourceKeycloakGroupRolesRead(data *schema.ResourceData, meta interface{}) var roleIds []string for _, realmRole := range roles.RealmMappings { - roleIds = append(roleIds, realmRole.Id) + if exhaustive || containsAString(sortedRoleIds, realmRole.Id) { + roleIds = append(roleIds, realmRole.Id) + } } for _, clientRoleMapping := range roles.ClientMappings { for _, clientRole := range clientRoleMapping.Mappings { - roleIds = append(roleIds, clientRole.Id) + if exhaustive || containsAString(sortedRoleIds, clientRole.Id) { + roleIds = append(roleIds, clientRole.Id) + } } } diff --git a/provider/resource_keycloak_group_roles_test.go b/provider/resource_keycloak_group_roles_test.go index 714d822b7..30be7d052 100644 --- a/provider/resource_keycloak_group_roles_test.go +++ b/provider/resource_keycloak_group_roles_test.go @@ -174,6 +174,122 @@ func TestAccKeycloakGroupRoles_update(t *testing.T) { }) } +func TestAccKeycloakGroupRoles_basicNonExhaustive(t *testing.T) { + t.Parallel() + + realmRoleName := acctest.RandomWithPrefix("tf-acc") + openIdClientName := acctest.RandomWithPrefix("tf-acc") + openIdRoleName := acctest.RandomWithPrefix("tf-acc") + samlClientName := acctest.RandomWithPrefix("tf-acc") + samlRoleName := acctest.RandomWithPrefix("tf-acc") + groupName := acctest.RandomWithPrefix("tf-acc") + + // no group roles + // multiple non_exhaustive + // no group roles -> nothing. + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testKeycloakGroupRoles_nonExhaustive(openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, groupName), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + { + ResourceName: "keycloak_group_roles.group_roles1", + ImportState: true, + ImportStateVerify: true, + }, + // check destroy + { + Config: testKeycloakGroupRoles_noGroupRoles(openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, groupName), + Check: testAccCheckKeycloakGroupHasNoRoles("keycloak_group.group"), + }, + }, + }) +} + +func TestAccKeycloakGroupRoles_updateNonExhaustive(t *testing.T) { + t.Parallel() + + realmRoleOneName := acctest.RandomWithPrefix("tf-acc") + realmRoleTwoName := acctest.RandomWithPrefix("tf-acc") + openIdClientName := acctest.RandomWithPrefix("tf-acc") + openIdRoleOneName := acctest.RandomWithPrefix("tf-acc") + openIdRoleTwoName := acctest.RandomWithPrefix("tf-acc") + samlClientName := acctest.RandomWithPrefix("tf-acc") + samlRoleOneName := acctest.RandomWithPrefix("tf-acc") + samlRoleTwoName := acctest.RandomWithPrefix("tf-acc") + groupName := acctest.RandomWithPrefix("tf-acc") + + allRoleIdSet1 := []string{ + "${keycloak_role.realm_role_one.id}", + "${keycloak_role.openid_client_role_one.id}", + "${keycloak_role.saml_client_role_one.id}", + } + + allRoleIdSet2 := []string{ + "${keycloak_role.realm_role_two.id}", + "${keycloak_role.openid_client_role_two.id}", + "${keycloak_role.saml_client_role_two.id}", + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: testAccProviderFactories, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []resource.TestStep{ + // initial setup, resource is defined but no roles are specified + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, []string{}, []string{}), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // add all roles + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, allRoleIdSet1, allRoleIdSet2), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // remove some + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, []string{ + "${keycloak_role.openid_client_role_one.id}", + }, allRoleIdSet2), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // add some and remove some + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, []string{ + "${data.keycloak_role.offline_access.id}", + "${keycloak_role.saml_client_role_one.id}", + }, allRoleIdSet2), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // add some and remove some again + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, []string{ + "${keycloak_role.realm_role_one.id}", + "${keycloak_role.openid_client_role_one.id}", + }, allRoleIdSet2), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // add all back + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, allRoleIdSet1, allRoleIdSet2), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // random scenario 1 + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, randomStringSliceSubset(allRoleIdSet1), randomStringSliceSubset(allRoleIdSet2)), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + // remove all + { + Config: testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, []string{}, []string{}), + Check: testAccCheckKeycloakGroupHasNonExhaustiveRoles("keycloak_group_roles.group_roles1"), + }, + }, + }) +} + func testAccCheckKeycloakGroupHasRoles(resourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName] @@ -243,6 +359,75 @@ func testAccCheckKeycloakGroupHasRoles(resourceName string) resource.TestCheckFu } } +func testAccCheckKeycloakGroupHasNonExhaustiveRoles(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource not found: %s", resourceName) + } + + realm := rs.Primary.Attributes["realm_id"] + groupId := rs.Primary.Attributes["group_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) + } + + group, err := keycloakClient.GetGroup(realm, groupId) + if err != nil { + return err + } + + groupRoleMappings, err := keycloakClient.GetGroupRoleMappings(realm, groupId) + if err != nil { + return err + } + + groupRoles, err := flattenRoleMapping(groupRoleMappings) + if err != nil { + return err + } + + if len(groupRoles) < len(roles) { + return fmt.Errorf("expected number of group roles should be greater than %d, got %d", len(roles), len(groupRoles)) + } + + 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 _, groupRole := range groupRoles { + if groupRole == expectedRoleString { + found = true + break + } + } + + if !found { + return fmt.Errorf("expected to find role %s assigned to group %s", expectedRoleString, group.Name) + } + } + + return nil + } +} + func testAccCheckKeycloakGroupHasNoRoles(resourceName string) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName] @@ -324,6 +509,63 @@ resource "keycloak_group_roles" "group_roles" { `, testAccRealm.Realm, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, groupName) } +func testKeycloakGroupRoles_nonExhaustive(openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, groupName string) string { + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client" "openid_client" { + client_id = "%s" + realm_id = data.keycloak_realm.realm.id + access_type = "CONFIDENTIAL" +} + +resource "keycloak_saml_client" "saml_client" { + client_id = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_role" "realm_role" { + name = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_role" "openid_client_role" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_openid_client.openid_client.id +} + +resource "keycloak_role" "saml_client_role" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_saml_client.saml_client.id +} + +data "keycloak_role" "offline_access" { + realm_id = data.keycloak_realm.realm.id + name = "offline_access" +} + +resource "keycloak_group" "group" { + realm_id = data.keycloak_realm.realm.id + name = "%s" +} + +resource "keycloak_group_roles" "group_roles1" { + realm_id = data.keycloak_realm.realm.id + group_id = keycloak_group.group.id + exhaustive = false + + role_ids = [ + keycloak_role.realm_role.id, + keycloak_role.openid_client_role.id, + ] +} + `, testAccRealm.Realm, openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, groupName) +} + func testKeycloakGroupRoles_noGroupRoles(openIdClientName, samlClientName, realmRoleName, openIdRoleName, samlRoleName, groupName string) string { return fmt.Sprintf(` data "keycloak_realm" "realm" { @@ -441,3 +683,85 @@ resource "keycloak_group_roles" "group_roles" { } `, testAccRealm.Realm, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, tfRoleIds) } + +func testKeycloakGroupRoles_updateNonExhaustive(openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName string, roleIds1 []string, roleIds2 []string) string { + tfRoleIds1 := fmt.Sprintf("role_ids = %s", arrayOfStringsForTerraformResource(roleIds1)) + tfRoleIds2 := fmt.Sprintf("role_ids = %s", arrayOfStringsForTerraformResource(roleIds2)) + + return fmt.Sprintf(` +data "keycloak_realm" "realm" { + realm = "%s" +} + +resource "keycloak_openid_client" "openid_client" { + client_id = "%s" + realm_id = data.keycloak_realm.realm.id + access_type = "CONFIDENTIAL" +} + +resource "keycloak_saml_client" "saml_client" { + client_id = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_role" "realm_role_one" { + name = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_role" "realm_role_two" { + name = "%s" + realm_id = data.keycloak_realm.realm.id +} + +resource "keycloak_role" "openid_client_role_one" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_openid_client.openid_client.id +} + +resource "keycloak_role" "openid_client_role_two" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_openid_client.openid_client.id +} + +resource "keycloak_role" "saml_client_role_one" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_saml_client.saml_client.id +} + +resource "keycloak_role" "saml_client_role_two" { + name = "%s" + realm_id = data.keycloak_realm.realm.id + client_id = keycloak_saml_client.saml_client.id +} + +data "keycloak_role" "offline_access" { + realm_id = data.keycloak_realm.realm.id + name = "offline_access" +} + +resource "keycloak_group" "group" { + realm_id = data.keycloak_realm.realm.id + name = "%s" +} + +resource "keycloak_group_roles" "group_roles1" { + realm_id = data.keycloak_realm.realm.id + group_id = keycloak_group.group.id + exhaustive = false + + %s +} + +resource "keycloak_group_roles" "group_roles2" { + realm_id = data.keycloak_realm.realm.id + group_id = keycloak_group.group.id + exhaustive = false + + %s +} + `, testAccRealm.Realm, openIdClientName, samlClientName, realmRoleOneName, realmRoleTwoName, openIdRoleOneName, openIdRoleTwoName, samlRoleOneName, samlRoleTwoName, groupName, tfRoleIds1, tfRoleIds2) +}