Decoupling legacy and dynamic user profiles and exposing metadata from admin api

Closes #22532

Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Pedro Igor 2023-08-17 16:26:52 -03:00
parent 430c883eda
commit ea3225a6e1
34 changed files with 635 additions and 143 deletions

View file

@ -19,6 +19,7 @@ package org.keycloak.representations.account;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import org.keycloak.json.StringListMapDeserializer; import org.keycloak.json.StringListMapDeserializer;
import org.keycloak.representations.idm.UserProfileMetadata;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;

View file

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.keycloak.representations.account; package org.keycloak.representations.idm;
import java.util.Map; import java.util.Map;

View file

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.keycloak.representations.account; package org.keycloak.representations.idm;
import java.util.List; import java.util.List;

View file

@ -66,6 +66,7 @@ public class UserRepresentation {
protected List<String> groups; protected List<String> groups;
private Map<String, Boolean> access; private Map<String, Boolean> access;
private UserProfileMetadata userProfileMetadata;
public String getSelf() { public String getSelf() {
return self; return self;
@ -312,4 +313,12 @@ public class UserRepresentation {
return attrs; return attrs;
} }
public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) {
this.userProfileMetadata = userProfileMetadata;
}
public UserProfileMetadata getUserProfileMetadata() {
return userProfileMetadata;
}
} }

View file

