User profile configuration scoped to user-federation provider

closes #23878

Co-Authored-By: mposolda <mposolda@gmail.com>

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Pedro Igor 2023-10-11 17:16:13 -03:00 committed by Marek Posolda
parent 040124bfa7
commit 2c611cb8fc
18 changed files with 564 additions and 6 deletions

View file

@ -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<AttributeContext> 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<AttributeContext> 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<String> 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<String> 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);
}
}

View file

@ -79,6 +79,11 @@ public abstract class AbstractLDAPStorageMapper implements LDAPStorageMapper {
return null;
}
@Override
public Set<String> getUserAttributes() {
return Collections.emptySet();
}
public static boolean parseBooleanParameter(ComponentModel mapperModel, String paramName) {
String paramm = mapperModel.getConfig().getFirst(paramName);
return Boolean.parseBoolean(paramm);

View file

@ -235,6 +235,11 @@ public class FullNameLDAPStorageMapper extends AbstractLDAPStorageMapper {
query.addWhereCondition(fullNameCondition);
}
@Override
public Set<String> 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;

View file

@ -94,6 +94,13 @@ public interface LDAPStorageMapper extends Provider {
*/
Set<String> 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<String> getUserAttributes();
/**
* Called when invoke proxy on LDAP federation provider
*

View file

@ -151,6 +151,11 @@ public class UserAttributeLDAPStorageMapper extends AbstractLDAPStorageMapper {
return isMandatoryInLdap? Collections.singleton(getLdapAttributeName()) : null;
}
@Override
public Set<String> 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;

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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);
}
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class UserStorageManager extends AbstractStorageManager<UserStorageProvider, UserStorageProviderModel>
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<UserStorageProvid
}
}
}
@Override
public void decorateUserProfile(RealmModel realm, UserProfileMetadata metadata) {
for (UserProfileDecorator decorator : getEnabledStorageProviders(session.getContext().getRealm(), UserProfileDecorator.class)
.collect(Collectors.toList())) {
decorator.decorateUserProfile(realm, metadata);
}
}
}

View file

@ -31,10 +31,12 @@ import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.storage.StorageId;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;
@ -224,7 +226,9 @@ public class DefaultAttributes extends HashMap<String, List<String>> 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);
}
}

View file

@ -296,4 +296,5 @@ public interface UserProvider extends Provider,
* @param component the component model
*/
void preRemove(RealmModel realm, ComponentModel component);
}

View file

@ -43,7 +43,7 @@ public final class AttributeMetadata {
private final String attributeName;
private String attributeDisplayName;
private AttributeGroupMetadata attributeGroupMetadata;
private final Predicate<AttributeContext> selector;
private Predicate<AttributeContext> selector;
private final List<Predicate<AttributeContext>> writeAllowed = new ArrayList<>();
/** Predicate to decide if attribute is required, it is handled as required if predicate is null */
private Predicate<AttributeContext> required;
@ -131,6 +131,10 @@ public final class AttributeMetadata {
return selector.test(context);
}
public void setSelector(Predicate<AttributeContext> selector) {
this.selector = selector;
}
private boolean allConditionsMet(List<Predicate<AttributeContext>> predicates, AttributeContext context) {
return predicates.stream().allMatch(p -> p.test(context));
}

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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);
}

View file

@ -73,6 +73,8 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
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();

View file

@ -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<String, UPGroup> asHashMap(List<UPGroup> groups) {
return groups.stream().collect(Collectors.toMap(g -> g.getName(), g -> g));
}

View file

@ -52,5 +52,12 @@
"person-name-prohibited-characters": {}
}
}
],
"groups": [
{
"name": "user-metadata",
"displayHeader": "User metadata",
"displayDescription": "Attributes, which refer to user metadata"
}
]
}

View file

@ -112,6 +112,14 @@ public class LoginUpdateProfilePage extends AbstractPage {
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 {
isDepartmentEnabled();

View file

@ -201,8 +201,12 @@ public class UserProfileAdminTest extends AbstractAdminTest {
assertEquals(group.getName(), mGroup.getName());
assertEquals(group.getDisplayHeader(), mGroup.getDisplayHeader());
assertEquals(group.getDisplayDescription(), mGroup.getDisplayDescription());
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());
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@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);
}
}
}

View file

@ -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);