From b7bef85f918ba62e7c039ee5a72d7cfdc454a1d3 Mon Sep 17 00:00:00 2001 From: Martin Kanis Date: Thu, 26 Feb 2026 13:44:12 +0100 Subject: [PATCH] Organization Groups - Identity Provider Mappers (#46592) Closes #45512 Signed-off-by: Martin Kanis --- .../OrganizationIdentityProviderResource.java | 17 + .../models/utils/KeycloakModelUtils.java | 75 ++++ .../mappers/AbstractClaimToGroupMapper.java | 22 +- .../AbstractAttributeToGroupMapper.java | 31 +- ...OrganizationIdentityProvidersResource.java | 51 +++ .../admin/IdentityProviderResource.java | 3 + .../OrganizationGroupOidcIdpMapperTest.java | 387 ++++++++++++++++++ .../OrganizationGroupSamlIdpMapperTest.java | 119 ++++++ 8 files changed, 656 insertions(+), 49 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupOidcIdpMapperTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupSamlIdpMapperTest.java diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java index 7b64bb5113f..aef4e2f5b87 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/OrganizationIdentityProviderResource.java @@ -17,12 +17,18 @@ package org.keycloak.admin.client.resource; +import java.util.List; + import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; public interface OrganizationIdentityProviderResource { @@ -33,4 +39,15 @@ public interface OrganizationIdentityProviderResource { @DELETE Response delete(); + + @Path("groups") + @GET + @Produces(MediaType.APPLICATION_JSON) + List getGroups(@QueryParam("search") String search, + @QueryParam("q") String searchQuery, + @QueryParam("exact") @DefaultValue("false") Boolean exact, + @QueryParam("first") Integer first, + @QueryParam("max") Integer max, + @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation, + @QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index 00a69e98655..cafee4ac167 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -47,6 +47,8 @@ import jakarta.transaction.Transaction; import org.keycloak.Config; import org.keycloak.Config.Scope; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.ConfigConstants; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.broker.social.SocialIdentityProviderFactory; import org.keycloak.cache.AlternativeLookupProvider; @@ -70,6 +72,7 @@ import org.keycloak.models.Constants; import org.keycloak.models.GroupModel; import org.keycloak.models.GroupProvider; import org.keycloak.models.GroupProviderFactory; +import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; @@ -950,6 +953,78 @@ public final class KeycloakModelUtils { return getGroupModel(session, realm, orgInternalGroup, split, 0); } + /** + * Validates and retrieves the organization for an Identity Provider mapper. + * This performs all necessary checks to ensure the IdP-organization relationship is valid: + * - Organizations feature is enabled + * - Organization exists and is enabled + * - Bidirectional link exists (organization still has this IdP) + * + * @param session the Keycloak session + * @param idpModel the identity provider model + * @return the validated organization if all checks pass, null otherwise + */ + public static OrganizationModel getOrganizationForIdpMapper(KeycloakSession session, IdentityProviderModel idpModel) { + String idpOrgId = idpModel.getOrganizationId(); + if (idpOrgId == null) { + return null; + } + + OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class); + if (orgProvider != null && orgProvider.isEnabled()) { + OrganizationModel organization = orgProvider.getById(idpOrgId); + + if (organization != null && organization.isEnabled() && organization.getIdentityProviders().anyMatch(idp -> idp.getAlias().equals(idpModel.getAlias()))) { + return organization; + } + } + + logger.warnf("Cannot obtain organization '%s' linked to IdP '%s'", idpModel.getAlias(), idpOrgId); + + return null; + } + + /** + * 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. + * + * @param session the Keycloak session + * @param realm the realm + * @param mapperModel the mapper model configuration containing the group path + * @param context the brokered identity context containing the IdP configuration + * @return the group if found and valid, null otherwise (mapper should be skipped) + */ + public static GroupModel getGroupForIdpMapper(KeycloakSession session, + RealmModel realm, + IdentityProviderMapperModel mapperModel, + BrokeredIdentityContext context) { + String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP); + 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); + } + + // If not found in organization (or IdP not in org context), try as realm group + if (group == null) { + group = findGroupByPath(session, realm, groupPath); + } + + if (group == null) { + logger.warnf("Unable to find group by path '%s' referenced by mapper '%s' on realm '%s'.", groupPath, mapperModel.getName(), realm.getName()); + return null; + } + + return group; + } + public static Stream getClientScopeMappingsStream(ClientModel client, ScopeContainerModel container) { return container.getScopeMappingsStream() .filter(role -> role.getContainer() instanceof ClientModel && diff --git a/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java index 5557731dd04..3472404b32e 100644 --- a/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimToGroupMapper.java @@ -18,7 +18,6 @@ package org.keycloak.broker.oidc.mappers; import org.keycloak.broker.provider.BrokeredIdentityContext; -import org.keycloak.broker.provider.ConfigConstants; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.KeycloakSession; @@ -26,22 +25,17 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; -import org.jboss.logging.Logger; - /** * @author Artur Baltabayev, * Daniel Fesenmeyer */ public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper { - private static final Logger LOG = Logger.getLogger(AbstractClaimToGroupMapper.class); - - @Override public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { - GroupModel group = this.getGroup(session, realm, mapperModel); + GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context); if (group == null) { return; } @@ -55,7 +49,7 @@ public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper { public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { - GroupModel group = this.getGroup(session, realm, mapperModel); + GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context); if (group == null) { return; } @@ -82,16 +76,4 @@ public abstract class AbstractClaimToGroupMapper extends AbstractClaimMapper { protected abstract boolean applies(final IdentityProviderMapperModel mapperModel, final BrokeredIdentityContext context); - private GroupModel getGroup(KeycloakSession session, final RealmModel realm, final IdentityProviderMapperModel mapperModel) { - String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP); - GroupModel group = KeycloakModelUtils.findGroupByPath(session, realm, groupPath); - - if (group == null) { - LOG.warnf("Unable to find group by path '%s' referenced by mapper '%s' on realm '%s'.", groupPath, - mapperModel.getName(), realm.getName()); - } - - return group; - } - } diff --git a/services/src/main/java/org/keycloak/broker/saml/mappers/AbstractAttributeToGroupMapper.java b/services/src/main/java/org/keycloak/broker/saml/mappers/AbstractAttributeToGroupMapper.java index 5e270fa5d69..b2f3951934e 100644 --- a/services/src/main/java/org/keycloak/broker/saml/mappers/AbstractAttributeToGroupMapper.java +++ b/services/src/main/java/org/keycloak/broker/saml/mappers/AbstractAttributeToGroupMapper.java @@ -18,7 +18,6 @@ package org.keycloak.broker.saml.mappers; import org.keycloak.broker.provider.AbstractIdentityProviderMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; -import org.keycloak.broker.provider.ConfigConstants; import org.keycloak.models.GroupModel; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.KeycloakSession; @@ -26,8 +25,6 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; -import org.jboss.logging.Logger; - /** * Abstract class that handles the logic for importing and updating brokered users for all mappers that map a SAML * attribute into a {@code Keycloak} group. @@ -36,12 +33,9 @@ import org.jboss.logging.Logger; */ public abstract class AbstractAttributeToGroupMapper extends AbstractIdentityProviderMapper { - private static final Logger LOG = Logger.getLogger(AbstractAttributeToGroupMapper.class); - - @Override public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { - GroupModel group = this.getGroup(session, realm, mapperModel); + GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context); if (group == null) { return; } @@ -53,7 +47,7 @@ public abstract class AbstractAttributeToGroupMapper extends AbstractIdentityPro @Override public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { - GroupModel group = this.getGroup(session, realm, mapperModel); + GroupModel group = KeycloakModelUtils.getGroupForIdpMapper(session, realm, mapperModel, context); if (group == null) { return; } @@ -79,25 +73,4 @@ public abstract class AbstractAttributeToGroupMapper extends AbstractIdentityPro */ protected abstract boolean applies(final IdentityProviderMapperModel mapperModel, final BrokeredIdentityContext context); - /** - * Obtains the {@link GroupModel} corresponding the group configured in the specified - * {@link IdentityProviderMapperModel}. - * If the group doesn't correspond to one of the realm's client group or to one of the realm's group, this method - * returns {@code null}. - * - * @param session - * @param realm a reference to the realm. - * @param mapperModel a reference to the {@link IdentityProviderMapperModel} containing the configured group. - * @return the {@link GroupModel} that corresponds to the mapper model group or {@code null}, if the group could not be found - */ - - private GroupModel getGroup(KeycloakSession session, final RealmModel realm, final IdentityProviderMapperModel mapperModel) { - String groupPath = mapperModel.getConfig().get(ConfigConstants.GROUP); - GroupModel group = KeycloakModelUtils.findGroupByPath(session, realm, groupPath); - - if (group == null) { - LOG.warnf("Unable to find group by path '%s' referenced by mapper '%s' on realm '%s'.", groupPath, mapperModel.getName(), realm.getName()); - } - return group; - } } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java index 6eecdd56080..d6d93067380 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationIdentityProvidersResource.java @@ -21,11 +21,13 @@ import java.util.stream.Stream; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; @@ -38,6 +40,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.StripSecretsUtils; import org.keycloak.organization.OrganizationProvider; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.KeycloakOpenAPI; @@ -48,6 +51,7 @@ import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.extensions.Extension; import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; @@ -61,12 +65,14 @@ public class OrganizationIdentityProvidersResource { private final KeycloakSession session; private final OrganizationProvider organizationProvider; private final OrganizationModel organization; + private final AdminEventBuilder adminEvent; public OrganizationIdentityProvidersResource(KeycloakSession session, OrganizationModel organization, AdminEventBuilder adminEvent) { this.realm = session == null ? null : session.getContext().getRealm(); this.session = session; this.organizationProvider = session == null ? null : session.getProvider(OrganizationProvider.class); this.organization = organization; + this.adminEvent = adminEvent; } @POST @@ -137,6 +143,51 @@ public class OrganizationIdentityProvidersResource { return toRepresentation(broker); } + /** + * Returns organization groups for the identity provider with the specified alias. + * It allows filtering and displaying only the organization groups that are valid for the given identity provider. + * + * Only returns groups if the identity provider is associated with the organization and the organization + * is enabled. Otherwise, returns an error or empty stream. + * + * @param alias the identity provider alias + * @param search a string to search for in group names + * @param searchQuery a query to search for group attributes, in the format 'key1:value1 key2:value2' + * @param exact if true, perform exact match on the search parameter + * @param first the position of the first result (pagination offset) + * @param max the maximum number of results to return + * @param briefRepresentation if true, return brief group representation; otherwise return full representation + * @return a stream of organization groups associated with the organization + */ + @Path("{alias}/groups") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS) + @Operation(summary = "Returns organization groups for the identity provider", + description = "Returns organization groups that can be used in identity provider mappers. " + + "Only returns groups if the identity provider is associated with the organization.") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "", content = @Content(schema = @Schema(implementation = GroupRepresentation.class, type = SchemaType.ARRAY))), + @APIResponse(responseCode = "404", description = "Not Found") + }) + public Stream getGroups( + @Parameter(description = "The alias of the identity provider") @PathParam("alias") String alias, + @Parameter(description = "A string to search for in group names") @QueryParam("search") String search, + @Parameter(description = "A query to search for group attributes, in the format 'key1:value1 key2:value2'") @QueryParam("q") String searchQuery, + @Parameter(description = "If true, perform exact match on the search parameter") @QueryParam("exact") @DefaultValue("false") Boolean exact, + @Parameter(description = "The position of the first result (pagination offset)") @QueryParam("first") Integer first, + @Parameter(description = "The maximum number of results to return") @QueryParam("max") Integer max, + @Parameter(description = "If true, return brief representation; otherwise return full representation") @QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation, + @Parameter(description = "If true, include subgroups count in the response") @QueryParam("subGroupsCount") @DefaultValue("false") boolean subGroupsCount) { + + // Validate that the identity provider is associated with the organization + getIdentityProvider(alias); + + OrganizationGroupsResource groupsResource = new OrganizationGroupsResource(session, organization, adminEvent); + return groupsResource.getGroups(search, searchQuery, exact, first, max, briefRepresentation, subGroupsCount); + } + @Path("{alias}") @DELETE @Produces(MediaType.APPLICATION_JSON) diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java index 4e6f330a58c..52a9b761dfb 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProviderResource.java @@ -348,6 +348,7 @@ public class IdentityProviderResource { } IdentityProviderMapperModel model = RepresentationToModel.toModel(mapper); + try { // model = realm.addIdentityProviderMapper(model); model = session.identityProviders().createMapper(model); @@ -408,6 +409,7 @@ public class IdentityProviderResource { IdentityProviderMapperModel model = session.identityProviders().getMapperById(id); if (model == null) throw new NotFoundException("Model not found"); model = RepresentationToModel.toModel(rep); + session.identityProviders().updateMapper(model); adminEvent.operation(OperationType.UPDATE).resource(ResourceType.IDENTITY_PROVIDER_MAPPER).resourcePath(session.getContext().getUri()).representation(rep).success(); @@ -518,4 +520,5 @@ public class IdentityProviderResource { IdentityProvider provider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias()); return provider.reloadKeys(); } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupOidcIdpMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupOidcIdpMapperTest.java new file mode 100644 index 00000000000..f919da5dc0c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupOidcIdpMapperTest.java @@ -0,0 +1,387 @@ +/* + * Copyright 2026 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.testsuite.organization.mapper; + +import java.util.List; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +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.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest; + +import com.google.common.collect.ImmutableMap; +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + + +public class OrganizationGroupOidcIdpMapperTest extends AbstractOrganizationTest { + + @Test + public void testAdvancedClaimToGroupMapperWithOrganizationGroup() { + // Create organization with IdP + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + // Create organization group + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName("test-org-group"); + String groupId; + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + groupId = ApiUtil.getCreatedId(response); + } + + GroupRepresentation createdGroup = orgResource.groups().group(groupId).toRepresentation(false); + String groupPath = createdGroup.getPath(); + + // Create AdvancedClaimToGroupMapper with organization group + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("org-group-mapper"); + mapper.setIdentityProviderMapper(AdvancedClaimToGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString()) + .put(ConfigConstants.GROUP, groupPath) + .put(AdvancedClaimToGroupMapper.CLAIM, "organization") + .put(AdvancedClaimToGroupMapper.CLAIM_VALUE, orgRep.getName()) + .build()); + + String mapperId; + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + mapperId = ApiUtil.getCreatedId(response); + } + + // Verify mapper was created + IdentityProviderMapperRepresentation createdMapper = testRealm().identityProviders() + .get(idp.getAlias()) + .getMapperById(mapperId); + + assertNotNull("Mapper should be created", createdMapper); + assertEquals("Mapper should reference org group", groupPath, createdMapper.getConfig().get(ConfigConstants.GROUP)); + } + + @Test + public void testCreateMapperWithOrganizationSubgroup() { + // Create organization with IdP + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + // Create parent organization group + GroupRepresentation parentGroup = new GroupRepresentation(); + parentGroup.setName("parent-group"); + String parentId; + try (Response response = orgResource.groups().addTopLevelGroup(parentGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + parentId = ApiUtil.getCreatedId(response); + } + + // Create child subgroup + GroupRepresentation childGroup = new GroupRepresentation(); + childGroup.setName("child-group"); + try (Response response = orgResource.groups().group(parentId).addSubGroup(childGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // Get the subgroup from the parent's children + List children = orgResource.groups().group(parentId).getSubGroups(null, null, null, null); + assertNotNull("Parent should have subgroups", children); + assertThat("Parent should have 1 subgroup", children.size(), is(1)); + + String childGroupPath = children.get(0).getPath(); + + // Create mapper with child subgroup + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("subgroup-mapper"); + mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString()) + .put(ConfigConstants.GROUP, childGroupPath) + .build()); + + String mapperId; + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + mapperId = ApiUtil.getCreatedId(response); + } + + // Verify mapper was created with subgroup path + IdentityProviderMapperRepresentation createdMapper = testRealm().identityProviders() + .get(idp.getAlias()) + .getMapperById(mapperId); + + assertNotNull("Mapper should be created", createdMapper); + assertEquals("Mapper should reference org subgroup", childGroupPath, createdMapper.getConfig().get(ConfigConstants.GROUP)); + } + + @Test + public void testGetGroupsEndpointForNonOrganizationIdp() { + // Create IdP NOT linked to organization + IdentityProviderRepresentation nonOrgIdp = bc.setUpIdentityProvider(); + nonOrgIdp.setAlias("non-org-idp"); + try (Response response = testRealm().identityProviders().create(nonOrgIdp)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + getCleanup().addCleanup(() -> testRealm().identityProviders().get("non-org-idp").remove()); + + // Create organization with groups + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName("test-org-group"); + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // Try to get groups for non-org IdP - should return NOT_FOUND + try { + testRealm().organizations().get(orgRep.getId()) + .identityProviders().get("non-org-idp").getGroups(null, null, false, null, null, true, false); + fail("Should have failed with NotFoundException"); + } catch (jakarta.ws.rs.NotFoundException e) { + // Expected + } + } + + @Test + public void testUserAddedToOrganizationGroupViaMapper() { + // Create organization with group + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName("mapper-test-group"); + String groupId; + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + groupId = ApiUtil.getCreatedId(response); + } + + String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath(); + + // Add hardcoded group mapper to the organization IdP + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("org-group-mapper"); + mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString()) + .put(ConfigConstants.GROUP, groupPath) + .build()); + + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // Authenticate via IdP - user should be added to org group + assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail()); + + // Verify user is member of the organization group + UserRepresentation user = getUserRepresentation(bc.getUserEmail()); + assertNotNull(user); + + List groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(1)); + assertThat(groupMembers.get(0).getId(), is(user.getId())); + } + + @Test + public void testUserNotAddedToGroupAfterIdpUnlinkedFromOrganization() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName("unlink-test-group"); + String groupId; + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + groupId = ApiUtil.getCreatedId(response); + } + + String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath(); + + // Add a HardcodedGroupMapper pointing to the org group + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("unlink-test-mapper"); + mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString()) + .put(ConfigConstants.GROUP, groupPath) + .build()); + + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // First login: user IS added to the org group while IdP is still linked + assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail()); + + List groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(1)); + + // Log out from both realms + UserRepresentation user = getUserRepresentation(bc.getUserEmail()); + realmsResouce().realm(bc.consumerRealmName()).users().get(user.getId()).logout(); + realmsResouce().realm(bc.providerRealmName()).logoutAll(); + + // Unlink IdP from organization - the IdP still exists in the realm but is no longer org-linked + try (Response response = orgResource.identityProviders().get(bc.getIDPAlias()).delete()) { + assertThat(response.getStatus(), is(Status.NO_CONTENT.getStatusCode())); + } + + // Remove the user from the group so the second login can prove the mapper does not re-add them + orgResource.groups().group(groupId).removeMember(user.getId()); + groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(0)); + + // Second login: bypass the org identity-first page (which hides the unlinked IdP) by + // navigating directly with kc_idp_hint. The IdP still exists in the realm so login succeeds, + // but the mapper cannot resolve the org group and the user is NOT re-added to it. + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + driver.navigate().to(driver.getCurrentUrl() + "&kc_idp_hint=" + bc.getIDPAlias()); + loginOrgIdp(bc.getUserLogin(), bc.getUserEmail(), false, true); + + // Verify user was not re-added to the org group + groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(0)); + } + + @Test + public void testRealmGroupAllowedWithOrganizationIdp() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + // Create REALM group in the consumer realm + GroupRepresentation realmGroup = new GroupRepresentation(); + realmGroup.setName("realm-test-group"); + try (Response response = realmsResouce().realm(bc.consumerRealmName()).groups().add(realmGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + String groupPath = realmsResouce().realm(bc.consumerRealmName()).getGroupByPath("/realm-test-group").getPath(); + + // Add mapper with REALM group to organization IdP + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("realm-group-mapper"); + mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.FORCE.toString()) + .put(ConfigConstants.GROUP, groupPath) + .build()); + + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // Authenticate via IdP - realm groups are always allowed + assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail()); + + // Verify user is member of the realm group + UserRepresentation user = getUserRepresentation(bc.getUserEmail()); + assertNotNull(user); + + List userGroups = realmsResouce().realm(bc.consumerRealmName()).users().get(user.getId()).groups(); + assertThat(userGroups, hasSize(1)); + assertThat(userGroups.get(0).getPath(), is(groupPath)); + } + + @Test + public void testHardcodedGroupMapperDoesNotAssignOrganizationGroupMembershipWhenOrganizationIsDisabled() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName("disabled-org-test-group"); + String groupId; + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + groupId = ApiUtil.getCreatedId(response); + } + + String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath(); + + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("disabled-org-test-mapper"); + mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString()) + .put(ConfigConstants.GROUP, groupPath) + .build()); + + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // First login: org is enabled, user IS added to org group + assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail()); + + List groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(1)); + + // When org is disabled, the IdP appears disabled, blocking further broker logins. + orgRep.setEnabled(false); + try (Response ignored = orgResource.update(orgRep)) { + assertThat(ignored.getStatus(), is(Status.NO_CONTENT.getStatusCode())); + } + + // Verify the org-linked IdP now appears disabled (org-aware wrapper) + IdentityProviderRepresentation updatedIdp = testRealm().identityProviders().get(bc.getIDPAlias()).toRepresentation(); + assertThat("IdP should appear disabled when org is disabled", updatedIdp.isEnabled(), is(false)); + + // Group membership assigned while the org was enabled is unaffected by the org being disabled + groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(1)); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupSamlIdpMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupSamlIdpMapperTest.java new file mode 100644 index 00000000000..7b8b775b612 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationGroupSamlIdpMapperTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 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.testsuite.organization.mapper; + +import java.util.List; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +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.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.broker.BrokerConfiguration; +import org.keycloak.testsuite.broker.KcSamlBrokerConfiguration; +import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest; + +import com.google.common.collect.ImmutableMap; +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNotNull; + +public class OrganizationGroupSamlIdpMapperTest extends AbstractOrganizationTest { + + @Override + protected BrokerConfiguration createBrokerConfiguration() { + return new KcSamlBrokerConfiguration() { + @Override + public RealmRepresentation createProviderRealm() { + RealmRepresentation realmRep = super.createProviderRealm(); + realmRep.setOrganizationsEnabled(true); + return realmRep; + } + + @Override + public String getIDPClientIdInProviderRealm() { + return "saml-broker"; + } + + @Override + public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) { + IdentityProviderRepresentation broker = super.setUpIdentityProvider(syncMode); + broker.getConfig().put(SAMLIdentityProviderConfig.ENTITY_ID, getIDPClientIdInProviderRealm()); + return broker; + } + }; + } + + @Test + public void testHardcodedGroupMapperAssignsOrganizationGroupMembershipWithSamlIdp() { + OrganizationRepresentation orgRep = createOrganization(); + OrganizationResource orgResource = testRealm().organizations().get(orgRep.getId()); + + GroupRepresentation orgGroup = new GroupRepresentation(); + orgGroup.setName("saml-test-group"); + String groupId; + try (Response response = orgResource.groups().addTopLevelGroup(orgGroup)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + groupId = ApiUtil.getCreatedId(response); + } + + String groupPath = orgResource.groups().group(groupId).toRepresentation(false).getPath(); + + // Create HardcodedGroupMapper pointing to the org group + IdentityProviderRepresentation idp = orgResource.identityProviders().get(bc.getIDPAlias()).toRepresentation(); + + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName("saml-hardcoded-group-mapper"); + mapper.setIdentityProviderMapper(HardcodedGroupMapper.PROVIDER_ID); + mapper.setIdentityProviderAlias(idp.getAlias()); + mapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.IMPORT.toString()) + .put(ConfigConstants.GROUP, groupPath) + .build()); + + try (Response response = testRealm().identityProviders().get(idp.getAlias()).addMapper(mapper)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + + // Authenticate via SAML IdP - user should be added to the org group via the mapper + assertBrokerRegistration(orgResource, bc.getUserLogin(), bc.getUserEmail()); + + UserRepresentation user = getUserRepresentation(bc.getUserEmail()); + assertNotNull(user); + + List groupMembers = orgResource.groups().group(groupId).getMembers(null, null, false); + assertThat(groupMembers, hasSize(1)); + assertThat(groupMembers.get(0).getId(), is(user.getId())); + } +}