Add exporting individual attributes
All checks were successful
/ Verify (pull_request) Successful in 41s

Based off #32

Also:
* rework README.md
* improve configuration error messages
This commit is contained in:
Stefan Bethke 2026-07-19 13:37:07 +02:00
commit c9f36f7100
6 changed files with 120 additions and 144 deletions

View file

@ -1,15 +1,12 @@
package de.ccc.hamburg.keycloak.attribute_endpoints;
import java.util.List;
import java.util.regex.Pattern;
import com.google.auto.service.AutoService;
import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.userprofile.config.UPConfig;
@ -17,7 +14,8 @@ import org.keycloak.services.ui.extend.UiPageProvider;
import org.keycloak.services.ui.extend.UiPageProviderFactory;
import org.keycloak.userprofile.UserProfileProvider;
import com.google.auto.service.AutoService;
import java.util.List;
import java.util.regex.Pattern;
/**
* Implements UiPageProvider to show a config page in the admin
@ -43,6 +41,7 @@ public class AdminUiPage implements UiPageProvider, UiPageProviderFactory<Compon
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "Configure endpoints of the Attribute Endpoint Provider.";
}
@ -50,7 +49,7 @@ public class AdminUiPage implements UiPageProvider, UiPageProviderFactory<Compon
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) {
String errorString = "\n";
Boolean hasError = false;
boolean hasError = false;
Pattern slugPattern = Pattern.compile("^[a-zA-Z0-9_-]*$");
String configAttributeSlug = model.getConfig().getFirst("slug");
@ -86,13 +85,15 @@ public class AdminUiPage implements UiPageProvider, UiPageProviderFactory<Compon
if (configAttributeGroup == null) {
hasError = true;
errorString += " • [Attribute Group] can not be empty\n";
} else if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) {
} else if (upconfig.getAttributes().stream().noneMatch(a ->
a.getName().equals(configAttributeGroup)
|| (a.getGroup() != null && a.getGroup().equals(configAttributeGroup)))) {
hasError = true;
errorString += " • [Attribute Group] does not exist\n";
errorString += " • [Attribute Group] no matching Attribute or Attribute Group\n";
}
String configAttributeRegex = model.getConfig().getFirst("attribute-regex");
Boolean regexIsBlank = configAttributeRegex == null;
boolean regexIsBlank = configAttributeRegex == null;
if (!regexIsBlank) {
try {
@ -113,37 +114,37 @@ public class AdminUiPage implements UiPageProvider, UiPageProviderFactory<Compon
return ProviderConfigurationBuilder.create()
.property()
.name("slug")
.label("Slug")
.label("Endpoint URL Slug")
.helpText(
"The slug in the path of the API endpoint (e.g. /realms/:realm/attribute-endpoint-provider/export/:slug)")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("auth-role")
.label("Endpoint Role")
.helpText("Calling this endpoint configuration requires an authenticated user that has this role.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("attribute-group")
.label("Attribute Group")
.helpText("The attribute group to export.")
.label("Attribute Name or Group")
.helpText("The attribute or attribute group to export.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("match-role")
.label("Match Role")
.helpText("Export only attributes of users with this role.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("auth-role")
.label("Auth Role")
.helpText("Role needeed by the authenticated account to be able to use this endpoint.")
.label("User Match Role")
.helpText("Only users with this role will have the attributes exported.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("attribute-regex")
.label("Attribute RegEx")
.helpText("A RegEx Rule used to verify each attribute value. Only matching values are returned.")
.label("Validation Regex")
.helpText("Only values matching this regex will be included in the result. Optional.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()

View file

@ -18,6 +18,7 @@ import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class AttributeEndpointsResourceProvider implements RealmResourceProvider {
@ -66,7 +67,36 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
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 a map of attribute names and their values
*/
@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(ctx.filter::matches)
.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.
*/
@ -87,37 +117,38 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
.toList();
if (componentList.isEmpty()) {
throw new NotFoundException("Endpoint not found");
throw new NotFoundException("Attribute Endpoint " + slug + " not found");
}
if (componentList.size() > 1) {
throw new NotFoundException(
"Endpoint Configuration Error - Multiple configurations exist for this endpoint.");
}
if (componentList.size() > 1) {
throw new NotFoundException(
"Attribute Endpoint Configuration Error - Multiple configurations exist for " + slug);
}
ComponentModel component = componentList.get(0);
RoleModel authRole = realm.getRole(component.getConfig().getFirst("auth-role"));
if (authRole == null) {
throw new ServerErrorException("Endpoint Configuration Error - auth-role does not exist.", 500);
throw new ServerErrorException("Attribute Endpoint " + slug + " Configuration Error - auth-role does not exist.", 500);
}
RoleModel matchRole = realm.getRole(component.getConfig().getFirst("match-role"));
if (matchRole == null) {
throw new ServerErrorException("Endpoint Configuration Error - match-role does not exist.", 500);
throw new ServerErrorException("Attribute Endpoint " + slug + " Configuration Error - match-role does not exist.", 500);
}
upconfig = session.getProvider(UserProfileProvider.class).getConfiguration();
configAttributeGroup = component.getConfig().getFirst("attribute-group");
if (upconfig.getGroups().stream().noneMatch(g -> g.getName().equals(configAttributeGroup))) {
throw new ServerErrorException("Endpoint Configuration Error - attribute-group does not exist.", 500);
if (upconfig.getGroups().stream().noneMatch(g -> g.getName().equals(configAttributeGroup)) &&
upconfig.getAttributes().stream().noneMatch(a -> a.getName().equals(configAttributeGroup))) {
throw new ServerErrorException("Attribute Endpoint " + slug + " Configuration Error - no attribute or attribute group named " + configAttributeGroup + " found", 500);
}
try {
filter = new RegExFilter(component.getConfig().getFirst("attribute-regex"));
} catch (Exception e) {
throw new ServerErrorException(
"Endpoint Configuration Error - attribute-regex is not a valid regex pattern.", 500);
"Attribute Endpoint " + slug + " Configuration Error - attribute-regex is not a valid regex pattern.", 500);
}
authUser = AttributeEndpointsResourceProvider.getAuth(session).getUser();
@ -129,7 +160,9 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider
// select all attributes that match configAttributeGroup, or that are in a group that matches configAttributeGroup
attributeNames = upconfig.getAttributes()
.stream()
.filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup))
.filter(a ->
a.getName().equals(configAttributeGroup)
|| (a.getGroup() != null && a.getGroup().equals(configAttributeGroup)))
.map(UPAttribute::getName)
.toList();

View file

@ -1,36 +1,35 @@
package de.ccc.hamburg.keycloak.attribute_endpoints;
import com.google.auto.service.AutoService;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import com.google.auto.service.AutoService;
@AutoService(RealmResourceProviderFactory.class)
public class AttributeEndpointsResourceProviderFactory implements RealmResourceProviderFactory {
static final String PROVIDER_ID = "attribute-endpoints-provider";
@Override
public RealmResourceProvider create(KeycloakSession keycloakSession) {
return new AttributeEndpointsResourceProvider(keycloakSession);
}
public RealmResourceProvider create(KeycloakSession keycloakSession) {
return new AttributeEndpointsResourceProvider(keycloakSession);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -112,7 +112,6 @@ public class AttributeEndpointsResourceProviderTest {
}
}
/* NOTYET
@Test
public void exportAttributeValuesMap_doorisSlug_returnsSshKeysPerAttribute() {
try (Response response = callAuthenticatedAs("export-dooris-ssh-keys",
@ -125,9 +124,7 @@ public class AttributeEndpointsResourceProviderTest {
response.getEntity());
}
}
*/
/* NOTYET
@Test
public void exportAttributeValues_mailingListChaosSlug_returnsAddressesOfAllMatchingUsers() {
try (Response response = callAuthenticatedAs("export-mailing-list-addresses",
@ -138,9 +135,7 @@ public class AttributeEndpointsResourceProviderTest {
assertEquals(List.of("hacker+chaos@example.net", "tester+chaos@example.com"), response.getEntity());
}
}
*/
/* NOTYET
@Test
public void exportAttributeValues_mailingListInternSlug_returnsAddressOfSingleMatchingUser() {
try (Response response = callAuthenticatedAs("export-mailing-list-addresses",
@ -151,9 +146,7 @@ public class AttributeEndpointsResourceProviderTest {
assertEquals(List.of("hacker+intern@example.net"), response.getEntity());
}
}
*/
/* NOTYET
@Test
public void exportAttributeValuesMap_mailingListChaosSlug_returnsAddressesPerAttribute() {
try (Response response = callAuthenticatedAs("export-mailing-list-addresses",
@ -166,7 +159,6 @@ public class AttributeEndpointsResourceProviderTest {
response.getEntity());
}
}
*/
@Test
public void exportAttributeValues_unknownSlug_throwsNotFound() {