From 464c02dfb065747a37516fc147bf0ed287a695db Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 12 Jul 2026 19:20:03 +0200 Subject: [PATCH] Add returning attributes split by name Adds an endpoint that returns the list of attribute values as a map of attribute names in the requested attribute group. This is useful for requesting the mailing list addresses, where the individual attributes apply to different mailing lists. --- .../AttributeEndpointsResourceProvider.java | 253 +++++++++++------- 1 file changed, 155 insertions(+), 98 deletions(-) diff --git a/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProvider.java b/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProvider.java index 5070e96..889b0b8 100644 --- a/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProvider.java +++ b/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProvider.java @@ -1,21 +1,11 @@ package de.ccc.hamburg.keycloak.attribute_endpoints; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; -import org.keycloak.models.ClientModel; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RoleModel; -import org.keycloak.models.UserModel; -import org.keycloak.models.UserProvider; +import org.keycloak.models.*; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.Auth; @@ -23,16 +13,13 @@ import org.keycloak.services.managers.AuthenticationManager.AuthResult; import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.userprofile.UserProfileProvider; -import jakarta.ws.rs.ForbiddenException; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.ServerErrorException; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class AttributeEndpointsResourceProvider implements RealmResourceProvider { private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class); @@ -51,83 +38,21 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider public void close() { } + /** + * Returns a list of all atrribute values selected by the attribute group attributeGroupName. + * + * @param attributeGroupName attribute group name + * @return a list of attribute values. + */ @GET @Path("export/{slug}") @Produces(MediaType.APPLICATION_JSON) - public Response exportAttributeValues(@PathParam("slug") String slug) { - KeycloakContext context = session.getContext(); - RealmModel realm = context.getRealm(); + public Response exportAttributeValues(@PathParam("slug") String attributeGroupName) { + AttributeExportContext ctx = new AttributeExportContext(attributeGroupName); - List componentList = realm.getComponentsStream() - .filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID)) - .filter(c -> c.getConfig().getFirst("slug").equals(slug)) - .toList(); - - Auth auth = AttributeEndpointsResourceProvider.getAuth(session); - - if (componentList.isEmpty()) { - throw new NotFoundException("Endpoint not found."); - } - - if (componentList.size() > 1) { - throw new NotFoundException( - "Endpoint Configuration Error - Multiple configurations exist for this endpoint."); - } - - ComponentModel component = componentList.get(0); - - String configAuthRole = component.getConfig().getFirst("auth-role"); - RoleModel authRole = realm.getRole(configAuthRole); - if (authRole == null) { - throw new ServerErrorException("Endpoint Configuration Error - auth-role does not exist.", 500); - } - - String configMatchRole = component.getConfig().getFirst("match-role"); - RoleModel matchRole = realm.getRole(configMatchRole); - if (matchRole == null) { - throw new ServerErrorException("Endpoint Configuration Error - match-role does not exist.", 500); - } - - UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); - UPConfig upconfig = profileProvider.getConfiguration(); - String configAttributeGroup = component.getConfig().getFirst("attribute-group"); - if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) { - throw new ServerErrorException("Endpoint Configuration Error - attribute-group does not exist.", 500); - } - - String configAttributeRegex = component.getConfig().getFirst("attribute-regex"); - Boolean regexIsBlank = configAttributeRegex == null; - - if (!regexIsBlank) { - try { - Pattern.compile(configAttributeRegex); - } catch (Exception e) { - throw new ServerErrorException( - "Endpoint Configuration Error - attribute-regex is not a valid regex pattern.", 500); - } - } - - UserModel authUser = auth.getUser(); - if (!authUser.hasRole(authRole)) { - throw new ForbiddenException("User does not have required auth role."); - } - - List attributeNames = upconfig.getAttributes() - .stream() - .filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup)) - .map(a -> a.getName()) - .toList(); - - UserProvider userProvider = session.users(); - // getRoleMembersStream only returns users with matchRole assigned directly; it misses - // users who have the role via group membership (incl. parent groups) or composite roles. - // hasRole() resolves the role the same way authUser.hasRole(authRole) does above. - Stream users = userProvider.searchForUserStream(realm, Map.of()) - .filter(user -> user.hasRole(matchRole)); - - List attribute_list = users + List attribute_list = ctx.users .map(user -> { - Stream attributeStream = attributeNames.stream() + Stream attributeStream = ctx.attributeNames.stream() .map(attributeName -> user.getAttributeStream(attributeName).toList()) .flatMap(Collection::stream); @@ -137,17 +62,149 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider }) .flatMap(List::stream) .filter(attribute -> { - if (regexIsBlank) { + if (ctx.regexIsBlank) { return true; } - final Pattern pattern = Pattern.compile(configAttributeRegex); + final Pattern pattern = Pattern.compile(ctx.configAttributeRegex); final Matcher matcher = pattern.matcher(attribute); return matcher.find(); }) .toList(); return Response.ok(attribute_list).build(); + } + /** + * Return a map of lists of attribute values. For each attribute in the named attribute + * group, add an entry in the resulting map with the attribute name as the key, and the + * values as a list as the values. + * + * @param attributeGroupName attribute group name + * @return + */ + @GET + @Path("export/{attributeGroupName}/map") + @Produces(MediaType.APPLICATION_JSON) + public Response exportAttributeValuesMap(@PathParam("attributeGroupName") String attributeGroupName) { + AttributeExportContext ctx = new AttributeExportContext(attributeGroupName); + + List userList = ctx.users.toList(); + + Map> attributeMap = ctx.attributeNames.stream() + .collect(Collectors.toMap( + attributeName -> attributeName, + attributeName -> userList.stream() + .flatMap(user -> user.getAttributeStream(attributeName)) + .filter(attribute -> !attribute.isEmpty()) + .filter(attribute -> { + if (ctx.regexIsBlank) { + return true; + } + final Pattern pattern = Pattern.compile(ctx.configAttributeRegex); + final Matcher matcher = pattern.matcher(attribute); + return matcher.find(); + }) + .toList())); + + return Response.ok(attributeMap).build(); + } + + + /** + * Resolves and validates the configuration and request state needed to export attribute + * values for a given slug, exposing the results as member variables. + */ + private class AttributeExportContext { + KeycloakContext context; + RealmModel realm; + List componentList; + Auth auth; + ComponentModel component; + String configAuthRole; + RoleModel authRole; + String configMatchRole; + RoleModel matchRole; + UserProfileProvider profileProvider; + UPConfig upconfig; + String configAttributeGroup; + String configAttributeRegex; + Boolean regexIsBlank; + UserModel authUser; + List attributeNames; + UserProvider userProvider; + Stream users; + + AttributeExportContext(String slug) { + context = session.getContext(); + realm = context.getRealm(); + + componentList = realm.getComponentsStream() + .filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID)) + .filter(c -> c.getConfig().getFirst("slug").equals(slug)) + .toList(); + + auth = AttributeEndpointsResourceProvider.getAuth(session); + + if (componentList.isEmpty()) { + throw new NotFoundException("Endpoint not found."); + } + + if (componentList.size() > 1) { + throw new NotFoundException( + "Endpoint Configuration Error - Multiple configurations exist for this endpoint."); + } + + component = componentList.get(0); + + configAuthRole = component.getConfig().getFirst("auth-role"); + authRole = realm.getRole(configAuthRole); + if (authRole == null) { + throw new ServerErrorException("Endpoint Configuration Error - auth-role does not exist.", 500); + } + + configMatchRole = component.getConfig().getFirst("match-role"); + matchRole = realm.getRole(configMatchRole); + if (matchRole == null) { + throw new ServerErrorException("Endpoint Configuration Error - match-role does not exist.", 500); + } + + profileProvider = session.getProvider(UserProfileProvider.class); + upconfig = profileProvider.getConfiguration(); + configAttributeGroup = component.getConfig().getFirst("attribute-group"); + if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) { + throw new ServerErrorException("Endpoint Configuration Error - attribute-group does not exist.", 500); + } + + configAttributeRegex = component.getConfig().getFirst("attribute-regex"); + regexIsBlank = configAttributeRegex == null; + + if (!regexIsBlank) { + try { + Pattern.compile(configAttributeRegex); + } catch (Exception e) { + throw new ServerErrorException( + "Endpoint Configuration Error - attribute-regex is not a valid regex pattern.", 500); + } + } + + authUser = auth.getUser(); + if (!authUser.hasRole(authRole)) { + throw new ForbiddenException("User does not have required auth role."); + } + + attributeNames = upconfig.getAttributes() + .stream() + .filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup)) + .map(a -> a.getName()) + .toList(); + + userProvider = session.users(); + // getRoleMembersStream only returns users with matchRole assigned directly; it misses + // users who have the role via group membership (incl. parent groups) or composite roles. + // hasRole() resolves the role the same way authUser.hasRole(authRole) does above. + users = userProvider.searchForUserStream(realm, Map.of()) + .filter(user -> user.hasRole(matchRole)); + } } private static Auth getAuth(KeycloakSession session) {