Allow permission configuration for username and email in user profile.

Enhanced Account API to respect access to these attributes.

Resolves #12599
This commit is contained in:
Joerg Matysiak 2022-05-24 16:59:38 +02:00 committed by Pedro Igor
parent e47bbba7ef
commit 62790b8ce0
8 changed files with 234 additions and 20 deletions

View file

@ -45,10 +45,10 @@ public final class AttributeMetadata {
private String attributeDisplayName;
private AttributeGroupMetadata attributeGroupMetadata;
private final Predicate<AttributeContext> selector;
private final Predicate<AttributeContext> writeAllowed;
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 final Predicate<AttributeContext> required;
private final Predicate<AttributeContext> readAllowed;
private final List<Predicate<AttributeContext>> readAllowed = new ArrayList<>();
private List<AttributeValidatorMetadata> validators;
private Map<String, Object> annotations;
private int guiOrder;
@ -93,11 +93,22 @@ public final class AttributeMetadata {
Predicate<AttributeContext> required,
Predicate<AttributeContext> readAllowed) {
this.attributeName = attributeName;
this.selector = selector;
this.writeAllowed = writeAllowed;
this.required = required;
this.readAllowed = readAllowed;
this.guiOrder = guiOrder;
this.selector = selector;
addWriteCondition(writeAllowed);
this.required = required;
addReadCondition(readAllowed);
}
AttributeMetadata(String attributeName, int guiOrder, Predicate<AttributeContext> selector, List<Predicate<AttributeContext>> writeAllowed,
Predicate<AttributeContext> required,
List<Predicate<AttributeContext>> readAllowed) {
this.attributeName = attributeName;
this.guiOrder = guiOrder;
this.selector = selector;
this.writeAllowed.addAll(writeAllowed);
this.required = required;
this.readAllowed.addAll(readAllowed);
}
public String getName() {
@ -121,16 +132,29 @@ public final class AttributeMetadata {
return selector.test(context);
}
private boolean allConditionsMet(List<Predicate<AttributeContext>> predicates, AttributeContext context) {
return predicates.stream().allMatch(p -> p.test(context));
}
public AttributeMetadata addReadCondition(Predicate<AttributeContext> readAllowed) {
this.readAllowed.add(readAllowed);
return this;
}
public AttributeMetadata addWriteCondition(Predicate<AttributeContext> 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);
}
/**

View file

@ -277,6 +277,7 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
}
Map<String, UPGroup> groupsByName = asHashMap(parsedConfig.getGroups());
RealmModel realm = session.getContext().getRealm();
int guiOrder = 0;
for (UPAttribute attrConfig : parsedConfig.getAttributes()) {
@ -343,10 +344,19 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
// 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<AttributeMetadata> 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 @@ public class DeclarativeUserProfileProvider extends AbstractUserProfileProvider<
.setAttributeGroupMetadata(groupMetadata);
} else {
final int localGuiOrder = guiOrder++;
// only add configured validators and annotations if attribute metadata exist
Predicate<AttributeContext> readAllowedFinal = readAllowed;
Predicate<AttributeContext> 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

View file

@ -132,6 +132,14 @@ public class VerifyProfilePage extends AbstractPage {
}
}
public boolean isUsernameEnabled() {
try {
return driver.findElement(By.id("username")).isEnabled();
} catch (NoSuchElementException nse) {
return false;
}
}
public boolean isDepartmentPresent() {
try {
isDepartmentEnabled();

View file

@ -66,7 +66,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
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 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
+ "{\"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 class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
}
}
@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

View file

@ -532,6 +532,63 @@ public class VerifyProfileTest extends AbstractTestRealmKeycloakTest {
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() {

View file

@ -535,6 +535,85 @@ public class UserProfileTest extends AbstractUserProfileTest {
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<String, Object> attributes = new HashMap<>();
attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId());
attributes.put(UserModel.EMAIL, "readonly@foo.bar");
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<String> attributesUpdated = new HashSet<>();
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
attributes.put(UserModel.EMAIL, "cannot-change@foo.bar");
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", "readonly@foo.bar", user.getEmail());
}
@Test
public void testUpdateEmail() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testUpdateEmail);
}
private static void testUpdateEmail(KeycloakSession session) {
Map<String, Object> attributes = new HashMap<>();
attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId());
attributes.put(UserModel.EMAIL, "canchange@foo.bar");
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<String> attributesUpdated = new HashSet<>();
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
attributes.put("email", "changed@foo.bar");
profile = provider.create(UserProfileContext.ACCOUNT, attributes, user);
profile.update();
assertEquals("E-Mail address should have been changed!", "changed@foo.bar", user.getEmail());
}
@Test
public void testDoNotUpdateUndefinedAttributes() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testDoNotUpdateUndefinedAttributes);

View file

@ -174,7 +174,7 @@
<input type="hidden" ui-select2="isRequiredScopes" id="isRequiredScopes" data-ng-model="requiredScopes" data-placeholder="Select a scope..." multiple/>
</div>
</div>
<fieldset class="border-top" data-ng-show="isNotUsernameOrEmail(currentAttribute.name)">
<fieldset class="border-top">
<legend collapsed><span class="text">{{:: 'user.profile.attribute.permission' | translate}}</span></legend>
<div class="form-group">
<label class="col-md-2 control-label" for="canUserView">{{:: 'user.profile.attribute.canUserView' | translate}}</label>

View file

@ -180,7 +180,7 @@ export class AccountPage extends React.Component<AccountPageProps, AccountPageSt
onSubmit={(event) => this.handleSubmit(event)}
className="personal-info-form"
>
{!this.isRegistrationEmailAsUsername && (
{!this.isRegistrationEmailAsUsername && fields.username != undefined && (
<FormGroup
label={Msg.localize("username")}
fieldId="user-name"