mirror of
https://github.com/keycloak/keycloak.git
synced 2026-06-03 22:03:16 -04:00
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:
parent
46bcdb36a4
commit
fe50faec43
8 changed files with 71 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
:
|
||||
</>
|
||||
)}
|
||||
{field.value}
|
||||
</Chip>
|
||||
)}
|
||||
|
|
@ -105,7 +138,7 @@ export const GroupComponent = ({
|
|||
{t("selectGroup")}
|
||||
</Button>
|
||||
</ActionListItem>
|
||||
{hasLinkedOrganization && (
|
||||
{shouldRenderOrgField && (
|
||||
<ActionListItem>
|
||||
<Button
|
||||
id="kc-join-org-groups-button"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -24,4 +24,5 @@ package org.keycloak.broker.provider;
|
|||
public interface ConfigConstants {
|
||||
String ROLE = "role";
|
||||
String GROUP = "group";
|
||||
String GROUP_TYPE = "groupType";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue