Skip to content

Commit

Permalink
[KEYCLOAK-17399] - Declarative User Profile and UI
Browse files Browse the repository at this point in the history
Co-authored-by: Vlastimil Elias <[email protected]>
  • Loading branch information
2 people authored and stianst committed Jun 14, 2021
1 parent d2a8a95 commit ef3a0ee
Show file tree
Hide file tree
Showing 91 changed files with 3,883 additions and 997 deletions.
3 changes: 2 additions & 1 deletion common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ public enum Feature {
WEB_AUTHN(Type.DEFAULT, Type.PREVIEW),
CLIENT_POLICIES(Type.DEFAULT),
CIBA(Type.PREVIEW),
MAP_STORAGE(Type.EXPERIMENTAL);
MAP_STORAGE(Type.EXPERIMENTAL),
DECLARATIVE_USER_PROFILE(Type.PREVIEW);

private final Type typeProject;
private final Type typeProduct;
Expand Down
8 changes: 4 additions & 4 deletions common/src/test/java/org/keycloak/common/ProfileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ public class ProfileTest {
@Test
public void checkDefaultsKeycloak() {
Assert.assertEquals("community", Profile.getName());
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CIBA);
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CIBA, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS);

Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType());
Expand All @@ -37,8 +37,8 @@ public void checkDefaultsRH_SSO() {
Profile.init();

Assert.assertEquals("product", Profile.getName());
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA);
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CIBA, Profile.Feature.DECLARATIVE_USER_PROFILE);
assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS);

Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,35 @@

package org.keycloak.representations.idm;

import java.util.List;

