diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java index 1d7d8c5d53..2d745bdbe4 100755 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/LDAPStorageProvider.java @@ -18,8 +18,10 @@ package org.keycloak.storage.ldap; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -60,6 +62,7 @@ import org.keycloak.models.utils.ReadOnlyUserModelDelegate; import org.keycloak.policy.PasswordPolicyManagerProvider; import org.keycloak.policy.PolicyError; import org.keycloak.models.cache.UserCache; +import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.storage.DatastoreProvider; import org.keycloak.storage.LegacyStoreManagers; import org.keycloak.storage.ReadOnlyException; @@ -86,6 +89,14 @@ import org.keycloak.storage.user.ImportedUserValidation; import org.keycloak.storage.user.UserLookupProvider; import org.keycloak.storage.user.UserQueryMethodsProvider; import org.keycloak.storage.user.UserRegistrationProvider; +import org.keycloak.userprofile.AbstractUserProfileProvider; +import org.keycloak.userprofile.AttributeContext; +import org.keycloak.userprofile.AttributeGroupMetadata; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileDecorator; +import org.keycloak.userprofile.UserProfileMetadata; +import org.keycloak.userprofile.UserProfileProvider; import static org.keycloak.utils.StreamsUtil.paginatedStream; @@ -101,7 +112,8 @@ public class LDAPStorageProvider implements UserStorageProvider, UserLookupProvider, UserRegistrationProvider, UserQueryMethodsProvider, - ImportedUserValidation { + ImportedUserValidation, + UserProfileDecorator { private static final Logger logger = Logger.getLogger(LDAPStorageProvider.class); private static final int DEFAULT_MAX_RESULTS = Integer.MAX_VALUE >> 1; @@ -963,4 +975,93 @@ public class LDAPStorageProvider implements UserStorageProvider, public String toString() { return "LDAPStorageProvider - " + getModel().getName(); } + + @Override + public void decorateUserProfile(RealmModel realm, UserProfileMetadata metadata) { + Predicate ldapUsersSelector = (attributeContext -> { + UserModel user = attributeContext.getUser(); + if (user == null) { + return false; + } + + if (model.isImportEnabled()) { + return getModel().getId().equals(user.getFederationLink()); + } else { + return getModel().getId().equals(new StorageId(user.getId()).getProviderId()); + } + }); + + Predicate onlyAdminCondition = context -> metadata.getContext() == UserProfileContext.USER_API; + + int guiOrder = (int) metadata.getAttributes().stream() + .map(AttributeMetadata::getName) + .distinct() + .count(); + + // 1 - get configured attributes from LDAP mappers and add them to the user profile (if they not already present) + Set attributes = new LinkedHashSet<>(); + realm.getComponentsStream(model.getId(), LDAPStorageMapper.class.getName()) + .sorted(ldapMappersComparator.sortAsc()) + .forEachOrdered(mapperModel -> { + LDAPStorageMapper ldapMapper = mapperManager.getMapper(mapperModel); + attributes.addAll(ldapMapper.getUserAttributes()); + }); + for (String attrName : attributes) { + // In case that attributes from LDAP mappers are explicitly defined on user profile, we can prefer defined configuration + if (!metadata.getAttribute(attrName).isEmpty()) { + logger.debugf("Ignore adding attribute '%s' to user profile by LDAP provider '%s' as attribute is already defined on user profile.", attrName, getModel().getName()); + } else { + logger.debugf("Adding attribute '%s' to user profile by LDAP provider '%s' for user profile context '%s'.", attrName, getModel().getName(), metadata.getContext().toString()); + // Writable and readable only by administrators by default. Applied only for LDAP users + AttributeMetadata attributeMetadata = metadata.addAttribute(attrName, guiOrder++, Collections.emptyList()) + .addWriteCondition(onlyAdminCondition) + .addReadCondition(onlyAdminCondition) + .setRequired(AttributeMetadata.ALWAYS_FALSE); + attributeMetadata.setSelector(ldapUsersSelector); + } + } + + // 2 - metadata attributes + Set metadataAttributes = new HashSet<>(List.of(LDAPConstants.LDAP_ID, LDAPConstants.LDAP_ENTRY_DN)); + if (getKerberosConfig().isAllowKerberosAuthentication()) { + metadataAttributes.add(KerberosConstants.KERBEROS_PRINCIPAL); + } + + AttributeGroupMetadata metadataGroup = lookupMetadataGroup(); + + for (String attrName : metadataAttributes) { + // In case that attributes like LDAP_ID, KERBEROS_PRINCIPAL are explicitly defined on user profile, we can prefer defined configuration + if (!metadata.getAttribute(attrName).isEmpty()) { + logger.debugf("Ignore adding metadata attribute '%s' to user profile by LDAP provider '%s' as attribute is already defined on user profile.", attrName, getModel().getName()); + } else { + logger.debugf("Adding metadata attribute '%s' to user profile by LDAP provider '%s' for user profile context '%s'.", attrName, getModel().getName(), metadata.getContext().toString()); + AttributeMetadata attributeMetadata = metadata.addAttribute(attrName, guiOrder++, Collections.emptyList()) + .addWriteCondition(AttributeMetadata.ALWAYS_FALSE) // Not writable for anyone + .addReadCondition(onlyAdminCondition) // Read-only for administrators + .setRequired(AttributeMetadata.ALWAYS_FALSE); + + if (metadataGroup != null) { + attributeMetadata.setAttributeGroupMetadata(metadataGroup); + } + attributeMetadata.setSelector(ldapUsersSelector); + } + } + + // 3 - make all attributes read-only for LDAP users in case that LDAP itself is read-only + if (getEditMode() == EditMode.READ_ONLY) { + for (AttributeMetadata attrMetadata : metadata.getAttributes()) { + attrMetadata.addWriteCondition(ldapUsersSelector.negate()); + } + } + } + + private AttributeGroupMetadata lookupMetadataGroup() { + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UPConfig config = provider.getConfiguration(); + return config.getGroups().stream() + .filter(upGroup -> AbstractUserProfileProvider.USER_METADATA_GROUP.equals(upGroup.getName())) + .map(upGroup -> new AttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations())) + .findAny() + .orElse(null); + } } diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java index 3bcbe46399..778b69f84e 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/AbstractLDAPStorageMapper.java @@ -79,6 +79,11 @@ public abstract class AbstractLDAPStorageMapper implements LDAPStorageMapper { return null; } + @Override + public Set getUserAttributes() { + return Collections.emptySet(); + } + public static boolean parseBooleanParameter(ComponentModel mapperModel, String paramName) { String paramm = mapperModel.getConfig().getFirst(paramName); return Boolean.parseBoolean(paramm); diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java index b7285d8291..b1d3baa94c 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/FullNameLDAPStorageMapper.java @@ -235,6 +235,11 @@ public class FullNameLDAPStorageMapper extends AbstractLDAPStorageMapper { query.addWhereCondition(fullNameCondition); } + @Override + public Set getUserAttributes() { + return new HashSet<>(List.of(UserModel.FIRST_NAME, UserModel.LAST_NAME)); + } + protected String getLdapFullNameAttrName() { String ldapFullNameAttrName = mapperModel.getConfig().getFirst(LDAP_FULL_NAME_ATTRIBUTE); return ldapFullNameAttrName == null ? LDAPConstants.CN : ldapFullNameAttrName; diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java index f399c22cd1..ed56ae00a6 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/LDAPStorageMapper.java @@ -94,6 +94,13 @@ public interface LDAPStorageMapper extends Provider { */ Set mandatoryAttributeNames(); + /** + * Method that returns user model attributes, which this mapper maps to Keycloak users + * + * @return user model attributes. Returns empty set if not user attributes provided by this mapper. Never returns null. + */ + Set getUserAttributes(); + /** * Called when invoke proxy on LDAP federation provider * diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java index 6a4ae32e57..7f9287804d 100644 --- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/UserAttributeLDAPStorageMapper.java @@ -151,6 +151,11 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper { return isMandatoryInLdap? Collections.singleton(getLdapAttributeName()) : null; } + @Override + public Set getUserAttributes() { + return Collections.singleton(getUserModelAttribute()); + } + // throw ModelDuplicateException if there is different user in model with same email protected void checkDuplicateEmail(String userModelAttrName, String email, RealmModel realm, KeycloakSession session, UserModel user) { if (email == null || realm.isDuplicateEmailsAllowed()) return; diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java index cdf0656750..fa44b7dee5 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserCacheSession.java @@ -63,6 +63,8 @@ import org.keycloak.storage.StorageId; import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.UserStorageProviderModel; import org.keycloak.storage.client.ClientStorageProvider; +import org.keycloak.userprofile.UserProfileDecorator; +import org.keycloak.userprofile.UserProfileMetadata; import java.util.Collections; import java.util.HashMap; @@ -78,7 +80,7 @@ import java.util.stream.Stream; * @author Bill Burke * @version $Revision: 1 $ */ -public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateComponent { +public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateComponent, UserProfileDecorator { protected static final Logger logger = Logger.getLogger(UserCacheSession.class); protected UserCacheManager cache; protected KeycloakSession session; @@ -946,4 +948,11 @@ public class UserCacheSession implements UserCache, OnCreateComponent, OnUpdateC ((OnCreateComponent) getDelegate()).onCreate(session, realm, model); } } + + @Override + public void decorateUserProfile(RealmModel realm, UserProfileMetadata metadata) { + if (getDelegate() instanceof UserProfileDecorator) { + ((UserProfileDecorator) getDelegate()).decorateUserProfile(realm, metadata); + } + } } diff --git a/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java b/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java index 8fd65506b4..fca704fd0a 100755 --- a/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java +++ b/model/legacy-private/src/main/java/org/keycloak/storage/UserStorageManager.java @@ -70,13 +70,15 @@ import org.keycloak.storage.user.UserLookupProvider; import org.keycloak.storage.user.UserQueryMethodsProvider; import org.keycloak.storage.user.UserQueryProvider; import org.keycloak.storage.user.UserRegistrationProvider; +import org.keycloak.userprofile.UserProfileDecorator; +import org.keycloak.userprofile.UserProfileMetadata; /** * @author Bill Burke * @version $Revision: 1 $ */ public class UserStorageManager extends AbstractStorageManager - implements UserProvider, OnUserCache, OnCreateComponent, OnUpdateComponent { + implements UserProvider, OnUserCache, OnCreateComponent, OnUpdateComponent, UserProfileDecorator { private static final Logger logger = Logger.getLogger(UserStorageManager.class); @@ -786,4 +788,12 @@ public class UserStorageManager extends AbstractStorageManager> implements for (String name : nameSet()) { AttributeMetadata metadata = getMetadata(name); - if (metadata == null || !metadata.canView(createAttributeContext(metadata))) { + if (metadata == null + || !metadata.canView(createAttributeContext(metadata)) + || !metadata.isSelected(createAttributeContext(metadata))) { attributes.remove(name); } } diff --git a/server-spi/src/main/java/org/keycloak/models/UserProvider.java b/server-spi/src/main/java/org/keycloak/models/UserProvider.java index 00634e61ec..19703b75f4 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/UserProvider.java @@ -296,4 +296,5 @@ public interface UserProvider extends Provider, * @param component the component model */ void preRemove(RealmModel realm, ComponentModel component); + } diff --git a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java index 5eb3e276d6..1440af754b 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -43,7 +43,7 @@ public final class AttributeMetadata { private final String attributeName; private String attributeDisplayName; private AttributeGroupMetadata attributeGroupMetadata; - private final Predicate selector; + private Predicate selector; private final List> writeAllowed = new ArrayList<>(); /** Predicate to decide if attribute is required, it is handled as required if predicate is null */ private Predicate required; @@ -131,6 +131,10 @@ public final class AttributeMetadata { return selector.test(context); } + public void setSelector(Predicate selector) { + this.selector = selector; + } + private boolean allConditionsMet(List> predicates, AttributeContext context) { return predicates.stream().allMatch(p -> p.test(context)); } diff --git a/server-spi/src/main/java/org/keycloak/userprofile/UserProfileDecorator.java b/server-spi/src/main/java/org/keycloak/userprofile/UserProfileDecorator.java new file mode 100644 index 0000000000..acf7b5508f --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/userprofile/UserProfileDecorator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 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.userprofile; + +import org.keycloak.models.RealmModel; + +/** + * @author Pedro Igor + */ +public interface UserProfileDecorator { + + /** + * Decorates user profile with additional metadata. For instance, metadata attributes, which are available just for your user-storage + * provider can be added there, so they are available just for the users coming from your provider + * + * @param metadata to decorate + */ + void decorateUserProfile(RealmModel realm, UserProfileMetadata metadata); +} diff --git a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java index fe11825018..d38f946e88 100644 --- a/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/AbstractUserProfileProvider.java @@ -73,6 +73,8 @@ public abstract class AbstractUserProfileProvider public static final String CONFIG_READ_ONLY_ATTRIBUTES = "read-only-attributes"; public static final String MAX_EMAIL_LOCAL_PART_LENGTH = "max-email-local-part-length"; + public static final String USER_METADATA_GROUP = "user-metadata"; + private static boolean editUsernameCondition(AttributeContext c) { KeycloakSession session = c.getSession(); KeycloakContext context = session.getContext(); diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index ce1b15259f..32a49785d5 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -46,11 +46,13 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.storage.DatastoreProvider; import org.keycloak.userprofile.config.DeclarativeUserProfileModel; import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPAttributePermissions; @@ -170,6 +172,8 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< ComponentModel component = getComponentModel().orElse(null); if (component == null) { + // makes sure user providers can override metadata for any attribute + decorateUserProfileMetadataWithUserStorage(realm, decoratedMetadata); return decoratedMetadata; } @@ -429,10 +433,23 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider< } } + if (session != null) { + // makes sure user providers can override metadata for any attribute + decorateUserProfileMetadataWithUserStorage(session.getContext().getRealm(), decoratedMetadata); + } + return decoratedMetadata; } + private void decorateUserProfileMetadataWithUserStorage(RealmModel realm, UserProfileMetadata userProfileMetadata) { + // makes sure user providers can override metadata for any attribute + UserProvider users = session.users(); + if (users instanceof UserProfileDecorator) { + ((UserProfileDecorator) users).decorateUserProfile(realm, userProfileMetadata); + } + } + private Map asHashMap(List groups) { return groups.stream().collect(Collectors.toMap(g -> g.getName(), g -> g)); } diff --git a/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json b/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json index eb41231b83..bde1f5551c 100644 --- a/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json +++ b/services/src/main/resources/org/keycloak/userprofile/config/keycloak-default-user-profile.json @@ -52,5 +52,12 @@ "person-name-prohibited-characters": {} } } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata" + } ] } \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginUpdateProfilePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginUpdateProfilePage.java index 5d0b1ee50e..e83abd37d2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginUpdateProfilePage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginUpdateProfilePage.java @@ -111,6 +111,14 @@ public class LoginUpdateProfilePage extends AbstractPage { public String getLabelForField(String fieldId) { return driver.findElement(By.cssSelector("label[for="+fieldId+"]")).getText(); } + + public WebElement getFieldById(String fieldId) { + try { + return driver.findElement(By.id(fieldId)); + } catch (NoSuchElementException nsee) { + return null; + } + } public boolean isDepartmentPresent() { try { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java index c3edca8fbb..01c705e8cb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/userprofile/UserProfileAdminTest.java @@ -201,7 +201,11 @@ public class UserProfileAdminTest extends AbstractAdminTest { assertEquals(group.getName(), mGroup.getName()); assertEquals(group.getDisplayHeader(), mGroup.getDisplayHeader()); assertEquals(group.getDisplayDescription(), mGroup.getDisplayDescription()); - assertEquals(group.getAnnotations().size(), mGroup.getAnnotations().size()); + if (group.getAnnotations() == null) { + assertEquals(group.getAnnotations(), mGroup.getAnnotations()); + } else { + assertEquals(group.getAnnotations().size(), mGroup.getAnnotations().size()); + } } assertEquals(config.getGroups().get(0).getName(), metadata.getAttributeMetadata(UserModel.FIRST_NAME).getGroup()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPUserProfileTest.java new file mode 100644 index 0000000000..7530bed7be --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPUserProfileTest.java @@ -0,0 +1,321 @@ +/* + * Copyright 2023 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.testsuite.federation.ldap; + +import java.io.IOException; +import java.util.Set; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.Profile; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserProfileAttributeMetadata; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPAttributePermissions; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.testsuite.ProfileAssume; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.forms.VerifyProfileTest; +import org.keycloak.testsuite.pages.LoginUpdateProfilePage; +import org.keycloak.testsuite.util.LDAPRule; +import org.keycloak.testsuite.util.LDAPTestUtils; +import org.keycloak.userprofile.config.UPConfigUtils; + +import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED; +import static org.keycloak.userprofile.AbstractUserProfileProvider.USER_METADATA_GROUP; + +/** + * @author Marek Posolda + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE) +public class LDAPUserProfileTest extends AbstractLDAPTest { + + @ClassRule + public static LDAPRule ldapRule = new LDAPRule(); + + @Page + protected LoginUpdateProfilePage updateProfilePage; + + @Override + protected LDAPRule getLDAPRule() { + return ldapRule; + } + + @Before + public void before() { + // don't run this test when map storage is enabled, as map storage doesn't support LDAP, yet + ProfileAssume.assumeFeatureDisabled(Profile.Feature.MAP_STORAGE); + } + + @Override + protected void afterImportTestRealm() { + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + + UserModel user = LDAPTestUtils.addLocalUser(session, appRealm, "marykeycloak", "mary@test.com", "Password1"); + user.setFirstName("Mary"); + user.setLastName("Kelly"); + + LDAPTestUtils.addZipCodeLDAPMapper(appRealm, ctx.getLdapModel()); + + // Delete all LDAP users and add some new for testing + LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm); + + LDAPObject john = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "johnkeycloak", "John", "Doe", "john@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), john, "Password1"); + + LDAPObject john2 = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "johnkeycloak2", "John", "Doe", "john2@email.org", null, "1234"); + LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), john2, "Password1"); + }); + + RealmRepresentation realm = testRealm().toRepresentation(); + VerifyProfileTest.enableDynamicUserProfile(realm); + testRealm().update(realm); + } + + @Test + public void testUserProfile() { + // Test user profile of user johnkeycloak in admin API + UserResource johnResource = ApiUtil.findUserByUsernameId(testRealm(), "johnkeycloak"); + UserRepresentation john = johnResource.toRepresentation(true); + + assertUser(john, "johnkeycloak", "john@email.org", "John", "Doe", "1234"); + assertProfileAttributes(john, null, false, "username", "email", "firstName", "lastName", "postal_code"); + assertProfileAttributes(john, USER_METADATA_GROUP, true, LDAPConstants.LDAP_ID, LDAPConstants.LDAP_ENTRY_DN); + + // Test Update profile + john.getRequiredActions().add(UserModel.RequiredAction.UPDATE_PROFILE.toString()); + johnResource.update(john); + + loginPage.open(); + loginPage.login("johnkeycloak", "Password1"); + updateProfilePage.assertCurrent(); + Assert.assertEquals("John", updateProfilePage.getFirstName()); + Assert.assertEquals("Doe", updateProfilePage.getLastName()); + Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled()); + Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled()); + Assert.assertNull(updateProfilePage.getFieldById("postal_code")); + updateProfilePage.prepareUpdate().submit(); + } + + @Test + public void testUserProfileWithDefinedAttribute() throws IOException { + UPConfig origConfig = testRealm().users().userProfile().getConfiguration(); + try { + UPConfig config = testRealm().users().userProfile().getConfiguration(); + // Set postal code + UPAttribute postalCode = new UPAttribute(); + postalCode.setName("postal_code"); + postalCode.setDisplayName("Postal Code"); + + UPAttributePermissions permissions = new UPAttributePermissions(); + permissions.setView(Set.of(UPConfigUtils.ROLE_USER, UPConfigUtils.ROLE_ADMIN)); + permissions.setEdit(Set.of(UPConfigUtils.ROLE_USER, UPConfigUtils.ROLE_ADMIN)); + postalCode.setPermissions(permissions); + config.getAttributes().add(postalCode); + testRealm().users().userProfile().update(config); + + // Defined postal_code in user profile config should have preference + UserResource johnResource = ApiUtil.findUserByUsernameId(testRealm(), "johnkeycloak"); + UserRepresentation john = johnResource.toRepresentation(true); + Assert.assertEquals("Postal Code", john.getUserProfileMetadata().getAttributeMetadata("postal_code").getDisplayName()); + + // update profile now. + john.getRequiredActions().add(UserModel.RequiredAction.UPDATE_PROFILE.toString()); + johnResource.update(john); + + loginPage.open(); + loginPage.login("johnkeycloak", "Password1"); + updateProfilePage.assertCurrent(); + + Assert.assertEquals("John", updateProfilePage.getFirstName()); + Assert.assertEquals("Doe", updateProfilePage.getLastName()); + Assert.assertEquals("1234", updateProfilePage.getFieldById("postal_code").getAttribute("value")); + Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled()); + Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled()); + Assert.assertTrue(updateProfilePage.getFieldById("postal_code").isEnabled()); + updateProfilePage.prepareUpdate().submit(); + } finally { + testRealm().users().userProfile().update(origConfig); + } + } + + @Test + public void testUserProfileWithReadOnlyLdap() { + // Test user profile of user johnkeycloak in admin console as well as account console. Check attributes are writable. + setLDAPReadOnly(); + try { + UserResource johnResource = ApiUtil.findUserByUsernameId(testRealm(), "johnkeycloak"); + UserRepresentation john = johnResource.toRepresentation(true); + + assertProfileAttributes(john, null, true, "username", "email", "firstName", "lastName", "postal_code"); + assertProfileAttributes(john, USER_METADATA_GROUP, true, LDAPConstants.LDAP_ID, LDAPConstants.LDAP_ENTRY_DN); + + // Test Update profile. Fields are read only + john.getRequiredActions().add(UserModel.RequiredAction.UPDATE_PROFILE.toString()); + johnResource.update(john); + + loginPage.open(); + loginPage.login("johnkeycloak", "Password1"); + updateProfilePage.assertCurrent(); + Assert.assertEquals("John", updateProfilePage.getFirstName()); + Assert.assertEquals("Doe", updateProfilePage.getLastName()); + Assert.assertFalse(updateProfilePage.getFieldById("firstName").isEnabled()); + Assert.assertFalse(updateProfilePage.getFieldById("lastName").isEnabled()); + Assert.assertNull(updateProfilePage.getFieldById("postal_code")); + updateProfilePage.prepareUpdate().submit(); + } finally { + setLDAPWritable(); + } + + } + + @Test + public void testUserProfileWithReadOnlyLdapLocalUser() { + // Test local user is writable and has only attributes defined explicitly in user-profile + setLDAPReadOnly(); + try { + UserResource maryResource = ApiUtil.findUserByUsernameId(testRealm(), "marykeycloak"); + UserRepresentation mary = maryResource.toRepresentation(true); + + // LDAP is read-only, but local user has all the attributes writable + assertProfileAttributes(mary, null, false, "username", "email", "firstName", "lastName"); + assertProfileAttributesNotPresent(mary, "postal_code", LDAPConstants.LDAP_ID, LDAPConstants.LDAP_ENTRY_DN); + + // Test Update profile + mary.getRequiredActions().add(UserModel.RequiredAction.UPDATE_PROFILE.toString()); + maryResource.update(mary); + + loginPage.open(); + loginPage.login("marykeycloak", "Password1"); + updateProfilePage.assertCurrent(); + Assert.assertEquals("Mary", updateProfilePage.getFirstName()); + Assert.assertEquals("Kelly", updateProfilePage.getLastName()); + Assert.assertTrue(updateProfilePage.getFieldById("firstName").isEnabled()); + Assert.assertTrue(updateProfilePage.getFieldById("lastName").isEnabled()); + Assert.assertNull(updateProfilePage.getFieldById("postal_code")); + updateProfilePage.prepareUpdate().submit(); + } finally { + setLDAPWritable(); + } + } + + @Test + public void testUserProfileWithoutImport() { + setLDAPImportDisabled(); + try { + // Test local user is writable and has only attributes defined explicitly in user-profile + // Test user profile of user johnkeycloak in admin API + UserResource johnResource = ApiUtil.findUserByUsernameId(testRealm(), "johnkeycloak2"); + UserRepresentation john = johnResource.toRepresentation(true); + + assertUser(john, "johnkeycloak2", "john2@email.org", "John", "Doe", "1234"); + assertProfileAttributes(john, null, false, "username", "email", "firstName", "lastName", "postal_code"); + assertProfileAttributes(john, USER_METADATA_GROUP, true, LDAPConstants.LDAP_ID, LDAPConstants.LDAP_ENTRY_DN); + } finally { + setLDAPImportEnabled(); + } + } + + private void setLDAPReadOnly() { + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + + ctx.getLdapModel().getConfig().putSingle(LDAPConstants.EDIT_MODE, UserStorageProvider.EditMode.READ_ONLY.toString()); + appRealm.updateComponent(ctx.getLdapModel()); + }); + } + + private void setLDAPWritable() { + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + + ctx.getLdapModel().getConfig().putSingle(LDAPConstants.EDIT_MODE, UserStorageProvider.EditMode.WRITABLE.toString()); + appRealm.updateComponent(ctx.getLdapModel()); + }); + } + + private void setLDAPImportDisabled() { + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + + ctx.getLdapModel().getConfig().putSingle(IMPORT_ENABLED, "false"); + appRealm.updateComponent(ctx.getLdapModel()); + }); + } + + private void setLDAPImportEnabled() { + testingClient.server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + + ctx.getLdapModel().getConfig().putSingle(IMPORT_ENABLED, "true"); + appRealm.updateComponent(ctx.getLdapModel()); + }); + } + + private void assertUser(UserRepresentation user, String expectedUsername, String expectedEmail, String expectedFirstName, String expectedLastname, String expectedPostalCode) { + Assert.assertNotNull(user); + Assert.assertEquals(expectedUsername, user.getUsername()); + Assert.assertEquals(expectedFirstName, user.getFirstName()); + Assert.assertEquals(expectedLastname, user.getLastName()); + Assert.assertEquals(expectedEmail, user.getEmail()); + Assert.assertEquals(expectedPostalCode, user.getAttributes().get("postal_code").get(0)); + + Assert.assertNotNull(user.getAttributes().get(LDAPConstants.LDAP_ID)); + Assert.assertNotNull(user.getAttributes().get(LDAPConstants.LDAP_ENTRY_DN)); + } + + + private void assertProfileAttributes(UserRepresentation user, String expectedGroup, boolean expectReadOnly, String... attributes) { + for (String attrName : attributes) { + UserProfileAttributeMetadata attrMetadata = user.getUserProfileMetadata().getAttributeMetadata(attrName); + Assert.assertNotNull("Attribute " + attrName + " was not present for user " + user.getUsername(), attrMetadata); + Assert.assertEquals("Attribute " + attrName + " for user " + user.getUsername() + ". Expected read-only: " + expectReadOnly + " but was not", expectReadOnly, attrMetadata.isReadOnly()); + Assert.assertEquals("Attribute " + attrName + " for user " + user.getUsername() + ". Expected group: " + expectedGroup + " but was " + attrMetadata.getGroup(), expectedGroup, attrMetadata.getGroup()); + } + } + + private void assertProfileAttributesNotPresent(UserRepresentation user, String... attributes) { + for (String attrName : attributes) { + UserProfileAttributeMetadata attrMetadata = user.getUserProfileMetadata().getAttributeMetadata(attrName); + Assert.assertNull("Attribute " + attrName + " was present for user " + user.getUsername(), attrMetadata); + } + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index 0e30fe7fb7..f46641d0a0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -42,8 +42,10 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Predicate; import org.junit.Assert; +import org.junit.ClassRule; import org.junit.Test; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.component.ComponentModel; @@ -57,7 +59,14 @@ import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.messages.Messages; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.mappers.LDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper; +import org.keycloak.testsuite.federation.ldap.LDAPTestContext; import org.keycloak.testsuite.runonserver.RunOnServer; +import org.keycloak.testsuite.util.LDAPRule; +import org.keycloak.testsuite.util.LDAPTestUtils; import org.keycloak.userprofile.AttributeGroupMetadata; import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPAttributePermissions; @@ -86,6 +95,9 @@ public class UserProfileTest extends AbstractUserProfileTest { protected static final String ATT_ADDRESS = "address"; + @ClassRule + public static LDAPRule ldapRule = new LDAPRule(); + @Override public void configureTestRealm(RealmRepresentation testRealm) { super.configureTestRealm(testRealm);