From 2c3c0a3dab7a7473392a71840aa1f5f1253d7994 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 12 Jul 2026 13:07:16 +0200 Subject: [PATCH 01/11] Fix matchRole lookup to include group-inherited and composite roles getRoleMembersStream only returns users with the role assigned directly, so exported attributes silently excluded users who have matchRole via group membership. Filter all realm users with hasRole() instead, matching the resolution already used for authRole. --- .../AttributeEndpointsResourceProvider.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 8c17ec3..5070e96 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 @@ -2,6 +2,7 @@ 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; @@ -118,7 +119,11 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider .toList(); UserProvider userProvider = session.users(); - Stream users = userProvider.getRoleMembersStream(realm, matchRole); + // 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 .map(user -> { From 464c02dfb065747a37516fc147bf0ed287a695db Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 12 Jul 2026 19:20:03 +0200 Subject: [PATCH 02/11] 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) { From cd31ec8a20b2e812b3c0ea53193fccb0c9a0c4e2 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 18 Jul 2026 17:39:01 +0200 Subject: [PATCH 03/11] Refactor and cleanup * simplify AttributeEndpointsResourceProvider * improve explanation of properties in AdminUiPage * add example for mailing lists and map endpoint * better explain configuration and operation of provider in main readme * adjust the Keycloak realm * Add unit test for endpoint class --- README.md | 117 ++----- attribute-endpoints-provider/pom.xml | 13 + .../attribute_endpoints/AdminUiPage.java | 47 +-- .../AttributeEndpointsResourceProvider.java | 121 +++---- ...ttributeEndpointsResourceProviderTest.java | 306 ++++++++++++++++++ testing/README.md | 48 ++- testing/client-test-export-dooris.sh | 25 +- testing/client-test-export-mailing-lists.sh | 27 ++ testing/compose.yaml | 13 +- testing/import/testing.json | 10 +- 10 files changed, 525 insertions(+), 202 deletions(-) create mode 100644 attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java create mode 100755 testing/client-test-export-mailing-lists.sh diff --git a/README.md b/README.md index 9cd3766..4fcf8f0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,38 @@ -# Attribute Endpoints Provider +# Attribute Endpoints Provider - Export User Attributes -This is a Keycloak Provider that exports an anonymized list of user profile attribute values. -For this it will provide API endpoints for every configured attribute-group. -The configuration of the provider is possible via an admin page. +This is a Keycloak Provider that exports user profile attribute values. +The selection of attributes and authorization is manage through an endpoint configuration. -Every endpoint responds with a list of all attribute values, that: -- are in the attribute group matching `attribute-group` -- match an optional RegEx Pattern `attribute-regex` -- belong to a user with a role matching `match-role` -- are non-empty +## Usage + +The provider adds an object type "Attribute Endpoints" to the Keycloak admin website. +You configure the provider by creating one or more endpoint configurations. + +Each endpoint configuration requires: +- a name that is the reference in the endpoint URL (slug) +- the name of a role that the calling user must have to be allowed to query this endpoint +- the name of an attribute or an attribute group that should be exported +- the name of a role that users must have to be included in the list +- an optional regular expression that must match to have a value included + +> **Note** The attribute (group) and the roles need to exist before you can create the endpoint configuration. + +There are two endpoints returning JSON: +- `export/slug`: collate all attribute values into a single list +- `export/slug/map`: produce a map of lists, with the key being the attribute name + +No other data is included in the response, in particular, there is no information on which attribute value is associated with which user. + +User Attribute values can be single or multi-value; the resulting list will include them as a flattened list. + +> **⚠️ Note** The authorization and the selection of the user attributes are not tied together in any way. +> Every user that has the matching role will have all the attributes included that are specified in the endpoint configuration. +> In other words, if you are exporting an attribute group, all attributes will be included. + +> **⚠️ Note** Keycloak has no concept of authorization for individual user attributes (for example, based on assigned roles). +> Users will be able to see and edit any attribute that has been made available to users, irrespective of whether a user would +> be included in an endpoint configuration export or not. -Multivalue attributes are flattened in the response. ## Building @@ -20,79 +42,8 @@ Once all dependencies are met, simply call `make` to build the provider, which s There's also `make clean` available for removing the output directory. +To add the provider to your Keycloak install, copy `attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar` to the Keycloak Provider directory (`/opt/keycloak/providers/). + ## Testing Setup See [testing/README.md](testing/README.md) for details on the Docker Compose based setup that includes a realm and the ability to attach a debugger to the running Keycloak. - -## Example Setup - -We assume an unconfigured, fresh Keycloak installation running under `http://localhost:8080`. -(This can be achieved by running the provided `compose.yaml` after building the provider as outlined in [Building](#building).) - - 1. Add a new realm - e.g. "TestRealm" - 2. Under `Realm Settings > User profile > Attributes Group`, add a new attribute Group - Example: - - `Name` = `"my-attributes-group"` - - `Display name` = `"Endpoint Attributes"` - - `Display description` = `"Attributes exported by the provider."` - 3. Under `Realm Settings > User profile > Attributes`, add a new attribute - Example: - - `Attribute [Name]` = `"ssh-keys"` - - `Display name ` = `"SSH Keys"` - - `Multivalued` = `On` - - `Attribute group` = `"my-attributes-group"` - - `Who can edit?` = `user, admin` - - `Validators` - You can add validators, which will limit what values the user can enter. These validators are ignored by the provider. - 4. Under `Realm roles`, add two new roles - Example: - 1. `Role name` = `"myattribute-match"` - 2. `Role name` = `"myattribute-export"` - 5. Under `Users`, add a new user - Example: - - `Username` = `"user"` - - `Email` = `"user@example.com"` - - `First name` = `"User"` - - `Last name` = `"User"` - - `SSH Keys` = `"example-value-1", "example-value-2"` - 6. In the Settings of the newly created user, go to `Role mapping > Assing role > Realm roles` and check the role `myattribute-match` - 7. create a second user to use the provider - - `Username` = `"bot-user"` - - `Email` = `"bot@example.com"` - - `First name` = `"Bot"` - - `Last name` = `"Bot"` - - After creating: - - give it the role `myattribute-export` - - set a password in the users settings `Creadentials > Set password`. For Example `"password"` -8. Under `Attribute Endpoints > Create item`, add a new endpoint to the provider - Example: - - `Slug` = `"ssh_keys"` - - `Attribute Group` = `"my-attributes-group"` - - `Match Role` = `"myattribute-match"` - - `Auth Role` = `"myattribute-export"` - - `Attribute RegEx` = `".*"` -9. Aquire an OIDC Access Token: - ```shell - curl --request POST \ - --url http://localhost:8080/realms/TestRealm/protocol/openid-connect/token \ - --header 'content-type: application/x-www-form-urlencoded' \ - --data scope=openid \ - --data username=bot-user \ - --data password=password \ - --data grant_type=password \ - --data client_id=admin-cli - ``` -10. copy the value of the response key `access_token` and use it in a second request: - ```shell - curl --request GET \ - --url http://localhost:8080/realms/TestRealm/attribute-endpoints-provider/export/ssh_keys \ - --header 'authorization: Bearer ey...' \ - --header 'content-type: application/json' - ``` -11. You should get a response like this: - ```json - ["example-value-1","example-value-2"] - ``` - -Although this example uses a simple bot account to authenticate to Keycloak, we recommend using a client with service account, when using this provider programmatically. diff --git a/attribute-endpoints-provider/pom.xml b/attribute-endpoints-provider/pom.xml index a6d1663..c525026 100644 --- a/attribute-endpoints-provider/pom.xml +++ b/attribute-endpoints-provider/pom.xml @@ -28,6 +28,19 @@ test + + org.mockito + mockito-core + 5.23.0 + test + + + + org.jboss.resteasy + resteasy-core + test + + org.keycloak keycloak-server-spi-private diff --git a/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java b/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java index cfd26b6..7c88e49 100644 --- a/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java +++ b/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java @@ -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 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 { - if (ctx.regexIsBlank) { - return true; - } - final Pattern pattern = Pattern.compile(ctx.configAttributeRegex); - final Matcher matcher = pattern.matcher(attribute); - return matcher.find(); - }) + .filter(ctx.filter::matches) .toList(); return Response.ok(attribute_list).build(); @@ -80,7 +73,7 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider * values as a list as the values. * * @param attributeGroupName attribute group name - * @return + * @return a map of attribute names and their values */ @GET @Path("export/{attributeGroupName}/map") @@ -96,14 +89,7 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider 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(); - }) + .filter(ctx.filter::matches) .toList())); return Response.ok(attributeMap).build(); @@ -115,87 +101,69 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider * 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; + UserModel authUser; + String configAttributeGroup; + RegExFilter filter; + UPConfig upconfig; Stream users; AttributeExportContext(String slug) { - context = session.getContext(); - realm = context.getRealm(); + RealmModel realm = session.getContext().getRealm(); - componentList = realm.getComponentsStream() + List 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."); + throw new NotFoundException("Attribute Endpoint " + slug + " not found"); } if (componentList.size() > 1) { throw new NotFoundException( - "Endpoint Configuration Error - Multiple configurations exist for this endpoint."); + "Attribute Endpoint Configuration Error - Multiple configurations exist for " + slug); } - component = componentList.get(0); + ComponentModel component = componentList.get(0); - configAuthRole = component.getConfig().getFirst("auth-role"); - authRole = realm.getRole(configAuthRole); + 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); } - configMatchRole = component.getConfig().getFirst("match-role"); - matchRole = realm.getRole(configMatchRole); + 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); } - profileProvider = session.getProvider(UserProfileProvider.class); - upconfig = profileProvider.getConfiguration(); + upconfig = session.getProvider(UserProfileProvider.class).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); + 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); } - 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); - } + try { + filter = new RegExFilter(component.getConfig().getFirst("attribute-regex")); + } catch (Exception e) { + throw new ServerErrorException( + "Attribute Endpoint " + slug + " Configuration Error - attribute-regex is not a valid regex pattern.", 500); } - authUser = auth.getUser(); + authUser = AttributeEndpointsResourceProvider.getAuth(session).getUser(); if (!authUser.hasRole(authRole)) { - LOG.info("User " + authUser.getUsername() + " does not have required role " + authRole.getName()); + LOG.info("User " + authUser.getUsername() + " does not have required role " + authRole.getName() + " for attribute endpoint " + slug); throw new ForbiddenException("User does not have required auth role."); } + // 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)) - .map(a -> a.getName()) + .filter(a -> + a.getName().equals(configAttributeGroup) + || (a.getGroup() != null && a.getGroup().equals(configAttributeGroup))) + .map(UPAttribute::getName) .toList(); UserProvider userProvider = session.users(); @@ -212,8 +180,25 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider } RealmModel realm = session.getContext().getRealm(); - ClientModel client = auth.getClient(); - return new Auth(realm, auth.getToken(), auth.getUser(), client, auth.getSession(), false); + ClientModel client = auth.client(); + return new Auth(realm, auth.token(), auth.user(), client, auth.session(), false); + } + + private static class RegExFilter { + Pattern pattern; + + RegExFilter(String regex) { + if (regex == null) + pattern = null; + else + pattern = Pattern.compile(regex); + } + + boolean matches(String input) { + if (pattern == null) + return true; + return pattern.matcher(input).matches(); + } } } diff --git a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java new file mode 100644 index 0000000..61684c2 --- /dev/null +++ b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java @@ -0,0 +1,306 @@ +package de.ccc.hamburg.keycloak.attribute_endpoints; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.core.Response; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.*; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.representations.userprofile.config.UPGroup; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; +import org.keycloak.userprofile.UserProfileProvider; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests the two endpoints of {@link AttributeEndpointsResourceProvider} against the realm + * configuration and expected results documented in {@code testing/README.md}. + */ +public class AttributeEndpointsResourceProviderTest { + + private static final String SSH_KEY_1 = "ssh-key-1"; + private static final String SSH_KEY_2 = "ssh-key-2"; + private static final String MAILING_LIST_CHAOS = "mailing-list-address-chaos"; + private static final String MAILING_LIST_INTERN = "mailing-list-address-intern"; + + private KeycloakSession session; + private RealmModel realm; + private Map roles; + private UserModel hacker; + private UserModel tester; + private UserModel noone; + + @Before + public void setUp() { + session = mock(KeycloakSession.class); + KeycloakContext context = mock(KeycloakContext.class); + when(session.getContext()).thenReturn(context); + + realm = mock(RealmModel.class); + when(context.getRealm()).thenReturn(realm); + + roles = new HashMap<>(); + for (String name : List.of("dooris-authorized", "export-dooris-ssh-keys", + "mailing-list-chaos-member", "mailing-list-intern-member", + "export-mailing-list-addresses")) { + roles.put(name, mock(RoleModel.class)); + } + when(realm.getRole(anyString())).thenAnswer(inv -> roles.get(inv.getArgument(0, String.class))); + + List components = List.of( + attributeEndpointComponent("dooris-ssh-keys", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + attributeEndpointComponent("mailing-list-addresses-chaos", + "export-mailing-list-addresses", "mailing-list-chaos-member", MAILING_LIST_CHAOS), + attributeEndpointComponent("mailing-list-addresses-intern", + "export-mailing-list-addresses", "mailing-list-intern-member", MAILING_LIST_INTERN)); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + UserProfileProvider userProfileProvider = mock(UserProfileProvider.class); + when(session.getProvider(UserProfileProvider.class)).thenReturn(userProfileProvider); + when(userProfileProvider.getConfiguration()).thenReturn(testUserProfileConfig()); + + hacker = user("hacker", Map.of( + SSH_KEY_1, "hacker-ssh-key-1", + SSH_KEY_2, "hacker-ssh-key-2", + MAILING_LIST_CHAOS, "hacker+chaos@example.net", + MAILING_LIST_INTERN, "hacker+intern@example.net"), + List.of("dooris-authorized", "mailing-list-chaos-member", "mailing-list-intern-member")); + tester = user("tester", Map.of( + SSH_KEY_1, "tester-ssh-key-1", + SSH_KEY_2, "tester-ssh-key-2", + MAILING_LIST_CHAOS, "tester+chaos@example.com", + MAILING_LIST_INTERN, "tester+intern@example.com"), + List.of("mailing-list-chaos-member")); + noone = user("noone", Map.of( + SSH_KEY_1, "noone-ssh-key-1", + SSH_KEY_2, "noone-ssh-key-2", + MAILING_LIST_CHAOS, "noone+chaos@example.org", + MAILING_LIST_INTERN, "noone+intern@example.org"), + Collections.emptyList()); + + UserProvider userProvider = mock(UserProvider.class); + when(session.users()).thenReturn(userProvider); + when(userProvider.searchForUserStream(eq(realm), eq(Map.of()))) + .thenAnswer(inv -> Stream.of(hacker, tester, noone)); + } + + @Test + public void exportAttributeValues_doorisSlug_returnsSshKeysOfMatchingUser() { + Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys")); + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker-ssh-key-1", "hacker-ssh-key-2"), response.getEntity()); + } + + @Test + public void exportAttributeValuesMap_doorisSlug_returnsSshKeysPerAttribute() { + try (Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValuesMap("dooris-ssh-keys"))) { + + assertEquals(200, response.getStatus()); + assertEquals(Map.of( + SSH_KEY_1, List.of("hacker-ssh-key-1"), + SSH_KEY_2, List.of("hacker-ssh-key-2")), + response.getEntity()); + } + } + + @Test + public void exportAttributeValues_mailingListChaosSlug_returnsAddressesOfAllMatchingUsers() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValues("mailing-list-addresses-chaos"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker+chaos@example.net", "tester+chaos@example.com"), response.getEntity()); + } + } + + @Test + public void exportAttributeValues_mailingListInternSlug_returnsAddressOfSingleMatchingUser() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValues("mailing-list-addresses-intern"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker+intern@example.net"), response.getEntity()); + } + } + + @Test + public void exportAttributeValuesMap_mailingListChaosSlug_returnsAddressesPerAttribute() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValuesMap("mailing-list-addresses-chaos"))) { + + assertEquals(200, response.getStatus()); + assertEquals(Map.of(MAILING_LIST_CHAOS, + List.of("hacker+chaos@example.net", "tester+chaos@example.com")), + response.getEntity()); + } + } + + @Test + public void exportAttributeValues_unknownSlug_throwsNotFound() { + NotFoundException exception = assertThrows(NotFoundException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("unknown-slug"))); + + assertEquals(404, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("not found")); + } + + @Test + public void exportAttributeValues_duplicateSlugConfiguration_throwsNotFound() { + List duplicateComponents = List.of( + attributeEndpointComponent("duplicate-slug", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + attributeEndpointComponent("duplicate-slug", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> duplicateComponents.stream()); + + NotFoundException exception = assertThrows(NotFoundException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("duplicate-slug"))); + + assertEquals(404, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("Multiple configurations exist")); + } + + @Test + public void exportAttributeValues_missingAuthRole_throwsServerError() { + List components = List.of( + attributeEndpointComponent("bad-auth-role", + "nonexistent-role", "dooris-authorized", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-auth-role"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("auth-role does not exist")); + } + + @Test + public void exportAttributeValues_missingMatchRole_throwsServerError() { + List components = List.of( + attributeEndpointComponent("bad-match-role", + "export-dooris-ssh-keys", "nonexistent-role", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-match-role"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("match-role does not exist")); + } + + @Test + public void exportAttributeValues_invalidAttributeRegex_throwsServerError() { + ComponentModel component = attributeEndpointComponent("bad-regex", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"); + component.put("attribute-regex", "["); + when(realm.getComponentsStream()).thenAnswer(inv -> Stream.of(component)); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-regex"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("attribute-regex is not a valid regex pattern")); + } + + @Test + public void exportAttributeValues_callerMissingAuthRole_throwsForbidden() { + ForbiddenException exception = assertThrows(ForbiddenException.class, + () -> callAuthenticatedAs("not-a-configured-role", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys"))); + + assertEquals(403, exception.getResponse().getStatus()); + } + + /** + * Runs {@code call} as if authenticated by a bearer token belonging to a service account + * that holds {@code requiredRole}, by intercepting construction of the + * {@link AppAuthManager.BearerTokenAuthenticator} the provider creates internally. + */ + private Response callAuthenticatedAs(String requiredRole, Supplier call) { + UserModel serviceAccount = mock(UserModel.class); + when(serviceAccount.hasRole(roles.get(requiredRole))).thenReturn(true); + + try (MockedConstruction ignored = Mockito.mockConstruction( + AppAuthManager.BearerTokenAuthenticator.class, + (mock, ctx) -> when(mock.authenticate()) + .thenReturn(new AuthResult(serviceAccount, null, null, null)))) { + return call.get(); + } + } + + private static ComponentModel attributeEndpointComponent(String slug, String authRole, String matchRole, + String attributeGroup) { + ComponentModel component = new ComponentModel(); + component.setProviderId(AdminUiPage.PROVIDER_ID); + component.put("slug", slug); + component.put("auth-role", authRole); + component.put("match-role", matchRole); + component.put("attribute-group", attributeGroup); + return component; + } + + /** + * Mirrors the User Profile configuration in testing. + */ + private static UPConfig testUserProfileConfig() { + UPConfig upConfig = new UPConfig(); + upConfig.setGroups(List.of(new UPGroup("dooris-ssh-keys"), new UPGroup("mailing-list-addresses"))); + upConfig.setAttributes(List.of( + attribute(SSH_KEY_1, "dooris-ssh-keys"), + attribute(SSH_KEY_2, "dooris-ssh-keys"), + attribute(MAILING_LIST_CHAOS, "mailing-list-addresses"), + attribute(MAILING_LIST_INTERN, "mailing-list-addresses"))); + return upConfig; + } + + private static UPAttribute attribute(String name, String group) { + UPAttribute attribute = new UPAttribute(name); + attribute.setGroup(group); + return attribute; + } + + private UserModel user(String username, Map attributes, List roleNames) { + UserModel user = mock(UserModel.class); + when(user.getUsername()).thenReturn(username); + + when(user.getAttributeStream(anyString())).thenAnswer(inv -> Stream.empty()); + attributes.forEach((name, value) -> + when(user.getAttributeStream(eq(name))).thenAnswer(inv -> Stream.of(value))); + + when(user.hasRole(any(RoleModel.class))).thenReturn(false); + roleNames.forEach(name -> when(user.hasRole(roles.get(name))).thenReturn(true)); + + return user; + } +} diff --git a/testing/README.md b/testing/README.md index 2c5600e..7a16c0e 100644 --- a/testing/README.md +++ b/testing/README.md @@ -1,9 +1,11 @@ -# Creating a Testing Keycloak +# Integration Testing + +This directory contains infrastructure and tests to verify end-to-end operation of the provider. + +## Creating a Testing Keycloak The Docker Compose setup in this directory sets up Keycloak and imports a realm `testing`. -## Starting Keycloak - Run `docker compose up -d` to start Keycloak. The admin console will be available at http://localhost:8080/. You can attach a Java debugger at `localhost:8081`. @@ -11,7 +13,7 @@ You can attach a Java debugger at `localhost:8081`. The realm export is in `import/testing.json`. It will be imported automatically when Keycloak starts. -## Updating the Realm +### Updating the Realm If you want to make changes to the testing realm and persist them, you need to export the realm. @@ -25,16 +27,36 @@ docker compose exec -it keycloak sh -c "cp -rp /opt/keycloak/data/h2 /tmp && env ## Running Integration Tests -This directory also contains shell scripts that exercise the attribute endpoint. +This directory contains shell scripts that exercise the attribute endpoint. Bring up Keycloak with `docker compose up -d`, then run one of the `client-test-export-`*`.sh` scripts. ```shell $ ./client-test-export-dooris.sh +All attributes collated into a single list [ "hacker-ssh-key-1", "hacker-ssh-key-2" ] +Results mapped per attribute +{ + "ssh-key-1": [ + "hacker-ssh-key-1" + ], + "ssh-key-2": [ + "hacker-ssh-key-2" + ] +} +$ ./client-test-export-mailing-lists.sh +chaos@ +[ + "hacker+chaos@example.net", + "tester+chaos@example.com" +] +intern@ +[ + "hacker+intern@example.net" +] ``` ## Realm `testing` Configuration @@ -43,16 +65,16 @@ The realm contains these objects: * Clients: * `export-dooris-ssh-keys` with secret `export-dooris-ssh-keys-secret` and user `service-account-export-dooris-ssh-keys` - * `export-mailing-list-addresses` with secret `export-mailing-list-addresses-secret` and role `export-mailing-list-addresses` + * `export-mailing-list-addresses` with secret `export-mailing-list-addresses-secret` and user `service-account-export-mailing-list-addresses` * Realm Roles: - * `dooris-authorized` - * `mailing-list-chaos-member` - * `mailing-list-intern-member` - * `export-dooris-ssh-keys` - * `export-mailing-list-addresses` + * `dooris-authorized` to assign to users whose SSH keys should be exported + * `mailing-list-chaos-member` to assign to users who are a member of the chaos mailing list + * `mailing-list-intern-member` to assign to users who are a member of the intern mailing list + * `export-dooris-ssh-keys` to assign to service account users that should be allowed to use the dooris endpoint config + * `export-mailing-list-addresses` to assign to service account users that should be allowed to use the mailing list endpoint configs * Groups: * `chaos` with role `mailing-list-chaos-member` - * `ìntern` with roles `dooris-authorized` and `mailing-list-intern-member` + * `ìntern` with roles `dooris-authorized`, `mailing-list-chaos-member` and `mailing-list-intern-member` * Users: * `tester` (Tony Tester), email `tester@example.com`, member of group `chaos` * Mailing List Addresses: @@ -81,7 +103,7 @@ The realm contains these objects: * `mailing-list-address-chaos` * Attribute Group `mailing-list-addresses` * `mailing-list-address-intern` - * Attribute Group `mailing-list-intern` + * Attribute Group `mailing-list-addresses` * `ssh-key-1` * Attribute Group `dooris-ssh-keys` * `ssh-key-2` diff --git a/testing/client-test-export-dooris.sh b/testing/client-test-export-dooris.sh index e3a8f18..82f5ad3 100755 --- a/testing/client-test-export-dooris.sh +++ b/testing/client-test-export-dooris.sh @@ -7,14 +7,21 @@ set -e ACCESS_TOKEN="$(curl -s --request POST \ - --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ - --header 'content-type: application/x-www-form-urlencoded' \ - --data scope=openid \ - --data client_id=export-dooris-ssh-keys \ - --data client_secret=export-dooris-ssh-keys-secret \ - --data grant_type=client_credentials | jq --raw-output .access_token -)" + --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data scope=openid \ + --data client_id=export-dooris-ssh-keys \ + --data client_secret=export-dooris-ssh-keys-secret \ + --data grant_type=client_credentials | jq --raw-output .access_token -)" +echo "All attributes collated into a single list" curl -s --request GET \ - --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys \ - --header "authorization: Bearer ${ACCESS_TOKEN}" \ - --header "content-type: application/json" | jq . - + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - + +echo "Results mapped per attribute" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys/map \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - diff --git a/testing/client-test-export-mailing-lists.sh b/testing/client-test-export-mailing-lists.sh new file mode 100755 index 0000000..4f15d76 --- /dev/null +++ b/testing/client-test-export-mailing-lists.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +# +# Use curl to run a test export of Dooris ssh keys +# + +set -e + +ACCESS_TOKEN="$(curl -s --request POST \ + --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data scope=openid \ + --data client_id=export-mailing-list-addresses \ + --data client_secret=export-mailing-list-addresses-secret \ + --data grant_type=client_credentials | jq --raw-output .access_token -)" + +echo "chaos@" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/mailing-list-addresses-chaos \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - + +echo "intern@" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/mailing-list-addresses-intern \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - diff --git a/testing/compose.yaml b/testing/compose.yaml index 0311532..3f73478 100644 --- a/testing/compose.yaml +++ b/testing/compose.yaml @@ -12,6 +12,17 @@ services: ports: - "8080:8080" - "8081:8081" + networks: + - keycloak volumes: - ./import:/opt/keycloak/data/import/ - - ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar \ No newline at end of file + - ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar + +networks: + # force a "local" IP address that Keycloak accepts HTTP (not HTTPS) requests from + keycloak: + driver: bridge + ipam: + config: + - subnet: 192.168.231.128/29 + gateway: 192.168.231.129 diff --git a/testing/import/testing.json b/testing/import/testing.json index 3744ff8..363df47 100644 --- a/testing/import/testing.json +++ b/testing/import/testing.json @@ -1478,7 +1478,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "a49de9bf-462b-4c61-bb52-0373732f4b1b", @@ -1538,7 +1538,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "dd4024e9-f080-4319-aeb7-7f9906c345c5", @@ -1605,8 +1605,8 @@ "subComponents" : { }, "config" : { "match-role" : [ "mailing-list-intern-member" ], + "attribute-group" : [ "mailing-list-address-intern" ], "auth-role" : [ "export-mailing-list-addresses" ], - "attribute-group" : [ "mailing-list-addresses" ], "slug" : [ "mailing-list-addresses-intern" ] } }, { @@ -1615,8 +1615,8 @@ "subComponents" : { }, "config" : { "match-role" : [ "dooris-authorized" ], - "auth-role" : [ "export-dooris-ssh-keys" ], "attribute-group" : [ "dooris-ssh-keys" ], + "auth-role" : [ "export-dooris-ssh-keys" ], "slug" : [ "dooris-ssh-keys" ] } }, { @@ -1626,7 +1626,7 @@ "config" : { "match-role" : [ "mailing-list-chaos-member" ], "auth-role" : [ "export-mailing-list-addresses" ], - "attribute-group" : [ "mailing-list-addresses" ], + "attribute-group" : [ "mailing-list-address-chaos" ], "slug" : [ "mailing-list-addresses-chaos" ] } } ] From 1c2adb1a92024b84d5d5a864606d0454f6c6472b Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 18 Jul 2026 18:26:19 +0200 Subject: [PATCH 04/11] Add running tests --- .forgejo/workflows/{verify.yaml => test.yaml} | 8 ++++---- Makefile | 6 ++++++ .../AttributeEndpointsResourceProviderTest.java | 9 +++++---- 3 files changed, 15 insertions(+), 8 deletions(-) rename .forgejo/workflows/{verify.yaml => test.yaml} (64%) diff --git a/.forgejo/workflows/verify.yaml b/.forgejo/workflows/test.yaml similarity index 64% rename from .forgejo/workflows/verify.yaml rename to .forgejo/workflows/test.yaml index 533a785..eaba13b 100644 --- a/.forgejo/workflows/verify.yaml +++ b/.forgejo/workflows/test.yaml @@ -3,8 +3,8 @@ on: push: jobs: - ansible-lint: - name: Verify + test: + name: Test runs-on: docker steps: - uses: actions/checkout@v7 @@ -12,6 +12,6 @@ jobs: run: | apt update apt install -y maven - - name: Run maven verify + - name: Run maven verify test run: | - mvn -f attribute-endpoints-provider verify + mvn -f attribute-endpoints-provider verify test diff --git a/Makefile b/Makefile index 767aa28..dd1858b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,12 @@ +install: + mvn -f attribute-endpoints-provider install + verify: mvn -f attribute-endpoints-provider verify +test: + mvn -f attribute-endpoints-provider test + clean: mvn -f attribute-endpoints-provider clean diff --git a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java index 61684c2..451ccae 100644 --- a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java +++ b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java @@ -106,11 +106,12 @@ public class AttributeEndpointsResourceProviderTest { @Test public void exportAttributeValues_doorisSlug_returnsSshKeysOfMatchingUser() { - Response response = callAuthenticatedAs("export-dooris-ssh-keys", - () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys")); + try (Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys"))) { - assertEquals(200, response.getStatus()); - assertEquals(List.of("hacker-ssh-key-1", "hacker-ssh-key-2"), response.getEntity()); + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker-ssh-key-1", "hacker-ssh-key-2"), response.getEntity()); + } } @Test From 149d98f90eba017e7174e26a91b41c4629ffe404 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 18 Jul 2026 19:59:22 +0200 Subject: [PATCH 05/11] Flag default-to-user-email When set for an export config, if the user doesn't have that attribute, their email will be substituted as a default. If any of the values is not a valid email address, they are filtered out. Closes #29 --- README.md | 1 + .../attribute_endpoints/AdminUiPage.java | 7 ++ .../AttributeEndpointsResourceProvider.java | 29 +++++++- ...ttributeEndpointsResourceProviderTest.java | 66 +++++++++++-------- testing/import/testing.json | 13 ++-- 5 files changed, 79 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 4fcf8f0..3c9986e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Each endpoint configuration requires: - the name of an attribute or an attribute group that should be exported - the name of a role that users must have to be included in the list - an optional regular expression that must match to have a value included +- whether to default to the user email if the attribute is empty > **Note** The attribute (group) and the roles need to exist before you can create the endpoint configuration. diff --git a/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java b/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java index 7c88e49..eec6aa1 100644 --- a/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java +++ b/attribute-endpoints-provider/src/main/java/de/ccc/hamburg/keycloak/attribute_endpoints/AdminUiPage.java @@ -148,6 +148,13 @@ public class AdminUiPage implements UiPageProvider, UiPageProviderFactory attribute_list = ctx.users .map(user -> { - Stream attributeStream = ctx.attributeNames.stream() - .map(attributeName -> user.getAttributeStream(attributeName).toList()) + Stream attributeStream = ctx.attributeNames.stream(); + + attributeStream = attributeStream.map(attributeName -> ctx.MapUserAttribute(user, attributeName).toList()) .flatMap(Collection::stream); return attributeStream @@ -87,7 +88,7 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider .collect(Collectors.toMap( attributeName -> attributeName, attributeName -> userList.stream() - .flatMap(user -> user.getAttributeStream(attributeName)) + .flatMap(user -> ctx.MapUserAttribute(user, attributeName)) .filter(attribute -> !attribute.isEmpty()) .filter(ctx.filter::matches) .toList())); @@ -101,9 +102,12 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider * values for a given slug, exposing the results as member variables. */ private class AttributeExportContext { + final Pattern validEmail = Pattern.compile("@"); + List attributeNames; UserModel authUser; String configAttributeGroup; + boolean defaultToUserEmail; RegExFilter filter; UPConfig upconfig; Stream users; @@ -169,6 +173,25 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider UserProvider userProvider = session.users(); users = userProvider.searchForUserStream(realm, Map.of()) .filter(user -> user.hasRole(matchRole)); + defaultToUserEmail = Boolean.parseBoolean(component.getConfig().getFirstOrDefault("default-to-user-email", "false")); + } + + /** + * If defaultToUserEmail is set, default to user email if no attribute exists, and remove any value that is not + * a valid email address. + * + * @param user the user whose attributes are being exported + * @param attributeName the attribute to export + * @return a stream of attribute values + */ + Stream MapUserAttribute(UserModel user, String attributeName) { + if (defaultToUserEmail) { + // need to load everything otherwise we can't check for non-existent attribute + Map> attrs = user.getAttributes(); + return attrs.getOrDefault(attributeName, + List.of(user.getEmail())).stream().filter(v -> !validEmail.matcher(v).matches()); + } + return user.getAttributeStream(attributeName); } } diff --git a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java index 451ccae..559f208 100644 --- a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java +++ b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java @@ -68,34 +68,36 @@ public class AttributeEndpointsResourceProviderTest { List components = List.of( attributeEndpointComponent("dooris-ssh-keys", - "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys", + false), attributeEndpointComponent("mailing-list-addresses-chaos", - "export-mailing-list-addresses", "mailing-list-chaos-member", MAILING_LIST_CHAOS), + "export-mailing-list-addresses", "mailing-list-chaos-member", MAILING_LIST_CHAOS, + true), attributeEndpointComponent("mailing-list-addresses-intern", - "export-mailing-list-addresses", "mailing-list-intern-member", MAILING_LIST_INTERN)); + "export-mailing-list-addresses", "mailing-list-intern-member", MAILING_LIST_INTERN, + true)); when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); UserProfileProvider userProfileProvider = mock(UserProfileProvider.class); when(session.getProvider(UserProfileProvider.class)).thenReturn(userProfileProvider); when(userProfileProvider.getConfiguration()).thenReturn(testUserProfileConfig()); - hacker = user("hacker", Map.of( - SSH_KEY_1, "hacker-ssh-key-1", - SSH_KEY_2, "hacker-ssh-key-2", - MAILING_LIST_CHAOS, "hacker+chaos@example.net", - MAILING_LIST_INTERN, "hacker+intern@example.net"), + hacker = user("hacker", "hacker@example.net", Map.of( + SSH_KEY_1, List.of("hacker-ssh-key-1"), + SSH_KEY_2, List.of("hacker-ssh-key-2"), + MAILING_LIST_INTERN, List.of("hacker+intern@example.net")), List.of("dooris-authorized", "mailing-list-chaos-member", "mailing-list-intern-member")); - tester = user("tester", Map.of( - SSH_KEY_1, "tester-ssh-key-1", - SSH_KEY_2, "tester-ssh-key-2", - MAILING_LIST_CHAOS, "tester+chaos@example.com", - MAILING_LIST_INTERN, "tester+intern@example.com"), + tester = user("tester", "tester@example.com", Map.of( + SSH_KEY_1, List.of("tester-ssh-key-1"), + SSH_KEY_2, List.of("tester-ssh-key-2"), + MAILING_LIST_CHAOS, List.of("tester+chaos@example.com"), + MAILING_LIST_INTERN, List.of("tester+intern@example.com")), List.of("mailing-list-chaos-member")); - noone = user("noone", Map.of( - SSH_KEY_1, "noone-ssh-key-1", - SSH_KEY_2, "noone-ssh-key-2", - MAILING_LIST_CHAOS, "noone+chaos@example.org", - MAILING_LIST_INTERN, "noone+intern@example.org"), + noone = user("noone", "noone@example.org", Map.of( + SSH_KEY_1, List.of("noone-ssh-key-1"), + SSH_KEY_2, List.of("noone-ssh-key-2"), + MAILING_LIST_CHAOS, List.of("noone+chaos@example.org"), + MAILING_LIST_INTERN, List.of("noone+intern@example.org", "-")), Collections.emptyList()); UserProvider userProvider = mock(UserProvider.class); @@ -134,7 +136,7 @@ public class AttributeEndpointsResourceProviderTest { .exportAttributeValues("mailing-list-addresses-chaos"))) { assertEquals(200, response.getStatus()); - assertEquals(List.of("hacker+chaos@example.net", "tester+chaos@example.com"), response.getEntity()); + assertEquals(List.of("hacker@example.net", "tester+chaos@example.com"), response.getEntity()); } } @@ -157,7 +159,7 @@ public class AttributeEndpointsResourceProviderTest { assertEquals(200, response.getStatus()); assertEquals(Map.of(MAILING_LIST_CHAOS, - List.of("hacker+chaos@example.net", "tester+chaos@example.com")), + List.of("hacker@example.net", "tester+chaos@example.com")), response.getEntity()); } } @@ -176,9 +178,11 @@ public class AttributeEndpointsResourceProviderTest { public void exportAttributeValues_duplicateSlugConfiguration_throwsNotFound() { List duplicateComponents = List.of( attributeEndpointComponent("duplicate-slug", - "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys", + false), attributeEndpointComponent("duplicate-slug", - "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys")); + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys", + false)); when(realm.getComponentsStream()).thenAnswer(inv -> duplicateComponents.stream()); NotFoundException exception = assertThrows(NotFoundException.class, @@ -193,7 +197,8 @@ public class AttributeEndpointsResourceProviderTest { public void exportAttributeValues_missingAuthRole_throwsServerError() { List components = List.of( attributeEndpointComponent("bad-auth-role", - "nonexistent-role", "dooris-authorized", "dooris-ssh-keys")); + "nonexistent-role", "dooris-authorized", "dooris-ssh-keys", + false)); when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); ServerErrorException exception = assertThrows(ServerErrorException.class, @@ -208,7 +213,8 @@ public class AttributeEndpointsResourceProviderTest { public void exportAttributeValues_missingMatchRole_throwsServerError() { List components = List.of( attributeEndpointComponent("bad-match-role", - "export-dooris-ssh-keys", "nonexistent-role", "dooris-ssh-keys")); + "export-dooris-ssh-keys", "nonexistent-role", "dooris-ssh-keys", + false)); when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); ServerErrorException exception = assertThrows(ServerErrorException.class, @@ -222,7 +228,8 @@ public class AttributeEndpointsResourceProviderTest { @Test public void exportAttributeValues_invalidAttributeRegex_throwsServerError() { ComponentModel component = attributeEndpointComponent("bad-regex", - "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"); + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys", + false); component.put("attribute-regex", "["); when(realm.getComponentsStream()).thenAnswer(inv -> Stream.of(component)); @@ -261,13 +268,14 @@ public class AttributeEndpointsResourceProviderTest { } private static ComponentModel attributeEndpointComponent(String slug, String authRole, String matchRole, - String attributeGroup) { + String attributeGroup, boolean defaultToUserEmail) { ComponentModel component = new ComponentModel(); component.setProviderId(AdminUiPage.PROVIDER_ID); component.put("slug", slug); component.put("auth-role", authRole); component.put("match-role", matchRole); component.put("attribute-group", attributeGroup); + component.put("default-to-user-email", Boolean.toString(defaultToUserEmail)); return component; } @@ -291,13 +299,15 @@ public class AttributeEndpointsResourceProviderTest { return attribute; } - private UserModel user(String username, Map attributes, List roleNames) { + private UserModel user(String username, String email, Map> attributes, List roleNames) { UserModel user = mock(UserModel.class); when(user.getUsername()).thenReturn(username); + when(user.getEmail()).thenReturn(email.isBlank() ? null : email); + when(user.getAttributes()).thenReturn(attributes); when(user.getAttributeStream(anyString())).thenAnswer(inv -> Stream.empty()); attributes.forEach((name, value) -> - when(user.getAttributeStream(eq(name))).thenAnswer(inv -> Stream.of(value))); + when(user.getAttributeStream(eq(name))).thenAnswer(inv -> value.stream())); when(user.hasRole(any(RoleModel.class))).thenReturn(false); roleNames.forEach(name -> when(user.hasRole(roles.get(name))).thenReturn(true)); diff --git a/testing/import/testing.json b/testing/import/testing.json index 363df47..c4acac3 100644 --- a/testing/import/testing.json +++ b/testing/import/testing.json @@ -447,8 +447,7 @@ "attributes" : { "ssh-key-1" : [ "hacker-ssh-key-1" ], "mailing-list-address-intern" : [ "hacker+intern@example.net" ], - "ssh-key-2" : [ "hacker-ssh-key-2" ], - "mailing-list-address-chaos" : [ "hacker+chaos@example.net" ] + "ssh-key-2" : [ "hacker-ssh-key-2" ] }, "enabled" : true, "createdTimestamp" : 1784376038850, @@ -1478,7 +1477,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper" ] } }, { "id" : "a49de9bf-462b-4c61-bb52-0373732f4b1b", @@ -1538,7 +1537,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-usermodel-property-mapper", "oidc-address-mapper", "saml-user-property-mapper" ] } }, { "id" : "dd4024e9-f080-4319-aeb7-7f9906c345c5", @@ -1605,8 +1604,9 @@ "subComponents" : { }, "config" : { "match-role" : [ "mailing-list-intern-member" ], - "attribute-group" : [ "mailing-list-address-intern" ], + "default-to-user-email" : [ "true" ], "auth-role" : [ "export-mailing-list-addresses" ], + "attribute-group" : [ "mailing-list-address-intern" ], "slug" : [ "mailing-list-addresses-intern" ] } }, { @@ -1625,8 +1625,9 @@ "subComponents" : { }, "config" : { "match-role" : [ "mailing-list-chaos-member" ], - "auth-role" : [ "export-mailing-list-addresses" ], + "default-to-user-email" : [ "true" ], "attribute-group" : [ "mailing-list-address-chaos" ], + "auth-role" : [ "export-mailing-list-addresses" ], "slug" : [ "mailing-list-addresses-chaos" ] } } ] From 0a84d2ddf90c46eadf7a083eef84f5ac6dc72d66 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 18 Jul 2026 21:38:50 +0200 Subject: [PATCH 06/11] Only build when pushing to main or on a pull request --- .forgejo/workflows/verify.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.forgejo/workflows/verify.yaml b/.forgejo/workflows/verify.yaml index 533a785..7f8e199 100644 --- a/.forgejo/workflows/verify.yaml +++ b/.forgejo/workflows/verify.yaml @@ -1,6 +1,8 @@ on: pull_request: push: + branches: + - main jobs: ansible-lint: From 89ce38f6c1551074926370999108248047d7359a Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 18 Jul 2026 21:40:39 +0200 Subject: [PATCH 07/11] Be less verbose when building --- .forgejo/workflows/verify.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/verify.yaml b/.forgejo/workflows/verify.yaml index 7f8e199..4f33cc9 100644 --- a/.forgejo/workflows/verify.yaml +++ b/.forgejo/workflows/verify.yaml @@ -16,4 +16,4 @@ jobs: apt install -y maven - name: Run maven verify run: | - mvn -f attribute-endpoints-provider verify + mvn -f attribute-endpoints-provider --batch --no-transfer-progress verify From 6cd678b4141b1dd4392ab87e97669aae21a9c880 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 18 Jul 2026 21:41:42 +0200 Subject: [PATCH 08/11] Spell option correctly --- .forgejo/workflows/verify.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/verify.yaml b/.forgejo/workflows/verify.yaml index 4f33cc9..9f88d22 100644 --- a/.forgejo/workflows/verify.yaml +++ b/.forgejo/workflows/verify.yaml @@ -16,4 +16,4 @@ jobs: apt install -y maven - name: Run maven verify run: | - mvn -f attribute-endpoints-provider --batch --no-transfer-progress verify + mvn -f attribute-endpoints-provider --batch-mode --no-transfer-progress verify From f204ab7f28071d4baf95840793918e2310607032 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 19 Jul 2026 10:27:51 +0200 Subject: [PATCH 09/11] Add just the testing setup from #26. Important: there are changes here to adapt the tests and the Keycloak test realm that are incompatible with the changes in #29. Ultimately, the changes in #29 need to be applied over this. --- Makefile | 6 + attribute-endpoints-provider/pom.xml | 13 + ...ttributeEndpointsResourceProviderTest.java | 315 ++++++++++++++++++ testing/README.md | 48 ++- testing/client-test-export-dooris.sh | 25 +- testing/client-test-export-mailing-lists.sh | 27 ++ testing/compose.yaml | 13 +- testing/import/testing.json | 8 +- 8 files changed, 428 insertions(+), 27 deletions(-) create mode 100644 attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java create mode 100755 testing/client-test-export-mailing-lists.sh diff --git a/Makefile b/Makefile index 767aa28..dd1858b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,12 @@ +install: + mvn -f attribute-endpoints-provider install + verify: mvn -f attribute-endpoints-provider verify +test: + mvn -f attribute-endpoints-provider test + clean: mvn -f attribute-endpoints-provider clean diff --git a/attribute-endpoints-provider/pom.xml b/attribute-endpoints-provider/pom.xml index a6d1663..c525026 100644 --- a/attribute-endpoints-provider/pom.xml +++ b/attribute-endpoints-provider/pom.xml @@ -28,6 +28,19 @@ test + + org.mockito + mockito-core + 5.23.0 + test + + + + org.jboss.resteasy + resteasy-core + test + + org.keycloak keycloak-server-spi-private diff --git a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java new file mode 100644 index 0000000..fb57f4b --- /dev/null +++ b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java @@ -0,0 +1,315 @@ +package de.ccc.hamburg.keycloak.attribute_endpoints; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.core.Response; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.*; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.representations.userprofile.config.UPGroup; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; +import org.keycloak.userprofile.UserProfileProvider; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests the two endpoints of {@link AttributeEndpointsResourceProvider} against the realm + * configuration and expected results documented in {@code testing/README.md}. + */ +public class AttributeEndpointsResourceProviderTest { + + private static final String SSH_KEY_1 = "ssh-key-1"; + private static final String SSH_KEY_2 = "ssh-key-2"; + private static final String MAILING_LIST_CHAOS = "mailing-list-address-chaos"; + private static final String MAILING_LIST_INTERN = "mailing-list-address-intern"; + + private KeycloakSession session; + private RealmModel realm; + private Map roles; + private UserModel hacker; + private UserModel tester; + private UserModel noone; + + @Before + public void setUp() { + session = mock(KeycloakSession.class); + KeycloakContext context = mock(KeycloakContext.class); + when(session.getContext()).thenReturn(context); + + realm = mock(RealmModel.class); + when(context.getRealm()).thenReturn(realm); + + roles = new HashMap<>(); + for (String name : List.of("dooris-authorized", "export-dooris-ssh-keys", + "mailing-list-chaos-member", "mailing-list-intern-member", + "export-mailing-list-addresses")) { + roles.put(name, mock(RoleModel.class)); + } + when(realm.getRole(anyString())).thenAnswer(inv -> roles.get(inv.getArgument(0, String.class))); + + List components = List.of( + attributeEndpointComponent("dooris-ssh-keys", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + attributeEndpointComponent("mailing-list-addresses-chaos", + "export-mailing-list-addresses", "mailing-list-chaos-member", MAILING_LIST_CHAOS), + attributeEndpointComponent("mailing-list-addresses-intern", + "export-mailing-list-addresses", "mailing-list-intern-member", MAILING_LIST_INTERN)); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + UserProfileProvider userProfileProvider = mock(UserProfileProvider.class); + when(session.getProvider(UserProfileProvider.class)).thenReturn(userProfileProvider); + when(userProfileProvider.getConfiguration()).thenReturn(testUserProfileConfig()); + + hacker = user("hacker", Map.of( + SSH_KEY_1, "hacker-ssh-key-1", + SSH_KEY_2, "hacker-ssh-key-2", + MAILING_LIST_CHAOS, "hacker+chaos@example.net", + MAILING_LIST_INTERN, "hacker+intern@example.net"), + List.of("dooris-authorized", "mailing-list-chaos-member", "mailing-list-intern-member")); + tester = user("tester", Map.of( + SSH_KEY_1, "tester-ssh-key-1", + SSH_KEY_2, "tester-ssh-key-2", + MAILING_LIST_CHAOS, "tester+chaos@example.com", + MAILING_LIST_INTERN, "tester+intern@example.com"), + List.of("mailing-list-chaos-member")); + noone = user("noone", Map.of( + SSH_KEY_1, "noone-ssh-key-1", + SSH_KEY_2, "noone-ssh-key-2", + MAILING_LIST_CHAOS, "noone+chaos@example.org", + MAILING_LIST_INTERN, "noone+intern@example.org"), + Collections.emptyList()); + + UserProvider userProvider = mock(UserProvider.class); + when(session.users()).thenReturn(userProvider); + when(userProvider.searchForUserStream(eq(realm), eq(Map.of()))) + .thenAnswer(inv -> Stream.of(hacker, tester, noone)); + } + + @Test + public void exportAttributeValues_doorisSlug_returnsSshKeysOfMatchingUser() { + try (Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker-ssh-key-1", "hacker-ssh-key-2"), response.getEntity()); + } + } + + /* NOTYET + @Test + public void exportAttributeValuesMap_doorisSlug_returnsSshKeysPerAttribute() { + try (Response response = callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValuesMap("dooris-ssh-keys"))) { + + assertEquals(200, response.getStatus()); + assertEquals(Map.of( + SSH_KEY_1, List.of("hacker-ssh-key-1"), + SSH_KEY_2, List.of("hacker-ssh-key-2")), + response.getEntity()); + } + } + */ + + /* NOTYET + @Test + public void exportAttributeValues_mailingListChaosSlug_returnsAddressesOfAllMatchingUsers() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValues("mailing-list-addresses-chaos"))) { + + assertEquals(200, response.getStatus()); + 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", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValues("mailing-list-addresses-intern"))) { + + assertEquals(200, response.getStatus()); + assertEquals(List.of("hacker+intern@example.net"), response.getEntity()); + } + } + */ + + /* NOTYET + @Test + public void exportAttributeValuesMap_mailingListChaosSlug_returnsAddressesPerAttribute() { + try (Response response = callAuthenticatedAs("export-mailing-list-addresses", + () -> new AttributeEndpointsResourceProvider(session) + .exportAttributeValuesMap("mailing-list-addresses-chaos"))) { + + assertEquals(200, response.getStatus()); + assertEquals(Map.of(MAILING_LIST_CHAOS, + List.of("hacker+chaos@example.net", "tester+chaos@example.com")), + response.getEntity()); + } + } + */ + + @Test + public void exportAttributeValues_unknownSlug_throwsNotFound() { + NotFoundException exception = assertThrows(NotFoundException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("unknown-slug"))); + + assertEquals(404, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("not found")); + } + + @Test + public void exportAttributeValues_duplicateSlugConfiguration_throwsNotFound() { + List duplicateComponents = List.of( + attributeEndpointComponent("duplicate-slug", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"), + attributeEndpointComponent("duplicate-slug", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> duplicateComponents.stream()); + + NotFoundException exception = assertThrows(NotFoundException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("duplicate-slug"))); + + assertEquals(404, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("Multiple configurations exist")); + } + + @Test + public void exportAttributeValues_missingAuthRole_throwsServerError() { + List components = List.of( + attributeEndpointComponent("bad-auth-role", + "nonexistent-role", "dooris-authorized", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-auth-role"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("auth-role does not exist")); + } + + @Test + public void exportAttributeValues_missingMatchRole_throwsServerError() { + List components = List.of( + attributeEndpointComponent("bad-match-role", + "export-dooris-ssh-keys", "nonexistent-role", "dooris-ssh-keys")); + when(realm.getComponentsStream()).thenAnswer(inv -> components.stream()); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-match-role"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("match-role does not exist")); + } + + @Test + public void exportAttributeValues_invalidAttributeRegex_throwsServerError() { + ComponentModel component = attributeEndpointComponent("bad-regex", + "export-dooris-ssh-keys", "dooris-authorized", "dooris-ssh-keys"); + component.put("attribute-regex", "["); + when(realm.getComponentsStream()).thenAnswer(inv -> Stream.of(component)); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> callAuthenticatedAs("export-dooris-ssh-keys", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("bad-regex"))); + + assertEquals(500, exception.getResponse().getStatus()); + assertTrue(exception.getMessage().contains("attribute-regex is not a valid regex pattern")); + } + + @Test + public void exportAttributeValues_callerMissingAuthRole_throwsForbidden() { + ForbiddenException exception = assertThrows(ForbiddenException.class, + () -> callAuthenticatedAs("not-a-configured-role", + () -> new AttributeEndpointsResourceProvider(session).exportAttributeValues("dooris-ssh-keys"))); + + assertEquals(403, exception.getResponse().getStatus()); + } + + /** + * Runs {@code call} as if authenticated by a bearer token belonging to a service account + * that holds {@code requiredRole}, by intercepting construction of the + * {@link AppAuthManager.BearerTokenAuthenticator} the provider creates internally. + */ + private Response callAuthenticatedAs(String requiredRole, Supplier call) { + UserModel serviceAccount = mock(UserModel.class); + when(serviceAccount.hasRole(roles.get(requiredRole))).thenReturn(true); + + try (MockedConstruction ignored = Mockito.mockConstruction( + AppAuthManager.BearerTokenAuthenticator.class, + (mock, ctx) -> when(mock.authenticate()) + .thenReturn(new AuthResult(serviceAccount, null, null, null)))) { + return call.get(); + } + } + + private static ComponentModel attributeEndpointComponent(String slug, String authRole, String matchRole, + String attributeGroup) { + ComponentModel component = new ComponentModel(); + component.setProviderId(AdminUiPage.PROVIDER_ID); + component.put("slug", slug); + component.put("auth-role", authRole); + component.put("match-role", matchRole); + component.put("attribute-group", attributeGroup); + return component; + } + + /** + * Mirrors the User Profile configuration in testing. + */ + private static UPConfig testUserProfileConfig() { + UPConfig upConfig = new UPConfig(); + upConfig.setGroups(List.of(new UPGroup("dooris-ssh-keys"), new UPGroup("mailing-list-addresses"))); + upConfig.setAttributes(List.of( + attribute(SSH_KEY_1, "dooris-ssh-keys"), + attribute(SSH_KEY_2, "dooris-ssh-keys"), + attribute(MAILING_LIST_CHAOS, "mailing-list-addresses"), + attribute(MAILING_LIST_INTERN, "mailing-list-addresses"))); + return upConfig; + } + + private static UPAttribute attribute(String name, String group) { + UPAttribute attribute = new UPAttribute(name); + attribute.setGroup(group); + return attribute; + } + + private UserModel user(String username, Map attributes, List roleNames) { + UserModel user = mock(UserModel.class); + when(user.getUsername()).thenReturn(username); + + when(user.getAttributeStream(anyString())).thenAnswer(inv -> Stream.empty()); + attributes.forEach((name, value) -> + when(user.getAttributeStream(eq(name))).thenAnswer(inv -> Stream.of(value))); + + when(user.hasRole(any(RoleModel.class))).thenReturn(false); + roleNames.forEach(name -> when(user.hasRole(roles.get(name))).thenReturn(true)); + + return user; + } +} diff --git a/testing/README.md b/testing/README.md index 2c5600e..7a16c0e 100644 --- a/testing/README.md +++ b/testing/README.md @@ -1,9 +1,11 @@ -# Creating a Testing Keycloak +# Integration Testing + +This directory contains infrastructure and tests to verify end-to-end operation of the provider. + +## Creating a Testing Keycloak The Docker Compose setup in this directory sets up Keycloak and imports a realm `testing`. -## Starting Keycloak - Run `docker compose up -d` to start Keycloak. The admin console will be available at http://localhost:8080/. You can attach a Java debugger at `localhost:8081`. @@ -11,7 +13,7 @@ You can attach a Java debugger at `localhost:8081`. The realm export is in `import/testing.json`. It will be imported automatically when Keycloak starts. -## Updating the Realm +### Updating the Realm If you want to make changes to the testing realm and persist them, you need to export the realm. @@ -25,16 +27,36 @@ docker compose exec -it keycloak sh -c "cp -rp /opt/keycloak/data/h2 /tmp && env ## Running Integration Tests -This directory also contains shell scripts that exercise the attribute endpoint. +This directory contains shell scripts that exercise the attribute endpoint. Bring up Keycloak with `docker compose up -d`, then run one of the `client-test-export-`*`.sh` scripts. ```shell $ ./client-test-export-dooris.sh +All attributes collated into a single list [ "hacker-ssh-key-1", "hacker-ssh-key-2" ] +Results mapped per attribute +{ + "ssh-key-1": [ + "hacker-ssh-key-1" + ], + "ssh-key-2": [ + "hacker-ssh-key-2" + ] +} +$ ./client-test-export-mailing-lists.sh +chaos@ +[ + "hacker+chaos@example.net", + "tester+chaos@example.com" +] +intern@ +[ + "hacker+intern@example.net" +] ``` ## Realm `testing` Configuration @@ -43,16 +65,16 @@ The realm contains these objects: * Clients: * `export-dooris-ssh-keys` with secret `export-dooris-ssh-keys-secret` and user `service-account-export-dooris-ssh-keys` - * `export-mailing-list-addresses` with secret `export-mailing-list-addresses-secret` and role `export-mailing-list-addresses` + * `export-mailing-list-addresses` with secret `export-mailing-list-addresses-secret` and user `service-account-export-mailing-list-addresses` * Realm Roles: - * `dooris-authorized` - * `mailing-list-chaos-member` - * `mailing-list-intern-member` - * `export-dooris-ssh-keys` - * `export-mailing-list-addresses` + * `dooris-authorized` to assign to users whose SSH keys should be exported + * `mailing-list-chaos-member` to assign to users who are a member of the chaos mailing list + * `mailing-list-intern-member` to assign to users who are a member of the intern mailing list + * `export-dooris-ssh-keys` to assign to service account users that should be allowed to use the dooris endpoint config + * `export-mailing-list-addresses` to assign to service account users that should be allowed to use the mailing list endpoint configs * Groups: * `chaos` with role `mailing-list-chaos-member` - * `ìntern` with roles `dooris-authorized` and `mailing-list-intern-member` + * `ìntern` with roles `dooris-authorized`, `mailing-list-chaos-member` and `mailing-list-intern-member` * Users: * `tester` (Tony Tester), email `tester@example.com`, member of group `chaos` * Mailing List Addresses: @@ -81,7 +103,7 @@ The realm contains these objects: * `mailing-list-address-chaos` * Attribute Group `mailing-list-addresses` * `mailing-list-address-intern` - * Attribute Group `mailing-list-intern` + * Attribute Group `mailing-list-addresses` * `ssh-key-1` * Attribute Group `dooris-ssh-keys` * `ssh-key-2` diff --git a/testing/client-test-export-dooris.sh b/testing/client-test-export-dooris.sh index b6d4696..82f5ad3 100755 --- a/testing/client-test-export-dooris.sh +++ b/testing/client-test-export-dooris.sh @@ -7,14 +7,21 @@ set -e ACCESS_TOKEN="$(curl -s --request POST \ - --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ - --header 'content-type: application/x-www-form-urlencoded' \ - --data scope=openid \ - --data client_id=export-dooris-ssh-keys \ - --data client_secret=export-dooris-ssh-keys-secret \ - --data grant_type=client_credentials | jq --raw-output .access_token -)" + --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data scope=openid \ + --data client_id=export-dooris-ssh-keys \ + --data client_secret=export-dooris-ssh-keys-secret \ + --data grant_type=client_credentials | jq --raw-output .access_token -)" +echo "All attributes collated into a single list" curl -s --request GET \ - --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys \ - --header "authorization: Bearer ${ACCESS_TOKEN}" \ - --header "content-type: application/json" | jq . - \ No newline at end of file + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - + +echo "Results mapped per attribute" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/dooris-ssh-keys/map \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - diff --git a/testing/client-test-export-mailing-lists.sh b/testing/client-test-export-mailing-lists.sh new file mode 100755 index 0000000..4f15d76 --- /dev/null +++ b/testing/client-test-export-mailing-lists.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +# +# Use curl to run a test export of Dooris ssh keys +# + +set -e + +ACCESS_TOKEN="$(curl -s --request POST \ + --url http://localhost:8080/realms/testing/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data scope=openid \ + --data client_id=export-mailing-list-addresses \ + --data client_secret=export-mailing-list-addresses-secret \ + --data grant_type=client_credentials | jq --raw-output .access_token -)" + +echo "chaos@" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/mailing-list-addresses-chaos \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - + +echo "intern@" +curl -s --request GET \ + --url http://localhost:8080/realms/testing/attribute-endpoints-provider/export/mailing-list-addresses-intern \ + --header "authorization: Bearer ${ACCESS_TOKEN}" \ + --header "content-type: application/json" | jq . - diff --git a/testing/compose.yaml b/testing/compose.yaml index 0311532..3f73478 100644 --- a/testing/compose.yaml +++ b/testing/compose.yaml @@ -12,6 +12,17 @@ services: ports: - "8080:8080" - "8081:8081" + networks: + - keycloak volumes: - ./import:/opt/keycloak/data/import/ - - ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar \ No newline at end of file + - ../attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar + +networks: + # force a "local" IP address that Keycloak accepts HTTP (not HTTPS) requests from + keycloak: + driver: bridge + ipam: + config: + - subnet: 192.168.231.128/29 + gateway: 192.168.231.129 diff --git a/testing/import/testing.json b/testing/import/testing.json index 3744ff8..dfa0fa7 100644 --- a/testing/import/testing.json +++ b/testing/import/testing.json @@ -1478,7 +1478,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "a49de9bf-462b-4c61-bb52-0373732f4b1b", @@ -1538,7 +1538,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "dd4024e9-f080-4319-aeb7-7f9906c345c5", @@ -1605,8 +1605,8 @@ "subComponents" : { }, "config" : { "match-role" : [ "mailing-list-intern-member" ], - "auth-role" : [ "export-mailing-list-addresses" ], "attribute-group" : [ "mailing-list-addresses" ], + "auth-role" : [ "export-mailing-list-addresses" ], "slug" : [ "mailing-list-addresses-intern" ] } }, { @@ -1615,8 +1615,8 @@ "subComponents" : { }, "config" : { "match-role" : [ "dooris-authorized" ], - "auth-role" : [ "export-dooris-ssh-keys" ], "attribute-group" : [ "dooris-ssh-keys" ], + "auth-role" : [ "export-dooris-ssh-keys" ], "slug" : [ "dooris-ssh-keys" ] } }, { From 218ee1dd578034cb9cad75b9851822ee7c8f7372 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 19 Jul 2026 10:45:08 +0200 Subject: [PATCH 10/11] Add just the refactoring from #26. This is based off #31, so that needs to be merged first. --- .../AttributeEndpointsResourceProvider.java | 216 +++++++++--------- ...ttributeEndpointsResourceProviderTest.java | 4 +- 2 files changed, 113 insertions(+), 107 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 15303dc..6c069c0 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,12 @@ 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.UPAttribute; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.Auth; @@ -23,16 +14,11 @@ 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.Pattern; +import java.util.stream.Stream; public class AttributeEndpointsResourceProvider implements RealmResourceProvider { private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class); @@ -51,81 +37,21 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider public void close() { } + /** + * Returns a list of all attribute 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)) { - LOG.info("User " + authUser.getUsername() + " does not have required role " + authRole.getName()); - 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(); - 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); @@ -134,18 +60,83 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider .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(); - }) + .filter(ctx.filter::matches) .toList(); return Response.ok(attribute_list).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 { + List attributeNames; + UserModel authUser; + String configAttributeGroup; + RegExFilter filter; + UPConfig upconfig; + Stream users; + + AttributeExportContext(String slug) { + RealmModel realm = session.getContext().getRealm(); + + List componentList = realm.getComponentsStream() + .filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID)) + .filter(c -> c.getConfig().getFirst("slug").equals(slug)) + .toList(); + + 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); + + RoleModel authRole = realm.getRole(component.getConfig().getFirst("auth-role")); + if (authRole == null) { + throw new ServerErrorException("Endpoint 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); + } + + 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); + } + + 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); + } + + authUser = AttributeEndpointsResourceProvider.getAuth(session).getUser(); + if (!authUser.hasRole(authRole)) { + LOG.info("User " + authUser.getUsername() + " does not have required role " + authRole.getName() + " for attribute endpoint " + slug); + throw new ForbiddenException("User does not have required auth role."); + } + + // 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)) + .map(UPAttribute::getName) + .toList(); + + UserProvider userProvider = session.users(); + users = userProvider.searchForUserStream(realm, Map.of()) + .filter(user -> user.hasRole(matchRole)); + } } private static Auth getAuth(KeycloakSession session) { @@ -156,8 +147,25 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider } RealmModel realm = session.getContext().getRealm(); - ClientModel client = auth.getClient(); - return new Auth(realm, auth.getToken(), auth.getUser(), client, auth.getSession(), false); + ClientModel client = auth.client(); + return new Auth(realm, auth.token(), auth.user(), client, auth.session(), false); + } + + private static class RegExFilter { + Pattern pattern; + + RegExFilter(String regex) { + if (regex == null) + pattern = null; + else + pattern = Pattern.compile(regex); + } + + boolean matches(String input) { + if (pattern == null) + return true; + return pattern.matcher(input).matches(); + } } } diff --git a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java index fb57f4b..7458a1f 100644 --- a/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java +++ b/attribute-endpoints-provider/src/test/java/de/ccc/hamburg/keycloak/attribute_endpoints/AttributeEndpointsResourceProviderTest.java @@ -24,9 +24,7 @@ import java.util.Map; import java.util.function.Supplier; import java.util.stream.Stream; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; From 510c045070bcac4c962b272ace62107fc1e15473 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 21 Jul 2026 21:20:15 +0200 Subject: [PATCH 11/11] Formatting --- .../AttributeEndpointsResourceProvider.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 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 6c069c0..7dc557f 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 @@ -66,7 +66,7 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider return Response.ok(attribute_list).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,13 +87,13 @@ public class AttributeEndpointsResourceProvider implements RealmResourceProvider .toList(); if (componentList.isEmpty()) { - throw new NotFoundException("Endpoint not found"); + throw new NotFoundException("Endpoint 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( + "Endpoint Configuration Error - Multiple configurations exist for this endpoint."); + } ComponentModel component = componentList.get(0);