/**
* @author <a href="mailto:[email protected]">Stian Thorgersen</a>
*/
public class ErrorRepresentation {
private String field;
private String errorMessage;
private Object[] params;
private List<ErrorRepresentation> errors;

public ErrorRepresentation() {
}

public ErrorRepresentation(String errorMessage) {
this.errorMessage = errorMessage;
}

public ErrorRepresentation(String field, String errorMessage, Object[] params) {
super();
this.field = field;
this.errorMessage = errorMessage;
this.params = params;
}

public String getField() {
return field;
}

public String getErrorMessage() {
return errorMessage;
}
Expand All @@ -42,4 +61,12 @@ public Object[] getParams() {
public void setParams(Object[] params) {
this.params = params;
}

public void setErrors(List<ErrorRepresentation> errors) {
this.errors = errors;
}

public List<ErrorRepresentation> getErrors() {
return errors;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2021 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.admin.client.resource;

import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

/**
* @author Vlastimil Elias <[email protected]>
*/
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface UserProfileResource {

@GET
@Consumes(MediaType.APPLICATION_JSON)
String getConfiguration();

@PUT
@Produces(MediaType.APPLICATION_JSON)
Response update(String text);
}
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ Integer count(@QueryParam("lastName") String last,
@Path("{id}")
@DELETE
Response delete(@PathParam("id") String id);


@Path("profile")
UserProfileResource userProfile();

}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public enum EventType {
UPDATE_TOTP_ERROR(true),
VERIFY_EMAIL(true),
VERIFY_EMAIL_ERROR(true),
VERIFY_PROFILE(true),
VERIFY_PROFILE_ERROR(true),

REMOVE_TOTP(true),
REMOVE_TOTP_ERROR(true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ public enum LoginFormsPages {
LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL,
OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE,
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM,
LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE;
LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, VERIFY_PROFILE;

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,26 @@ public final class AttributeMetadata {

private final String attributeName;
private final Predicate<AttributeContext> selector;
private final Predicate<AttributeContext> readOnly;
private final Predicate<AttributeContext> writeAllowed;
/** 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 List<AttributeValidatorMetadata> validators;
private Map<String, Object> annotations;

AttributeMetadata(String attributeName) {
this(attributeName, ALWAYS_TRUE, ALWAYS_FALSE, ALWAYS_TRUE);
this(attributeName, ALWAYS_TRUE, ALWAYS_TRUE, ALWAYS_TRUE, ALWAYS_TRUE);
}

AttributeMetadata(String attributeName, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
this(attributeName, ALWAYS_TRUE, readOnly, required);
AttributeMetadata(String attributeName, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required) {
this(attributeName, ALWAYS_TRUE, writeAllowed, required, ALWAYS_TRUE);
}

AttributeMetadata(String attributeName, Predicate<AttributeContext> selector) {
this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE);
this(attributeName, selector, ALWAYS_FALSE, ALWAYS_TRUE, ALWAYS_TRUE);
}

AttributeMetadata(String attributeName, List<String> scopes, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
AttributeMetadata(String attributeName, List<String> scopes, Predicate<AttributeContext> writeAllowed, Predicate<AttributeContext> required) {
this(attributeName, context -> {
KeycloakSession session = context.getSession();
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
Expand All @@ -81,14 +82,17 @@ public final class AttributeMetadata {

return authSession.getClientScopes().stream()
.map(id -> clientScopes.getClientScopeById(realm, id).getName()).anyMatch(scopes::contains);
}, readOnly, required);
}, writeAllowed, required, ALWAYS_TRUE);
}

AttributeMetadata(String attributeName, Predicate<AttributeContext> selector, Predicate<AttributeContext> readOnly, Predicate<AttributeContext> required) {
AttributeMetadata(String attributeName, Predicate<AttributeContext> selector, Predicate<AttributeContext> writeAllowed,
Predicate<AttributeContext> required,
Predicate<AttributeContext> readAllowed) {
this.attributeName = attributeName;
this.selector = selector;
this.readOnly = readOnly;
this.writeAllowed = writeAllowed;
this.required = required;
this.readAllowed = readAllowed;
}

public String getName() {
Expand All @@ -100,10 +104,14 @@ public boolean isSelected(AttributeContext context) {
}

public boolean isReadOnly(AttributeContext context) {
return readOnly.test(context);
return !writeAllowed.test(context);
}

/**
public boolean canView(AttributeContext context) {
return readAllowed.test(context);
}

/**
* Check if attribute is required based on it's predicate, it is handled as required if predicate is null
* @param context to evaluate requirement of the attribute from
* @return true if attribute is required in provided context
Expand Down Expand Up @@ -140,15 +148,15 @@ public AttributeMetadata addAnnotations(Map<String, Object> annotations) {
if(this.annotations == null) {
this.annotations = new HashMap<>();
}

this.annotations.putAll(annotations);
}
return this;
}

@Override
public AttributeMetadata clone() {
AttributeMetadata cloned = new AttributeMetadata(attributeName, selector, readOnly, required);
AttributeMetadata cloned = new AttributeMetadata(attributeName, selector, writeAllowed, required, readAllowed);
// we clone validators list to allow adding or removing validators. Validators
// itself are not cloned as we do not expect them to be reconfigured.
if (validators != null) {
Expand All @@ -160,4 +168,19 @@ public AttributeMetadata clone() {
}
return cloned;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof AttributeMetadata)) return false;

AttributeMetadata that = (AttributeMetadata) o;

return that.getName().equals(getName());
}

@Override
public int hashCode() {
return attributeName.hashCode();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import org.keycloak.models.UserModel;
import org.keycloak.validate.ValidationError;

/**
Expand Down Expand Up @@ -108,4 +110,61 @@ default String getFirstValue(String name) {
* @return the attributes
*/
Set<Map.Entry<String, List<String>>> attributeSet();

/**
* <p>Returns the metadata associated with the attribute with the given {@code name}.
*
* <p>The {@link AttributeMetadata} is a copy of the original metadata. The original metadata
* keeps immutable.
*
* @param name the attribute name
* @return the metadata
*/
AttributeMetadata getMetadata(String name);

/**
* Returns whether the attribute with the given {@code name} is required.
*
* @param name the attribute name
* @return {@code true} if the attribute is required. Otherwise, {@code false}.
*/
boolean isRequired(String name);

/**
* Similar to {{@link #getReadable(boolean)}} but with the possibility to add or remove
* the root attributes.
*
* @param includeBuiltin if the root attributes should be included.
* @return the attributes with read/write permission.
*/
default Map<String, List<String>> getReadable(boolean includeBuiltin) {
return getReadable().entrySet().stream().filter(entry -> {
if (includeBuiltin) {
return true;
}
return !isRootAttribute(entry.getKey());
}).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}

/**
* Returns only the attributes that have read/write permissions.
*
* @return the attributes with read/write permission.
*/
Map<String, List<String>> getReadable();

/**
* Returns whether the attribute with the given {@code name} is a root attribute.
*
* @param name the attribute name
* @return
*/
default boolean isRootAttribute(String name) {
return UserModel.USERNAME.equals(name)
|| UserModel.EMAIL.equals(name)
|| UserModel.FIRST_NAME.equals(name)
|| UserModel.LAST_NAME.equals(name);
}

Map<String, List<String>> toMap();
}
Loading

0 comments on commit ef3a0ee

Please sign in to comment.