From 661aca44520b17769800973d2f35db9530a862a1 Mon Sep 17 00:00:00 2001 From: bal1imb Date: Mon, 13 Sep 2021 04:32:35 -0700 Subject: [PATCH] KEYCLOAK-19283 Implemented new identity provider mapper "Advanced claim to group mapper" alongside tests. --- .../provider/BrokeredIdentityContext.java | 34 ++++ .../broker/provider/ConfigConstants.java | 1 + .../java/org/keycloak/models/Constants.java | 3 + .../provider/ProviderConfigProperty.java | 1 + .../mappers/AbstractClaimToGroupMapper.java | 88 +++++++++ .../mappers/AdvancedClaimToGroupMapper.java | 120 +++++++++++ ...oak.broker.provider.IdentityProviderMapper | 1 + .../testsuite/admin/IdentityProviderTest.java | 4 +- .../AbstractAdvancedGroupMapperTest.java | 186 ++++++++++++++++++ .../broker/AbstractGroupMapperTest.java | 75 +++++++ .../OidcAdvancedClaimToGroupMapperTest.java | 37 ++++ .../messages/admin-messages_en.properties | 4 + .../theme/base/admin/resources/js/app.js | 166 ++++++++++++++++ .../partials/modal/group-selector.html | 50 +++++ .../templates/kc-provider-config.html | 10 + .../lib/angular/treeview/angular.treeview.js | 2 - 16 files changed, 778 insertions(+), 4 deletions(-) create mode 100644 services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java create mode 100644 services/src/main/java/org/keycloak/broker/oidc/mappers/AdvancedClaimToGroupMapper.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractAdvancedGroupMapperTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractGroupMapperTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcAdvancedClaimToGroupMapperTest.java create mode 100644 themes/src/main/resources/theme/base/admin/resources/partials/modal/group-selector.html diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java index 945ed8c9d2..272fcaf165 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java @@ -224,6 +224,21 @@ public class BrokeredIdentityContext { return roles; } + /** + * Obtains the set of groups that were assigned by mappers. + * + * @return a {@link Set} containing the groups. + */ + @SuppressWarnings("unchecked") + private Set getMapperAssignedGroups() { + Set groups = (Set) this.contextData.get(Constants.MAPPER_GRANTED_GROUPS); + if (groups == null) { + groups = new HashSet<>(); + this.contextData.put(Constants.MAPPER_GRANTED_GROUPS, groups); + } + return groups; + } + /** * Verifies if a mapper has already granted the specified role. * @@ -234,6 +249,16 @@ public class BrokeredIdentityContext { return this.getMapperGrantedRoles().contains(roleName); } + /** + * Verifies if a mapper has already assigned the specified group. + * + * @param groupId the id of the group. + * @return {@code true} if a mapper has already assigned the group; {@code false} otherwise. + */ + public boolean hasMapperAssignedGroup(final String groupId) { + return this.getMapperAssignedGroups().contains(groupId); + } + /** * Adds the specified role to the set of roles granted by mappers. * @@ -243,6 +268,15 @@ public class BrokeredIdentityContext { this.getMapperGrantedRoles().add(roleName); } + /** + * Adds the specified group to the set of groups assigned by mappers. + * + * @param groupId the id of the group. + */ + public void addMapperAssignedGroup(final String groupId) { + this.getMapperAssignedGroups().add(groupId); + } + /** * @deprecated use {@link #setFirstName(String)} and {@link #setLastName(String)} instead * @param name diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ConfigConstants.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ConfigConstants.java index 05f0d4de54..562af6a0f9 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/ConfigConstants.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ConfigConstants.java @@ -23,4 +23,5 @@ package org.keycloak.broker.provider; */ public interface ConfigConstants { String ROLE = "role"; + String GROUP = "group"; } diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index 85bb4efe45..014512a848 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -99,6 +99,9 @@ public final class Constants { // Roles already granted by a mapper when updating brokered users. public static final String MAPPER_GRANTED_ROLES = "MAPPER_GRANTED_ROLES"; + // Groups already assigned by a mapper when updating brokered users. + public static final String MAPPER_GRANTED_GROUPS = "MAPPER_GRANTED_GROUPS"; + // Indication to admin-rest-endpoint that realm keys should be re-generated public static final String GENERATE = "GENERATE"; diff --git a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java index 3abda27e47..4e678deaf1 100755 --- a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java +++ b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java @@ -38,6 +38,7 @@ public class ProviderConfigProperty { public static final String SCRIPT_TYPE="Script"; public static final String FILE_TYPE="File"; public static final String ROLE_TYPE="Role"; + public static final String GROUP_TYPE="Group"; /** * Possibility to configure single String value, which needs to be chosen from the list of predefined values (HTML select) diff --git a/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java new file mode 100644 index 0000000000..ba8b69260d --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java @@ -0,0 +1,88 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.oidc.mappers; + +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.ConfigConstants; +import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.models.GroupModel; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; + +public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper { + + @Override + public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, + IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + + GroupModel group = this.getGroup(realm, mapperModel); + if (applies(mapperModel, context)) { + user.joinGroup(group); + } + } + + @Override + public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, + IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + + GroupModel group = this.getGroup(realm, mapperModel); + String groupId = mapperModel.getConfig().get(ConfigConstants.GROUP); + + if (!context.hasMapperAssignedGroup(groupId)) { + if (applies(mapperModel, context)) { + context.addMapperAssignedGroup(groupId); + user.joinGroup(group); + } else { + user.leaveGroup(group); + } + } + } + + /** + * This method must be implemented by subclasses and they must return {@code true} if their mapping can be applied + * (i.e. user has the OIDC claim that should be mapped) or {@code false} otherwise. + * + * @param mapperModel a reference to the {@link IdentityProviderMapperModel}. + * @param context a reference to the {@link BrokeredIdentityContext}. + * @return {@code true} if the mapping can be applied or {@code false} otherwise.* + */ + protected abstract boolean applies(final IdentityProviderMapperModel mapperModel, + final BrokeredIdentityContext context); + + /** + * Obtains the {@link GroupModel} corresponding the group configured in the specified + * {@link IdentityProviderMapperModel}. + * If the group doesn't exist, this method throws an {@link IdentityBrokerException}. + * + * @param realm a reference to the realm. + * @param mapperModel a reference to the {@link IdentityProviderMapperModel} containing the configured group. + * @return the {@link GroupModel} + * @throws IdentityBrokerException if the group doesn't exist. + */ + private GroupModel getGroup(final RealmModel realm, final IdentityProviderMapperModel mapperModel) { + GroupModel group = KeycloakModelUtils.findGroupByPath(realm, mapperModel.getConfig().get(ConfigConstants.GROUP)); + + if (group == null) { + throw new IdentityBrokerException("Unable to find group: " + group.getId()); + } + return group; + } +} diff --git a/services/src/main/java/org/keycloak/broker/oidc/mappers/AdvancedClaimToGroupMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/AdvancedClaimToGroupMapper.java new file mode 100644 index 0000000000..7241c0b223 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/AdvancedClaimToGroupMapper.java @@ -0,0 +1,120 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.broker.oidc.mappers; + +import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.ConfigConstants; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.keycloak.utils.RegexUtils.valueMatchesRegex; + +public class AdvancedClaimToGroupMapper extends AbstractClaimToGroupMapper { + + public static final String CLAIM_PROPERTY_NAME = "claims"; + public static final String ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME = "are.claim.values.regex"; + + public static final String[] COMPATIBLE_PROVIDERS = {KeycloakOIDCIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID}; + + private static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty claimsProperty = new ProviderConfigProperty(); + claimsProperty.setName(CLAIM_PROPERTY_NAME); + claimsProperty.setLabel("Claims"); + claimsProperty.setHelpText("Name and value of the claims to search for in token. You can reference nested claims using a '.', i.e. 'address.locality'. To use dot (.) literally, escape it with backslash (\\.)"); + claimsProperty.setType(ProviderConfigProperty.MAP_TYPE); + configProperties.add(claimsProperty); + ProviderConfigProperty isClaimValueRegexProperty = new ProviderConfigProperty(); + isClaimValueRegexProperty.setName(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME); + isClaimValueRegexProperty.setLabel("Regex Claim Values"); + isClaimValueRegexProperty.setHelpText("If enabled claim values are interpreted as regular expressions."); + isClaimValueRegexProperty.setType(ProviderConfigProperty.BOOLEAN_TYPE); + configProperties.add(isClaimValueRegexProperty); + ProviderConfigProperty groupProperty = new ProviderConfigProperty(); + groupProperty.setName(ConfigConstants.GROUP); + groupProperty.setLabel("Group"); + groupProperty.setHelpText("Group to assign the user to if claim is present."); + groupProperty.setType(ProviderConfigProperty.GROUP_TYPE); + configProperties.add(groupProperty); + } + + public static final String PROVIDER_ID = "oidc-advanced-group-idp-mapper"; + + @Override + public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) { + return true; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String[] getCompatibleProviders() { + return COMPATIBLE_PROVIDERS; + } + + @Override + public String getDisplayCategory() { + return "Group Importer"; + } + + @Override + public String getDisplayType() { + return "Advanced Claim to Group"; + } + + @Override + public String getHelpText() { + return "If all claims exists, assign the user to the specified group."; + } + + @Override + protected boolean applies(IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + Map claims = mapperModel.getConfigMap(CLAIM_PROPERTY_NAME); + boolean areClaimValuesRegex = Boolean.parseBoolean(mapperModel.getConfig().get(ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME)); + + for (Map.Entry claim : claims.entrySet()) { + Object value = getClaimValue(context, claim.getKey()); + + boolean claimValuesMismatch = !(areClaimValuesRegex ? valueMatchesRegex(claim.getValue(), value) : valueEquals(claim.getValue(), value)); + if (claimValuesMismatch) { + return false; + } + } + + return true; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper index d9fc841828..306d7c3459 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper @@ -20,6 +20,7 @@ org.keycloak.broker.provider.HardcodedAttributeMapper org.keycloak.broker.provider.HardcodedUserSessionAttributeMapper org.keycloak.broker.oidc.mappers.ClaimToRoleMapper org.keycloak.broker.oidc.mappers.AdvancedClaimToRoleMapper +org.keycloak.broker.oidc.mappers.AdvancedClaimToGroupMapper org.keycloak.broker.oidc.mappers.ExternalKeycloakRoleToRoleMapper org.keycloak.broker.oidc.mappers.UserAttributeMapper org.keycloak.broker.oidc.mappers.UsernameTemplateMapper diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java index f5cfaf12b8..5d57e70729 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java @@ -562,12 +562,12 @@ public class IdentityProviderTest extends AbstractAdminTest { create(createRep("keycloak-oidc", "keycloak-oidc")); provider = realm.identityProviders().get("keycloak-oidc"); mapperTypes = provider.getMapperTypes(); - assertMapperTypes(mapperTypes, "keycloak-oidc-role-to-role-idp-mapper", "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-role-idp-mapper"); + assertMapperTypes(mapperTypes, "keycloak-oidc-role-to-role-idp-mapper", "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-group-idp-mapper", "oidc-advanced-role-idp-mapper"); create(createRep("oidc", "oidc")); provider = realm.identityProviders().get("oidc"); mapperTypes = provider.getMapperTypes(); - assertMapperTypes(mapperTypes, "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-role-idp-mapper"); + assertMapperTypes(mapperTypes, "oidc-user-attribute-idp-mapper", "oidc-role-idp-mapper", "oidc-username-idp-mapper", "oidc-advanced-group-idp-mapper", "oidc-advanced-role-idp-mapper"); create(createRep("saml", "saml")); provider = realm.identityProviders().get("saml"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractAdvancedGroupMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractAdvancedGroupMapperTest.java new file mode 100644 index 0000000000..cda5d21817 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractAdvancedGroupMapperTest.java @@ -0,0 +1,186 @@ +package org.keycloak.testsuite.broker; + +import static org.keycloak.models.IdentityProviderMapperSyncMode.FORCE; +import static org.keycloak.models.IdentityProviderMapperSyncMode.IMPORT; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.UserRepresentation; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import java.util.List; + +public abstract class AbstractAdvancedGroupMapperTest extends AbstractGroupMapperTest { + + private static final String CLAIMS_OR_ATTRIBUTES = "[\n" + + " {\n" + + " \"key\": \"" + KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME + "\",\n" + + " \"value\": \"value 1\"\n" + + " },\n" + + " {\n" + + " \"key\": \"" + KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2 + "\",\n" + + " \"value\": \"value 2\"\n" + + " }\n" + + "]"; + + private static final String CLAIMS_OR_ATTRIBUTES_REGEX = "[\n" + + " {\n" + + " \"key\": \"" + KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME + "\",\n" + + " \"value\": \"va.*\"\n" + + " },\n" + + " {\n" + + " \"key\": \"" + KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2 + "\",\n" + + " \"value\": \"value 2\"\n" + + " }\n" + + "]"; + + private String newValueForAttribute2 = ""; + + @Before + public void addMapperTestGroupToConsumerRealm() { + GroupRepresentation mapperTestGroup = new GroupRepresentation(); + mapperTestGroup.setName(MAPPER_TEST_GROUP_NAME); + mapperTestGroup.setPath(MAPPER_TEST_GROUP_PATH); + + adminClient.realm(bc.consumerRealmName()).groups().add(mapperTestGroup); + } + + @Test + public void allValuesMatch() { + createAdvancedGroupMapper(CLAIMS_OR_ATTRIBUTES, false); + createUserInProviderRealm(ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("value 1").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add("value 2").build()) + .build()); + + logInAsUserInIDPForFirstTime(); + + UserRepresentation user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + assertThatUserHasBeenAssignedToGroup(user); + } + + @Test + public void valuesMismatch() { + createAdvancedGroupMapper(CLAIMS_OR_ATTRIBUTES, false); + createUserInProviderRealm(ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("value 1").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add("value mismatch").build()) + .build()); + + logInAsUserInIDPForFirstTime(); + + UserRepresentation user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + assertThatUserHasNotBeenAssignedToGroup(user); + } + + @Test + public void valuesMatchIfNoClaimsSpecified() { + createAdvancedGroupMapper("[]", false); + createUserInProviderRealm(ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("some value").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add("some value").build()) + .build()); + + logInAsUserInIDPForFirstTime(); + + UserRepresentation user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + assertThatUserHasBeenAssignedToGroup(user); + } + + @Test + public void allValuesMatchRegex() { + createAdvancedGroupMapper(CLAIMS_OR_ATTRIBUTES_REGEX, true); + createUserInProviderRealm(ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("value 1").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add("value 2").build()) + .build()); + + logInAsUserInIDPForFirstTime(); + + UserRepresentation user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + assertThatUserHasBeenAssignedToGroup(user); + } + + @Test + public void valuesMismatchRegex() { + createAdvancedGroupMapper(CLAIMS_OR_ATTRIBUTES_REGEX, true); + createUserInProviderRealm(ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("mismatch").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add("value 2").build()) + .build()); + + logInAsUserInIDPForFirstTime(); + + UserRepresentation user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + assertThatUserHasNotBeenAssignedToGroup(user); + } + + @Test + public void updateBrokeredUserMismatchLeavesGroup() { + newValueForAttribute2 = "value mismatch"; + UserRepresentation user = createMapperAndLoginAsUserTwiceWithMapper(FORCE, false); + + assertThatUserHasNotBeenAssignedToGroup(user); + } + + @Test + public void updateBrokeredUserMismatchDoesNotLeaveGroupInImportMode() { + newValueForAttribute2 = "value mismatch"; + UserRepresentation user = createMapperAndLoginAsUserTwiceWithMapper(IMPORT, false); + + assertThatUserHasBeenAssignedToGroup(user); + } + + @Test + public void updateBrokeredUserMatchDoesntLeaveGroup() { + newValueForAttribute2 = "value 2"; + UserRepresentation user = createMapperAndLoginAsUserTwiceWithMapper(FORCE, false); + + assertThatUserHasBeenAssignedToGroup(user); + } + + @Test + public void updateBrokeredUserIsAssignedToGroupInForceModeWhenCreatingTheMapperAfterFirstLogin() { + newValueForAttribute2 = "value 2"; + UserRepresentation user = createMapperAndLoginAsUserTwiceWithMapper(FORCE, true); + + assertThatUserHasBeenAssignedToGroup(user); + } + + public UserRepresentation createMapperAndLoginAsUserTwiceWithMapper(IdentityProviderMapperSyncMode syncMode, boolean createAfterFirstLogin) { + return loginAsUserTwiceWithMapper(syncMode, createAfterFirstLogin, ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("value 1").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add("value 2").build()) + .build()); + } + + @Override + protected void updateUser() { + UserRepresentation user = findUser(bc.providerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + ImmutableMap> matchingAttributes = ImmutableMap.>builder() + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME, ImmutableList.builder().add("value 1").build()) + .put(KcOidcBrokerConfiguration.ATTRIBUTE_TO_MAP_NAME_2, ImmutableList.builder().add(newValueForAttribute2).build()) + .put("some.other.attribute", ImmutableList.builder().add("some value").build()) + .build(); + user.setAttributes(matchingAttributes); + adminClient.realm(bc.providerRealmName()).users().get(user.getId()).update(user); + } + + @Override + protected void createMapperInIdp(IdentityProviderRepresentation idp, IdentityProviderMapperSyncMode syncMode) { + createMapperInIdp(idp, CLAIMS_OR_ATTRIBUTES, false, syncMode); + } + + protected void createAdvancedGroupMapper(String claimsOrAttributeRepresentation, boolean areClaimsOrAttributeValuesRegexes) { + IdentityProviderRepresentation idp = setupIdentityProvider(); + createMapperInIdp(idp, claimsOrAttributeRepresentation, areClaimsOrAttributeValuesRegexes, IMPORT); + } + + abstract protected void createMapperInIdp( + IdentityProviderRepresentation idp, String claimsOrAttributeRepresentation, boolean areClaimsOrAttributeValuesRegexes, IdentityProviderMapperSyncMode syncMode); +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractGroupMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractGroupMapperTest.java new file mode 100644 index 0000000000..1d22f9f738 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractGroupMapperTest.java @@ -0,0 +1,75 @@ +package org.keycloak.testsuite.broker; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot; + +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.UserRepresentation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public abstract class AbstractGroupMapperTest extends AbstractIdentityProviderMapperTest { + + public static final String MAPPER_TEST_GROUP_NAME = "mapper-test"; + public static final String MAPPER_TEST_GROUP_PATH = "/" + MAPPER_TEST_GROUP_NAME; + + protected abstract void createMapperInIdp( + IdentityProviderRepresentation idp, IdentityProviderMapperSyncMode syncMode); + + protected void updateUser() { + } + + protected UserRepresentation loginAsUserTwiceWithMapper( + IdentityProviderMapperSyncMode syncMode, boolean createAfterFirstLogin, + Map> userConfig) { + final IdentityProviderRepresentation idp = setupIdentityProvider(); + if (!createAfterFirstLogin) { + createMapperInIdp(idp, syncMode); + } + createUserInProviderRealm(userConfig); + + logInAsUserInIDPForFirstTime(); + + UserRepresentation user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + if (!createAfterFirstLogin) { + assertThatUserHasBeenAssignedToGroup(user); + } else { + assertThatUserHasNotBeenAssignedToGroup(user); + } + + if (createAfterFirstLogin) { + createMapperInIdp(idp, syncMode); + } + logoutFromRealm(getConsumerRoot(), bc.consumerRealmName()); + + updateUser(); + + logInAsUserInIDP(); + user = findUser(bc.consumerRealmName(), bc.getUserLogin(), bc.getUserEmail()); + return user; + } + + protected void assertThatUserHasBeenAssignedToGroup(UserRepresentation user) { + List groupNames = new ArrayList<>(); + + realm.users().get(user.getId()).groups().forEach(group -> { + groupNames.add(group.getName()); + }); + + assertTrue(groupNames.contains(MAPPER_TEST_GROUP_NAME)); + } + + protected void assertThatUserHasNotBeenAssignedToGroup(UserRepresentation user) { + List groupNames = new ArrayList<>(); + + realm.users().get(user.getId()).groups().forEach(group -> { + groupNames.add(group.getName()); + }); + + assertFalse(groupNames.contains(MAPPER_TEST_GROUP_NAME)); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcAdvancedClaimToGroupMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcAdvancedClaimToGroupMapperTest.java new file mode 100644 index 0000000000..1b21993312 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/OidcAdvancedClaimToGroupMapperTest.java @@ -0,0 +1,37 @@ +package org.keycloak.testsuite.broker; + +import org.keycloak.admin.client.resource.IdentityProviderResource; +import org.keycloak.broker.oidc.mappers.AdvancedClaimToGroupMapper; +import org.keycloak.broker.provider.ConfigConstants; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; + +import com.google.common.collect.ImmutableMap; + +public class OidcAdvancedClaimToGroupMapperTest extends AbstractAdvancedGroupMapperTest { + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return new KcOidcBrokerConfiguration(); + } + + @Override + protected void createMapperInIdp(IdentityProviderRepresentation idp, String claimsOrAttributeRepresentation, + boolean areClaimsOrAttributeValuesRegexes, IdentityProviderMapperSyncMode syncMode) { + IdentityProviderMapperRepresentation advancedClaimToGroupMapper = new IdentityProviderMapperRepresentation(); + advancedClaimToGroupMapper.setName("advanced-claim-to-group-mapper"); + advancedClaimToGroupMapper.setIdentityProviderMapper(AdvancedClaimToGroupMapper.PROVIDER_ID); + advancedClaimToGroupMapper.setConfig(ImmutableMap. builder() + .put(IdentityProviderMapperModel.SYNC_MODE, syncMode.toString()) + .put(AdvancedClaimToGroupMapper.CLAIM_PROPERTY_NAME, claimsOrAttributeRepresentation) + .put(AdvancedClaimToGroupMapper.ARE_CLAIM_VALUES_REGEX_PROPERTY_NAME, + areClaimsOrAttributeValuesRegexes ? "true" : "false") + .put(ConfigConstants.GROUP, MAPPER_TEST_GROUP_PATH) + .build()); + + IdentityProviderResource idpResource = realm.identityProviders().get(idp.getAlias()); + advancedClaimToGroupMapper.setIdentityProviderAlias(bc.getIDPAlias()); + idpResource.addMapper(advancedClaimToGroupMapper).close(); + } +} diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 09a540aa47..34220b682e 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -253,6 +253,8 @@ aggregate.attrs.label=Aggregate attribute values aggregate.attrs.tooltip=Indicates if attribute values should be aggregated with the group attributes. If using OpenID Connect mapper the multivalued option needs to be enabled too in order to get all the values. Duplicated values are discarded and the order of values is not guaranteed with this option. selectRole.label=Select Role selectRole.tooltip=Enter role in the textbox to the left, or click this button to browse and select the role you want. +selectGroup.label=Select Group +selectGroup.tooltip=Enter group in the textbox to the left, or click this button to browse and select the group you want. tokenClaimName.label=Token Claim Name tokenClaimName.tooltip=Name of the claim to insert into the token. This can be a fully qualified name like 'address.street'. In this case, a nested json object will be created. To prevent nesting and use dot literally, escape the dot with backslash (\\.). jsonType.label=Claim JSON Type @@ -1061,10 +1063,12 @@ export-clients=Export clients action=Action role-selector=Role Selector +group-selector=Group Selector realm-roles.tooltip=Realm roles that can be selected. select-a-role=Select a role select-realm-role=Select realm role +select-group=Select group client-roles.tooltip=Client roles that can be selected. select-client-role=Select client role diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js index addb63a14c..d437d33b62 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -3048,6 +3048,154 @@ module.controller('RoleSelectorModalCtrl', function($scope, realm, config, confi }) }); +module.controller('GroupSelectorModalCtrl', function($scope, $q, realm, config, configName, GroupsCount, Groups, Group, GroupChildren, Notifications, Dialog, ComponentUtils, $modalInstance, $translate) { + $scope.realm = realm; + $scope.groupList = [ + { + "id" : "realm", + "name": $translate.instant('groups'), + "subGroups" : [] + } + ]; + $scope.groupSelector = { + searchCriteria: undefined, + currentPage: 1, + pageSize: 20, + numberOfPages: 1 + }; + $scope.groupSelector.currentPageInput = $scope.groupSelector.currentPage; + + var refreshGroups = function (search) { + console.log('refreshGroups'); + $scope.groupSelector.currentPageInput = $scope.groupSelector.currentPage; + + var first = ($scope.groupSelector.currentPage * $scope.groupSelector.pageSize) - $scope.groupSelector.pageSize; + console.log('first:' + first); + var queryParams = { + realm : realm.realm, + first : first, + max : $scope.groupSelector.pageSize + }; + var countParams = { + realm : realm.realm, + top : 'true' + }; + + if(angular.isDefined(search) && search !== '') { + queryParams.search = search; + countParams.search = search; + } + + var promiseGetGroups = $q.defer(); + Groups.query(queryParams, function(entry) { + promiseGetGroups.resolve(entry); + }, function() { + promiseGetGroups.reject($translate.instant('group.fetch.fail', {params: queryParams})); + }); + promiseGetGroups.promise.then(function(groups) { + $scope.groupList = [ + { + "id" : "realm", + "name": $translate.instant('groups'), + "subGroups": ComponentUtils.sortGroups('name', groups) + } + ]; + if (angular.isDefined(search) && search !== '') { + // Add highlight for concrete text match + setTimeout(function () { + document.querySelectorAll('span').forEach(function (element) { + if (element.textContent.indexOf(search) != -1) { + angular.element(element).addClass('highlight'); + } + }); + }, 500); + } + }, function (failed) { + Notifications.error(failed); + }); + + var promiseCount = $q.defer(); + console.log('countParams: realm[' + countParams.realm); + GroupsCount.query(countParams, function(entry) { + promiseCount.resolve(entry); + }, function() { + promiseCount.reject($translate.instant('group.fetch.fail', {params: countParams})); + }); + promiseCount.promise.then(function(entry) { + if(angular.isDefined(entry.count) && entry.count > $scope.groupSelector.pageSize) { + $scope.groupSelector.numberOfPages = Math.ceil(entry.count/$scope.groupSelector.pageSize); + } else { + $scope.groupSelector.numberOfPages = 1; + } + }, function (failed) { + Notifications.error(failed); + }); + }; + + refreshGroups(); + + $scope.$watch('groupSelector.currentPage', function(newValue, oldValue) { + if(parseInt(newValue, 10) !== oldValue) { + refreshGroups($scope.groupSelector.searchCriteria); + } + }); + + $scope.clearSearch = function() { + $scope.groupSelector.searchCriteria = ''; + if (parseInt($scope.groupSelector.currentPage, 10) === 1) { + refreshGroups(); + } else { + $scope.groupSelector.currentPage = 1; + } + }; + + $scope.searchGroup = function() { + if (parseInt($scope.groupSelector.currentPage, 10) === 1) { + refreshGroups($scope.groupSelector.searchCriteria); + } else { + $scope.groupSelector.currentPage = 1; + } + }; + + $scope.selectGroup = function(selected) { + if(!selected || selected.id === "realm") return; + + config[configName] = selected.path; + $modalInstance.close(); + } + + $scope.edit = $scope.selectGroup; + + $scope.cancel = function() { + $modalInstance.dismiss(); + } + + var isLeaf = function(node) { + return node.id !== "realm" && (!node.subGroups || node.subGroups.length === 0); + }; + + $scope.getGroupClass = function(node) { + if (node.id === "realm") { + return 'pficon pficon-users'; + } + if (isLeaf(node)) { + return 'normal'; + } + if (node.subGroups.length && node.collapsed) return 'collapsed'; + if (node.subGroups.length && !node.collapsed) return 'expanded'; + return 'collapsed'; + + }; + + $scope.getSelectedClass = function(node) { + if (node.selected) { + return 'selected'; + } + return undefined; + } +}); + + module.controller('ProviderConfigCtrl', function ($modal, $scope, $route, ComponentUtils, Client) { clientSelectControl($scope, $route.current.params.realm, Client); $scope.fileNames = {}; @@ -3091,6 +3239,24 @@ module.controller('ProviderConfigCtrl', function ($modal, $scope, $route, Compon }) } + $scope.openGroupSelector = function (configName, config) { + $modal.open({ + templateUrl: resourceUrl + '/partials/modal/group-selector.html', + controller: 'GroupSelectorModalCtrl', + resolve: { + realm: function () { + return $scope.realm; + }, + config: function () { + return config; + }, + configName: function () { + return configName; + } + } + }) + } + $scope.changeClient = function(configName, config, client, multivalued) { if (!client || !client.id) { config[configName] = null; diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/modal/group-selector.html b/themes/src/main/resources/theme/base/admin/resources/partials/modal/group-selector.html new file mode 100644 index 0000000000..d6dfa4d24f --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/modal/group-selector.html @@ -0,0 +1,50 @@ + +
+
+ + + + + + + + + + + +
+
+
+
+ +
+ +
+
+
+ + +
+ +
+
+
+
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html index 70225bcec5..132a7bc4a4 100755 --- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html @@ -29,6 +29,16 @@ +
+
+
+ +
+
+ +
+
+
diff --git a/themes/src/main/resources/theme/keycloak/common/resources/lib/angular/treeview/angular.treeview.js b/themes/src/main/resources/theme/keycloak/common/resources/lib/angular/treeview/angular.treeview.js index 0e6512a1be..2e56cad795 100755 --- a/themes/src/main/resources/theme/keycloak/common/resources/lib/angular/treeview/angular.treeview.js +++ b/themes/src/main/resources/theme/keycloak/common/resources/lib/angular/treeview/angular.treeview.js @@ -45,7 +45,6 @@ var nodeChildren = attrs.nodeChildren || 'children'; //tree template - var template = '
    ' + '
  • ' + @@ -55,7 +54,6 @@ '
  • ' + '
'; - //check tree id, tree model if( treeId && treeModel ) { //root node