Distinguish realm and org groups in IdP mapper config

Closes #46735

Signed-off-by: vramik <vramik@redhat.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Vlasta Ramik 2026-03-09 13:46:06 +01:00 committed by GitHub
parent 46bcdb36a4
commit fe50faec43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 71 additions and 21 deletions

View file

@ -1055,6 +1055,8 @@ identityProviderEntityId=Identity provider entity ID
userInfoSignedResponseAlgorithm=User info signed response algorithm
selectGroup=Select group
selectOrgGroup=Select Organization Group
groupType=Group type
groupTypeHelp=Indicates whether the selected group is a realm group or an organization group. This determines where the mapper will look for the group when it is executed.
scopePermissions.groups.view-members-description=Policies that decide if an administrator can view the members of this group
tableOfGroups=Table of groups
allowed-protocol-mappers.tooltip=List of allowed protocol mapper providers. If there is an attempt to register client, which contains some protocol mappers, which were not allowed, registration request will be rejected.

View file

@ -8,7 +8,7 @@ import {
FormGroup,
} from "@patternfly/react-core";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { HelpItem } from "@keycloak/keycloak-ui-shared";
@ -17,6 +17,7 @@ import {
useGroupResource,
GroupResourceContext,
} from "../../context/group-resource/GroupResourceContext";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { GroupPickerDialog } from "../group/GroupPickerDialog";
import type { ComponentProps } from "./components";
@ -31,10 +32,27 @@ export const GroupComponent = ({
const [open, setOpen] = useState(false);
const [openOrgGroups, setOpenOrgGroups] = useState(false);
const [groups, setGroups] = useState<GroupRepresentation[]>();
const { control } = useFormContext();
const { control, setValue } = useFormContext();
const { adminClient } = useAdminClient();
const serverInfo = useServerInfo();
const hasLinkedOrganization = useGroupResource().isOrgGroups();
const groupTypeFieldName = convertToName("groupType");
// Get group type enum values from server
const groupTypes = serverInfo.enums?.["type"] || [];
const GROUP_TYPE_REALM =
groupTypes.find((t: string) => t === "REALM") || "REALM";
const GROUP_TYPE_ORG =
groupTypes.find((t: string) => t === "ORGANIZATION") || "ORGANIZATION";
const groupType = useWatch({
name: groupTypeFieldName,
control,
defaultValue: GROUP_TYPE_REALM,
});
const shouldRenderOrgField =
hasLinkedOrganization || groupType == GROUP_TYPE_ORG;
return (
<Controller
name={convertToName(name!)}
@ -52,6 +70,7 @@ export const GroupComponent = ({
}}
onConfirm={(groups) => {
field.onChange(groups?.[0].path);
setValue(groupTypeFieldName, GROUP_TYPE_REALM);
setGroups(groups);
setOpen(false);
}}
@ -69,6 +88,7 @@ export const GroupComponent = ({
}}
onConfirm={(groups) => {
field.onChange(groups?.[0].path);
setValue(groupTypeFieldName, GROUP_TYPE_ORG);
setGroups(groups);
setOpenOrgGroups(false);
}}
@ -89,7 +109,20 @@ export const GroupComponent = ({
<ActionListItem>
<ChipGroup>
{field.value && (
<Chip onClick={() => field.onChange(undefined)}>
<Chip
onClick={() => {
field.onChange(undefined);
setValue(groupTypeFieldName, undefined);
}}
>
{shouldRenderOrgField && (
<>
{groupType === GROUP_TYPE_REALM
? t("realm")
: t("organization")}
:&nbsp;
</>
)}
{field.value}
</Chip>
)}
@ -105,7 +138,7 @@ export const GroupComponent = ({
{t("selectGroup")}
</Button>
</ActionListItem>
{hasLinkedOrganization && (
{shouldRenderOrgField && (
<ActionListItem>
<Button
id="kc-join-org-groups-button"

View file

@ -48,9 +48,7 @@ export default function AddMapper() {
const { t } = useTranslation();
const form = useForm<IdPMapperRepresentationWithAttributes>({
shouldUnregister: true,
});
const form = useForm<IdPMapperRepresentationWithAttributes>();
const { handleSubmit } = form;
const { addAlert, addError } = useAlerts();
const navigate = useNavigate();

View file

@ -24,4 +24,5 @@ package org.keycloak.broker.provider;
public interface ConfigConstants {
String ROLE = "role";
String GROUP = "group";
String GROUP_TYPE = "groupType";
}

View file

@ -986,15 +986,15 @@ public final class KeycloakModelUtils {
/**
* Retrieves and validates a group for use in an Identity Provider mapper.
* This method handles organization-aware group lookup.
*
* When the IdP is linked to an organization, this method first attempts to find the group
* within that organization's groups. If not found (or IdP not linked to org), it falls back
* to searching realm groups.
* The lookup strategy is determined by the {@code groupType} config value:
* <ul>
* <li>{@code "ORGANIZATION"} searches within the organization groups linked to the IdP</li>
* <li>{@code "REALM"} or missing searches realm groups</li>
* </ul>
*
* @param session the Keycloak session
* @param realm the realm
* @param mapperModel the mapper model configuration containing the group path
* @param mapperModel the mapper model configuration containing the group path and group type
* @param context the brokered identity context containing the IdP configuration
* @return the group if found and valid, null otherwise (mapper should be skipped)
*/
@ -1003,17 +1003,26 @@ public final class KeycloakModelUtils {
IdentityProviderMapperModel mapperModel,
BrokeredIdentityContext context) {
String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP);
String groupTypeStr = mapperModel.getConfig().get(ConfigConstants.GROUP_TYPE);
GroupModel group = null;
// Check if IdP is linked to organization and validate the relationship
OrganizationModel organization = getOrganizationForIdpMapper(session, context.getIdpConfig());
if (organization != null) {
group = findGroupByPath(session, realm, organization, groupPath);
// Parse the group type from config
GroupModel.Type groupType = null;
if (groupTypeStr != null) {
try {
groupType = GroupModel.Type.valueOf(groupTypeStr);
} catch (IllegalArgumentException e) {
// Invalid group type, treat as null
}
}
// If not found in organization (or IdP not in org context), try as realm group
if (group == null) {
if (groupType == GroupModel.Type.ORGANIZATION) {
OrganizationModel organization = getOrganizationForIdpMapper(session, context.getIdpConfig());
if (organization != null) {
group = findGroupByPath(session, realm, organization, groupPath);
}
} else {
// GroupModel.Type.REALM or null search realm groups
group = findGroupByPath(session, realm, groupPath);
}

View file

@ -50,6 +50,7 @@ import org.keycloak.events.EventType;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation;
@ -99,7 +100,7 @@ import org.jboss.resteasy.reactive.NoCache;
@Extension(name = KeycloakOpenAPI.Profiles.ADMIN , value = "")
public class ServerInfoAdminResource {
private static final Map<String, List<String>> ENUMS = createEnumsMap(EventType.class, OperationType.class, ResourceType.class);
private static final Map<String, List<String>> ENUMS = createEnumsMap(EventType.class, OperationType.class, ResourceType.class, GroupModel.Type.class);
private final KeycloakSession session;
private final AdminAuth auth;

View file

@ -26,6 +26,7 @@ import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.broker.oidc.mappers.AdvancedClaimToGroupMapper;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.broker.provider.HardcodedGroupMapper;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.representations.idm.GroupRepresentation;
@ -209,6 +210,7 @@ public class OrganizationGroupOidcIdpMapperTest extends AbstractOrganizationTest
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, groupPath)
.put(ConfigConstants.GROUP_TYPE, GroupModel.Type.ORGANIZATION.name())
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
@ -252,6 +254,7 @@ public class OrganizationGroupOidcIdpMapperTest extends AbstractOrganizationTest
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString())
.put(ConfigConstants.GROUP, groupPath)
.put(ConfigConstants.GROUP_TYPE, GroupModel.Type.ORGANIZATION.name())
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {
@ -358,6 +361,7 @@ public class OrganizationGroupOidcIdpMapperTest extends AbstractOrganizationTest
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString())
.put(ConfigConstants.GROUP, groupPath)
.put(ConfigConstants.GROUP_TYPE, GroupModel.Type.ORGANIZATION.name())
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {

View file

@ -26,6 +26,7 @@ import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.broker.provider.ConfigConstants;
import org.keycloak.broker.provider.HardcodedGroupMapper;
import org.keycloak.broker.saml.SAMLIdentityProviderConfig;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.IdentityProviderSyncMode;
@ -100,6 +101,7 @@ public class OrganizationGroupSamlIdpMapperTest extends AbstractOrganizationTest
mapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString())
.put(ConfigConstants.GROUP, groupPath)
.put(ConfigConstants.GROUP_TYPE, GroupModel.Type.ORGANIZATION.name())
.build());
try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) {