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:
Pedro Igor 2023-11-24 18:31:26 -03:00
parent ed5bf7096a
commit c7f63d5843
38 changed files with 679 additions and 80 deletions

View file

@ -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 + "]";

View file

@ -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({

View file

@ -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,

View file

@ -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";

View file

@ -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

View file

@ -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">

View file

@ -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,

View file

@ -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,

View file

@ -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,

View 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`);

View file

@ -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 [];

View file

@ -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={

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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";

View file

@ -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,

View file

@ -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

View file

@ -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>
);

View file

@ -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),
),
);
}

View file

@ -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",
}

View file

@ -53,7 +53,7 @@ export class Realms extends Resource {
void
>({
method: "PUT",
path: "/{realm}",
path: "/{realm}/ui-ext",
urlParamKeys: ["realm"],
});

View file

@ -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";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -177,4 +177,6 @@ public interface Attributes {
}
Map<String, List<String>> toMap();
Map<String, List<String>> getUnmanagedAttributes();
}

View file

@ -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);

View file

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

View file

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

View file

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

View file

@ -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"));
}
}