Add options to change behavior on how unmanaged attributes are managed
Closes #24934 Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
parent
ed5bf7096a
commit
c7f63d5843
38 changed files with 679 additions and 80 deletions
|
@ -31,9 +31,17 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
|
|||
*/
|
||||
public class UPConfig {
|
||||
|
||||
public enum UnmanagedAttributePolicy {
|
||||
ENABLED,
|
||||
ADMIN_VIEW,
|
||||
ADMIN_EDIT
|
||||
}
|
||||
|
||||
private List<UPAttribute> attributes;
|
||||
private List<UPGroup> groups;
|
||||
|
||||
private UnmanagedAttributePolicy unmanagedAttributePolicy;
|
||||
|
||||
public List<UPAttribute> 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 + "]";
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<KeyValueInput name="attributes" />
|
||||
<KeyValueInput name={name} isDisabled={isDisabled} />
|
||||
</FormProvider>
|
||||
{!noSaveCancelButtons && (
|
||||
<ActionGroup className="kc-attributes__action-group">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
6
js/apps/admin-ui/src/components/users/resource.ts
Normal file
6
js/apps/admin-ui/src/components/users/resource.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint";
|
||||
|
||||
export const getUnmanagedAttributes = (
|
||||
id: string,
|
||||
): Promise<Record<string, string[]> | undefined> =>
|
||||
fetchAdminUI(`ui-ext/users/${id}/unmanagedAttributes`);
|
|
@ -34,7 +34,7 @@ export const RealmsProvider = ({ children }: PropsWithChildren) => {
|
|||
}
|
||||
|
||||
try {
|
||||
return await fetchAdminUI<string[]>("ui-ext/realms", {});
|
||||
return await fetchAdminUI<string[]>("ui-ext/realms/names", {});
|
||||
} catch (error) {
|
||||
if (error instanceof NetworkError && error.response.status < 500) {
|
||||
return [];
|
||||
|
|
|
@ -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<RealmRepresentation, "groups">;
|
||||
|
@ -56,6 +62,18 @@ export const RealmSettingsGeneralTab = ({
|
|||
|
||||
const requireSslTypes = ["all", "external", "none"];
|
||||
|
||||
const [userProfileConfig, setUserProfileConfig] =
|
||||
useState<UserProfileConfig>();
|
||||
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 });
|
||||
})}
|
||||
>
|
||||
<FormGroup
|
||||
label={t("realmId")}
|
||||
|
@ -244,13 +277,52 @@ export const RealmSettingsGeneralTab = ({
|
|||
label={t("on")}
|
||||
labelOff={t("off")}
|
||||
isChecked={field.value === "true"}
|
||||
onChange={(value) => field.onChange(value.toString())}
|
||||
onChange={(value) => {
|
||||
field.onChange(value.toString());
|
||||
setUserProfileEnabled(value);
|
||||
}}
|
||||
aria-label={t("userProfileEnabled")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
{isUserProfileEnabled && (
|
||||
<FormGroup
|
||||
label={t("unmanagedAttributes")}
|
||||
fieldId="kc-user-profile-unmanaged-attribute-policy"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("unmanagedAttributesHelpText")}
|
||||
fieldLabelId="unmanagedAttributes"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
toggleId="kc-user-profile-unmanaged-attribute-policy"
|
||||
onToggle={() =>
|
||||
setIsUnmanagedAttributeOpen(!isUnmanagedAttributeOpen)
|
||||
}
|
||||
onSelect={(_, value) => {
|
||||
if (userProfileConfig) {
|
||||
userProfileConfig.unmanagedAttributePolicy =
|
||||
value as UnmanagedAttributePolicy;
|
||||
setUserProfileConfig(userProfileConfig);
|
||||
}
|
||||
setIsUnmanagedAttributeOpen(false);
|
||||
}}
|
||||
selections={userProfileConfig?.unmanagedAttributePolicy}
|
||||
variant={SelectVariant.single}
|
||||
isOpen={isUnmanagedAttributeOpen}
|
||||
>
|
||||
{unmanagedAttributePolicies.map((policy) => (
|
||||
<SelectOption key={policy} value={policy}>
|
||||
{t(`unmanagedAttributePolicy.${policy}`)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormGroup
|
||||
label={t("endpoints")}
|
||||
labelIcon={
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||
import type {
|
||||
UserProfileAttribute,
|
||||
UserProfileConfig,
|
||||
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||
import {
|
||||
AlertVariant,
|
||||
Button,
|
||||
|
|
|
@ -49,6 +49,11 @@ import { ClientPoliciesTab, toClientPolicies } from "./routes/ClientPolicies";
|
|||
import { RealmSettingsTab, toRealmSettings } from "./routes/RealmSettings";
|
||||
import { SecurityDefenses } from "./security-defences/SecurityDefenses";
|
||||
import { UserProfileTab } from "./user-profile/UserProfileTab";
|
||||
import { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||
|
||||
export interface UIRealmRepresentation extends RealmRepresentation {
|
||||
upConfig?: UserProfileConfig;
|
||||
}
|
||||
|
||||
type RealmSettingsHeaderProps = {
|
||||
onChange: (value: boolean) => 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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<UserFormFields>({ mode: "onChange" });
|
||||
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||
const [user, setUser] = useState<UserRepresentation>();
|
||||
const [user, setUser] = useState<UIUserRepresentation>();
|
||||
const [bruteForced, setBruteForced] = useState<BruteForced>();
|
||||
const [isUnmanagedAttributesEnabled, setUnmanagedAttributesEnabled] =
|
||||
useState<boolean>();
|
||||
const [userProfileMetadata, setUserProfileMetadata] =
|
||||
useState<UserProfileMetadata>();
|
||||
const [refreshCount, setRefreshCount] = useState(0);
|
||||
const refresh = () => setRefreshCount((count) => count + 1);
|
||||
const lightweightUser = isLightweightUser(user?.id);
|
||||
const [upConfig, setUpConfig] = useState<UserProfileConfig>();
|
||||
|
||||
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() {
|
|||
/>
|
||||
</PageSection>
|
||||
</Tab>
|
||||
{!userProfileMetadata && (
|
||||
{isUnmanagedAttributesEnabled && (
|
||||
<Tab
|
||||
data-testid="attributes"
|
||||
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
||||
{...attributesTab}
|
||||
>
|
||||
<UserAttributes user={user} save={save} />
|
||||
<UserAttributes
|
||||
user={user}
|
||||
save={save}
|
||||
upConfig={upConfig}
|
||||
isUserProfileEnabled={!!userProfileMetadata}
|
||||
/>
|
||||
</Tab>
|
||||
)}
|
||||
<Tab
|
||||
|
|
|
@ -7,13 +7,24 @@ import {
|
|||
AttributesForm,
|
||||
} from "../components/key-value-form/AttributeForm";
|
||||
import { UserFormFields, toUserFormFields } from "./form-state";
|
||||
import {
|
||||
UnmanagedAttributePolicy,
|
||||
UserProfileConfig,
|
||||
} from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||
|
||||
type UserAttributesProps = {
|
||||
user: UserRepresentation;
|
||||
save: (user: UserFormFields) => void;
|
||||
upConfig?: UserProfileConfig;
|
||||
isUserProfileEnabled: boolean;
|
||||
};
|
||||
|
||||
export const UserAttributes = ({ user, save }: UserAttributesProps) => {
|
||||
export const UserAttributes = ({
|
||||
user,
|
||||
save,
|
||||
upConfig,
|
||||
isUserProfileEnabled,
|
||||
}: UserAttributesProps) => {
|
||||
const form = useFormContext<UserFormFields>();
|
||||
|
||||
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
|
||||
}
|
||||
/>
|
||||
</PageSection>
|
||||
);
|
||||
|
|
|
@ -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<string, string | string[]>;
|
||||
unmanagedAttributes?: KeyValueType[] | Record<string, string | string[]>;
|
||||
};
|
||||
|
||||
export interface UIUserRepresentation extends UserRepresentation {
|
||||
unmanagedAttributes?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
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<string, string[]> = {},
|
||||
unmanagedAttributes: Record<string, string[]> = {},
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(attributes).filter(
|
||||
([key]) => !Object.hasOwn(unmanagedAttributes, key),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ export class Realms extends Resource {
|
|||
void
|
||||
>({
|
||||
method: "PUT",
|
||||
path: "/{realm}",
|
||||
path: "/{realm}/ui-ext",
|
||||
urlParamKeys: ["realm"],
|
||||
});
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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<String> realmList() {
|
||||
public Stream<String> getRealmNames() {
|
||||
Stream<String> realms = session.realms().getRealmsStream().filter(Objects::nonNull).map(RealmModel::getName);
|
||||
return throwIfEmpty(realms, new ForbiddenException());
|
||||
}
|
|
@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
|
||||
*/
|
||||
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<String, List<String>> getUnmanagedAttributes() {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
|
||||
|
||||
if (provider.isEnabled(realm)) {
|
||||
UserProfile profile = provider.create(USER_API, user);
|
||||
Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable(false);
|
||||
Map<String, List<String>> attributes = new HashMap<>(user.getAttributes());
|
||||
UPConfig upConfig = provider.getConfiguration();
|
||||
|
||||
if (upConfig.getUnmanagedAttributePolicy() == null) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
Map<String, List<String>> 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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<String, List<String>> implements
|
|||
protected final UserProfileContext context;
|
||||
protected final KeycloakSession session;
|
||||
private final Map<String, AttributeMetadata> metadataByAttribute;
|
||||
private final UPConfig upConfig;
|
||||
protected final UserModel user;
|
||||
private Map<String, List<String>> unmanagedAttributes = new HashMap<>();
|
||||
|
||||
public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
|
||||
UserProfileMetadata profileMetadata,
|
||||
|
@ -74,11 +78,16 @@ public class DefaultAttributes extends HashMap<String, List<String>> 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<String, List<String>> 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<String, List<String>> 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<String, List<String>> 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<String, List<String>> implements
|
|||
String key = entry.getKey();
|
||||
|
||||
if (!isSupportedAttribute(key)) {
|
||||
if (!isManagedAttribute(key) && isAllowUnmanagedAttribute()) {
|
||||
unmanagedAttributes.put(key, (List<String>) entry.getValue());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -362,9 +405,32 @@ public class DefaultAttributes extends HashMap<String, List<String>> 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<String, List<String>> newAttributes, List<String> lowerCaseEmailList) {
|
||||
if (isServiceAccountUser()) {
|
||||
return;
|
||||
|
@ -390,7 +456,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
return false;
|
||||
}
|
||||
|
||||
if (metadataByAttribute.containsKey(name)) {
|
||||
if (isManagedAttribute(name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -406,6 +472,10 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
return isRootAttribute(name);
|
||||
}
|
||||
|
||||
private boolean isManagedAttribute(String name) {
|
||||
return metadataByAttribute.containsKey(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>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<String, List<String>> implements
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getUnmanagedAttributes() {
|
||||
return unmanagedAttributes;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String, List<String>> attribute : attributes.getWritable().entrySet()) {
|
||||
Map<String, List<String>> writable = new HashMap<>(attributes.getWritable());
|
||||
|
||||
for (Map.Entry<String, List<String>> attribute : writable.entrySet()) {
|
||||
String name = attribute.getKey();
|
||||
List<String> currentValue = user.getAttributeStream(name)
|
||||
.filter(Objects::nonNull).collect(Collectors.toList());
|
||||
|
|
|
@ -35,7 +35,7 @@ import org.keycloak.sessions.AuthenticationSessionModel;
|
|||
/**
|
||||
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
|
||||
*/
|
||||
public final class AttributeMetadata {
|
||||
public class AttributeMetadata {
|
||||
|
||||
public static final Predicate<AttributeContext> ALWAYS_TRUE = context -> true;
|
||||
public static final Predicate<AttributeContext> ALWAYS_FALSE = context -> false;
|
||||
|
|
|
@ -177,4 +177,6 @@ public interface Attributes {
|
|||
}
|
||||
|
||||
Map<String, List<String>> toMap();
|
||||
|
||||
Map<String, List<String>> getUnmanagedAttributes();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<Attribute> toAttributes(Map<String, List<String>> 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());
|
||||
|
|
|
@ -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<String, List<String>> am = profile.getAttributes().getReadable();
|
||||
Attributes profileAttributes = profile.getAttributes();
|
||||
Map<String, List<String>> am = profileAttributes.getReadable();
|
||||
|
||||
if(am == null)
|
||||
return null;
|
||||
Map<String, List<String>> unmanagedAttributes = profileAttributes.getUnmanagedAttributes();
|
||||
|
||||
List<UserProfileAttributeMetadata> 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());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<String, Object> 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"));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue