From 2a4e6f294470184af0de07618501cfc78e1a6367 Mon Sep 17 00:00:00 2001
From: Joerg Matysiak
Date: Tue, 24 May 2022 16:59:38 +0200
Subject: [PATCH] Allow permission configuration for username and email in user
profile. Enhanced Account API to respect access to these attributes.
Resolves #12599
---
.../userprofile/AttributeMetadata.java | 40 ++++++++--
.../DeclarativeUserProfileProvider.java | 25 ++++--
.../testsuite/pages/VerifyProfilePage.java | 8 ++
...AccountRestServiceWithUserProfileTest.java | 37 ++++++++-
.../testsuite/forms/VerifyProfileTest.java | 57 +++++++++++++
.../user/profile/UserProfileTest.java | 79 +++++++++++++++++++
.../partials/realm-user-profile.html | 2 +-
.../app/content/account-page/AccountPage.tsx | 2 +-
8 files changed, 232 insertions(+), 18 deletions(-)
diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java
index b4717024ddef..32761201414f 100644
--- a/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java
+++ b/server-spi-private/src/main/java/org/keycloak/userprofile/AttributeMetadata.java
@@ -45,10 +45,10 @@ public final class AttributeMetadata {
private String attributeDisplayName;
private AttributeGroupMetadata attributeGroupMetadata;
private final Predicate selector;
- private final Predicate writeAllowed;
+ private final List> writeAllowed = new ArrayList<>();
/** Predicate to decide if attribute is required, it is handled as required if predicate is null */
private final Predicate required;
- private final Predicate readAllowed;
+ private final List> readAllowed = new ArrayList<>();
private List validators;
private Map annotations;
private int guiOrder;
@@ -93,11 +93,22 @@ public final class AttributeMetadata {
Predicate required,
Predicate readAllowed) {
this.attributeName = attributeName;
+ this.guiOrder = guiOrder;
this.selector = selector;
- this.writeAllowed = writeAllowed;
+ addWriteCondition(writeAllowed);
this.required = required;
- this.readAllowed = readAllowed;
+ addReadCondition(readAllowed);
+ }
+
+ AttributeMetadata(String attributeName, int guiOrder, Predicate selector, List> writeAllowed,
+ Predicate required,
+ List> readAllowed) {
+ this.attributeName = attributeName;
this.guiOrder = guiOrder;
+ this.selector = selector;
+ this.writeAllowed.addAll(writeAllowed);
+ this.required = required;
+ this.readAllowed.addAll(readAllowed);
}
public String getName() {
@@ -116,21 +127,34 @@ public AttributeMetadata setGuiOrder(int guiOrder) {
public AttributeGroupMetadata getAttributeGroupMetadata() {
return attributeGroupMetadata;
}
-
+
public boolean isSelected(AttributeContext context) {
return selector.test(context);
}
+ private boolean allConditionsMet(List> predicates, AttributeContext context) {
+ return predicates.stream().allMatch(p -> p.test(context));
+ }
+
+ public AttributeMetadata addReadCondition(Predicate readAllowed) {
+ this.readAllowed.add(readAllowed);
+ return this;
+ }
+
+ public AttributeMetadata addWriteCondition(Predicate writeAllowed) {
+ this.writeAllowed.add(writeAllowed);
+ return this;
+ }
public boolean isReadOnly(AttributeContext context) {
- return !writeAllowed.test(context);
+ return !canEdit(context);
}
public boolean canView(AttributeContext context) {
- return readAllowed.test(context);
+ return allConditionsMet(readAllowed, context);
}
public boolean canEdit(AttributeContext context) {
- return writeAllowed.test(context);
+ return allConditionsMet(writeAllowed, context);
}
/**
diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java
index c53323912168..156ef7e4a425 100644
--- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java
+++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java
@@ -277,6 +277,7 @@ protected UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata de
}
Map groupsByName = asHashMap(parsedConfig.getGroups());
+ RealmModel realm = session.getContext().getRealm();
int guiOrder = 0;
for (UPAttribute attrConfig : parsedConfig.getAttributes()) {
@@ -343,10 +344,19 @@ protected UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata de
// make sure username and email are writable if permissions are not set
if (permissions == null || permissions.isEmpty()) {
writeAllowed = AttributeMetadata.ALWAYS_TRUE;
+ readAllowed = AttributeMetadata.ALWAYS_TRUE;
}
List atts = decoratedMetadata.getAttribute(attributeName);
+ // Add ImmutableAttributeValidator to ensure that attributes that are configured
+ // as read-only are marked as such.
+ // Skip this for username in realms with username = email to allow change of email
+ // address on initial login with profile via idp
+ if (!realm.isRegistrationEmailAsUsername() || !UserModel.USERNAME.equals(attributeName)) {
+ validators.add(new AttributeValidatorMetadata(ImmutableAttributeValidator.ID));
+ }
+
if (atts.isEmpty()) {
// attribute metadata doesn't exist so we have to add it. We keep it optional as Abstract base
// doesn't require it.
@@ -356,12 +366,17 @@ protected UserProfileMetadata decorateUserProfileForCache(UserProfileMetadata de
.setAttributeGroupMetadata(groupMetadata);
} else {
final int localGuiOrder = guiOrder++;
- // only add configured validators and annotations if attribute metadata exist
+ Predicate readAllowedFinal = readAllowed;
+ Predicate writeAllowedFinal = writeAllowed;
+
+ // add configured validators and annotations to existing attribute metadata
atts.stream().forEach(c -> c.addValidator(validators)
- .addAnnotations(annotations)
- .setAttributeDisplayName(attrConfig.getDisplayName())
- .setGuiOrder(localGuiOrder)
- .setAttributeGroupMetadata(groupMetadata));
+ .addAnnotations(annotations)
+ .setAttributeDisplayName(attrConfig.getDisplayName())
+ .setGuiOrder(localGuiOrder)
+ .setAttributeGroupMetadata(groupMetadata)
+ .addReadCondition(readAllowedFinal)
+ .addWriteCondition(writeAllowedFinal));
}
} else {
// always add validation for immutable/read-only attributes
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java
index a916655d53d3..b86e31a4e140 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/VerifyProfilePage.java
@@ -132,6 +132,14 @@ public boolean isUsernamePresent() {
}
}
+ public boolean isUsernameEnabled() {
+ try {
+ return driver.findElement(By.id("username")).isEnabled();
+ } catch (NoSuchElementException nse) {
+ return false;
+ }
+ }
+
public boolean isDepartmentPresent() {
try {
isDepartmentEnabled();
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 a97593be68ae..e303f3febb72 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
@@ -66,7 +66,7 @@ protected boolean isDeclarativeUserProfile() {
return true;
}
- private static String UP_CONFIG_FOR_METADATA = "{\"attributes\": ["
+ private final static String UP_CONFIG_FOR_METADATA = "{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {\"scopes\":[\"profile\"]}, \"displayName\": \"${profile.firstName}\", \"validations\": {\"length\": { \"max\": 255 }}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ALL + ", \"required\": {}, \"displayName\": \"Last name\", \"annotations\": {\"formHintKey\" : \"userEmailFormFieldHint\", \"anotherKey\" : 10, \"yetAnotherKey\" : \"some value\"}},"
+ "{\"name\": \"attr_with_scope_selector\"," + PERMISSIONS_ALL + ", \"selector\": {\"scopes\": [\"profile\"]}},"
@@ -78,20 +78,26 @@ protected boolean isDeclarativeUserProfile() {
+ "{\"name\": \"attr_no_permission\"," + PERMISSIONS_ADMIN_ONLY + "}"
+ "]}";
- private static String UP_CONFIG_NO_ACCESS_TO_NAME_FIELDS = "{\"attributes\": ["
+ private final static String UP_CONFIG_NO_ACCESS_TO_NAME_FIELDS = "{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ADMIN_ONLY + ", \"required\": {}, \"displayName\": \"${profile.firstName}\", \"validations\": {\"length\": { \"max\": 255 }}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ADMIN_ONLY + ", \"required\": {}, \"displayName\": \"Last name\", \"annotations\": {\"formHintKey\" : \"userEmailFormFieldHint\", \"anotherKey\" : 10, \"yetAnotherKey\" : \"some value\"}},"
+ "{\"name\": \"attr_readonly\"," + PERMISSIONS_ADMIN_EDITABLE + "},"
+ "{\"name\": \"attr_no_permission\"," + PERMISSIONS_ADMIN_ONLY + "}"
+ "]}";
- private static String UP_CONFIG_RO_ACCESS_TO_NAME_FIELDS = "{\"attributes\": ["
+ private final static String UP_CONFIG_RO_ACCESS_TO_NAME_FIELDS = "{\"attributes\": ["
+ "{\"name\": \"firstName\"," + PERMISSIONS_ADMIN_EDITABLE + ", \"required\": {}, \"displayName\": \"${profile.firstName}\", \"validations\": {\"length\": { \"max\": 255 }}},"
+ "{\"name\": \"lastName\"," + PERMISSIONS_ADMIN_EDITABLE + ", \"required\": {}, \"displayName\": \"Last name\", \"annotations\": {\"formHintKey\" : \"userEmailFormFieldHint\", \"anotherKey\" : 10, \"yetAnotherKey\" : \"some value\"}},"
+ "{\"name\": \"attr_readonly\"," + PERMISSIONS_ADMIN_EDITABLE + "},"
+ "{\"name\": \"attr_no_permission\"," + PERMISSIONS_ADMIN_ONLY + "}"
+ "]}";
+ private final static String UP_CONFIG_RO_USERNAME_AND_EMAIL = "{\"attributes\": ["
+ + "{\"name\": \"email\"," + PERMISSIONS_ADMIN_EDITABLE + ", \"required\": {}, \"displayName\": \"${email}\", \"annotations\": {\"formHintKey\" : \"userEmailFormFieldHint\", \"anotherKey\" : 10, \"yetAnotherKey\" : \"some value\"}},"
+ + "{\"name\": \"attr_readonly\"," + PERMISSIONS_ADMIN_EDITABLE + "},"
+ + "{\"name\": \"attr_no_permission\"," + PERMISSIONS_ADMIN_ONLY + "}"
+ + "]}";
+
@Test
@Override
@@ -187,6 +193,31 @@ public void testGetUserProfileMetadata_RoAccessToNameFields() throws IOException
}
}
+ @Test
+ public void testGetUserProfileMetadata_RoAccessToUsernameAndEmail() throws IOException {
+
+ try {
+ RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
+ realmRep.setEditUsernameAllowed(false);
+ adminClient.realm("test").update(realmRep);
+
+ setUserProfileConfiguration(UP_CONFIG_RO_USERNAME_AND_EMAIL);
+
+ UserRepresentation user = getUser();
+ assertNotNull(user.getUserProfileMetadata());
+
+ assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
+ assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
+
+ assertUserProfileAttributeMetadata(user, "attr_readonly", "attr_readonly", false, true);
+ assertNull(getUserProfileAttributeMetadata(user, "attr_no_permission"));
+ } finally {
+ RealmRepresentation realmRep = testRealm().toRepresentation();
+ realmRep.setEditUsernameAllowed(true);
+ testRealm().update(realmRep);
+ }
+ }
+
@Test
@Override
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java
index ca4aca76387c..2b78eaf2ea42 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/VerifyProfileTest.java
@@ -532,6 +532,63 @@ public void testRequiredReadOnlyAttribute() {
assertEquals("Last", user.getLastName());
}
+ @Test
+ public void testAdminOnlyAttributeNotVisibleToUser() {
+
+ setUserProfileConfiguration("{\"attributes\": ["
+ + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
+ + "{\"name\": \"department\"," + PERMISSIONS_ADMIN_ONLY + "},"
+ + "{\"name\": \"requiredAttrToTriggerVerifyPage\"," + PERMISSIONS_ALL + ", \"required\": {}}"
+ + "]}");
+
+ loginPage.open();
+ loginPage.login("login-test6", "password");
+
+ verifyProfilePage.assertCurrent();
+ Assert.assertEquals("ExistingLast", verifyProfilePage.getLastName());
+ Assert.assertFalse("Admin-only attribute should not be visible for user", verifyProfilePage.isDepartmentPresent());
+ }
+
+
+ @Test
+ public void testUsernameReadOnlyInProfile() {
+
+ setUserProfileConfiguration("{\"attributes\": ["
+ + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
+ + "{\"name\": \"username\"," + PERMISSIONS_ADMIN_EDITABLE + "},"
+ + "{\"name\": \"requiredAttrToTriggerVerifyPage\"," + PERMISSIONS_ALL + ", \"required\": {}}"
+ + "]}");
+
+ loginPage.open();
+ loginPage.login("login-test6", "password");
+
+ verifyProfilePage.assertCurrent();
+ Assert.assertEquals("ExistingLast", verifyProfilePage.getLastName());
+
+ Assert.assertFalse("username should not be editable by user", verifyProfilePage.isUsernameEnabled());
+ }
+
+ @Test
+ public void testUsernameReadNotVisibleInProfile() {
+
+ setUserProfileConfiguration("{\"attributes\": ["
+ + "{\"name\": \"firstName\"," + PERMISSIONS_ALL + ", \"required\": {}},"
+ + "{\"name\": \"lastName\"," + PERMISSIONS_ALL + "},"
+ + "{\"name\": \"username\"," + PERMISSIONS_ADMIN_ONLY + "},"
+ + "{\"name\": \"requiredAttrToTriggerVerifyPage\"," + PERMISSIONS_ALL + ", \"required\": {}}"
+ + "]}");
+
+ loginPage.open();
+ loginPage.login("login-test6", "password");
+
+ verifyProfilePage.assertCurrent();
+ Assert.assertEquals("ExistingLast", verifyProfilePage.getLastName());
+
+ Assert.assertFalse("username should not be shown to user", verifyProfilePage.isUsernamePresent());
+ }
+
@Test
public void testAttributeNotVisible() {
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 bed6fb1355f3..7e1ac36f6d7a 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
@@ -535,6 +535,85 @@ private static void testReadonlyUpdates(KeycloakSession session) {
assertTrue(profile.getAttributes().isReadOnly("department"));
}
+ @Test
+ public void testReadonlyEmailCannotBeUpdated() {
+ getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadonlyEmailCannotBeUpdated);
+ }
+
+ private static void testReadonlyEmailCannotBeUpdated(KeycloakSession session) {
+ Map attributes = new HashMap<>();
+
+ attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId());
+ attributes.put(UserModel.EMAIL, "[email protected]");
+
+ UserProfileProvider provider = getDynamicUserProfileProvider(session);
+
+ // configure email r/o for user
+ provider.setConfiguration("{\"attributes\": [{\"name\": \"email\", \"permissions\": {\"edit\": [ \"admin\"]}}]}");
+
+ UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes);
+ UserModel user = profile.create();
+
+ assertThat(profile.getAttributes().nameSet(),
+ containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL));
+
+ profile = provider.create(UserProfileContext.USER_API, attributes, user);
+
+ Set attributesUpdated = new HashSet<>();
+
+ profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
+
+ attributes.put(UserModel.EMAIL, "[email protected]");
+
+ profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
+
+ try {
+ profile.update();
+ fail("Should fail since email is read only");
+ } catch (ValidationException ve) {
+ assertTrue(ve.isAttributeOnError("email"));
+ }
+
+ assertEquals("E-Mail address shouldn't be changed", "[email protected]", user.getEmail());
+ }
+
+ @Test
+ public void testUpdateEmail() {
+ getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testUpdateEmail);
+ }
+
+ private static void testUpdateEmail(KeycloakSession session) {
+ Map attributes = new HashMap<>();
+
+ attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId());
+ attributes.put(UserModel.EMAIL, "[email protected]");
+
+ UserProfileProvider provider = getDynamicUserProfileProvider(session);
+
+ // configure email r/w for user
+ provider.setConfiguration("{\"attributes\": [{\"name\": \"email\", \"permissions\": {\"edit\": [ \"user\", \"admin\"]}}]}");
+
+ UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes);
+ UserModel user = profile.create();
+
+ assertThat(profile.getAttributes().nameSet(),
+ containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL));
+
+ profile = provider.create(UserProfileContext.USER_API, attributes, user);
+
+ Set attributesUpdated = new HashSet<>();
+
+ profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
+
+ attributes.put("email", "[email protected]");
+
+ profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
+
+ profile.update();
+
+ assertEquals("E-Mail address should have been changed!", "[email protected]", user.getEmail());
+ }
+
@Test
public void testDoNotUpdateUndefinedAttributes() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testDoNotUpdateUndefinedAttributes);
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html
index a8c9b03f5d20..df56951743bf 100755
--- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-user-profile.html
@@ -174,7 +174,7 @@
-