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:
parent
e47bbba7ef
commit
62790b8ce0
8 changed files with 234 additions and 20 deletions
|
@ -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() {
|
||||
|
@ -116,21 +127,34 @@ public final class AttributeMetadata {
|
|||
public AttributeGroupMetadata getAttributeGroupMetadata() {
|
||||
return attributeGroupMetadata;
|
||||
}
|
||||
|
||||
|
||||
public boolean isSelected(AttributeContext context) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue