diff --git a/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java b/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java index ee97faa9f5..29cbb448c7 100644 --- a/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java +++ b/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java @@ -31,9 +31,17 @@ import com.fasterxml.jackson.annotation.JsonIgnore; */ public class UPConfig { + public enum UnmanagedAttributePolicy { + ENABLED, + ADMIN_VIEW, + ADMIN_EDIT + } + private List attributes; private List groups; + private UnmanagedAttributePolicy unmanagedAttributePolicy; + public List getAttributes() { return attributes; } @@ -83,6 +91,14 @@ public class UPConfig { return null; } + public UnmanagedAttributePolicy getUnmanagedAttributePolicy() { + return unmanagedAttributePolicy; + } + + public void setUnmanagedAttributePolicy(UnmanagedAttributePolicy unmanagedAttributePolicy) { + this.unmanagedAttributePolicy = unmanagedAttributePolicy; + } + @Override public String toString() { return "UPConfig [attributes=" + attributes + ", groups=" + groups + "]"; diff --git a/js/apps/account-ui/test/admin-client.ts b/js/apps/account-ui/test/admin-client.ts index e9b2581440..eb57189c39 100644 --- a/js/apps/account-ui/test/admin-client.ts +++ b/js/apps/account-ui/test/admin-client.ts @@ -1,6 +1,6 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; const adminClient = new KeycloakAdminClient({ diff --git a/js/apps/account-ui/test/personal-info/personal-info.spec.ts b/js/apps/account-ui/test/personal-info/personal-info.spec.ts index 158a49976e..fe63972b28 100644 --- a/js/apps/account-ui/test/personal-info/personal-info.spec.ts +++ b/js/apps/account-ui/test/personal-info/personal-info.spec.ts @@ -1,4 +1,4 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { expect, test } from "@playwright/test"; import { createUser, diff --git a/js/apps/admin-ui/cypress/support/util/AdminClient.ts b/js/apps/admin-ui/cypress/support/util/AdminClient.ts index c6359b2e83..f52fe45ad3 100644 --- a/js/apps/admin-ui/cypress/support/util/AdminClient.ts +++ b/js/apps/admin-ui/cypress/support/util/AdminClient.ts @@ -4,7 +4,7 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/ import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { merge } from "lodash-es"; diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 2bfe396bbe..67ce254526 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -136,6 +136,16 @@ editIdPMapper=Edit Identity Provider Mapper representation=Representation remove=Remove userProfile=User profile +unmanagedAttributes=Unmanaged Attributes +unmanagedAttributesHelpText=Unmanaged attributes are user attributes not explicitly defined in the user profile configuration. \ + By default, unmanaged attributes are `Disabled` and are not available from any context such as registration, account, and the administration console. \ + By setting `Enabled`, unmanaged attributes are fully recognized by the server and accessible through all contexts, useful if you are starting migrating an existing realm to the declarative user profile and you don't have yet all user attributes defined in the user profile configuration. \ + By setting `Only administrators can write`, unmanaged attributes can be managed only through the administration console and API, useful if you have already defined any custom attribute that can be managed by users but you are unsure about adding other attributes that should only be managed by administrators. \ + By setting `Only administrators can view`, unmanaged attributes are read-only and only available through the administration console and API. +unmanagedAttributePolicy.DISABLED=Disabled +unmanagedAttributePolicy.ENABLED=Enabled +unmanagedAttributePolicy.ADMIN_VIEW=Only administrators can view +unmanagedAttributePolicy.ADMIN_EDIT=Only administrators can write confirmPasswordDoesNotMatch=Password and confirmation does not match. eventTypes.DELETE_ACCOUNT_ERROR.description=Delete account error provider=Provider @@ -2352,7 +2362,7 @@ userName=Username clientProfileDescription=Description ellipticCurveHelp=Elliptic curve used in ECDSA fromPredefinedMapper=From predefined mappers -attributesGroup=Attributes group +attributesGroup=Attributes Group ssoSessionMax=Max time before a session is expired. Tokens and browser sessions are invalidated when a session is expired. clientDeleteError=Could not delete client\: {{error}} optimizeLookup=Optimize REDIRECT signing key lookup diff --git a/js/apps/admin-ui/src/components/key-value-form/AttributeForm.tsx b/js/apps/admin-ui/src/components/key-value-form/AttributeForm.tsx index 906eb1a9a5..fcb6f19ae8 100644 --- a/js/apps/admin-ui/src/components/key-value-form/AttributeForm.tsx +++ b/js/apps/admin-ui/src/components/key-value-form/AttributeForm.tsx @@ -16,6 +16,8 @@ export type AttributesFormProps = { save?: (model: AttributeForm) => void; reset?: () => void; fineGrainedAccess?: boolean; + name?: string; + isDisabled?: boolean; }; export const AttributesForm = ({ @@ -23,6 +25,8 @@ export const AttributesForm = ({ reset, save, fineGrainedAccess, + name = "attributes", + isDisabled = false, }: AttributesFormProps) => { const { t } = useTranslation(); const noSaveCancelButtons = !save && !reset; @@ -38,7 +42,7 @@ export const AttributesForm = ({ fineGrainedAccess={fineGrainedAccess} > - + {!noSaveCancelButtons && ( diff --git a/js/apps/admin-ui/src/components/users/UserDataTable.tsx b/js/apps/admin-ui/src/components/users/UserDataTable.tsx index b77aadaf91..d2691709b6 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTable.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTable.tsx @@ -1,6 +1,6 @@ import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { AlertVariant, diff --git a/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx b/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx index 2105ba7a68..b1bc8275e7 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTableAttributeSearchForm.tsx @@ -1,4 +1,4 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { ActionGroup, Alert, diff --git a/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx b/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx index 67dddb05e6..f47c22ecd5 100644 --- a/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx +++ b/js/apps/admin-ui/src/components/users/UserDataTableToolbarItems.tsx @@ -1,5 +1,5 @@ import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { Button, ButtonVariant, diff --git a/js/apps/admin-ui/src/components/users/resource.ts b/js/apps/admin-ui/src/components/users/resource.ts new file mode 100644 index 0000000000..55d98e20d2 --- /dev/null +++ b/js/apps/admin-ui/src/components/users/resource.ts @@ -0,0 +1,6 @@ +import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint"; + +export const getUnmanagedAttributes = ( + id: string, +): Promise | undefined> => + fetchAdminUI(`ui-ext/users/${id}/unmanagedAttributes`); diff --git a/js/apps/admin-ui/src/context/RealmsContext.tsx b/js/apps/admin-ui/src/context/RealmsContext.tsx index 606944ac96..b9ac9e3c40 100644 --- a/js/apps/admin-ui/src/context/RealmsContext.tsx +++ b/js/apps/admin-ui/src/context/RealmsContext.tsx @@ -34,7 +34,7 @@ export const RealmsProvider = ({ children }: PropsWithChildren) => { } try { - return await fetchAdminUI("ui-ext/realms", {}); + return await fetchAdminUI("ui-ext/realms/names", {}); } catch (error) { if (error instanceof NetworkError && error.response.status < 500) { return []; diff --git a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx index 689a8fd10b..f827ea3868 100644 --- a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx @@ -29,10 +29,16 @@ import { convertToFormValues, } from "../util"; import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; +import { + UnmanagedAttributePolicy, + UserProfileConfig, +} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import { useFetch } from "../utils/useFetch"; +import { UIRealmRepresentation } from "./RealmSettingsTabs"; type RealmSettingsGeneralTabProps = { - realm: RealmRepresentation; - save: (realm: RealmRepresentation) => void; + realm: UIRealmRepresentation; + save: (realm: UIRealmRepresentation) => void; }; type FormFields = Omit; @@ -56,6 +62,18 @@ export const RealmSettingsGeneralTab = ({ const requireSslTypes = ["all", "external", "none"]; + const [userProfileConfig, setUserProfileConfig] = + useState(); + const unmanagedAttributePolicies = [ + UnmanagedAttributePolicy.Disabled, + UnmanagedAttributePolicy.Enabled, + UnmanagedAttributePolicy.AdminView, + UnmanagedAttributePolicy.AdminEdit, + ]; + const [isUnmanagedAttributeOpen, setIsUnmanagedAttributeOpen] = + useState(false); + const [isUserProfileEnabled, setUserProfileEnabled] = useState(false); + const setupForm = () => { convertToFormValues(realm, setValue); if (realm.attributes?.["acr.loa.map"]) { @@ -68,8 +86,15 @@ export const RealmSettingsGeneralTab = ({ result, ); } + setUserProfileEnabled(realm.attributes?.["userProfileEnabled"] === "true"); }; + useFetch( + () => adminClient.users.getProfile({ realm: realmName }), + (config) => setUserProfileConfig(config), + [], + ); + useEffect(setupForm, []); return ( @@ -78,7 +103,15 @@ export const RealmSettingsGeneralTab = ({ isHorizontal role="manage-realm" className="pf-u-mt-lg" - onSubmit={handleSubmit(save)} + onSubmit={handleSubmit((data) => { + if ( + UnmanagedAttributePolicy.Disabled === + userProfileConfig?.unmanagedAttributePolicy + ) { + userProfileConfig.unmanagedAttributePolicy = undefined; + } + save({ ...data, upConfig: userProfileConfig }); + })} > field.onChange(value.toString())} + onChange={(value) => { + field.onChange(value.toString()); + setUserProfileEnabled(value); + }} aria-label={t("userProfileEnabled")} /> )} /> )} + {isUserProfileEnabled && ( + + } + > + + + )} void; @@ -164,7 +169,7 @@ const RealmSettingsHeader = ({ }; type RealmSettingsTabsProps = { - realm: RealmRepresentation; + realm: UIRealmRepresentation; refresh: () => void; }; @@ -194,7 +199,7 @@ export const RealmSettingsTabs = ({ useEffect(setupForm, [setValue, realm]); - const save = async (r: RealmRepresentation) => { + const save = async (r: UIRealmRepresentation) => { r = convertFormValuesToObject(r); if ( r.attributes?.["acr.loa.map"] && @@ -210,7 +215,7 @@ export const RealmSettingsTabs = ({ } try { - const savedRealm: RealmRepresentation = { + const savedRealm: UIRealmRepresentation = { ...realm, ...r, id: r.realm, diff --git a/js/apps/admin-ui/src/realm-settings/routes/UserProfile.tsx b/js/apps/admin-ui/src/realm-settings/routes/UserProfile.tsx index 1a0fe9a33e..f98a00bc45 100644 --- a/js/apps/admin-ui/src/realm-settings/routes/UserProfile.tsx +++ b/js/apps/admin-ui/src/realm-settings/routes/UserProfile.tsx @@ -3,7 +3,11 @@ import type { Path } from "react-router-dom"; import { generateEncodedPath } from "../../utils/generateEncodedPath"; import type { AppRouteObject } from "../../routes"; -export type UserProfileTab = "attributes" | "attributes-group" | "json-editor"; +export type UserProfileTab = + | "attributes" + | "attributes-group" + | "unmanaged-attributes" + | "json-editor"; export type UserProfileParams = { realm: string; diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx index 2f3178a986..8a28a718e6 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/UserProfileContext.tsx @@ -1,4 +1,4 @@ -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { AlertVariant } from "@patternfly/react-core"; import { PropsWithChildren, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx index d03d61750e..649bee33a0 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/attribute/AttributeGeneralSettings.tsx @@ -1,5 +1,5 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; -import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { Divider, FormGroup, diff --git a/js/apps/admin-ui/src/user/EditUser.tsx b/js/apps/admin-ui/src/user/EditUser.tsx index 9fe743ffec..73f2b9e7c9 100644 --- a/js/apps/admin-ui/src/user/EditUser.tsx +++ b/js/apps/admin-ui/src/user/EditUser.tsx @@ -1,6 +1,8 @@ -import type { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs//userProfileMetadata"; +import type { + UserProfileMetadata, + UserProfileConfig, +} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; -import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { AlertVariant, ButtonVariant, @@ -44,11 +46,13 @@ import { UserFormFields, toUserFormFields, toUserRepresentation, + filterManagedAttributes, + UIUserRepresentation, } from "./form-state"; import { UserParams, UserTab, toUser } from "./routes/User"; import { toUsers } from "./routes/Users"; import { isLightweightUser } from "./utils"; - +import { getUnmanagedAttributes } from "../components/users/resource"; import "./user-section.css"; export default function EditUser() { @@ -61,13 +65,16 @@ export default function EditUser() { const isFeatureEnabled = useIsFeatureEnabled(); const form = useForm({ mode: "onChange" }); const [realm, setRealm] = useState(); - const [user, setUser] = useState(); + const [user, setUser] = useState(); const [bruteForced, setBruteForced] = useState(); + const [isUnmanagedAttributesEnabled, setUnmanagedAttributesEnabled] = + useState(); const [userProfileMetadata, setUserProfileMetadata] = useState(); const [refreshCount, setRefreshCount] = useState(0); const refresh = () => setRefreshCount((count) => count + 1); const lightweightUser = isLightweightUser(user?.id); + const [upConfig, setUpConfig] = useState(); const toTab = (tab: UserTab) => toUser({ @@ -91,22 +98,19 @@ export default function EditUser() { async () => Promise.all([ adminClient.realms.findOne({ realm: realmName }), - adminClient.users.findOne({ id: id!, userProfileMetadata: true }), + adminClient.users.findOne({ + id: id!, + userProfileMetadata: true, + }) as UIUserRepresentation, adminClient.attackDetection.findOne({ id: id! }), + getUnmanagedAttributes(id!), + adminClient.users.getProfile({ realm: realmName }), ]), - ([realm, user, attackDetection]) => { + ([realm, user, attackDetection, unmanagedAttributes, upConfig]) => { if (!user || !realm || !attackDetection) { throw new Error(t("notFound")); } - setRealm(realm); - setUser(user); - - const isBruteForceProtected = realm.bruteForceProtected; - const isLocked = isBruteForceProtected && attackDetection.disabled; - - setBruteForced({ isBruteForceProtected, isLocked }); - const isUserProfileEnabled = isFeatureEnabled(Feature.DeclarativeUserProfile) && realm.attributes?.userProfileEnabled === "true"; @@ -115,6 +119,30 @@ export default function EditUser() { isUserProfileEnabled ? user.userProfileMetadata : undefined, ); + if (isUserProfileEnabled) { + user.unmanagedAttributes = unmanagedAttributes; + user.attributes = filterManagedAttributes( + user.attributes, + unmanagedAttributes, + ); + } + + if ( + upConfig.unmanagedAttributePolicy !== undefined || + !isUserProfileEnabled + ) { + setUnmanagedAttributesEnabled(true); + } + + setRealm(realm); + setUser(user); + setUpConfig(upConfig); + + const isBruteForceProtected = realm.bruteForceProtected; + const isLocked = isBruteForceProtected && attackDetection.disabled; + + setBruteForced({ isBruteForceProtected, isLocked }); + form.reset(toUserFormFields(user, isUserProfileEnabled)); }, [refreshCount], @@ -259,13 +287,18 @@ export default function EditUser() { /> - {!userProfileMetadata && ( + {isUnmanagedAttributesEnabled && ( {t("attributes")}} {...attributesTab} > - + )} void; + upConfig?: UserProfileConfig; + isUserProfileEnabled: boolean; }; -export const UserAttributes = ({ user, save }: UserAttributesProps) => { +export const UserAttributes = ({ + user, + save, + upConfig, + isUserProfileEnabled, +}: UserAttributesProps) => { const form = useFormContext(); return ( @@ -25,9 +36,14 @@ export const UserAttributes = ({ user, save }: UserAttributesProps) => { reset={() => form.reset({ ...form.getValues(), - attributes: toUserFormFields(user, false).attributes, + attributes: toUserFormFields(user, isUserProfileEnabled).attributes, }) } + name={isUserProfileEnabled ? "unmanagedAttributes" : "attributes"} + isDisabled={ + UnmanagedAttributePolicy.AdminView == + upConfig?.unmanagedAttributePolicy + } /> ); diff --git a/js/apps/admin-ui/src/user/form-state.ts b/js/apps/admin-ui/src/user/form-state.ts index c038b25e1d..922d48b8b0 100644 --- a/js/apps/admin-ui/src/user/form-state.ts +++ b/js/apps/admin-ui/src/user/form-state.ts @@ -6,28 +6,62 @@ import { } from "../components/key-value-form/key-value-convert"; export type UserFormFields = Omit< - UserRepresentation, - "attributes" | "userProfileMetadata" + UIUserRepresentation, + "attributes" | "userProfileMetadata | unmanagedAttributes" > & { attributes?: KeyValueType[] | Record; + unmanagedAttributes?: KeyValueType[] | Record; }; +export interface UIUserRepresentation extends UserRepresentation { + unmanagedAttributes?: Record; +} + export function toUserFormFields( - data: UserRepresentation, + data: UIUserRepresentation, userProfileEnabled: boolean, ): UserFormFields { const attributes = userProfileEnabled ? data.attributes : arrayToKeyValue(data.attributes); - - return { ...data, attributes }; + const unmanagedAttributes = arrayToKeyValue(data.unmanagedAttributes); + return { ...data, attributes, unmanagedAttributes }; } -export function toUserRepresentation(data: UserFormFields): UserRepresentation { +export function toUserRepresentation( + data: UserFormFields, +): UIUserRepresentation { const username = data.username?.trim(); const attributes = Array.isArray(data.attributes) ? keyValueToArray(data.attributes) : data.attributes; + const unmanagedAttributes = Array.isArray(data.unmanagedAttributes) + ? keyValueToArray(data.unmanagedAttributes) + : data.unmanagedAttributes; - return { ...data, username, attributes }; + for (const key in unmanagedAttributes) { + if (attributes && Object.hasOwn(attributes, key)) { + throw Error( + `Attribute ${key} is a managed attribute and is already available from the user details.`, + ); + } + } + + return { + ...data, + username, + attributes: { ...unmanagedAttributes, ...attributes }, + unmanagedAttributes: undefined, + }; +} + +export function filterManagedAttributes( + attributes: Record = {}, + unmanagedAttributes: Record = {}, +) { + return Object.fromEntries( + Object.entries(attributes).filter( + ([key]) => !Object.hasOwn(unmanagedAttributes, key), + ), + ); } diff --git a/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts b/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts index 5ebf5e1b9c..060064cc1d 100644 --- a/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts +++ b/js/libs/keycloak-admin-client/src/defs/userProfileMetadata.ts @@ -1,6 +1,7 @@ -export default interface UserProfileConfig { +export interface UserProfileConfig { attributes?: UserProfileAttribute[]; groups?: UserProfileGroup[]; + unmanagedAttributePolicy?: UnmanagedAttributePolicy; } export interface UserProfileAttribute { name?: string; @@ -53,3 +54,10 @@ export interface UserProfileMetadata { attributes?: UserProfileAttributeMetadata[]; groups?: UserProfileAttributeGroupMetadata[]; } + +export enum UnmanagedAttributePolicy { + Disabled = "DISABLED", + Enabled = "ENABLED", + AdminView = "ADMIN_VIEW", + AdminEdit = "ADMIN_EDIT", +} diff --git a/js/libs/keycloak-admin-client/src/resources/realms.ts b/js/libs/keycloak-admin-client/src/resources/realms.ts index d760cfbf8d..b817082a55 100644 --- a/js/libs/keycloak-admin-client/src/resources/realms.ts +++ b/js/libs/keycloak-admin-client/src/resources/realms.ts @@ -53,7 +53,7 @@ export class Realms extends Resource { void >({ method: "PUT", - path: "/{realm}", + path: "/{realm}/ui-ext", urlParamKeys: ["realm"], }); diff --git a/js/libs/keycloak-admin-client/src/resources/users.ts b/js/libs/keycloak-admin-client/src/resources/users.ts index ed0340c689..4582846b34 100644 --- a/js/libs/keycloak-admin-client/src/resources/users.ts +++ b/js/libs/keycloak-admin-client/src/resources/users.ts @@ -7,8 +7,10 @@ import type { RequiredActionAlias } from "../defs/requiredActionProviderRepresen import type RoleRepresentation from "../defs/roleRepresentation.js"; import type { RoleMappingPayload } from "../defs/roleRepresentation.js"; import type UserConsentRepresentation from "../defs/userConsentRepresentation.js"; -import type UserProfileConfig from "../defs/userProfileMetadata.js"; -import type { UserProfileMetadata } from "../defs/userProfileMetadata.js"; +import type { + UserProfileConfig, + UserProfileMetadata, +} from "../defs/userProfileMetadata.js"; import type UserRepresentation from "../defs/userRepresentation.js"; import type UserSessionRepresentation from "../defs/userSessionRepresentation.js"; import Resource from "./resource.js"; diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AdminExtResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AdminExtResource.java index d0a700028a..a9e9fbeff4 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AdminExtResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AdminExtResource.java @@ -46,7 +46,17 @@ public final class AdminExtResource { } @Path("/realms") - public RealmResource realms() { - return new RealmResource(session); + public UIRealmsResource realms() { + return new UIRealmsResource(session); + } + + @Path("/") + public UIRealmResource realm() { + return new UIRealmResource(session, auth, adminEvent); + } + + @Path("/users") + public UsersResource users() { + return new UsersResource(session); } } diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmResource.java new file mode 100644 index 0000000000..12ebd30a1d --- /dev/null +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmResource.java @@ -0,0 +1,98 @@ +/* + * + * * Copyright 2023 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.ui.rest; + +import java.io.IOException; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status.Family; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.keycloak.admin.ui.rest.model.UIRealmRepresentation; +import org.keycloak.models.KeycloakSession; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.services.resources.admin.AdminEventBuilder; +import org.keycloak.services.resources.admin.RealmAdminResource; +import org.keycloak.services.resources.admin.UserProfileResource; +import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.util.JsonSerialization; + +/** + * This JAX-RS resource is decorating the Admin Realm API in order to support specific behaviors from the + * administration console. + * + * Its use is restricted to the built-in administration console. + */ +public class UIRealmResource { + + private final RealmAdminResource delegate; + private final KeycloakSession session; + private final AdminPermissionEvaluator auth; + + public UIRealmResource(KeycloakSession session, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) { + this.session = session; + this.auth = auth; + this.delegate = new RealmAdminResource(session, auth, adminEvent); + } + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Operation( hidden = true ) + public Response updateRealm(UIRealmRepresentation rep) { + Response response = delegate.updateRealm(rep); + + if (isSuccessful(response)) { + updateUserProfileConfiguration(rep); + } + + return response; + } + + private void updateUserProfileConfiguration(UIRealmRepresentation rep) { + UPConfig upConfig = rep.getUpConfig(); + + if (upConfig == null) { + return; + } + + String rawUpConfig; + + try { + rawUpConfig = JsonSerialization.writeValueAsString(upConfig); + } catch (IOException e) { + throw new InternalServerErrorException("Failed to parse user profile config", e); + } + + Response response = new UserProfileResource(session, auth).update(rawUpConfig); + + if (isSuccessful(response)) { + return; + } + + throw new InternalServerErrorException("Failed to update user profile configuration"); + } + + private boolean isSuccessful(Response response) { + return Family.SUCCESSFUL.equals(response.getStatusInfo().getFamily()); + } +} diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/RealmResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java similarity index 90% rename from rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/RealmResource.java rename to rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java index b64dc07783..9124ea7136 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/RealmResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java @@ -1,6 +1,12 @@ package org.keycloak.admin.ui.rest; +import static org.keycloak.utils.StreamsUtil.throwIfEmpty; + +import java.util.Objects; +import java.util.stream.Stream; + import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -13,19 +19,16 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.services.ForbiddenException; -import java.util.Objects; -import java.util.stream.Stream; +public class UIRealmsResource { -import static org.keycloak.utils.StreamsUtil.throwIfEmpty; - -public class RealmResource { private final KeycloakSession session; - public RealmResource(KeycloakSession session) { + public UIRealmsResource(KeycloakSession session) { this.session = session; } @GET + @Path("names") @NoCache @Produces(MediaType.APPLICATION_JSON) @Operation( @@ -42,7 +45,7 @@ public class RealmResource { ) )} ) - public Stream realmList() { + public Stream getRealmNames() { Stream realms = session.realms().getRealmsStream().filter(Objects::nonNull).map(RealmModel::getName); return throwIfEmpty(realms, new ForbiddenException()); } diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java new file mode 100644 index 0000000000..3b8be05a46 --- /dev/null +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java @@ -0,0 +1,68 @@ +package org.keycloak.admin.ui.rest; + +import static org.keycloak.userprofile.UserProfileContext.USER_API; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.annotations.cache.NoCache; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileProvider; + +/** + * @author Pedro Igor + */ +public class UserResource { + + private final KeycloakSession session; + private final UserModel user; + + public UserResource(KeycloakSession session, UserModel user) { + this.session = session; + this.user = user; + } + + @GET + @Path("unmanagedAttributes") + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public Map> getUnmanagedAttributes() { + RealmModel realm = session.getContext().getRealm(); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + + if (provider.isEnabled(realm)) { + UserProfile profile = provider.create(USER_API, user); + Map> managedAttributes = profile.getAttributes().getReadable(false); + Map> attributes = new HashMap<>(user.getAttributes()); + UPConfig upConfig = provider.getConfiguration(); + + if (upConfig.getUnmanagedAttributePolicy() == null) { + return Collections.emptyMap(); + } + + Map> unmanagedAttributes = profile.getAttributes().getUnmanagedAttributes(); + managedAttributes.entrySet().removeAll(unmanagedAttributes.entrySet()); + attributes.entrySet().removeAll(managedAttributes.entrySet()); + + attributes.remove(UserModel.USERNAME); + attributes.remove(UserModel.EMAIL); + attributes.remove(UserModel.FIRST_NAME); + attributes.remove(UserModel.LAST_NAME); + + return attributes; + } + + return Collections.emptyMap(); + } + +} diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UsersResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UsersResource.java new file mode 100644 index 0000000000..f01b69f28c --- /dev/null +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UsersResource.java @@ -0,0 +1,28 @@ +package org.keycloak.admin.ui.rest; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +public class UsersResource { + private final KeycloakSession session; + + public UsersResource(KeycloakSession session) { + this.session = session; + } + + @Path("{id}") + public UserResource getUser(@PathParam("id") String id) { + RealmModel realm = session.getContext().getRealm(); + UserModel user = session.users().getUserById(realm, id); + + if (user == null) { + throw new NotFoundException(); + } + + return new UserResource(session, user); + } +} diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/UIRealmRepresentation.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/UIRealmRepresentation.java new file mode 100644 index 0000000000..4a18dccec8 --- /dev/null +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/UIRealmRepresentation.java @@ -0,0 +1,36 @@ +/* + * + * * Copyright 2023 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.ui.rest.model; + +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.userprofile.config.UPConfig; + +public class UIRealmRepresentation extends RealmRepresentation { + + private UPConfig upConfig; + + public UPConfig getUpConfig() { + return upConfig; + } + + public void setUpConfig(UPConfig upConfig) { + this.upConfig = upConfig; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java index 8d5cf9e379..c7911c274e 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java @@ -37,6 +37,8 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.storage.StorageId; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -65,7 +67,9 @@ public class DefaultAttributes extends HashMap> implements protected final UserProfileContext context; protected final KeycloakSession session; private final Map metadataByAttribute; + private final UPConfig upConfig; protected final UserModel user; + private Map> unmanagedAttributes = new HashMap<>(); public DefaultAttributes(UserProfileContext context, Map attributes, UserModel user, UserProfileMetadata profileMetadata, @@ -74,11 +78,16 @@ public class DefaultAttributes extends HashMap> implements this.user = user; this.session = session; this.metadataByAttribute = configureMetadata(profileMetadata.getAttributes()); + this.upConfig = session.getProvider(UserProfileProvider.class).getConfiguration(); putAll(Collections.unmodifiableMap(normalizeAttributes(attributes))); } @Override public boolean isReadOnly(String attributeName) { + if (!isManagedAttribute(attributeName)) { + return !isAllowEditUnmanagedAttribute(); + } + if (UserModel.USERNAME.equals(attributeName)) { if (isServiceAccountUser()) { return true; @@ -98,6 +107,23 @@ public class DefaultAttributes extends HashMap> implements return getMetadata(attributeName) == null; } + private boolean isAllowEditUnmanagedAttribute() { + UnmanagedAttributePolicy unmanagedAttributesPolicy = upConfig.getUnmanagedAttributePolicy(); + + if (!isAllowUnmanagedAttribute()) { + return false; + } + + switch (unmanagedAttributesPolicy) { + case ENABLED: + return true; + case ADMIN_EDIT: + return UserProfileContext.USER_API.equals(context); + } + + return false; + } + /** * Checks whether an attribute is marked as read only by looking at its metadata. * @@ -195,8 +221,8 @@ public class DefaultAttributes extends HashMap> implements AttributeMetadata metadata = getMetadata(name); RealmModel realm = session.getContext().getRealm(); - if (UserModel.USERNAME.equals(name) - && realm.isRegistrationEmailAsUsername()) { + if ((UserModel.USERNAME.equals(name) && realm.isRegistrationEmailAsUsername()) + || !isManagedAttribute(name)) { continue; } @@ -210,13 +236,27 @@ public class DefaultAttributes extends HashMap> implements @Override public AttributeMetadata getMetadata(String name) { - AttributeMetadata metadata = metadataByAttribute.get(name); + if (unmanagedAttributes.containsKey(name)) { + return new AttributeMetadata(name, Integer.MAX_VALUE) { + final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); - if (metadata == null) { - return null; + @Override + public boolean canView(AttributeContext context) { + return canEdit(context) + || (UnmanagedAttributePolicy.ADMIN_VIEW.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext())); + } + + @Override + public boolean canEdit(AttributeContext context) { + return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy) + || (UnmanagedAttributePolicy.ADMIN_EDIT.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext())); + } + }; } - return metadata.clone(); + return Optional.ofNullable(metadataByAttribute.get(name)) + .map(AttributeMetadata::clone) + .orElse(null); } @Override @@ -302,6 +342,9 @@ public class DefaultAttributes extends HashMap> implements String key = entry.getKey(); if (!isSupportedAttribute(key)) { + if (!isManagedAttribute(key) && isAllowUnmanagedAttribute()) { + unmanagedAttributes.put(key, (List) entry.getValue()); + } continue; } @@ -362,9 +405,32 @@ public class DefaultAttributes extends HashMap> implements } } + if (isAllowUnmanagedAttribute()) { + newAttributes.putAll(unmanagedAttributes); + } + return newAttributes; } + private boolean isAllowUnmanagedAttribute() { + UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); + + if (unmanagedAttributePolicy == null) { + // unmanaged attributes disabled + return false; + } + + switch (unmanagedAttributePolicy) { + case ADMIN_EDIT: + case ADMIN_VIEW: + // unmanaged attributes only available through the admin context + return UserProfileContext.USER_API.equals(context); + } + + // allow unmanaged attributes if enabled to all contexts + return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy); + } + private void setUserName(Map> newAttributes, List lowerCaseEmailList) { if (isServiceAccountUser()) { return; @@ -390,7 +456,7 @@ public class DefaultAttributes extends HashMap> implements return false; } - if (metadataByAttribute.containsKey(name)) { + if (isManagedAttribute(name)) { return true; } @@ -406,6 +472,10 @@ public class DefaultAttributes extends HashMap> implements return isRootAttribute(name); } + private boolean isManagedAttribute(String name) { + return metadataByAttribute.containsKey(name); + } + /** *

Returns whether an attribute is read only based on the provider configuration (using provider config), * usually related to internal attributes managed by the server. @@ -434,4 +504,9 @@ public class DefaultAttributes extends HashMap> implements return false; } + + @Override + public Map> getUnmanagedAttributes() { + return unmanagedAttributes; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java index 53968dfa44..92792cc830 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java @@ -19,6 +19,7 @@ package org.keycloak.userprofile; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -105,7 +106,9 @@ public final class DefaultUserProfile implements UserProfile { } try { - for (Map.Entry> attribute : attributes.getWritable().entrySet()) { + Map> writable = new HashMap<>(attributes.getWritable()); + + for (Map.Entry> attribute : writable.entrySet()) { String name = attribute.getKey(); List currentValue = user.getAttributeStream(name) .filter(Objects::nonNull).collect(Collectors.toList()); diff --git a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java index 1440af754b..1967c68051 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/AttributeMetadata.java @@ -35,7 +35,7 @@ import org.keycloak.sessions.AuthenticationSessionModel; /** * @author Pedro Igor */ -public final class AttributeMetadata { +public class AttributeMetadata { public static final Predicate ALWAYS_TRUE = context -> true; public static final Predicate ALWAYS_FALSE = context -> false; diff --git a/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java b/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java index 080208f520..af5a560b0a 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java @@ -177,4 +177,6 @@ public interface Attributes { } Map> toMap(); + + Map> getUnmanagedAttributes(); } diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index abfca5cc9e..c3d0ac3cf9 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -299,6 +299,8 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { break; case UPDATE_USER_PROFILE: attributes.put("profile", new VerifyProfileBean(user, formData, session)); + userCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR); + attributes.put("user", new ProfileBean(userCtx, formData)); break; case IDP_REVIEW_USER_PROFILE: UpdateProfileContext idpCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java index 5f45d7a97a..ff44cfbfa6 100644 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/AbstractUserProfileBean.java @@ -12,6 +12,7 @@ import jakarta.ws.rs.core.MultivaluedMap; import org.keycloak.models.KeycloakSession; import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileProvider; @@ -89,9 +90,11 @@ public abstract class AbstractUserProfileBean { private List toAttributes(Map> attributes, boolean writeableOnly) { if(attributes == null) return null; - return attributes.keySet().stream().map(name -> profile.getAttributes().getMetadata(name)) - .filter((am) -> writeableOnly ? !profile.getAttributes().isReadOnly(am.getName()) : true) + Attributes profileAttributes = profile.getAttributes(); + return attributes.keySet().stream().map(profileAttributes::getMetadata) .filter(Objects::nonNull) + .filter((am) -> writeableOnly ? !profileAttributes.isReadOnly(am.getName()) : true) + .filter((am) -> !profileAttributes.getUnmanagedAttributes().containsKey(am.getName())) .map(Attribute::new) .sorted() .collect(Collectors.toList()); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java index 4c973060ea..a5beb764e4 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java @@ -52,12 +52,12 @@ import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.AttributeValidatorMetadata; +import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPGroup; -import org.keycloak.util.JsonSerialization; import org.keycloak.validate.Validators; /** @@ -119,14 +119,17 @@ public class UserProfileResource { } public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) { - Map> am = profile.getAttributes().getReadable(); + Attributes profileAttributes = profile.getAttributes(); + Map> am = profileAttributes.getReadable(); if(am == null) return null; + Map> unmanagedAttributes = profileAttributes.getUnmanagedAttributes(); List attributes = am.keySet().stream() - .map(name -> profile.getAttributes().getMetadata(name)) + .map(profileAttributes::getMetadata) .filter(Objects::nonNull) + .filter(attributeMetadata -> !unmanagedAttributes.containsKey(attributeMetadata.getName())) .sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder)) .map(sam -> toRestMetadata(sam, session, profile)) .collect(Collectors.toList()); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 1dd3f83566..edb5131f3e 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -60,10 +60,7 @@ import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.models.utils.RoleUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.RedirectUtils; -import org.keycloak.provider.ConfiguredProvider; 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.ErrorRepresentation; import org.keycloak.representations.idm.FederatedIdentityRepresentation; @@ -85,12 +82,9 @@ import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.validation.Validation; import org.keycloak.storage.ReadOnlyException; -import org.keycloak.userprofile.AttributeMetadata; -import org.keycloak.userprofile.AttributeValidatorMetadata; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.ValidationException; -import org.keycloak.utils.GroupUtils; import org.keycloak.utils.ProfileHelper; import jakarta.ws.rs.BadRequestException; @@ -110,7 +104,6 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.UriBuilder; -import org.keycloak.validate.Validators; import java.net.URI; import java.text.MessageFormat; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index f46641d0a0..d04b586178 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -58,6 +58,7 @@ import org.keycloak.models.UserModel; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy; import org.keycloak.services.messages.Messages; import org.keycloak.storage.StorageId; import org.keycloak.storage.ldap.idm.model.LDAPObject; @@ -1769,4 +1770,66 @@ public class UserProfileTest extends AbstractUserProfileTest { assertTrue(profile.getAttributes().contains(UserModel.FIRST_NAME)); assertTrue(profile.getAttributes().contains(UserModel.LAST_NAME)); } + + @Test + public void testUnmanagedPolicy() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testUnmanagedPolicy); + } + + private static void testUnmanagedPolicy(KeycloakSession session) throws IOException { + UPConfig config = new UPConfig(); + UPAttribute bar = new UPAttribute("bar"); + UPAttributePermissions permissions = new UPAttributePermissions(); + + permissions.setEdit(Set.of("user", "admin")); + + bar.setPermissions(permissions); + + config.addAttribute(bar); + + UserProfileProvider provider = getUserProfileProvider(session); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + + // can't create attribute if policy is disabled + Map attributes = new HashMap<>(); + attributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId() + "@keycloak.org"); + attributes.put(UserModel.EMAIL, org.keycloak.models.utils.KeycloakModelUtils.generateId() + "@keycloak.org"); + attributes.put("foo", List.of("foo")); + UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); + UserModel user = profile.create(); + assertFalse(user.getAttributes().containsKey("foo")); + + // user already set with an unmanaged attribute, and it should be visible if policy is adminEdit + user.setSingleAttribute("foo", "foo"); + profile = provider.create(UserProfileContext.USER_API, attributes, user); + assertFalse(profile.getAttributes().contains("foo")); + config.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ADMIN_EDIT); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + profile = provider.create(UserProfileContext.USER_API, attributes, user); + assertTrue(profile.getAttributes().contains("foo")); + assertFalse(profile.getAttributes().isReadOnly("foo")); + + // user already set with an unmanaged attribute, and it should be visible if policy is adminView but read-only + config.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ADMIN_VIEW); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + profile = provider.create(UserProfileContext.USER_API, attributes, user); + assertTrue(profile.getAttributes().contains("foo")); + assertTrue(profile.getAttributes().isReadOnly("foo")); + + // user already set with an unmanaged attribute, but it is not available to user-facing contexts + config.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ADMIN_VIEW); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes, user); + assertFalse(profile.getAttributes().contains("foo")); + + // user already set with an unmanaged attribute, and it is available to all contexts + config.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + profile = provider.create(UserProfileContext.UPDATE_PROFILE, attributes, user); + assertTrue(profile.getAttributes().contains("foo")); + assertFalse(profile.getAttributes().isReadOnly("foo")); + profile = provider.create(UserProfileContext.USER_API, attributes, user); + assertTrue(profile.getAttributes().contains("foo")); + assertFalse(profile.getAttributes().isReadOnly("foo")); + } }