> getUnmanagedAttributes();
+
+ /**
+ * Returns the annotations for an attribute with the given {@code name}.
+ *
+ *
The annotations returned by this method might differ from those returned directly from
+ * the {@link AttributeMetadata#getAnnotations()} if the implementation supports annotations
+ * being resolved dynamically based on contextual data. See {@link AttributeMetadata#setAnnotationDecorator(Function)}.
+ *
+ * @param name the name of the attribute
+ * @return the annotations
+ */
+ default Map getAnnotations(String name) {
+ AttributeMetadata metadata = getMetadata(name);
+
+ if (metadata == null) {
+ return Collections.emptyMap();
+ }
+
+ return metadata.getAnnotations();
+ }
}
diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java
index 86323fbc4ce..4a20a8039c9 100644
--- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java
+++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java
@@ -28,6 +28,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -42,13 +43,14 @@ import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
-import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.UserModel;
import org.keycloak.organization.validator.OrganizationMemberValidator;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
+import org.keycloak.representations.userprofile.config.UPAttribute;
+import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.messages.Messages;
import org.keycloak.userprofile.config.UPConfigUtils;
@@ -57,7 +59,6 @@ import org.keycloak.userprofile.validator.BrokeringFederatedUsernameHasValueVali
import org.keycloak.userprofile.validator.DuplicateEmailValidator;
import org.keycloak.userprofile.validator.DuplicateUsernameValidator;
import org.keycloak.userprofile.validator.EmailExistsAsUsernameValidator;
-import org.keycloak.userprofile.validator.ImmutableAttributeValidator;
import org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator;
import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameEmailValueValidator;
import org.keycloak.userprofile.validator.RegistrationEmailAsUsernameUsernameValueValidator;
@@ -67,6 +68,7 @@ import org.keycloak.userprofile.validator.UsernameMutationValidator;
import org.keycloak.validate.ValidatorConfig;
import org.keycloak.validate.validators.EmailValidator;
+import static java.util.Optional.ofNullable;
import static org.keycloak.common.util.ObjectUtil.isBlank;
import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY;
import static org.keycloak.userprofile.UserProfileContext.ACCOUNT;
@@ -412,7 +414,8 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
new AttributeValidatorMetadata(DuplicateEmailValidator.ID),
new AttributeValidatorMetadata(EmailExistsAsUsernameValidator.ID),
new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()))
- .setAttributeDisplayName("${email}");
+ .setAttributeDisplayName("${email}")
+ .setAnnotationDecorator(DeclarativeUserProfileProviderFactory::getEmailAnnotationDecorator);
List readonlyValidators = new ArrayList<>();
@@ -485,7 +488,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
// The user-defined configuration is always parsed during init and should be avoided as much as possible
// If no user-defined configuration is set, the system default configuration must have been set
// In Quarkus, the system default configuration is set at build time for optimization purposes
- UPConfig defaultConfig = Optional.ofNullable(config.get("configFile"))
+ UPConfig defaultConfig = ofNullable(config.get("configFile"))
.map(Paths::get)
.map(UPConfigUtils::parseConfig)
.orElse(PARSED_DEFAULT_RAW_CONFIG);
@@ -498,4 +501,36 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
PARSED_DEFAULT_RAW_CONFIG = null;
setDefaultConfig(defaultConfig);
}
+
+ private static Map getEmailAnnotationDecorator(AttributeContext c) {
+ AttributeMetadata m = c.getMetadata();
+ Map rawAnnotations = Optional.ofNullable(m.getAnnotations()).orElse(Map.of());
+
+ if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
+ UserProfileProvider provider = c.getSession().getProvider(UserProfileProvider.class);
+ UPConfig upConfig = provider.getConfiguration();
+ UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL);
+ UPAttributePermissions permissions = attribute.getPermissions();
+
+ if (permissions == null) {
+ return rawAnnotations;
+ }
+
+ Set writePermissions = permissions.getEdit();
+ boolean isWritable = writePermissions.contains(UPConfigUtils.ROLE_USER);
+ RealmModel realm = c.getSession().getContext().getRealm();
+
+ if ((realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) || !isWritable) {
+ return rawAnnotations;
+ }
+
+ Map annotations = new HashMap<>(rawAnnotations);
+
+ annotations.put("kc.required.action.supported", isWritable);
+
+ return annotations;
+ }
+
+ return rawAnnotations;
+ }
}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java
index 0d4287c0f1f..5db6912239e 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java
@@ -16,6 +16,9 @@
*/
package org.keycloak.testsuite.account;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull;
@@ -25,6 +28,8 @@ import static org.keycloak.testsuite.account.AccountRestServiceTest.getUserProfi
import static org.keycloak.testsuite.util.userprofile.UserProfileUtil.PERMISSIONS_ALL;
import static org.keycloak.testsuite.util.userprofile.UserProfileUtil.PERMISSIONS_ADMIN_EDITABLE;
import static org.keycloak.testsuite.util.userprofile.UserProfileUtil.PERMISSIONS_ADMIN_ONLY;
+import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_ADMIN;
+import static org.keycloak.userprofile.config.UPConfigUtils.ROLE_USER;
import java.io.IOException;
import java.util.Collections;
@@ -32,10 +37,13 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import org.junit.Before;
import org.junit.Test;
+import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.broker.provider.util.SimpleHttp;
+import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.UserModel;
@@ -43,6 +51,10 @@ import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.userprofile.config.UPAttribute;
+import org.keycloak.representations.userprofile.config.UPAttributePermissions;
+import org.keycloak.representations.userprofile.config.UPConfig;
+import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.util.userprofile.UserProfileUtil;
import org.keycloak.userprofile.UserProfileContext;
@@ -158,6 +170,33 @@ public class AccountRestServiceWithUserProfileTest extends AbstractRestServiceTe
}
}
+ @EnableFeature(value = Profile.Feature.UPDATE_EMAIL, skipRestart = true)
+ @Test
+ public void testUpdateEmailLink() throws Exception {
+ RealmResource realm = adminClient.realm("test");
+ RealmRepresentation realmRep = realm.toRepresentation();
+
+ try {
+ realmRep.setEditUsernameAllowed(false);
+ realm.update(realmRep);
+
+ UserRepresentation user = getUser();
+ assertNotNull(user.getUserProfileMetadata());
+ assertThat(user.getUserProfileMetadata().getAttributeMetadata(UserModel.EMAIL).getAnnotations().get("kc.required.action.supported"), is(true));
+
+ UPConfig upConfig = realm.users().userProfile().getConfiguration();
+ UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL);
+ attribute.setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN)));
+ realm.users().userProfile().update(upConfig);
+ user = getUser();
+ assertNotNull(user.getUserProfileMetadata());
+ assertThat(user.getUserProfileMetadata().getAttributeMetadata(UserModel.EMAIL).getAnnotations().get("kc.required.action.supported"), is(nullValue()));
+ } finally {
+ realmRep.setEditUsernameAllowed(true);
+ realm.update(realmRep);
+ }
+ }
+
@Test
public void testGetUserProfileMetadata_RoAccessToNameFields() throws IOException {
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 3e6d6a8aab5..aeec98ff209 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
@@ -19,8 +19,10 @@
package org.keycloak.testsuite.user.profile;
+import static java.util.Optional.ofNullable;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
@@ -2311,6 +2313,77 @@ public class UserProfileTest extends AbstractUserProfileTest {
}
}
+ @EnableFeature(Feature.UPDATE_EMAIL)
+ @Test
+ public void testEmailAnnotationsInAccountContext() {
+ getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testEmailAnnotationsInAccountContext);
+ }
+
+ private static void testEmailAnnotationsInAccountContext(KeycloakSession session) {
+ UserProfileProvider provider = getUserProfileProvider(session);
+ String userName = org.keycloak.models.utils.KeycloakModelUtils.generateId();
+ Map attributes = new HashMap<>();
+
+ attributes.put(UserModel.USERNAME, userName);
+ String originalEmail = userName + "@keycloak.org";
+ attributes.put(UserModel.EMAIL, originalEmail);
+ attributes.put(UserModel.FIRST_NAME, "Joe");
+ attributes.put(UserModel.LAST_NAME, "Doe");
+ attributes.put("address", "some address");
+
+ UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
+ UserModel user = profile.create();
+ RealmModel realm = session.getContext().getRealm();
+
+ try {
+ realm.setEditUsernameAllowed(false);
+ realm.setRegistrationEmailAsUsername(true);
+ profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
+ assertFalse(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).containsKey("kc.required.action.supported"));
+ } finally {
+ realm.setEditUsernameAllowed(true);
+ realm.setRegistrationEmailAsUsername(false);
+ }
+
+ try {
+ realm.setEditUsernameAllowed(true);
+ realm.setRegistrationEmailAsUsername(true);
+ profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
+ assertThat(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).get("kc.required.action.supported"), is(true));
+ } finally {
+ realm.setEditUsernameAllowed(true);
+ realm.setRegistrationEmailAsUsername(false);
+ }
+
+ try {
+ realm.setEditUsernameAllowed(false);
+ realm.setRegistrationEmailAsUsername(false);
+ UPConfig upConfig = provider.getConfiguration();
+ UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL);
+ attribute.setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN)));
+ provider.setConfiguration(upConfig);
+ profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
+ assertFalse(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).containsKey("kc.required.action.supported"));
+ } finally {
+ realm.setEditUsernameAllowed(true);
+ realm.setRegistrationEmailAsUsername(false);
+ }
+
+ try {
+ realm.setEditUsernameAllowed(false);
+ realm.setRegistrationEmailAsUsername(false);
+ UPConfig upConfig = provider.getConfiguration();
+ UPAttribute attribute = upConfig.getAttribute(UserModel.EMAIL);
+ attribute.setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN, ROLE_USER)));
+ provider.setConfiguration(upConfig);
+ profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
+ assertThat(ofNullable(profile.getAttributes().getAnnotations(UserModel.EMAIL)).orElse(Map.of()).get("kc.required.action.supported"), is(true));
+ } finally {
+ realm.setEditUsernameAllowed(true);
+ realm.setRegistrationEmailAsUsername(false);
+ }
+ }
+
@Test
public void testMultivalued() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testMultivalued);