Add returning attributes split by name
All checks were successful
/ Verify (push) Successful in 44s
/ Verify (pull_request) Successful in 48s

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.
This commit is contained in:
Stefan Bethke 2026-07-12 19:20:03 +02:00
commit 464c02dfb0

View file

@ -1,21 +1,11 @@
package de.ccc.hamburg.keycloak.attribute_endpoints; package de.ccc.hamburg.keycloak.attribute_endpoints;
import java.util.Collection; import jakarta.ws.rs.*;
import java.util.List; import jakarta.ws.rs.core.MediaType;
import java.util.Map; import jakarta.ws.rs.core.Response;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.*;
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.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.Auth; 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.services.resource.RealmResourceProvider;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import jakarta.ws.rs.ForbiddenException; import java.util.Collection;
import jakarta.ws.rs.GET; import java.util.List;
import jakarta.ws.rs.NotAuthorizedException; import java.util.Map;
import jakarta.ws.rs.NotFoundException; import java.util.regex.Matcher;
import jakarta.ws.rs.Path; import java.util.regex.Pattern;
import jakarta.ws.rs.PathParam; import java.util.stream.Collectors;
import jakarta.ws.rs.Produces; import java.util.stream.Stream;
import jakarta.ws.rs.ServerErrorException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
public class AttributeEndpointsResourceProvider implements RealmResourceProvider { public class AttributeEndpointsResourceProvider implements RealmResourceProvider {
private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class); private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class);
@ -51,19 +38,112 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
public void close() { 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 @GET
@Path("export/{slug}") @Path("export/{slug}")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Response exportAttributeValues(@PathParam("slug") String slug) { public Response exportAttributeValues(@PathParam("slug") String attributeGroupName) {
KeycloakContext context = session.getContext(); AttributeExportContext ctx = new AttributeExportContext(attributeGroupName);
RealmModel realm = context.getRealm();
List<ComponentModel> componentList = realm.getComponentsStream() List<String> attribute_list = ctx.users
.map(user -> {
Stream<String> attributeStream = ctx.attributeNames.stream()
.map(attributeName -> user.getAttributeStream(attributeName).toList())
.flatMap(Collection::stream);
return attributeStream
.filter(attribute -> !attribute.isEmpty())
.toList();
})
.flatMap(List::stream)
.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(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<UserModel> userList = ctx.users.toList();
Map<String, List<String>> 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<ComponentModel> 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<String> attributeNames;
UserProvider userProvider;
Stream<UserModel> users;
AttributeExportContext(String slug) {
context = session.getContext();
realm = context.getRealm();
componentList = realm.getComponentsStream()
.filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID)) .filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID))
.filter(c -> c.getConfig().getFirst("slug").equals(slug)) .filter(c -> c.getConfig().getFirst("slug").equals(slug))
.toList(); .toList();
Auth auth = AttributeEndpointsResourceProvider.getAuth(session); auth = AttributeEndpointsResourceProvider.getAuth(session);
if (componentList.isEmpty()) { if (componentList.isEmpty()) {
throw new NotFoundException("Endpoint not found."); throw new NotFoundException("Endpoint not found.");
@ -74,29 +154,29 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
"Endpoint Configuration Error - Multiple configurations exist for this endpoint."); "Endpoint Configuration Error - Multiple configurations exist for this endpoint.");
} }
ComponentModel component = componentList.get(0); component = componentList.get(0);
String configAuthRole = component.getConfig().getFirst("auth-role"); configAuthRole = component.getConfig().getFirst("auth-role");
RoleModel authRole = realm.getRole(configAuthRole); authRole = realm.getRole(configAuthRole);
if (authRole == null) { if (authRole == null) {
throw new ServerErrorException("Endpoint Configuration Error - auth-role does not exist.", 500); throw new ServerErrorException("Endpoint Configuration Error - auth-role does not exist.", 500);
} }
String configMatchRole = component.getConfig().getFirst("match-role"); configMatchRole = component.getConfig().getFirst("match-role");
RoleModel matchRole = realm.getRole(configMatchRole); matchRole = realm.getRole(configMatchRole);
if (matchRole == null) { if (matchRole == null) {
throw new ServerErrorException("Endpoint Configuration Error - match-role does not exist.", 500); throw new ServerErrorException("Endpoint Configuration Error - match-role does not exist.", 500);
} }
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); profileProvider = session.getProvider(UserProfileProvider.class);
UPConfig upconfig = profileProvider.getConfiguration(); upconfig = profileProvider.getConfiguration();
String configAttributeGroup = component.getConfig().getFirst("attribute-group"); configAttributeGroup = component.getConfig().getFirst("attribute-group");
if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) { if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) {
throw new ServerErrorException("Endpoint Configuration Error - attribute-group does not exist.", 500); throw new ServerErrorException("Endpoint Configuration Error - attribute-group does not exist.", 500);
} }
String configAttributeRegex = component.getConfig().getFirst("attribute-regex"); configAttributeRegex = component.getConfig().getFirst("attribute-regex");
Boolean regexIsBlank = configAttributeRegex == null; regexIsBlank = configAttributeRegex == null;
if (!regexIsBlank) { if (!regexIsBlank) {
try { try {
@ -107,47 +187,24 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
} }
} }
UserModel authUser = auth.getUser(); authUser = auth.getUser();
if (!authUser.hasRole(authRole)) { if (!authUser.hasRole(authRole)) {
throw new ForbiddenException("User does not have required auth role."); throw new ForbiddenException("User does not have required auth role.");
} }
List<String> attributeNames = upconfig.getAttributes() attributeNames = upconfig.getAttributes()
.stream() .stream()
.filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup)) .filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup))
.map(a -> a.getName()) .map(a -> a.getName())
.toList(); .toList();
UserProvider userProvider = session.users(); userProvider = session.users();
// getRoleMembersStream only returns users with matchRole assigned directly; it misses // getRoleMembersStream only returns users with matchRole assigned directly; it misses
// users who have the role via group membership (incl. parent groups) or composite roles. // 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. // hasRole() resolves the role the same way authUser.hasRole(authRole) does above.
Stream<UserModel> users = userProvider.searchForUserStream(realm, Map.of()) users = userProvider.searchForUserStream(realm, Map.of())
.filter(user -> user.hasRole(matchRole)); .filter(user -> user.hasRole(matchRole));
List<String> attribute_list = users
.map(user -> {
Stream<String> attributeStream = attributeNames.stream()
.map(attributeName -> user.getAttributeStream(attributeName).toList())
.flatMap(Collection::stream);
return attributeStream
.filter(attribute -> !attribute.isEmpty())
.toList();
})
.flatMap(List::stream)
.filter(attribute -> {
if (regexIsBlank) {
return true;
} }
final Pattern pattern = Pattern.compile(configAttributeRegex);
final Matcher matcher = pattern.matcher(attribute);
return matcher.find();
})
.toList();
return Response.ok(attribute_list).build();
} }
private static Auth getAuth(KeycloakSession session) { private static Auth getAuth(KeycloakSession session) {