Skip to content

Commit

Permalink
Decoupling legacy and dynamic user profiles and exposing metadata fro…
Browse files Browse the repository at this point in the history
…m admin api

Closes keycloak#22532

Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
  • Loading branch information
pedroigor and edewit committed Aug 29, 2023
1 parent 430c883 commit ea3225a
Show file tree
Hide file tree
Showing 34 changed files with 635 additions and 143 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import org.keycloak.json.StringListMapDeserializer;
import org.keycloak.representations.idm.UserProfileMetadata;

import java.util.ArrayList;
import java.util.Arrays;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.representations.account;
package org.keycloak.representations.idm;

import java.util.Map;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.representations.account;
package org.keycloak.representations.idm;

import java.util.List;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public class UserRepresentation {

protected List<String> groups;
private Map<String, Boolean> access;
private UserProfileMetadata userProfileMetadata;

public String getSelf() {
return self;
Expand Down Expand Up @@ -312,4 +313,12 @@ public Map<String, List<String>> toAttributes() {

return attrs;
}

public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) {
this.userProfileMetadata = userProfileMetadata;
}

public UserProfileMetadata getUserProfileMetadata() {
return userProfileMetadata;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public interface UserResource {
@GET
UserRepresentation toRepresentation();

@GET
UserRepresentation toRepresentation(@QueryParam("userProfileMetadata") boolean userProfileMetadata);

@PUT
void update(UserRepresentation userRepresentation);

Expand Down
2 changes: 1 addition & 1 deletion js/apps/admin-ui/src/authentication/policies/OtpPolicy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type OtpPolicyProps = {

type FormFields = Omit<
RealmRepresentation,
"clients" | "components" | "groups"
"clients" | "components" | "groups" | "users" | "federatedUsers"
>;

export const OtpPolicy = ({ realm, realmUpdated }: OtpPolicyProps) => {
Expand Down
4 changes: 3 additions & 1 deletion js/apps/admin-ui/src/realm-settings/EmailTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type RealmSettingsEmailTabProps = {
save: (realm: RealmRepresentation) => void;
};

type FormType = Omit<RealmRepresentation, "users" | "federatedUsers">;

export const RealmSettingsEmailTab = ({
realm,
save,
Expand All @@ -51,7 +53,7 @@ export const RealmSettingsEmailTab = ({
reset: resetForm,
getValues,
formState: { errors },
} = useForm<RealmRepresentation>({ defaultValues: realm });
} = useForm<FormType>({ defaultValues: realm });

const reset = () => resetForm(realm);
const watchFromValue = watch("smtpServer.from", "");
Expand Down
4 changes: 2 additions & 2 deletions js/apps/admin-ui/src/user/EditUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function EditUser() {
useFetch(
async () => {
const [user, currentRealm, attackDetection] = await Promise.all([
adminClient.users.findOne({ id: id! }),
adminClient.users.findOne({ id: id!, userProfileMetadata: true }),
adminClient.realms.findOne({ realm }),
adminClient.attackDetection.findOne({ id: id! }),
]);
Expand Down Expand Up @@ -103,7 +103,7 @@ const EditUserForm = ({ user, bruteForced, refresh }: EditUserFormProps) => {
const { addAlert, addError } = useAlerts();
const navigate = useNavigate();
const { hasAccess } = useAccess();
const userForm = useForm<UserRepresentation>({
const userForm = useForm<Omit<UserRepresentation, "userProfileMetadata">>({
mode: "onChange",
defaultValues: user,
});
Expand Down
2 changes: 1 addition & 1 deletion js/apps/admin-ui/src/user/UserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export const UserForm = ({
</FormGroup>
)}
{isUserProfileEnabled ? (
<UserProfileFields />
<UserProfileFields config={user?.userProfileMetadata!} />
) : (
<>
{!realm?.registrationEmailAsUsername && (
Expand Down
11 changes: 6 additions & 5 deletions js/apps/admin-ui/src/user/UserProfileFields.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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 { Fragment } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";

import { useFormContext } from "react-hook-form";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
import { OptionComponent } from "./components/OptionsComponent";
import { SelectComponent } from "./components/SelectComponent";
import { TextAreaComponent } from "./components/TextAreaComponent";
import { TextComponent } from "./components/TextComponent";
import { DEFAULT_ROLES, fieldName } from "./utils";

type UserProfileFieldsProps = {
config: UserProfileConfig;
roles?: string[];
};

Expand Down Expand Up @@ -78,21 +79,21 @@ export const isValidComponentType = (value: string): value is Field =>
value in FIELDS;

export const UserProfileFields = ({
config,
roles = ["admin"],
}: UserProfileFieldsProps) => {
const { t } = useTranslation("realm-settings");
const { config } = useUserProfile();

return (
<ScrollForm
sections={[{ name: "" }, ...(config?.groups || [])].map((g) => ({
sections={[{ name: "" }, ...(config.groups || [])].map((g) => ({
title: g.displayHeader || g.name || t("general"),
panel: (
<div className="pf-c-form">
{g.displayDescription && (
<Text className="pf-u-pb-lg">{g.displayDescription}</Text>
)}
{config?.attributes?.map((attribute) => (
{config.attributes?.map((attribute) => (
<Fragment key={attribute.name}>
{(attribute.group || "") === g.name &&
(attribute.permissions?.view || DEFAULT_ROLES).some((r) =>
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/user/components/OptionsComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const OptionComponent = (attr: UserProfileAttribute) => {
field.onChange([option]);
}
}}
readOnly={attr.readOnly}
/>
))}
</>
Expand Down
13 changes: 3 additions & 10 deletions js/apps/admin-ui/src/user/components/SelectComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@ import {
import { useTranslation } from "react-i18next";

import { Options } from "../UserProfileFields";
import { DEFAULT_ROLES, fieldName } from "../utils";
import { fieldName } from "../utils";
import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup";

export const SelectComponent = ({
roles = [],
...attribute
}: UserProfileFieldsProps) => {
export const SelectComponent = (attribute: UserProfileFieldsProps) => {
const { t } = useTranslation("users");
const { control } = useFormContext();
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -74,11 +71,7 @@ export const SelectComponent = ({
variant={isMultiValue(field) ? "typeaheadmulti" : "single"}
aria-label={t("common:selectOne")}
isOpen={open}
isDisabled={
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
roles.includes(r),
)
}
readOnly={attribute.readOnly}
>
{options.map((option) => (
<SelectOption
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/user/components/TextAreaComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const TextAreaComponent = (attr: UserProfileAttribute) => {
{...register(fieldName(attr))}
cols={attr.annotations?.["inputTypeCols"] as number}
rows={attr.annotations?.["inputTypeRows"] as number}
readOnly={attr.readOnly}
/>
</UserProfileGroup>
);
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/user/components/TextComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const TextComponent = (attr: UserProfileAttribute) => {
data-testid={attr.name}
type={type}
placeholder={attr.annotations?.["inputTypePlaceholder"] as string}
readOnly={attr.readOnly}
{...register(fieldName(attr))}
/>
</UserProfileGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface UserProfileAttribute {
validations?: Record<string, Record<string, unknown>>;
annotations?: Record<string, unknown>;
required?: UserProfileAttributeRequired;
readOnly?: boolean;
permissions?: UserProfileAttributePermissions;
selector?: UserProfileAttributeSelector;
displayName?: string;
Expand Down
2 changes: 2 additions & 0 deletions js/libs/keycloak-admin-client/src/defs/userRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type UserConsentRepresentation from "./userConsentRepresentation.js";
import type CredentialRepresentation from "./credentialRepresentation.js";
import type FederatedIdentityRepresentation from "./federatedIdentityRepresentation.js";
import type { RequiredActionAlias } from "./requiredActionProviderRepresentation.js";
import type UserProfileConfig from "./userProfileConfig.js";

export default interface UserRepresentation {
id?: string;
Expand Down Expand Up @@ -30,4 +31,5 @@ export default interface UserRepresentation {
realmRoles?: string[];
self?: string;
serviceAccountClientId?: string;
userProfileMetadata?: UserProfileConfig;
}
2 changes: 1 addition & 1 deletion js/libs/keycloak-admin-client/src/resources/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class Users extends Resource<{ realm?: string }> {
*/

public findOne = this.makeRequest<
{ id: string },
{ id: string; userProfileMetadata?: boolean },
UserRepresentation | undefined
>({
method: "GET",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ default String getFirstValue(String name) {
Set<String> nameSet();

/**
* Returns all attributes defined.
* Returns all attributes that can be written.
*
* @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}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
public static final String READ_ONLY_ATTRIBUTE_KEY = "kc.read.only";

protected final UserProfileContext context;
private final KeycloakSession session;
protected final KeycloakSession session;
private final Map<String, AttributeMetadata> metadataByAttribute;
protected final UserModel user;

Expand All @@ -74,6 +74,18 @@ public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes,

@Override
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)) {
return true;
}
Expand Down Expand Up @@ -163,8 +175,18 @@ public Set<String> nameSet() {
}

@Override
public Set<Entry<String, List<String>>> attributeSet() {
return entrySet();
public Map<String, List<String>> getWritable() {
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
Expand Down Expand Up @@ -198,6 +220,10 @@ public Map<String, List<String>> toMap() {
return this;
}

protected boolean isServiceAccountUser() {
return user != null && user.getServiceAccountClientLink() != null;
}

private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
return new AttributeContext(context, session, attribute, user, metadata);
}
Expand Down Expand Up @@ -262,8 +288,8 @@ private Map<String, List<String>> normalizeAttributes(Map<String, ?> attributes)
key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
}

List<String> values;
Object value = entry.getValue();
List<String> values;

if (value instanceof String) {
values = Collections.singletonList((String) value);
Expand Down Expand Up @@ -292,29 +318,34 @@ private Map<String, List<String>> normalizeAttributes(Map<String, ?> attributes)
}

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()));
}
}

List<String> email = newAttributes.get(UserModel.EMAIL);
List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, Collections.emptyList());

if (email != null && realm.isRegistrationEmailAsUsername()) {
final List<String> lowerCaseEmailList = email.stream()
if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) {
List<String> lowerCaseEmailList = email.stream()
.filter(Objects::nonNull)
.map(String::toLowerCase)
.collect(Collectors.toList());

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;
}

private void setUserName(Map<String, List<String>> newAttributes, List<String> lowerCaseEmailList) {
if (user != null && user.getServiceAccountClientLink() != null) {
if (isServiceAccountUser()) {
return;
}
newAttributes.put(UserModel.USERNAME, lowerCaseEmailList);
Expand Down Expand Up @@ -342,16 +373,11 @@ protected boolean isSupportedAttribute(String name) {
return true;
}

// expect any attribute if managing the user profile using REST
if (UserProfileContext.USER_API.equals(context) || UserProfileContext.ACCOUNT.equals(context)) {
return true;
}

if (isReadOnly(name)) {
if (isServiceAccountUser()) {
return true;
}

if (user != null && user.getServiceAccountClientLink() != null) {
if (isReadOnlyInternalAttribute(name)) {
return true;
}

Expand Down
Loading

0 comments on commit ea3225a

Please sign in to comment.