@ -48,6 +48,9 @@ public interface UserResource {
@GET @GET
UserRepresentation toRepresentation(); UserRepresentation toRepresentation();
@GET
UserRepresentation toRepresentation(@QueryParam("userProfileMetadata") boolean userProfileMetadata);
@PUT @PUT
void update(UserRepresentation userRepresentation); void update(UserRepresentation userRepresentation);

View file

@ -42,7 +42,7 @@ type OtpPolicyProps = {
type FormFields = Omit< type FormFields = Omit<
RealmRepresentation, RealmRepresentation,
"clients" | "components" | "groups" "clients" | "components" | "groups" | "users" | "federatedUsers"
>; >;
export const OtpPolicy = ({ realm, realmUpdated }: OtpPolicyProps) => { export const OtpPolicy = ({ realm, realmUpdated }: OtpPolicyProps) => {

View file

@ -34,6 +34,8 @@ type RealmSettingsEmailTabProps = {
save: (realm: RealmRepresentation) => void; save: (realm: RealmRepresentation) => void;
}; };
type FormType = Omit<RealmRepresentation, "users" | "federatedUsers">;
export const RealmSettingsEmailTab = ({ export const RealmSettingsEmailTab = ({
realm, realm,
save, save,
@ -51,7 +53,7 @@ export const RealmSettingsEmailTab = ({
reset: resetForm, reset: resetForm,
getValues, getValues,
formState: { errors }, formState: { errors },
} = useForm<RealmRepresentation>({ defaultValues: realm }); } = useForm<FormType>({ defaultValues: realm });
const reset = () => resetForm(realm); const reset = () => resetForm(realm);
const watchFromValue = watch("smtpServer.from", ""); const watchFromValue = watch("smtpServer.from", "");

View file

@ -61,7 +61,7 @@ export default function EditUser() {
useFetch( useFetch(
async () => { async () => {
const [user, currentRealm, attackDetection] = await Promise.all([ const [user, currentRealm, attackDetection] = await Promise.all([
adminClient.users.findOne({ id: id! }), adminClient.users.findOne({ id: id!, userProfileMetadata: true }),
adminClient.realms.findOne({ realm }), adminClient.realms.findOne({ realm }),
adminClient.attackDetection.findOne({ id: id! }), adminClient.attackDetection.findOne({ id: id! }),
]); ]);
@ -103,7 +103,7 @@ const EditUserForm = ({ user, bruteForced, refresh }: EditUserFormProps) => {
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasAccess } = useAccess(); const { hasAccess } = useAccess();
const userForm = useForm<UserRepresentation>({ const userForm = useForm<Omit<UserRepresentation, "userProfileMetadata">>({
mode: "onChange", mode: "onChange",
defaultValues: user, defaultValues: user,
}); });

View file

@ -224,7 +224,7 @@ export const UserForm = ({
</FormGroup> </FormGroup>
)} )}
{isUserProfileEnabled ? ( {isUserProfileEnabled ? (
<UserProfileFields /> <UserProfileFields config={user?.userProfileMetadata!} />
) : ( ) : (
<> <>
{!realm?.registrationEmailAsUsername && ( {!realm?.registrationEmailAsUsername && (

View file

@ -1,11 +1,11 @@
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { Text } from "@patternfly/react-core"; import { Text } from "@patternfly/react-core";
import { Fragment } from "react"; import { Fragment } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";
import { ScrollForm } from "../components/scroll-form/ScrollForm"; import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
import { OptionComponent } from "./components/OptionsComponent"; import { OptionComponent } from "./components/OptionsComponent";
import { SelectComponent } from "./components/SelectComponent"; import { SelectComponent } from "./components/SelectComponent";
import { TextAreaComponent } from "./components/TextAreaComponent"; import { TextAreaComponent } from "./components/TextAreaComponent";
@ -13,6 +13,7 @@ import { TextComponent } from "./components/TextComponent";
import { DEFAULT_ROLES, fieldName } from "./utils"; import { DEFAULT_ROLES, fieldName } from "./utils";
type UserProfileFieldsProps = { type UserProfileFieldsProps = {
config: UserProfileConfig;
roles?: string[]; roles?: string[];
}; };
@ -78,21 +79,21 @@ export const isValidComponentType = (value: string): value is Field =>
value in FIELDS; value in FIELDS;
export const UserProfileFields = ({ export const UserProfileFields = ({
config,
roles = ["admin"], roles = ["admin"],
}: UserProfileFieldsProps) => { }: UserProfileFieldsProps) => {
const { t } = useTranslation("realm-settings"); const { t } = useTranslation("realm-settings");
const { config } = useUserProfile();
return ( return (
<ScrollForm <ScrollForm
sections={[{ name: "" }, ...(config?.groups || [])].map((g) => ({ sections={[{ name: "" }, ...(config.groups || [])].map((g) => ({
title: g.displayHeader || g.name || t("general"), title: g.displayHeader || g.name || t("general"),
panel: ( panel: (
<div className="pf-c-form"> <div className="pf-c-form">
{g.displayDescription && ( {g.displayDescription && (
<Text className="pf-u-pb-lg">{g.displayDescription}</Text> <Text className="pf-u-pb-lg">{g.displayDescription}</Text>
)} )}
{config?.attributes?.map((attribute) => ( {config.attributes?.map((attribute) => (
<Fragment key={attribute.name}> <Fragment key={attribute.name}>
{(attribute.group || "") === g.name && {(attribute.group || "") === g.name &&
(attribute.permissions?.view || DEFAULT_ROLES).some((r) => (attribute.permissions?.view || DEFAULT_ROLES).some((r) =>

View file

@ -42,6 +42,7 @@ export const OptionComponent = (attr: UserProfileAttribute) => {
field.onChange([option]); field.onChange([option]);
} }
}} }}
readOnly={attr.readOnly}
/> />
))} ))}
</> </>

View file

@ -9,13 +9,10 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Options } from "../UserProfileFields"; import { Options } from "../UserProfileFields";
import { DEFAULT_ROLES, fieldName } from "../utils"; import { fieldName } from "../utils";
import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup";
export const SelectComponent = ({ export const SelectComponent = (attribute: UserProfileFieldsProps) => {
roles = [],
...attribute
}: UserProfileFieldsProps) => {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const { control } = useFormContext(); const { control } = useFormContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -74,11 +71,7 @@ export const SelectComponent = ({
variant={isMultiValue(field) ? "typeaheadmulti" : "single"} variant={isMultiValue(field) ? "typeaheadmulti" : "single"}
aria-label={t("common:selectOne")} aria-label={t("common:selectOne")}
isOpen={open} isOpen={open}
isDisabled={ readOnly={attribute.readOnly}
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
roles.includes(r),
)
}
> >
{options.map((option) => ( {options.map((option) => (
<SelectOption <SelectOption

View file

@ -15,6 +15,7 @@ export const TextAreaComponent = (attr: UserProfileAttribute) => {
{...register(fieldName(attr))} {...register(fieldName(attr))}
cols={attr.annotations?.["inputTypeCols"] as number} cols={attr.annotations?.["inputTypeCols"] as number}
rows={attr.annotations?.["inputTypeRows"] as number} rows={attr.annotations?.["inputTypeRows"] as number}
readOnly={attr.readOnly}
/> />
</UserProfileGroup> </UserProfileGroup>
); );

View file

@ -18,6 +18,7 @@ export const TextComponent = (attr: UserProfileAttribute) => {
data-testid={attr.name} data-testid={attr.name}
type={type} type={type}
placeholder={attr.annotations?.["inputTypePlaceholder"] as string} placeholder={attr.annotations?.["inputTypePlaceholder"] as string}
readOnly={attr.readOnly}
{...register(fieldName(attr))} {...register(fieldName(attr))}
/> />
</UserProfileGroup> </UserProfileGroup>

View file

@ -10,6 +10,7 @@ export interface UserProfileAttribute {
validations?: Record<string, Record<string, unknown>>; validations?: Record<string, Record<string, unknown>>;
annotations?: Record<string, unknown>; annotations?: Record<string, unknown>;
required?: UserProfileAttributeRequired; required?: UserProfileAttributeRequired;
readOnly?: boolean;
permissions?: UserProfileAttributePermissions; permissions?: UserProfileAttributePermissions;
selector?: UserProfileAttributeSelector; selector?: UserProfileAttributeSelector;
displayName?: string; displayName?: string;

View file

@ -2,6 +2,7 @@ import type UserConsentRepresentation from "./userConsentRepresentation.js";
import type CredentialRepresentation from "./credentialRepresentation.js"; import type CredentialRepresentation from "./credentialRepresentation.js";
import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js"; import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";
import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js"; import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js";
import type UserProfileConfig from "./userProfileConfig.js";
export default interface UserRepresentation { export default interface UserRepresentation {
id?: string; id?: string;
@ -30,4 +31,5 @@ export default interface UserRepresentation {
realmRoles?: string[]; realmRoles?: string[];
self?: string; self?: string;
serviceAccountClientId?: string; serviceAccountClientId?: string;
userProfileMetadata?: UserProfileConfig;
} }

View file

@ -48,7 +48,7 @@ export class Users extends Resource<{ realm?: string }> {
*/ */
public findOne = this.makeRequest< public findOne = this.makeRequest<
{ id: string }, { id: string; userProfileMetadata?: boolean },
UserRepresentation | undefined UserRepresentation | undefined
>({ >({
method: "GET", method: "GET",

View file

@ -105,11 +105,11 @@ public interface Attributes {
Set<String> nameSet(); Set<String> nameSet();
/** /**
* Returns all attributes defined. * Returns all attributes that can be written.
* *
* @return the attributes * @return the attributes
*/ */
Set<Map.Entry<String, List<String>>> attributeSet(); Map<String, List<String>> getWritable();
/** /**
* <p>Returns the metadata associated with the attribute with the given {@code name}. * <p>Returns the metadata associated with the attribute with the given {@code name}.

View file

@ -58,7 +58,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
public static final String READ_ONLY_ATTRIBUTE_KEY = "kc.read.only"; public static final String READ_ONLY_ATTRIBUTE_KEY = "kc.read.only";
protected final UserProfileContext context; protected final UserProfileContext context;
private final KeycloakSession session; protected final KeycloakSession session;
private final Map<String, AttributeMetadata> metadataByAttribute; private final Map<String, AttributeMetadata> metadataByAttribute;
protected final UserModel user; protected final UserModel user;
@ -74,6 +74,18 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
@Override @Override
public boolean isReadOnly(String attributeName) { public boolean isReadOnly(String attributeName) {
if (UserModel.USERNAME.equals(attributeName)) {
if (isServiceAccountUser()) {
return true;
}
}
if (UserModel.EMAIL.equals(attributeName)) {
if (isServiceAccountUser()) {
return false;
}
}
if (isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName)) { if (isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName)) {
return true; return true;
} }
@ -163,8 +175,18 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
} }
@Override @Override
public Set<Entry<String, List<String>>> attributeSet() { public Map<String, List<String>> getWritable() {
return entrySet(); Map<String, List<String>> attributes = new HashMap<>(this);
for (String name : nameSet()) {
AttributeMetadata metadata = getMetadata(name);
if (metadata == null || !metadata.canEdit(createAttributeContext(metadata))) {
attributes.remove(name);
}
}
return attributes;
} }
@Override @Override
@ -198,6 +220,10 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return this; return this;
} }
protected boolean isServiceAccountUser() {
return user != null && user.getServiceAccountClientLink() != null;
}
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) { private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
return new AttributeContext(context, session, attribute, user, metadata); return new AttributeContext(context, session, attribute, user, metadata);
} }
@ -262,8 +288,8 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length()); key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
} }
List<String> values;
Object value = entry.getValue(); Object value = entry.getValue();
List<String> values;
if (value instanceof String) { if (value instanceof String) {
values = Collections.singletonList((String) value); values = Collections.singletonList((String) value);
@ -292,29 +318,34 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
} }
if (user != null) { if (user != null) {
List<String> username = newAttributes.get(UserModel.USERNAME); List<String> username = newAttributes.getOrDefault(UserModel.USERNAME, Collections.emptyList());
if (username == null || username.isEmpty() || (!realm.isEditUsernameAllowed() && UserProfileContext.USER_API.equals(context))) { if (username.isEmpty() && isReadOnly(UserModel.USERNAME)) {
setUserName(newAttributes, Collections.singletonList(user.getUsername())); setUserName(newAttributes, Collections.singletonList(user.getUsername()));
} }
} }
List<String> email = newAttributes.get(UserModel.EMAIL); List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, Collections.emptyList());
if (email != null && realm.isRegistrationEmailAsUsername()) { if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) {
final List<String> lowerCaseEmailList = email.stream() List<String> lowerCaseEmailList = email.stream()
.filter(Objects::nonNull) .filter(Objects::nonNull)
.map(String::toLowerCase) .map(String::toLowerCase)
.collect(Collectors.toList()); .collect(Collectors.toList());
setUserName(newAttributes, lowerCaseEmailList); setUserName(newAttributes, lowerCaseEmailList);
if (user != null && isReadOnly(UserModel.EMAIL)) {
newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail()));
setUserName(newAttributes, Collections.singletonList(user.getEmail()));
}
} }
return newAttributes; return newAttributes;
} }
private void setUserName(Map<String, List<String>> newAttributes, List<String> lowerCaseEmailList) { private void setUserName(Map<String, List<String>> newAttributes, List<String> lowerCaseEmailList) {
if (user != null && user.getServiceAccountClientLink() != null) { if (isServiceAccountUser()) {
return; return;
} }
newAttributes.put(UserModel.USERNAME, lowerCaseEmailList); newAttributes.put(UserModel.USERNAME, lowerCaseEmailList);
@ -342,16 +373,11 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return true; return true;
} }
// expect any attribute if managing the user profile using REST if (isServiceAccountUser()) {
if (UserProfileContext.USER_API.equals(context) || UserProfileContext.ACCOUNT.equals(context)) {
return true; return true;
} }
if (isReadOnly(name)) { if (isReadOnlyInternalAttribute(name)) {
return true;
}
if (user != null && user.getServiceAccountClientLink() != null) {
return true; return true;
} }

View file

@ -104,13 +104,8 @@ public final class DefaultUserProfile implements UserProfile {
} }
try { try {
for (Map.Entry<String, List<String>> attribute : attributes.attributeSet()) { for (Map.Entry<String, List<String>> attribute : attributes.getWritable().entrySet()) {
String name = attribute.getKey(); String name = attribute.getKey();
if (attributes.isReadOnly(name)) {
continue;
}
List<String> currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList()); List<String> currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList());
List<String> updatedValue = attribute.getValue().stream().filter(Objects::nonNull).collect(Collectors.toList()); List<String> updatedValue = attribute.getValue().stream().filter(Objects::nonNull).collect(Collectors.toList());
@ -118,6 +113,7 @@ public final class DefaultUserProfile implements UserProfile {
if (!removeAttributes && updatedValue.isEmpty()) { if (!removeAttributes && updatedValue.isEmpty()) {
continue; continue;
} }
user.setAttribute(name, updatedValue); user.setAttribute(name, updatedValue);
if (UserModel.EMAIL.equals(name) && metadata.getContext().isResetEmailVerified()) { if (UserModel.EMAIL.equals(name) && metadata.getContext().isResetEmailVerified()) {
@ -139,7 +135,7 @@ public final class DefaultUserProfile implements UserProfile {
attrsToRemove.removeAll(attributes.nameSet()); attrsToRemove.removeAll(attributes.nameSet());
for (String attr : attrsToRemove) { for (String attr : attrsToRemove) {
if (this.attributes.isReadOnly(attr)) { if (attributes.isReadOnly(attr)) {
continue; continue;
} }

View file

@ -41,6 +41,9 @@ import org.keycloak.userprofile.UserProfileProvider;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -153,7 +156,9 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
}; };
UserProfileProvider profileProvider = context.getSession().getProvider(UserProfileProvider.class); UserProfileProvider profileProvider = context.getSession().getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(UserProfileContext.IDP_REVIEW, formData, updatedProfile); Map<String, List<String>> attributes = new HashMap<>(formData);
attributes.putIfAbsent(UserModel.USERNAME, Collections.singletonList(updatedProfile.getUsername()));
UserProfile profile = profileProvider.create(UserProfileContext.IDP_REVIEW, attributes, updatedProfile);
try { try {
String oldEmail = userCtx.getEmail(); String oldEmail = userCtx.getEmail();

View file

@ -157,6 +157,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
public static UserProfile validateEmailUpdate(KeycloakSession session, UserModel user, String newEmail) { public static UserProfile validateEmailUpdate(KeycloakSession session, UserModel user, String newEmail) {
MultivaluedMap<String, String> formData = new MultivaluedHashMap<>(); MultivaluedMap<String, String> formData = new MultivaluedHashMap<>();
formData.putSingle(UserModel.USERNAME, user.getUsername());
formData.putSingle(UserModel.EMAIL, newEmail); formData.putSingle(UserModel.EMAIL, newEmail);
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(UserProfileContext.UPDATE_EMAIL, formData, user); UserProfile profile = profileProvider.create(UserProfileContext.UPDATE_EMAIL, formData, user);

View file

@ -70,8 +70,8 @@ import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.account.UserProfileAttributeMetadata; import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.account.UserProfileMetadata; import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;

View file

@ -58,7 +58,10 @@ import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.models.utils.RoleUtils; import org.keycloak.models.utils.RoleUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.provider.ProviderFactory; import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.FederatedIdentityRepresentation;
@ -80,6 +83,8 @@ import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.storage.ReadOnlyException; import org.keycloak.storage.ReadOnlyException;
import org.keycloak.userprofile.AttributeMetadata;
import org.keycloak.userprofile.AttributeValidatorMetadata;
import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException;
@ -102,6 +107,8 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.validate.Validators;
import java.net.URI; import java.net.URI;
import java.text.MessageFormat; import java.text.MessageFormat;
import java.util.ArrayList; import java.util.ArrayList;
@ -302,7 +309,9 @@ public class UserResource {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS) @Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@Operation( summary = "Get representation of the user") @Operation( summary = "Get representation of the user")
public UserRepresentation getUser() { public UserRepresentation getUser(
@Parameter(description = "Indicates if the user profile metadata should be added to the response") @QueryParam("userProfileMetadata") boolean userProfileMetadata
) {
auth.users().requireView(user); auth.users().requireView(user);
UserRepresentation rep = ModelToRepresentation.toRepresentation(session, realm, user); UserRepresentation rep = ModelToRepresentation.toRepresentation(session, realm, user);
@ -325,6 +334,10 @@ public class UserResource {
rep.setAttributes(readableAttributes); rep.setAttributes(readableAttributes);
} }
if (userProfileMetadata) {
rep.setUserProfileMetadata(createUserProfileMetadata(profile));
}
return rep; return rep;
} }
@ -1054,4 +1067,35 @@ public class UserResource {
rep.setLastAccess(Time.toMillis(clientSession.getTimestamp())); rep.setLastAccess(Time.toMillis(clientSession.getTimestamp()));
return rep; return rep;
} }
private UserProfileMetadata createUserProfileMetadata(final UserProfile profile) {
Map<String, List<String>> am = profile.getAttributes().getReadable();
if(am == null)
return null;
List<UserProfileAttributeMetadata> attributes = am.keySet().stream()
.map(name -> profile.getAttributes().getMetadata(name))
.filter(Objects::nonNull)
.sorted((a,b) -> Integer.compare(a.getGuiOrder(), b.getGuiOrder()))
.map(sam -> toRestMetadata(sam, profile))
.collect(Collectors.toList());
return new UserProfileMetadata(attributes);
}
private UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, UserProfile profile) {
return new UserProfileAttributeMetadata(am.getName(),
am.getAttributeDisplayName(),
profile.getAttributes().isRequired(am.getName()),
profile.getAttributes().isReadOnly(am.getName()),
am.getAnnotations(),
toValidatorMetadata(am));
}
private Map<String, Map<String, Object>> toValidatorMetadata(AttributeMetadata am){
// we return only validators which are instance of ConfiguredProvider. Others are expected as internal.
return am.getValidators() == null ? null : am.getValidators().stream()
.filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider))
.collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig));
}
} }

View file

@ -77,21 +77,11 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
KeycloakContext context = session.getContext(); KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm(); RealmModel realm = context.getRealm();
switch (c.getContext()) { if (IDP_REVIEW.equals(c.getContext())) {
case REGISTRATION_PROFILE:
case IDP_REVIEW:
return !realm.isRegistrationEmailAsUsername(); return !realm.isRegistrationEmailAsUsername();
case ACCOUNT_OLD:
case ACCOUNT:
case UPDATE_PROFILE:
return realm.isEditUsernameAllowed();
case UPDATE_EMAIL:
return realm.isRegistrationEmailAsUsername();
case USER_API:
return true;
default:
return false;
} }
return realm.isEditUsernameAllowed();
} }
private static boolean readUsernameCondition(AttributeContext c) { private static boolean readUsernameCondition(AttributeContext c) {
@ -110,27 +100,47 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
return realm.isEditUsernameAllowed(); return realm.isEditUsernameAllowed();
case UPDATE_EMAIL: case UPDATE_EMAIL:
return false; return false;
default:
return true;
} }
return true;
} }
private static boolean editEmailCondition(AttributeContext c) { private static boolean editEmailCondition(AttributeContext c) {
return !Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL) || (c.getContext() != UPDATE_PROFILE && c.getContext() != ACCOUNT); RealmModel realm = c.getSession().getContext().getRealm();
if (REGISTRATION_PROFILE.equals(c.getContext())) {
return true;
}
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
return !(UPDATE_PROFILE.equals(c.getContext()) || ACCOUNT.equals(c.getContext()));
}
UserModel user = c.getUser();
if (user != null && realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
return false;
}
return true;
} }
private static boolean readEmailCondition(AttributeContext c) { private static boolean readEmailCondition(AttributeContext c) {
UserProfileContext context = c.getContext(); UserProfileContext context = c.getContext();
if (UPDATE_PROFILE.equals(context)) { if (REGISTRATION_PROFILE.equals(context)) {
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) { return true;
return false;
} }
if (Profile.isFeatureEnabled(Feature.UPDATE_EMAIL)) {
return !UPDATE_PROFILE.equals(context);
}
if (UPDATE_PROFILE.equals(context)) {
RealmModel realm = c.getSession().getContext().getRealm(); RealmModel realm = c.getSession().getContext().getRealm();
if (realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) { if (realm.isRegistrationEmailAsUsername()) {
return false; return realm.isEditUsernameAllowed();
} }
} }
@ -396,8 +406,10 @@ public abstract class AbstractUserProfileProvider<U extends UserProfileProvider>
UserProfileMetadata metadata = new UserProfileMetadata(USER_API); UserProfileMetadata metadata = new UserProfileMetadata(USER_API);
metadata.addAttribute(UserModel.USERNAME, -2, new AttributeValidatorMetadata(UsernameHasValueValidator.ID)); metadata.addAttribute(UserModel.USERNAME, -2, new AttributeValidatorMetadata(UsernameHasValueValidator.ID))
metadata.addAttribute(UserModel.EMAIL, -1, new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build())); .addWriteCondition(AbstractUserProfileProvider::editUsernameCondition);
metadata.addAttribute(UserModel.EMAIL, -1, new AttributeValidatorMetadata(EmailValidator.ID, ValidatorConfig.builder().config(EmailValidator.IGNORE_EMPTY_VALUE, true).build()))
.addWriteCondition(AbstractUserProfileProvider::editEmailCondition);
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>(); List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();

View file

@ -1,11 +1,12 @@
package org.keycloak.userprofile; package org.keycloak.userprofile;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
/** /**
@ -22,28 +23,66 @@ public class LegacyAttributes extends DefaultAttributes {
@Override @Override
protected boolean isSupportedAttribute(String name) { protected boolean isSupportedAttribute(String name) {
if (super.isSupportedAttribute(name)) {
return true; return true;
} }
if (name.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) { @Override
public boolean isReadOnly(String name) {
RealmModel realm = session.getContext().getRealm();
if (isReadOnlyInternalAttribute(name)) {
return true; return true;
} }
if (user == null) {
return false;
}
if (UserModel.USERNAME.equals(name)) {
if (isServiceAccountUser()) {
return true;
}
if (UserProfileContext.IDP_REVIEW.equals(context)) {
return false;
}
return !realm.isEditUsernameAllowed();
}
if (UserModel.EMAIL.equals(name)) {
if (isServiceAccountUser()) {
return false;
}
if (UserProfileContext.IDP_REVIEW.equals(context)) {
return false;
}
if (realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
return true;
}
}
return false; return false;
} }
@Override @Override
public boolean isReadOnly(String attributeName) { public Map<String, List<String>> getReadable() {
return isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName); if(user == null || user.getAttributes() == null) {
return Collections.emptyMap();
}
return new HashMap<>(user.getAttributes());
} }
@Override @Override
public Map<String, List<String>> getReadable() { public Map<String, List<String>> getWritable() {
if(user == null || user.getAttributes() == null) Map<String, List<String>> attributes = new HashMap<>(this);
return new HashMap<>();
return new HashMap<>(user.getAttributes()); for (String name : nameSet()) {
if (isReadOnly(name)) {
attributes.remove(name);
}
}
return attributes;
} }
@Override @Override

View file

@ -51,11 +51,6 @@ public class ImmutableAttributeValidator implements SimpleValidator {
public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) { public ValidationContext validate(Object input, String inputHint, ValidationContext context, ValidatorConfig config) {
UserProfileAttributeValidationContext ac = (UserProfileAttributeValidationContext) context; UserProfileAttributeValidationContext ac = (UserProfileAttributeValidationContext) context;
AttributeContext attributeContext = ac.getAttributeContext(); AttributeContext attributeContext = ac.getAttributeContext();
if (!isReadOnly(attributeContext)) {
return context;
}
UserModel user = attributeContext.getUser(); UserModel user = attributeContext.getUser();
if (user == null) { if (user == null) {
@ -65,7 +60,7 @@ public class ImmutableAttributeValidator implements SimpleValidator {
List<String> currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList()); List<String> currentValue = user.getAttributeStream(inputHint).collect(Collectors.toList());
List<String> values = (List<String>) input; List<String> values = (List<String>) input;
if (!CollectionUtil.collectionEquals(currentValue, values)) { if (!CollectionUtil.collectionEquals(currentValue, values) && isReadOnly(attributeContext)) {
if (currentValue.isEmpty() && !notBlankValidator().validate(values).isValid()) { if (currentValue.isEmpty() && !notBlankValidator().validate(values).isValid()) {
return context; return context;
} }

View file

@ -22,12 +22,14 @@ import org.junit.Assert;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.Profile;
import org.keycloak.common.enums.AccountRestApiVersion; import org.keycloak.common.enums.AccountRestApiVersion;
import org.keycloak.common.util.ObjectUtil; import org.keycloak.common.util.ObjectUtil;
import org.keycloak.credential.CredentialTypeMetadata; import org.keycloak.credential.CredentialTypeMetadata;
@ -44,7 +46,7 @@ import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.account.SessionRepresentation; import org.keycloak.representations.account.SessionRepresentation;
import org.keycloak.representations.account.UserProfileAttributeMetadata; import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; import org.keycloak.representations.idm.AuthenticationExecutionRepresentation;
@ -62,6 +64,7 @@ import org.keycloak.services.util.ResolveRelative;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.admin.authentication.AbstractAuthenticationTest; import org.keycloak.testsuite.admin.authentication.AbstractAuthenticationTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.TokenUtil; import org.keycloak.testsuite.util.TokenUtil;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
@ -96,14 +99,79 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
public AssertEvents events = new AssertEvents(this); public AssertEvents events = new AssertEvents(this);
@Test @Test
public void testGetUserProfileMetadata_EditUsernameAllowed() throws IOException { public void testEditUsernameAllowed() throws IOException {
UserRepresentation user = getUser(); UserRepresentation user = getUser();
String originalUsername = user.getUsername();
String originalEmail = user.getEmail();
RealmResource realm = adminClient.realm("test");
RealmRepresentation realmRep = realm.toRepresentation();
Boolean registrationEmailAsUsername = realmRep.isRegistrationEmailAsUsername();
Boolean editUsernameAllowed = realmRep.isEditUsernameAllowed();
try {
realmRep.setRegistrationEmailAsUsername(false);
realmRep.setEditUsernameAllowed(true);
realm.update(realmRep);
user = getUser();
assertNotNull(user.getUserProfileMetadata()); assertNotNull(user.getUserProfileMetadata());
// can write both username and email
assertUserProfileAttributeMetadata(user, "username", "${username}", true, false); assertUserProfileAttributeMetadata(user, "username", "${username}", true, false);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false); assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
user.setUsername("changed-username");
user.setEmail("changed-email@keycloak.org");
user = updateAndGet(user);
assertEquals("changed-username", user.getUsername());
assertEquals("changed-email@keycloak.org", user.getEmail());
realmRep.setRegistrationEmailAsUsername(false);
realmRep.setEditUsernameAllowed(false);
realm.update(realmRep);
user = getUser();
assertNotNull(user.getUserProfileMetadata());
// username is readonly but email is writable
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
user.setUsername("should-not-change");
user.setEmail("changed-email@keycloak.org");
updateError(user, 400, Messages.READ_ONLY_USERNAME);
realmRep.setRegistrationEmailAsUsername(true);
realmRep.setEditUsernameAllowed(true);
realm.update(realmRep);
user = getUser();
assertNotNull(user.getUserProfileMetadata());
// username is read-only and is the same as email, but email is writable
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, false);
user.setUsername("should-be-the-email");
user.setEmail("user@keycloak.org");
user = updateAndGet(user);
assertEquals("user@keycloak.org", user.getUsername());
assertEquals("user@keycloak.org", user.getEmail());
realmRep.setRegistrationEmailAsUsername(true);
realmRep.setEditUsernameAllowed(false);
realm.update(realmRep);
user = getUser();
assertNotNull(user.getUserProfileMetadata());
// username is read-only and is the same as email, but email is read-only
assertUserProfileAttributeMetadata(user, "username", "${username}", true, true);
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
user.setUsername("should-be-the-email");
user.setEmail("should-not-change@keycloak.org");
user = updateAndGet(user);
assertEquals("user@keycloak.org", user.getUsername());
assertEquals("user@keycloak.org", user.getEmail());
} finally {
realmRep.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realmRep.setEditUsernameAllowed(editUsernameAllowed);
realm.update(realmRep);
user.setUsername(originalUsername);
user.setEmail(originalEmail);
updateAndGet(user);
}
} }
@Test @Test
@ -113,12 +181,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
} }
@Test @Test
public void testGetUserProfileMetadata_EditUsernameDisallowed() throws IOException { public void testEditUsernameDisallowed() throws IOException {
try { try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); RealmResource realm = adminClient.realm("test");
RealmRepresentation realmRep = realm.toRepresentation();
realmRep.setEditUsernameAllowed(false); realmRep.setEditUsernameAllowed(false);
adminClient.realm("test").update(realmRep); realm.update(realmRep);
UserRepresentation user = getUser(); UserRepresentation user = getUser();
assertNotNull(user.getUserProfileMetadata()); assertNotNull(user.getUserProfileMetadata());
@ -130,6 +198,13 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
Assert.assertEquals(1, upm.getValidators().size()); Assert.assertEquals(1, upm.getValidators().size());
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID)); Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
realmRep.setRegistrationEmailAsUsername(true);
realm.update(realmRep);
user = getUser();
upm = assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
Assert.assertEquals(1, upm.getValidators().size());
Assert.assertTrue(upm.getValidators().containsKey(EmailValidator.ID));
assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false); assertUserProfileAttributeMetadata(user, "firstName", "${firstName}", true, false);
assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false); assertUserProfileAttributeMetadata(user, "lastName", "${lastName}", true, false);
} finally { } finally {
@ -152,10 +227,12 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
protected UserProfileAttributeMetadata assertUserProfileAttributeMetadata(UserRepresentation user, String attName, String displayName, boolean required, boolean readOnly) { protected UserProfileAttributeMetadata assertUserProfileAttributeMetadata(UserRepresentation user, String attName, String displayName, boolean required, boolean readOnly) {
UserProfileAttributeMetadata uam = getUserProfileAttributeMetadata(user, attName); UserProfileAttributeMetadata uam = getUserProfileAttributeMetadata(user, attName);
if (isDeclarativeUserProfile()) {
assertNotNull(uam); assertNotNull(uam);
assertEquals("Unexpected display name for attribute " + uam.getName(), displayName, uam.getDisplayName()); assertEquals("Unexpected display name for attribute " + uam.getName(), displayName, uam.getDisplayName());
assertEquals("Unexpected required flag for attribute " + uam.getName(), required, uam.isRequired()); assertEquals("Unexpected required flag for attribute " + uam.getName(), required, uam.isRequired());
assertEquals("Unexpected readonly flag for attribute " + uam.getName(), readOnly, uam.isReadOnly()); assertEquals("Unexpected readonly flag for attribute " + uam.getName(), readOnly, uam.isReadOnly());
}
return uam; return uam;
} }
@ -378,7 +455,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user = updateAndGet(user); user = updateAndGet(user);
assertEquals("test-user@localhost", user.getUsername()); assertEquals("test-user@localhost", user.getUsername());
realmRep.setRegistrationEmailAsUsername(true); realmRep.setRegistrationEmailAsUsername(true);
adminClient.realm("test").update(realmRep); adminClient.realm("test").update(realmRep);
@ -1537,6 +1613,35 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
// custom-audience client is used only in this test so no need to revert the changes // custom-audience client is used only in this test so no need to revert the changes
} }
@Test
@EnableFeature(Profile.Feature.UPDATE_EMAIL)
public void testEmailWhenUpdateEmailEnabled() throws Exception {
reconnectAdminClient();
RealmRepresentation realm = testRealm().toRepresentation();
Boolean registrationEmailAsUsername = realm.isRegistrationEmailAsUsername();
Boolean editUsernameAllowed = realm.isEditUsernameAllowed();
try {
realm.setRegistrationEmailAsUsername(true);
realm.setEditUsernameAllowed(true);
testRealm().update(realm);
UserRepresentation user = getUser(true);
assertNotNull(user.getEmail());
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
realm.setRegistrationEmailAsUsername(false);
realm.setEditUsernameAllowed(false);
testRealm().update(realm);
user = getUser(true);
assertNotNull(user.getEmail());
assertUserProfileAttributeMetadata(user, "email", "${email}", true, true);
} finally {
realm.setRegistrationEmailAsUsername(registrationEmailAsUsername);
realm.setEditUsernameAllowed(editUsernameAllowed);
testRealm().update(realm);
}
}
protected boolean isDeclarativeUserProfile() { protected boolean isDeclarativeUserProfile() {
return false; return false;
} }

View file

@ -37,8 +37,8 @@ import org.keycloak.common.Profile;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.account.UserProfileAttributeMetadata; import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.account.UserProfileMetadata; import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
@ -101,7 +101,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
@Test @Test
@Override @Override
public void testGetUserProfileMetadata_EditUsernameAllowed() throws IOException { public void testEditUsernameAllowed() throws IOException {
setUserProfileConfiguration(UP_CONFIG_FOR_METADATA); setUserProfileConfiguration(UP_CONFIG_FOR_METADATA);
@ -221,7 +221,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
@Test @Test
@Override @Override
public void testGetUserProfileMetadata_EditUsernameDisallowed() throws IOException { public void testEditUsernameDisallowed() throws IOException {
try { try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -261,6 +261,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
} finally { } finally {
RealmRepresentation realmRep = testRealm().toRepresentation(); RealmRepresentation realmRep = testRealm().toRepresentation();
realmRep.setEditUsernameAllowed(true); realmRep.setEditUsernameAllowed(true);
realmRep.setRegistrationEmailAsUsername(false);
testRealm().update(realmRep); testRealm().update(realmRep);
} }
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.actions; package org.keycloak.testsuite.actions;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import java.util.Arrays; import java.util.Arrays;
import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.drone.api.annotation.Drone;
@ -184,10 +185,18 @@ public abstract class AbstractRequiredActionUpdateEmailTest extends AbstractTest
setRegistrationEmailAsUsername(testRealm(), true); setRegistrationEmailAsUsername(testRealm(), true);
try { try {
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost");
String firstName = user.getFirstName();
String lastName = user.getLastName();
assertNotNull(firstName);
assertNotNull(lastName);
changeEmailUsingRequiredAction("new@localhost", true); changeEmailUsingRequiredAction("new@localhost", true);
user = ActionUtil.findUserWithAdminClient(adminClient, "new@localhost");
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "new@localhost");
Assert.assertNotNull(user); Assert.assertNotNull(user);
firstName = user.getFirstName();
lastName = user.getLastName();
assertNotNull(firstName);
assertNotNull(lastName);
} finally { } finally {
setRegistrationEmailAsUsername(testRealm(), genuineRegistrationEmailAsUsername); setRegistrationEmailAsUsername(testRealm(), genuineRegistrationEmailAsUsername);
} }

View file

@ -109,7 +109,6 @@ public class DeclarativeUserTest extends AbstractAdminTest {
ApiUtil.getCreatedId(response); ApiUtil.getCreatedId(response);
} catch (WebApplicationException e) { } catch (WebApplicationException e) {
// it's ok when the client has already been created for a previous test // it's ok when the client has already been created for a previous test
assertThat(e.getResponse().getStatus(), equalTo(409));
} }
testRealmUserManagerClient = AdminClientUtil.createAdminClient(true, realmRep.getRealm(), testRealmUserManagerClient = AdminClientUtil.createAdminClient(true, realmRep.getRealm(),

View file

@ -21,6 +21,7 @@ import org.hamcrest.Matchers;
import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.After; import org.junit.After;
import org.junit.Assume;
import org.junit.Before; import org.junit.Before;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Rule; import org.junit.Rule;
@ -102,10 +103,12 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@ -156,6 +159,28 @@ public class UserTest extends AbstractAdminTest {
@Page @Page
protected LoginPage loginPage; protected LoginPage loginPage;
protected Set<String> managedAttributes = new HashSet<>();
{
managedAttributes.add("test");
managedAttributes.add("attr");
managedAttributes.add("attr1");
managedAttributes.add("attr2");
managedAttributes.add("attr3");
managedAttributes.add("foo");
managedAttributes.add("bar");
managedAttributes.add("phoneNumber");
managedAttributes.add("usercertificate");
managedAttributes.add("saml.persistent.name.id.for.foo");
managedAttributes.add(LDAPConstants.LDAP_ID);
managedAttributes.add("LDap_Id");
managedAttributes.add("deniedSomeAdmin");
for (int i = 1; i < 10; i++) {
managedAttributes.add("test" + i);
}
}
@Before @Before
public void beforeUserTest() { public void beforeUserTest() {
createAppClientInRealm(REALM_NAME); createAppClientInRealm(REALM_NAME);
@ -630,11 +655,9 @@ public class UserTest extends AbstractAdminTest {
user.setFirstName("First" + i); user.setFirstName("First" + i);
user.setLastName("Last" + i); user.setLastName("Last" + i);
HashMap<String, List<String>> attributes = new HashMap<>(); addAttribute(user, "test", Collections.singletonList("test" + i));
attributes.put("test", Collections.singletonList("test" + i)); addAttribute(user, "test" + i, Collections.singletonList("test" + i));
attributes.put("test" + i, Collections.singletonList("test" + i)); addAttribute(user, "attr", Collections.singletonList("common"));
attributes.put("attr", Collections.singletonList("common"));
user.setAttributes(attributes);
ids.add(createUser(user)); ids.add(createUser(user));
} }
@ -642,6 +665,15 @@ public class UserTest extends AbstractAdminTest {
return ids; return ids;
} }
private void addAttribute(UserRepresentation user, String name, List<String> values) {
Map<String, List<String>> attributes = Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>());
attributes.put(name, values);
managedAttributes.add(name);
user.setAttributes(attributes);
}
@Test @Test
public void countByAttribute() { public void countByAttribute() {
createUsers(); createUsers();
@ -1145,6 +1177,7 @@ public class UserTest extends AbstractAdminTest {
@Test @Test
public void wildcardSearch() { public void wildcardSearch() {
Assume.assumeFalse("Default validators do not allow special chars", isDeclarativeUserProfile());
createUser("0user\\\\0", "email0@emal"); createUser("0user\\\\0", "email0@emal");
createUser("1user\\\\", "email1@emal"); createUser("1user\\\\", "email1@emal");
createUser("2user\\\\%", "email2@emal"); createUser("2user\\\\%", "email2@emal");
@ -1435,12 +1468,20 @@ public class UserTest extends AbstractAdminTest {
String user2Id = createUser(user2); String user2Id = createUser(user2);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size()); assertEquals(2, user1.getAttributes().size());
}
assertAttributeValue("value1user1", user1.getAttributes().get("attr1")); assertAttributeValue("value1user1", user1.getAttributes().get("attr1"));
assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
user2 = realm.users().get(user2Id).toRepresentation(); user2 = realm.users().get(user2Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user2.getAttributes().size());
} else {
assertEquals(2, user2.getAttributes().size()); assertEquals(2, user2.getAttributes().size());
}
assertAttributeValue("value1user2", user2.getAttributes().get("attr1")); assertAttributeValue("value1user2", user2.getAttributes().get("attr1"));
vals = user2.getAttributes().get("attr2"); vals = user2.getAttributes().get("attr2");
assertEquals(2, vals.size()); assertEquals(2, vals.size());
@ -1452,7 +1493,11 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1); updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(3, user1.getAttributes().size()); assertEquals(3, user1.getAttributes().size());
}
assertAttributeValue("value3user1", user1.getAttributes().get("attr1")); assertAttributeValue("value3user1", user1.getAttributes().get("attr1"));
assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); assertAttributeValue("value4user1", user1.getAttributes().get("attr3"));
@ -1461,7 +1506,11 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1); updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size()); assertEquals(2, user1.getAttributes().size());
}
assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); assertAttributeValue("value4user1", user1.getAttributes().get("attr3"));
@ -1470,7 +1519,11 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1); updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
assertNotNull(user1.getAttributes()); assertNotNull(user1.getAttributes());
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size()); assertEquals(2, user1.getAttributes().size());
}
// empty attributes should remove attributes // empty attributes should remove attributes
user1.setAttributes(Collections.emptyMap()); user1.setAttributes(Collections.emptyMap());
@ -1488,14 +1541,22 @@ public class UserTest extends AbstractAdminTest {
realm.users().get(user1Id).update(user1); realm.users().get(user1Id).update(user1);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(2, user1.getAttributes().size()); assertEquals(2, user1.getAttributes().size());
}
user1.getAttributes().remove("foo"); user1.getAttributes().remove("foo");
realm.users().get(user1Id).update(user1); realm.users().get(user1Id).update(user1);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
if (isDeclarativeUserProfile()) {
assertEquals(managedAttributes.size(), user1.getAttributes().size());
} else {
assertEquals(1, user1.getAttributes().size()); assertEquals(1, user1.getAttributes().size());
} }
}
@Test @Test
public void updateUserWithReadOnlyAttributes() { public void updateUserWithReadOnlyAttributes() {
@ -1547,8 +1608,12 @@ public class UserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
assertEquals("foo", user1.getAttributes().get("usercertificate").get(0)); assertEquals("foo", user1.getAttributes().get("usercertificate").get(0));
assertEquals("bar", user1.getAttributes().get("saml.persistent.name.id.for.foo").get(0)); assertEquals("bar", user1.getAttributes().get("saml.persistent.name.id.for.foo").get(0));
if (isDeclarativeUserProfile()) {
assertTrue(user1.getAttributes().get(LDAPConstants.LDAP_ID).isEmpty());
} else {
assertFalse(user1.getAttributes().containsKey(LDAPConstants.LDAP_ID)); assertFalse(user1.getAttributes().containsKey(LDAPConstants.LDAP_ID));
} }
}
@Test @Test
public void testImportUserWithNullAttribute() { public void testImportUserWithNullAttribute() {
@ -2291,10 +2356,11 @@ public class UserTest extends AbstractAdminTest {
} }
@Test @Test
public void updateUserWithEmailAsUsername() { public void updateUserWithEmailAsUsernameEditUsernameDisabled() {
switchRegistrationEmailAsUsername(true); switchRegistrationEmailAsUsername(true);
getCleanup().addCleanup(() -> switchRegistrationEmailAsUsername(false)); getCleanup().addCleanup(() -> switchRegistrationEmailAsUsername(false));
RealmRepresentation rep = realm.toRepresentation();
assertFalse(rep.isEditUsernameAllowed());
String id = createUser(); String id = createUser();
UserResource user = realm.users().get(id); UserResource user = realm.users().get(id);
@ -2304,6 +2370,25 @@ public class UserTest extends AbstractAdminTest {
userRep.setEmail("user11@localhost"); userRep.setEmail("user11@localhost");
updateUser(user, userRep); updateUser(user, userRep);
userRep = realm.users().get(id).toRepresentation();
assertEquals("user1@localhost", userRep.getUsername());
}
@Test
public void updateUserWithEmailAsUsernameEditUsernameAllowed() {
switchRegistrationEmailAsUsername(true);
getCleanup().addCleanup(() -> switchRegistrationEmailAsUsername(false));
switchEditUsernameAllowedOn(true);
getCleanup().addCleanup(() -> switchEditUsernameAllowedOn(false));
String id = createUser();
UserResource user = realm.users().get(id);
UserRepresentation userRep = user.toRepresentation();
assertEquals("user1@localhost", userRep.getUsername());
userRep.setEmail("user11@localhost");
updateUser(user, userRep);
userRep = realm.users().get(id).toRepresentation(); userRep = realm.users().get(id).toRepresentation();
assertEquals("user11@localhost", userRep.getUsername()); assertEquals("user11@localhost", userRep.getUsername());
} }
@ -2349,12 +2434,20 @@ public class UserTest extends AbstractAdminTest {
UserRepresentation update = new UserRepresentation(); UserRepresentation update = new UserRepresentation();
update.setId(userId); update.setId(userId);
if (isDeclarativeUserProfile()) {
// user profile requires sending all attributes otherwise they are removed
update.setEmail(email);
}
update.setAttributes(Map.of("phoneNumber", List.of("123"))); update.setAttributes(Map.of("phoneNumber", List.of("123")));
updateUser(realm.users().get(userId), update); updateUser(realm.users().get(userId), update);
UserRepresentation updated = realm.users().get(userId).toRepresentation(); UserRepresentation updated = realm.users().get(userId).toRepresentation();
assertThat(updated.getUsername(), equalTo(userName)); assertThat(updated.getUsername(), equalTo(userName));
if (isDeclarativeUserProfile()) {
assertThat(updated.getAttributes().get("phoneNumber"), equalTo(List.of("123")));
} else {
assertThat(updated.getAttributes(), equalTo(Map.of("phoneNumber", List.of("123")))); assertThat(updated.getAttributes(), equalTo(Map.of("phoneNumber", List.of("123"))));
}
assertThat(updated.getEmail(), equalTo(email)); assertThat(updated.getEmail(), equalTo(email));
} }
@ -2774,6 +2867,12 @@ public class UserTest extends AbstractAdminTest {
firstRealm.setRealm("first-realm"); firstRealm.setRealm("first-realm");
adminClient.realms().create(firstRealm); adminClient.realms().create(firstRealm);
getCleanup().addCleanup(new AutoCloseable() {
@Override
public void close() throws Exception {
adminClient.realms().realm(firstRealm.getRealm()).remove();
}
});
realm = adminClient.realm(firstRealm.getRealm()); realm = adminClient.realm(firstRealm.getRealm());
realmId = realm.toRepresentation().getId(); realmId = realm.toRepresentation().getId();
@ -2798,6 +2897,8 @@ public class UserTest extends AbstractAdminTest {
fail("Should not have access to firstUser from another realm"); fail("Should not have access to firstUser from another realm");
} catch (NotFoundException nfe) { } catch (NotFoundException nfe) {
// ignore // ignore
} finally {
adminClient.realm(secondRealm.getRealm()).remove();
} }
} }
@ -3319,4 +3420,8 @@ public class UserTest extends AbstractAdminTest {
actualRepresentation actualRepresentation
); );
} }
protected boolean isDeclarativeUserProfile() {
return false;
}
} }

View file

@ -0,0 +1,103 @@
/*
* Copyright 2016 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.testsuite.admin;
import static org.junit.Assert.assertNotNull;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.common.Profile.Feature;
import org.keycloak.models.LDAPConstants;
import org.keycloak.representations.idm.UserProfileAttributeMetadata;
import org.keycloak.representations.idm.UserProfileMetadata;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.forms.VerifyProfileTest;
import org.keycloak.userprofile.config.UPAttribute;
import org.keycloak.userprofile.config.UPAttributePermissions;
import org.keycloak.userprofile.config.UPConfig;
import org.keycloak.util.JsonSerialization;
@EnableFeature(Feature.DECLARATIVE_USER_PROFILE)
public class UserTestWithUserProfile extends UserTest {
@Before
public void onBefore() throws IOException {
RealmRepresentation realmRep = realm.toRepresentation();
VerifyProfileTest.disableDynamicUserProfile(realm);
assertAdminEvents.poll();
realm.update(realmRep);
assertAdminEvents.poll();
VerifyProfileTest.enableDynamicUserProfile(realmRep);
realm.update(realmRep);
assertAdminEvents.poll();
VerifyProfileTest.setUserProfileConfiguration(realm, null);
UPConfig upConfig = JsonSerialization.readValue(realm.users().userProfile().getConfiguration(), UPConfig.class);
for (String name : managedAttributes) {
upConfig.addAttribute(createAttributeMetadata(name));
}
VerifyProfileTest.setUserProfileConfiguration(realm, JsonSerialization.writeValueAsString(upConfig));
}
@Test
public void testUserProfileMetadata() {
String userId = createUser("user-metadata", "user-metadata@keycloak.org");
UserRepresentation user = realm.users().get(userId).toRepresentation(true);
UserProfileMetadata metadata = user.getUserProfileMetadata();
assertNotNull(metadata);
for (String name : managedAttributes) {
assertNotNull(getAttributeMetadata(metadata, name));
}
}
@Nullable
private static UserProfileAttributeMetadata getAttributeMetadata(UserProfileMetadata metadata, String name) {
UserProfileAttributeMetadata attrMetadata = null;
for (UserProfileAttributeMetadata m : metadata.getAttributes()) {
if (name.equals(m.getName())) {
attrMetadata = m;
}
}
return attrMetadata;
}
private UPAttribute createAttributeMetadata(String name) {
UPAttribute attribute = new UPAttribute();
attribute.setName(name);
UPAttributePermissions permissions = new UPAttributePermissions();
permissions.setEdit(Set.of("user", "admin"));
attribute.setPermissions(permissions);
this.managedAttributes.add(name);
return attribute;
}
@Override
protected boolean isDeclarativeUserProfile() {
return true;
}
}

View file

@ -48,6 +48,7 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException; import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.LDAPConstants;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
@ -517,7 +518,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = profile.create(); UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(), assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE, "address", "department")); containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE, "department"));
assertNull(user.getFirstAttribute("department")); assertNull(user.getFirstAttribute("department"));
@ -644,17 +645,16 @@ public class UserProfileTest extends AbstractUserProfileTest {
provider.setConfiguration("{\"attributes\": [{\"name\": \"department\", \"permissions\": {\"edit\": [\"admin\"]}}," provider.setConfiguration("{\"attributes\": [{\"name\": \"department\", \"permissions\": {\"edit\": [\"admin\"]}},"
+ "{\"name\": \"phone\", \"permissions\": {\"edit\": [\"admin\"]}}," + "{\"name\": \"phone\", \"permissions\": {\"edit\": [\"admin\"]}},"
+ "{\"name\": \"address\", \"permissions\": {\"edit\": [\"admin\"]}}]}"); + "{\"name\": \"address\", \"permissions\": {\"edit\": [\"admin\"]}}]}");
UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes); UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes);
UserModel user = profile.create(); UserModel user = profile.create();
assertThat(profile.getAttributes().nameSet(), assertThat(profile.getAttributes().nameSet(),
containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE, "address", "department", "phone")); containsInAnyOrder(UserModel.USERNAME, UserModel.EMAIL, UserModel.LOCALE, "address", "department", "phone"));
attributes.put("address", Arrays.asList("change-address"));
attributes.put("department", Arrays.asList("changed-sales"));
attributes.put("phone", Arrays.asList("changed-phone"));
profile = provider.create(UserProfileContext.USER_API, attributes, user); profile = provider.create(UserProfileContext.USER_API, attributes, user);
Set<String> attributesUpdated = new HashSet<>(); Set<String> attributesUpdated = new HashSet<>();
profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName))); profile.update((attributeName, userModel, oldValue) -> assertTrue(attributesUpdated.add(attributeName)));
assertThat(attributesUpdated, containsInAnyOrder("department", "address", "phone")); assertThat(attributesUpdated, containsInAnyOrder("department", "address", "phone"));
@ -1314,6 +1314,32 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.validate(); profile.validate();
} }
@Test
public void testReadOnlyInternalAttributeValidation() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testReadOnlyInternalAttributeValidation);
}
private static void testReadOnlyInternalAttributeValidation(KeycloakSession session) throws IOException {
RealmModel realm = session.getContext().getRealm();
UserModel maria = session.users().addUser(realm, "maria");
maria.setAttribute(LDAPConstants.LDAP_ID, List.of("1"));
DeclarativeUserProfileProvider provider = getDynamicUserProfileProvider(session);
Map<String, List<String>> attributes = new HashMap<>();
attributes.put(LDAPConstants.LDAP_ID, List.of("2"));
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes, maria);
try {
profile.validate();
fail("Should fail validation");
} catch (ValidationException ve) {
assertTrue(ve.isAttributeOnError(LDAPConstants.LDAP_ID));
}
}
@Test @Test
public void testRequiredByClientScope() { public void testRequiredByClientScope() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testRequiredByClientScope); getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testRequiredByClientScope);
@ -1464,6 +1490,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
UserModel user = session.users().addUser(realm, username); UserModel user = session.users().addUser(realm, username);
Map<String, Object> attributes = new HashMap<>(); Map<String, Object> attributes = new HashMap<>();
attributes.put(UserModel.USERNAME, user.getUsername());
attributes.put(UserModel.EMAIL, "test@keycloak.com"); attributes.put(UserModel.EMAIL, "test@keycloak.com");
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes, user); UserProfile profile = provider.create(UserProfileContext.USER_API, attributes, user);
@ -1502,10 +1529,20 @@ public class UserProfileTest extends AbstractUserProfileTest {
attributes.put(UserModel.EMAIL, Arrays.asList("new-email@test.com")); attributes.put(UserModel.EMAIL, Arrays.asList("new-email@test.com"));
attributes.put("foo", "changed"); attributes.put("foo", "changed");
profile = provider.create(UserProfileContext.USER_API, attributes, user); profile = provider.create(UserProfileContext.USER_API, attributes, user);
try {
profile.update(false); profile.update(false);
fail("Should fail validation");
} catch (ValidationException ve) {
assertTrue(ve.isAttributeOnError(UserModel.USERNAME));
assertTrue(ve.hasError(Messages.MISSING_USERNAME));
}
attributes.put(UserModel.USERNAME, Collections.singletonList(user.getUsername()));
profile = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update(false);
profile = provider.create(UserProfileContext.USER_API, user); profile = provider.create(UserProfileContext.USER_API, user);
Attributes userAttributes = profile.getAttributes(); Attributes userAttributes = profile.getAttributes();
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); assertEquals("Test Value", userAttributes.getFirstValue("test-attribute"));
assertEquals("changed", userAttributes.getFirstValue("foo")); assertEquals("changed", userAttributes.getFirstValue("foo"));